Sprites d'icônes MapLibre : intégrer Pinhead dans une carte MapTiler
Guide complet et sourcé pour générer un sprite.json/sprite.png pour MapLibre à partir des icônes Pinhead : spécification du format, outils (Spreet, spritezero), génération par code en Node.js et dans le navigateur, upload chez MapTiler, chargement dynamique sans sprite.
Tu affiches une carte MapLibre GL JS avec des tuiles MapTiler. Tu veux y piquer des icônes custom — disons des pictos de POI façon Maki/Temaki. Pinhead propose plus de 1 000 icônes SVG conçues exactement pour ce cas (lisibles à taille d’épingle, en domaine public). Reste à les embarquer dans le sprite MapLibre : un fichier sprite.png atlas plus un sprite.json qui décrit les zones.
Cet article passe en revue quatre façons de le faire — upload dans MapTiler Cloud, génération CLI avec Spreet, génération par code, et chargement dynamique sans sprite — et détaille le format du sprite, les pièges (@2x, SDF, limites MapTiler) et comment référencer le résultat.
1. Anatomie d’un sprite MapLibre
Un sprite, au sens MapLibre, est une paire de fichiers :
sprite.png: un atlas — une seule image PNG qui empile toutes les icônessprite.json: un index qui donne la position (x,y) et la taille (width,height) de chaque icône dans l’atlas
Source : MapLibre Style Spec — Sprite
Format JSON minimal
{
"airport": {
"width": 24,
"height": 24,
"x": 0,
"y": 0,
"pixelRatio": 1
},
"hospital": {
"width": 24,
"height": 24,
"x": 24,
"y": 0,
"pixelRatio": 1
}
}
Les cinq champs ci-dessus sont obligatoires. Chaque clé est l’identifiant que tu utiliseras ensuite dans un style, via "icon-image": "hospital".
Champs optionnels
| Champ | Usage |
|---|---|
content | [left, top, right, bottom] — zone utile pour icon-text-fit (quand une icône sert de fond à un label) |
stretchX, stretchY | Zones étirables [[from, to], ...] — pour les panneaux qui s’adaptent à la longueur du texte |
sdf | Booléen — active le Signed Distance Field (coloration runtime via icon-color) |
textFitWidth, textFitHeight | stretchOrShrink / stretchOnly / proportional |
Source : MapLibre Style Spec — Sprite
Le mécanisme @2x
Sur un écran Retina, MapLibre tente automatiquement de charger une version haute résolution. Si ton style déclare :
"sprite": "https://cdn.example.com/icons/sprite"
Le navigateur demandera, selon le devicePixelRatio :
sprite.json+sprite.png(densité 1×)sprite@2x.json+sprite@2x.png(densité 2×)
Il faut donc générer les deux jeux, avec pixelRatio: 2 dans le JSON 2×. Les coordonnées x, y, width, height sont alors exprimées en pixels de l’image PNG (donc doublées par rapport au 1×).
Référencement dans un style
Dans le style.json :
{
"version": 8,
"sprite": "https://cdn.example.com/icons/sprite",
"sources": { "...": "..." },
"layers": [
{
"id": "pois",
"type": "symbol",
"source": "mon-geojson",
"layout": {
"icon-image": "hospital",
"icon-size": 1
}
}
]
}
À noter : depuis la v2 de MapLibre, on peut déclarer plusieurs sprites en passant un tableau avec des id — l’icône se référence alors "icon-image": "monId:hospital". Source : MapLibre Style Spec.
2. Pinhead : ce qu’on embarque
Pinhead (waysidemapping/pinhead sur GitHub) est une bibliothèque de plus de 1 000 icônes SVG cartographiques, dérivées et harmonisées à partir de Maki, Temaki, OSM Carto et NPMap.
- Licence : CC0 (domaine public). Usage libre, y compris commercial, sans attribution requise.
- Format : SVG individuels, plus un
index.jsonetindex.complete.json(avec le code SVG inline) - Pas de sprite pré-généré : c’est à toi de le construire
- Distribution :
- Téléchargement en ZIP depuis pinhead.ink (< 1 Mo compressé)
- Repo GitHub
waysidemapping/pinheaddans le dossiericons/ - URLs versionnées :
https://pinhead.ink/v*/
Source : pinhead.ink et github.com/waysidemapping/pinhead.
Sélectionner le juste nécessaire
Les 1 000+ icônes pèsent peu en SVG, mais une fois rastérisées et empilées dans un sprite, tu paies la surface. Pour une carte donnée, on n’a généralement besoin que de 10 à 50 pictos. Deux approches :
Approche 1 — copier à la main : tu navigues sur pinhead.ink, tu télécharges les SVG un par un, tu les poses dans ./icons/. Rapide, pas de dépendance.
Approche 2 — liste d’ID : tu clones le repo et tu copies les fichiers nommés par leur ID. Plus reproductible quand la sélection évolue :
ICONS=(hospital airport restaurant parking bus)
mkdir -p ./sprite-src
for icon in "${ICONS[@]}"; do
cp pinhead/icons/"$icon".svg ./sprite-src/
done
Le fichier final reflète exactement ce que la carte utilise.
3. Quatre approches pour produire le sprite
| Approche | Effort | Contrôle | Quand choisir |
|---|---|---|---|
| Upload MapTiler Cloud | Faible | Limité | Style hébergé chez MapTiler, < 500 icônes, projet stable |
| Spreet (CLI) | Moyen | Élevé | Pipeline reproductible, CI, auto-hébergement |
| Génération par code | Moyen-élevé | Total | Besoin d’un build custom ou sélection dynamique |
addImage / styleimagemissing | Faible | N/A (pas de sprite) | Petite quantité d’icônes, POC |
3.1 Upload dans MapTiler Cloud
C’est l’option no-code. Dans Map Designer, tu ouvres le gestionnaire d’icônes et tu charges les SVG (un par un ou en ZIP). MapTiler régénère le sprite et met à jour le style.
Limites documentées (source : MapTiler — Using Sprites) :
- 256×256 px max par icône
- 500 icônes max par ensemble
- 4096×4096 px max pour la taille totale du sprite
- Formats acceptés : SVG (vectoriel) ou PNG (raster), 2× et 3× optionnels
L’upload génère automatiquement les 4 fichiers sprite.{png,json} et sprite@2x.{png,json} et les expose sur l’URL du style (https://api.maptiler.com/maps/<id>/style.json → champ sprite).
Quand c’est le bon choix : le style reste hébergé chez MapTiler, la liste d’icônes bouge peu, pas de CI nécessaire. C’est la voie la plus simple. Le défaut : impossible de régénérer automatiquement depuis un repo Pinhead qui se met à jour.
3.2 Spreet — la CLI qui fait tout
Spreet (écrit en Rust, v0.13.1 en avril 2026) est l’outil de référence pour MapLibre. Il lit un dossier de SVG et produit les quatre fichiers attendus.
Installation (source : README Spreet) :
# macOS / Linux via Homebrew
brew install flother/taps/spreet
# Via Cargo
cargo install spreet
# Docker
docker run -v $(pwd)/icons:/app/icons -v $(pwd):/app/output \
ghcr.io/flother/spreet icons output/sprite
Usage basique :
# 1× (standard)
spreet ./sprite-src ./dist/sprite
# 2× (Retina)
spreet --retina ./sprite-src ./dist/sprite@2x
Produit sprite.png, sprite.json, sprite@2x.png, sprite@2x.json — exactement les noms attendus par MapLibre.
Flags utiles :
| Flag | Effet |
|---|---|
--retina | Ratio pixel 2× (équivaut à --ratio 2) |
--unique | Déduplique les icônes identiques (partagent la même zone) |
--minify-index-file | JSON sans espaces |
--recursive | Inclut les sous-dossiers |
--spacing N | Pixels de marge entre icônes (évite le bleed) |
--sdf | Génère un sprite SDF (tout ou rien) |
Script de génération complet :
#!/usr/bin/env bash
set -euo pipefail
SRC=./sprite-src
OUT=./dist
mkdir -p "$OUT"
spreet --unique --minify-index-file "$SRC" "$OUT/sprite"
spreet --retina --unique --minify-index-file "$SRC" "$OUT/sprite@2x"
echo "Généré :"
ls -1 "$OUT"
Limites connues :
--sdfs’applique à tout le sprite ou rien — si tu veux mixer SDF (icônes colorables au runtime) et non-SDF (icônes avec couleurs d’origine), il faut générer deux sprites séparés et les déclarer en tableau dans le style- Les SVG doivent avoir des dimensions fixes (
width/heightouviewBoxexploitables) — Spreet échoue sur des SVG sans taille explicite
Source : Spreet sur lib.rs.
3.3 Génération par code (Node.js)
Si tu veux piloter la génération depuis ton pipeline JS (par exemple : télécharger une sélection dynamique d’icônes Pinhead puis construire le sprite dans un script Vite), tu peux assembler toi-même avec :
sharppour rastériser les SVG et composer le PNGbin-pack(ou un bin-packer équivalent) pour l’algorithme d’empaquetage
Version minimale :
// build-sprite.mjs
import { readdir, readFile, writeFile } from "node:fs/promises";
import { join, parse } from "node:path";
import sharp from "sharp";
const SRC = "./sprite-src";
const OUT = "./dist";
const SIZE = 24; // Taille cible en pixels (1×)
const RATIOS = [
{ suffix: "", pixelRatio: 1 },
{ suffix: "@2x", pixelRatio: 2 },
];
async function rasterize(svgPath, size, ratio) {
const buf = await readFile(svgPath);
return sharp(buf)
.resize(size * ratio, size * ratio)
.png()
.toBuffer();
}
// Bin-packing naïf en grille — suffit pour < 500 icônes
function packGrid(items, cellSize) {
const cols = Math.ceil(Math.sqrt(items.length));
return items.map((item, i) => ({
...item,
x: (i % cols) * cellSize,
y: Math.floor(i / cols) * cellSize,
}));
}
async function buildSprite({ suffix, pixelRatio }) {
const files = (await readdir(SRC)).filter((f) => f.endsWith(".svg"));
const cellSize = SIZE * pixelRatio;
const rasterized = await Promise.all(
files.map(async (file) => ({
id: parse(file).name,
buffer: await rasterize(join(SRC, file), SIZE, pixelRatio),
width: cellSize,
height: cellSize,
})),
);
const placed = packGrid(rasterized, cellSize);
const cols = Math.ceil(Math.sqrt(placed.length));
const atlasSize = cols * cellSize;
// Composition du PNG
const composite = placed.map(({ buffer, x, y }) => ({
input: buffer,
left: x,
top: y,
}));
await sharp({
create: {
width: atlasSize,
height: atlasSize,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite(composite)
.png()
.toFile(`${OUT}/sprite${suffix}.png`);
// Index JSON
const index = Object.fromEntries(
placed.map(({ id, x, y, width, height }) => [
id,
{ width, height, x, y, pixelRatio },
]),
);
await writeFile(
`${OUT}/sprite${suffix}.json`,
JSON.stringify(index, null, 2),
);
}
for (const ratio of RATIOS) await buildSprite(ratio);
console.log("Sprite généré dans", OUT);
Cette approche vaut le coup quand :
- Tu veux filtrer dynamiquement la liste d’icônes (ex. uniquement celles référencées par ton GeoJSON)
- Tu veux intégrer au pipeline Vite/Webpack sans dépendance Rust externe
- Tu veux ajuster par icône (couleur, taille différente, annotation avec
content/stretchX)
Le bin-packing en grille gaspille de l’espace. Pour aller plus loin, remplacer par un vrai algorithme (MaxRects via maxrects-packer, par exemple).
3.4 Chargement dynamique sans sprite
Quatrième option : ne pas générer de sprite du tout. MapLibre GL JS expose deux API qui permettent d’ajouter des images au runtime.
map.addImage(id, image) : ajoute une image au sprite virtuel en mémoire. Source : MapLibre GL JS Map.
Événement styleimagemissing : émis quand le renderer demande une icon-image qu’il ne trouve pas — tu peux la charger à la volée. Source : MapLibre — Display a remote SVG symbol.
Pattern : on pointe icon-image sur l’URL ou l’ID de l’icône, et on intercepte la demande manquante :
const loaded = new Set();
map.on("styleimagemissing", async (e) => {
const id = e.id;
if (loaded.has(id)) return;
loaded.add(id);
// e.id vaut par ex. "hospital" — on résout vers le SVG Pinhead
const url = `https://pinhead.ink/v1/icons/${id}.svg`;
const response = await fetch(url);
const svgText = await response.text();
const dataUri =
"data:image/svg+xml;charset=utf-8," + encodeURIComponent(svgText);
const image = new Image(24, 24);
await new Promise((resolve) => {
image.onload = resolve;
image.src = dataUri;
});
map.addImage(id, image);
});
Avantages :
- Zéro build step, zéro fichier à héberger
- La carte ne charge que ce qu’elle affiche réellement
- Facile à prototyper
Inconvénients :
- Pas de cache entre sessions — chaque chargement refetche (sauf à mettre en cache côté Service Worker)
- Pas de @2x automatique — il faut gérer soi-même la densité
- Coût réseau par icône — pour 50+ symboles, un vrai sprite est plus efficient (une seule image, une seule requête)
- CORS — le serveur SVG doit envoyer les bons headers si cross-origin
Voie à privilégier pour un POC, pour une carte avec 5–10 icônes, ou pour des icônes générées dynamiquement (templates SVG interpolés avec des valeurs runtime — voir aussi le plugin maplibre-gl-svg qui automatise ce pattern).
4. Intégration avec MapTiler
Trois scénarios selon où tu héberges le sprite.
4.1 Sprite hébergé chez MapTiler, style MapTiler
Upload via Map Designer (cf. section 3.1). Le style généré par MapTiler pointe déjà vers le sprite. Dans le code, on charge simplement le style :
import maplibregl from "maplibre-gl";
const map = new maplibregl.Map({
container: "map",
style: `https://api.maptiler.com/maps/streets-v2/style.json?key=${MAPTILER_KEY}`,
center: [2.35, 48.85],
zoom: 11,
});
Le sprite dans le style pointé est déjà valide, rien à configurer côté client.
4.2 Sprite self-hosted, style MapTiler modifié
Cas classique : on veut garder les tuiles et le style MapTiler, mais remplacer ou étendre les icônes par les Pinhead. On charge le style MapTiler, on le patch, puis on instancie la carte.
const styleResponse = await fetch(
`https://api.maptiler.com/maps/streets-v2/style.json?key=${MAPTILER_KEY}`,
);
const style = await styleResponse.json();
// Option A : remplacer le sprite
style.sprite = "https://cdn.example.com/sprites/pinhead";
// Option B : utiliser plusieurs sprites (v2+)
style.sprite = [
{
id: "default",
url: style.sprite, // sprite MapTiler d'origine
},
{
id: "pinhead",
url: "https://cdn.example.com/sprites/pinhead",
},
];
// Dans les couches, référencer : "icon-image": "pinhead:hospital"
const map = new maplibregl.Map({ container: "map", style });
L’option B évite de casser les icônes natives du style. C’est la voie la plus robuste quand tu ajoutes des POIs custom sans vouloir toucher au rendu de base.
Source : MapLibre Style Spec — Sprite (section sur les sprites multiples).
4.3 Sprite + style entièrement self-hosted
Pour auto-héberger le sprite, il suffit d’un bucket S3, d’un CDN, ou d’un simple dossier statique derrière Caddy/nginx. Les quatre fichiers doivent être servis avec les bons headers CORS (Access-Control-Allow-Origin) si le style est chargé depuis un autre domaine.
Configuration Caddy minimale :
cdn.example.com {
root * /var/www/sprites
file_server
header Access-Control-Allow-Origin "*"
header Cache-Control "public, max-age=31536000, immutable"
}
Le immutable en cache — les sprites sont traditionnellement versionnés par URL (ex. /sprites/v3/sprite) pour pouvoir changer sans invalidation.
5. SDF : colorer les icônes au runtime
Par défaut, une icône affichée est rastérisée telle quelle, avec ses couleurs d’origine. Le Signed Distance Field (SDF) change la donne : l’icône est encodée comme un champ de distance monochrome, ce qui permet de la colorer dynamiquement via icon-color dans le style.
Activation avec Spreet :
spreet --sdf ./sprite-src ./dist/sprite-sdf
Usage dans le style :
{
"id": "pois",
"type": "symbol",
"layout": { "icon-image": "hospital" },
"paint": { "icon-color": "#e74c3c" }
}
Pièges :
- SDF s’applique à tout le sprite. Si tu veux mixer SDF et non-SDF, génère deux sprites distincts et déclare-les en tableau (cf. 4.2).
- Les SVG en entrée doivent être monochromes pour un SDF propre — les couleurs seront écrasées.
- Le SDF gonfle le PNG (plus de pixels non nuls autour des formes), mais l’atlas reste efficace sur des pictos simples.
Pinhead étant globalement monochrome, il se prête très bien au SDF.
6. Pièges et limitations
6.1 Tailles incohérentes dans les SVG source
Spreet et la plupart des outils utilisent width/height ou le viewBox pour rastériser. Si les SVG Pinhead n’ont pas tous la même taille canonique (cf. les sources multiples : Maki 15px, Temaki 15px, NPMap 22px), le sprite se retrouve avec des icônes de tailles différentes.
Solution : normaliser en amont avec svgo ou un script sharp qui force une taille cible (ex. 24×24) avant de passer à Spreet.
6.2 Oublier le @2x
Si tu ne fournis que sprite.{png,json}, ton rendu sera flou sur Retina. MapLibre se rabattra sur le 1× et l’upscalera. Toujours générer les deux paires.
6.3 Mauvais pixelRatio dans le JSON 2×
Le pixelRatio doit valoir 2 dans sprite@2x.json, et les coordonnées x/y/width/height doivent être en pixels du PNG 2× (donc doublées). Spreet le gère correctement — une génération manuelle doit faire attention.
6.4 Limite MapTiler à 4096×4096 px
Avec 500 icônes 64×64 (pour gérer du 2×) empaquetées, on atteint vite la limite. Soit on réduit la taille cible (24×24 ou 32×32 suffisent pour des POI), soit on passe en self-hosted (aucune limite).
Source : MapTiler — Using Sprites.
6.5 Cache navigateur et nouvelles icônes
Un sprite est mis en cache agressivement. Quand on ajoute une icône, si l’URL ne change pas, les clients conservent l’ancienne version. Deux stratégies :
- Versionner l’URL :
/sprites/v4/sprite— impose de mettre à jour lespritedans le style à chaque release - Hash dans le nom :
/sprites/sprite-a1b2c3— injecté à la build
La seconde est préférable quand le style est régénéré automatiquement.
6.6 CORS quand sprite et style sont sur des domaines différents
Si le style.json est servi depuis app.example.com et le sprite depuis cdn.example.com, le navigateur bloque le chargement sans les bons headers. Tester avec les DevTools avant de déployer.
7. Recette complète : Pinhead + Spreet + MapTiler
Workflow reproductible pour ajouter une quinzaine de POIs Pinhead à une carte MapTiler.
Arborescence :
project/
├── scripts/
│ └── build-sprite.sh
├── sprite-src/ # SVG sélectionnés depuis Pinhead
│ ├── hospital.svg
│ ├── airport.svg
│ └── ...
├── public/
│ └── sprites/ # Destination servie statiquement
└── src/
└── map.ts
Script de build (scripts/build-sprite.sh) :
#!/usr/bin/env bash
set -euo pipefail
SRC=./sprite-src
OUT=./public/sprites
rm -rf "$OUT"
mkdir -p "$OUT"
spreet --unique --minify-index-file "$SRC" "$OUT/sprite"
spreet --retina --unique --minify-index-file "$SRC" "$OUT/sprite@2x"
echo "✓ Sprite généré ($(ls "$OUT" | wc -l) fichiers)"
Côté carte (src/map.ts) :
import maplibregl from "maplibre-gl";
import type { StyleSpecification } from "maplibre-gl";
const MAPTILER_KEY = import.meta.env.VITE_MAPTILER_KEY;
const SPRITE_URL = `${window.location.origin}/sprites/sprite`;
async function loadPatchedStyle(): Promise<StyleSpecification> {
const res = await fetch(
`https://api.maptiler.com/maps/streets-v2/style.json?key=${MAPTILER_KEY}`,
);
const style = (await res.json()) as StyleSpecification;
// Déclarer les deux sprites côte à côte
style.sprite = [
{ id: "default", url: style.sprite as string },
{ id: "pin", url: SPRITE_URL },
];
return style;
}
export async function initMap(container: HTMLElement) {
const style = await loadPatchedStyle();
const map = new maplibregl.Map({
container,
style,
center: [2.35, 48.85],
zoom: 12,
});
map.on("load", () => {
map.addSource("pois", {
type: "geojson",
data: "/data/pois.geojson",
});
map.addLayer({
id: "pois-layer",
type: "symbol",
source: "pois",
layout: {
"icon-image": ["concat", "pin:", ["get", "kind"]],
"icon-size": 1,
"icon-allow-overlap": true,
},
});
});
return map;
}
Le GeoJSON contient des features avec une propriété kind ("hospital", "airport"…) qui correspond exactement aux noms de fichiers Pinhead sélectionnés. L’expression ["concat", "pin:", ["get", "kind"]] préfixe par l’ID du sprite.
Intégration CI : le script de build tourne dans le job GitHub Actions, avant le vite build. Comme les sprites sont dans public/, ils sont copiés tels quels dans dist/ et servis par le même domaine que l’app (pas de problème CORS).
8. Récapitulatif
| Besoin | Solution |
|---|---|
| Prototyper vite, < 10 icônes | styleimagemissing + addImage |
| Style MapTiler modifié à la main | Upload via Map Designer |
| Sprite reproductible en CI | Spreet + self-hosting |
| Pipeline JS custom, sélection dynamique | Génération par code (sharp + bin-pack) |
| Coloration runtime | --sdf sur Spreet, sprite SDF séparé |
Pinhead + Spreet + MapTiler couvre 90 % des cas. La voie dynamique (addImage) reste utile pour des icônes générées à la volée — par exemple un picto de véhicule dont la couleur dépend de son état — mais ne remplace pas un vrai sprite pour une carte riche.
Le point à retenir : le sprite est un format simple et ouvert (un PNG + un JSON de métadonnées). Rien n’empêche de le générer toi-même si ton pipeline le justifie. Dans le doute, commence par Spreet — il fait bien une chose et la fait bien.