Maplibre gl emoji symbols

Maplibre gl renders maps using WebGL, which provides better performance for large datasets than html/svg markers. However, this WebGL rendering strategy doesn’t support complex text rendering for special characters like emojis 🤡. So I made an emoji sprite sheet for maplibre.

If you’d like to skip to the end, here’s a sprite map of all 4,293 openmoji emojis at the time of this writing.

emoji

Sprite Generation

First download openmoji, and extract the color/svg folder to and svg sub directory your working directory. If you don’t have node installed, install it with a tool like volta and then

Terminal window
npm install --save-dev sharp canvas

Here’s the program you can copy into a generate-emoji-sprite.ts script:

import fs from "fs";
import path from "path";
import sharp from "sharp";
import { createCanvas, ImageData } from "canvas";
const OUTPUT_DIR = path.join(process.cwd(), "sprite");
const SVG_DIR = path.join(OUTPUT_DIR, "openmoji");
// Ensure directories exist
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
if (!fs.existsSync(SVG_DIR)) {
fs.mkdirSync(SVG_DIR, { recursive: true });
}
interface SpriteData {
x: number;
y: number;
width: number;
height: number;
pixelRatio: number;
}
async function generateSprites(svgFiles: string[], outputPrefix: string) {
console.log(`Generating ${outputPrefix} sprite maps...`);
// Calculate grid size
const gridSize = Math.ceil(Math.sqrt(svgFiles.length));
const spriteSize = 64;
const padding = 2;
const canvasSize = gridSize * (spriteSize + padding);
// Create canvas for sprite
const canvas = createCanvas(canvasSize, canvasSize);
const ctx = canvas.getContext("2d");
// Create canvas for retina sprite
const retinaCanvas = createCanvas(canvasSize * 2, canvasSize * 2);
const retinaCtx = retinaCanvas.getContext("2d");
// Process each SVG
const spriteData: Record<string, SpriteData> = {};
const spriteData2x: Record<string, SpriteData> = {};
for (let i = 0; i < svgFiles.length; i++) {
const svgFile = svgFiles[i];
const emojiCode = path.basename(svgFile, ".svg");
// Calculate position in grid
const row = Math.floor(i / gridSize);
const col = i % gridSize;
const x = col * (spriteSize + padding);
const y = row * (spriteSize + padding);
// Read and resize SVG
const svgBuffer = fs.readFileSync(svgFile);
const resized = await sharp(svgBuffer)
.resize(spriteSize, spriteSize)
.toBuffer();
// Composite onto sprite
const image = await sharp(resized).raw().toBuffer();
ctx.putImageData(
new ImageData(new Uint8ClampedArray(image), spriteSize, spriteSize),
x,
y
);
// Composite onto retina sprite
const resized2x = await sharp(svgBuffer)
.resize(spriteSize * 2, spriteSize * 2)
.toBuffer();
const image2x = await sharp(resized2x).raw().toBuffer();
retinaCtx.putImageData(
new ImageData(
new Uint8ClampedArray(image2x),
spriteSize * 2,
spriteSize * 2
),
x * 2,
y * 2
);
// Store sprite data
spriteData[emojiCode] = {
x,
y,
width: spriteSize,
height: spriteSize,
pixelRatio: 1,
};
spriteData2x[emojiCode] = {
x: x * 2,
y: y * 2,
width: spriteSize * 2,
height: spriteSize * 2,
pixelRatio: 2,
};
}
// Save sprite files
const spriteBuffer = canvas.toBuffer("image/png");
const sprite2xBuffer = retinaCanvas.toBuffer("image/png");
fs.writeFileSync(path.join(OUTPUT_DIR, `${outputPrefix}.png`), spriteBuffer);
fs.writeFileSync(
path.join(OUTPUT_DIR, `${outputPrefix}@2x.png`),
sprite2xBuffer
);
// Save sprite data
fs.writeFileSync(
path.join(OUTPUT_DIR, `${outputPrefix}.json`),
JSON.stringify(spriteData, null, 2)
);
fs.writeFileSync(
path.join(OUTPUT_DIR, `${outputPrefix}@2x.json`),
JSON.stringify(spriteData2x, null, 2)
);
console.log(`${outputPrefix} sprite generation complete!`);
}
async function main() {
try {
// Get all SVG files
const allSvgFiles = fs
.readdirSync(SVG_DIR)
.filter((file) => file.endsWith(".svg"))
.map((file) => path.join(SVG_DIR, file));
console.log(`Found ${allSvgFiles.length} SVG files`);
// Generate sprite sheet
await generateSprites(allSvgFiles, "sprite");
} catch (error) {
console.error("Error:", error);
process.exit(1);
}
}
main();

and then you can

Terminal window
ts-node generate-emoji-sprite.ts

This script generates png sprites at two resolutions sheet and the JSON metadata files that MapLibre uses to determine the coordinates of each emoji within the sprite.

Implementing in React with MapLibre

Here’s how I’m using the spritesheet generated by that script in react:

import Map, {
Layer,
Source,
type StyleSpecification,
} from "react-map-gl/maplibre";
import "maplibre-gl/dist/maplibre-gl.css";
import { useEffect, useState } from "react";
export function ListMapGL(geoJSON: GeoJSON.FeatureCollection) {
const [mapStyle, setMapStyle] = useState<StyleSpecification | undefined>();
useEffect(() => {
fetch(
"https://api.maptiler.com/maps/streets/style.json?key=get_your_own_OpIi9ZULNHzrESv6T2vL"
)
.then((res) => res.json())
.then((json) => {
setMapStyle({
...json,
sprite: `${window.location.origin}/assets/sprite/sprite-popular`,
});
});
}, []);
return (
<>
{mapStyle ? (
<Map
initialViewState={{
latitude: 34,
longitude: -118,
zoom: 10,
}}
projection="mercator"
mapStyle={mapStyle}
>
<Source id="places" type="geojson" data={geoJSON} generateId>
<Layer
id="place-symbols"
type="symbol"
layout={{
"icon-image": ["get", "emoji"],
"icon-size": 0.36,
"icon-allow-overlap": true,
"icon-ignore-placement": true,
}}
/>
</Source>
</Map>
) : null}
</>
);
}

Using the emoji sprites in react

This gets us emojis in the map, but if we want these emojis to match in the rest of the app, we need a react emoji rendering plan. We could load in svgs, or use the sprite sheet. I opted for the spritesheet for the performance boost. Here’s a component for rendering these sprites in react:

interface SpriteData {
x: number;
y: number;
width: number;
height: number;
pixelRatio: number;
}
interface SpriteMap {
[key: string]: SpriteData;
}
import spriteData from "path/to/sprite-popular.json";
import spriteData2x from "path/to/sprite-popular@2x.json";
export function EmojiSprite({
emojiCode,
scale = 0.5,
}: {
emojiCode: string;
className?: string;
scale?: number;
}) {
const sprite = (spriteData as SpriteMap)[emojiCode];
const sprite2x = (spriteData2x as SpriteMap)[emojiCode];
if (!sprite || !sprite2x) {
return null;
}
return (
<div
style={
{
backgroundImage: "url('/assets/sprite/sprite-popular.png')",
backgroundPosition: `-${sprite.x}px -${sprite.y}px`,
width: `${sprite.width}px`,
height: `${sprite.height}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
} as React.CSSProperties
}
/>
);
}

I wouldn’t recommend using the full 4k emojis since that’s a lot of megabytes, but you can pick your subset. Happy emoji mapping!