fr

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ônes
  • sprite.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

ChampUsage
content[left, top, right, bottom] — zone utile pour icon-text-fit (quand une icône sert de fond à un label)
stretchX, stretchYZones étirables [[from, to], ...] — pour les panneaux qui s’adaptent à la longueur du texte
sdfBooléen — active le Signed Distance Field (coloration runtime via icon-color)
textFitWidth, textFitHeightstretchOrShrink / 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.json et index.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/pinhead dans le dossier icons/
    • 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

ApprocheEffortContrôleQuand choisir
Upload MapTiler CloudFaibleLimitéStyle hébergé chez MapTiler, < 500 icônes, projet stable
Spreet (CLI)MoyenÉlevéPipeline reproductible, CI, auto-hébergement
Génération par codeMoyen-élevéTotalBesoin d’un build custom ou sélection dynamique
addImage / styleimagemissingFaibleN/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 :

FlagEffet
--retinaRatio pixel 2× (équivaut à --ratio 2)
--uniqueDéduplique les icônes identiques (partagent la même zone)
--minify-index-fileJSON sans espaces
--recursiveInclut les sous-dossiers
--spacing NPixels de marge entre icônes (évite le bleed)
--sdfGé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 :

  • --sdf s’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/height ou viewBox exploitables) — 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 :

  • sharp pour rastériser les SVG et composer le PNG
  • bin-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 le sprite dans 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

BesoinSolution
Prototyper vite, < 10 icônesstyleimagemissing + addImage
Style MapTiler modifié à la mainUpload via Map Designer
Sprite reproductible en CISpreet + self-hosting
Pipeline JS custom, sélection dynamiqueGé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.