How to Build a Consistent Marker Icon Set for Place Categories
- API or service
- Map Marker API + Places API
- Task
- Place categories → consistent category markers
- Examples
- Difficulty
- Intermediate
- Time
- 15 min
Build a consistent marker icon set for place categories
A map that shows cafes, restaurants, supermarkets, pharmacies, hotels, and parks needs one marker per category. When the markers come from different sources, they differ in shape, size, outline, and anchor point, and the map looks noisy. A consistent icon set uses the same marker shape and size for all categories and varies only the icon and colour.
The Geoapify Map Marker API generates marker PNGs from URL parameters. This guide fixes every shared parameter in one URL template and changes only icon and color per Places API category. The input is a category table. The result is seven marker PNGs (six categories and a fallback) with one display size and one anchor, used in Leaflet and MapLibre GL to show 118 real places around Dupont Circle in Washington, DC.
Task flow: category table → one marker URL template → seven marker PNGs → measured display size and anchor → Places API results matched to categories → Leaflet markers or a MapLibre GL symbol layer.
The Map Marker API only renders the images. The Places API provides the places and their categories. Leaflet and MapLibre GL position, scale, and display the markers in the browser.
Plan the marker set: fixed parameters and per-category values
Decide which marker parameters are shared and which ones identify a category. Every parameter that is not part of the category identity stays the same in all URLs.
| Parameter | Value | Role |
|---|---|---|
type |
awesome |
Shared pin shape |
size |
42 |
Shared pin height in CSS pixels, without the shadow |
scaleFactor |
2 |
Shared double-resolution output for high-density screens |
iconType |
material |
Shared icon library |
contentSize |
18 |
Shared icon height in CSS pixels |
strokeColor |
#ffffff |
Shared white outline |
shadowColor |
#000000 |
Shared neutral shadow |
icon |
per category | Category symbol |
color |
per category | Category colour, also used as contentColor |
Set strokeColor and shadowColor explicitly. When they are omitted, the API tints the outline and the shadow from the background color, so every category gets a slightly different edge. When contentSize is omitted, the API chooses a size proportional to size. A fixed value keeps the icon size predictable when you change size later.
Use one icon library for the entire set. Icons from different libraries have different stroke widths and proportions, even when they show the same subject. This guide uses Material icons (iconType=material) because Material Design icons are available under the Apache License 2.0 and need no extra attribution. Font Awesome Free icons (iconType=awesome, the default for type=awesome) require CC BY 4.0 attribution. Lucide icons (iconType=lucide) are an outline-style alternative. See Marker Icon API attribution.
Category table
The keys are Places API categories. The other row is the fallback for any place that does not match a table category.
| Places category | Label | icon (Material) |
color |
Contrast with white |
|---|---|---|---|---|
catering.cafe |
Cafe | local_cafe |
#6d4c41 |
7.61:1 |
catering.restaurant |
Restaurant | restaurant |
#d55e00 |
3.87:1 |
commercial.supermarket |
Supermarket | local_grocery_store |
#0077bb |
4.82:1 |
healthcare.pharmacy |
Pharmacy | local_pharmacy |
#cc3377 |
4.88:1 |
accommodation.hotel |
Hotel | hotel |
#332288 |
12.17:1 |
leisure.park |
Park | park |
#2e7d32 |
5.13:1 |
other (fallback) |
Other place | place |
#5f6b7a |
5.43:1 |
The API draws the icon in contentColor inside a white circle. Using the category colour for both color and contentColor keeps each marker in one colour, and the contrast column compares that colour with the white circle. Every value is at least 3:1, the WCAG minimum for meaningful graphics. The restaurant orange has the lowest contrast; do not choose a lighter colour for any category.
Colour-blind-safe distinctness
Six or seven hues cannot all stay distinct for every type of colour vision deficiency. The palette above takes the restaurant, supermarket, and hotel colours from colour-blind-friendly palettes (Okabe–Ito vermillion, Paul Tol blue and indigo) and was checked in a simulation of protanopia, deuteranopia, and tritanopia. The pair that remains closest is pharmacy and fallback under protanopia. That is why the category must never depend on colour alone:
- Give every category its own icon. The icon is the primary signal; the colour is a second one.
- Show a legend with the marker image and a text label, generated from the same table.
- Put the category label in the popup or the marker's accessible name.
Fallback for unknown categories
A place can match none of the table keys. For example, a request for the parent category catering also returns catering.fast_food and catering.bar. Map those places to other instead of skipping them, and display them with the neutral place marker.
The fallback must be a real marker image. When an icon name does not exist in the selected library, the API still returns HTTP 200 with a marker that has an empty white circle. It does not return an error. Requests with icon=no_such_icon_xyz returned the same blank marker for material, awesome, and lucide. Check every icon in the set visually after you choose or change names.
Invalid parameter values do return errors. An unknown iconType returns HTTP 400 with iconType must be in ['material', 'awesome', 'lucide'] or empty, and an invalid colour returns HTTP 400 with "color" must be a valid color value.
Build the marker URLs and measure the PNGs
All seven markers come from one URL template. Only icon, color, and contentColor change:
https://api.geoapify.com/v2/icon/?type=awesome&size=42&scaleFactor=2&iconType=material&contentSize=18&strokeColor=%23ffffff&shadowColor=%23000000&icon={icon}&color={color}&contentColor={color}&apiKey=YOUR_API_KEY
Colours must be URL-encoded: # becomes %23. The cafe marker, for example:
https://api.geoapify.com/v2/icon/?type=awesome&size=42&scaleFactor=2&iconType=material&contentSize=18&strokeColor=%23ffffff&shadowColor=%23000000&icon=local_cafe&color=%236d4c41&contentColor=%236d4c41&apiKey=YOUR_API_KEY
Build the URLs in JavaScript
Keep the table and the shared parameters in one module, for example marker-set.js. URLSearchParams encodes # as %23, so colours can stay in their normal CSS form.
export const GEOAPIFY_API_KEY = "YOUR_API_KEY";
// Shared by every marker: shape, size, resolution, icon library, outline, and shadow.
const MARKER_BASE = {
type: "awesome",
size: "42",
scaleFactor: "2",
iconType: "material",
contentSize: "18",
strokeColor: "#ffffff",
shadowColor: "#000000"
};
// Only the icon and the colour identify a category.
export const MARKER_STYLES = {
"catering.cafe": { label: "Cafe", icon: "local_cafe", color: "#6d4c41" },
"catering.restaurant": { label: "Restaurant", icon: "restaurant", color: "#d55e00" },
"commercial.supermarket": { label: "Supermarket", icon: "local_grocery_store", color: "#0077bb" },
"healthcare.pharmacy": { label: "Pharmacy", icon: "local_pharmacy", color: "#cc3377" },
"accommodation.hotel": { label: "Hotel", icon: "hotel", color: "#332288" },
"leisure.park": { label: "Park", icon: "park", color: "#2e7d32" },
other: { label: "Other place", icon: "place", color: "#5f6b7a" }
};
// Display size and anchor in CSS pixels, derived from the measured 64 × 94 px PNGs.
export const MARKER_SIZE = [32, 47];
export const MARKER_ANCHOR = [16, 42];
export const MARKER_SHADOW = 5;
export function markerUrl(category) {
const style = MARKER_STYLES[category] ?? MARKER_STYLES.other;
const params = new URLSearchParams({
...MARKER_BASE,
icon: style.icon,
color: style.color,
contentColor: style.color,
apiKey: GEOAPIFY_API_KEY
});
return `https://api.geoapify.com/v2/icon/?${params}`;
}
// Use the first table category that the place belongs to, or the fallback.
export function markerCategory(placeCategories = []) {
return Object.keys(MARKER_STYLES).find((key) => placeCategories.includes(key)) ?? "other";
}
markerUrl("catering.cafe") returns the cafe URL shown above. An unknown key, such as catering.fast_food, returns the other marker URL.
Measure the returned PNGs
Requesting all seven URLs returned seven PNG images with the same pixel dimensions:
| Category | icon |
PNG size (scaleFactor=2) |
|---|---|---|
catering.cafe |
local_cafe |
64 × 94 px |
catering.restaurant |
restaurant |
64 × 94 px |
commercial.supermarket |
local_grocery_store |
64 × 94 px |
healthcare.pharmacy |
local_pharmacy |
64 × 94 px |
accommodation.hotel |
hotel |
64 × 94 px |
leisure.park |
park |
64 × 94 px |
other |
place |
64 × 94 px |
The image size depends on type, size, scaleFactor, and shadow, not on the content. The same 64 × 94 px size was returned for Font Awesome and Lucide icons, for a marker without an icon, for an unknown icon name, for text=12, and with or without contentSize. Colours do not change the size either. One display size and one anchor therefore work for the entire set.
scaleFactor multiplies the pixels but not the intended display size:
scaleFactor |
PNG size | ÷ scaleFactor |
Display size |
|---|---|---|---|
1 |
32 × 47 px | ÷ 1 | 32 × 47 CSS px |
2 |
64 × 94 px | ÷ 2 | 32 × 47 CSS px |
3 |
96 × 141 px | ÷ 3 | 32 × 47 CSS px |
Calculate the display size and anchor
The location of a pin marker is its tip, not the image centre and not the image bottom:
- Display size: 64 px ÷ 2 = 32 CSS px wide; 94 px ÷ 2 = 47 CSS px high.
- Pin height:
size= 42 CSS px.sizesets the height of the pin itself. - Shadow height: 47 − 42 = 5 CSS px below the pin tip. In the 2× PNG, the opaque pin ends at the tip, and the rows below it contain only the translucent shadow.
- Horizontal anchor: 32 ÷ 2 = 16 CSS px, the centre of the image.
- Vertical anchor: 42 CSS px from the top, which is 5 CSS px above the image bottom.
| Library | Size setting | Anchor setting |
|---|---|---|
Leaflet L.icon |
iconSize: [32, 47] |
iconAnchor: [16, 42] |
MapLibre GL addImage |
pixelRatio: 2 (64 × 94 px → 32 × 47) |
"icon-anchor": "bottom", "icon-offset": [0, 5] |
The Leaflet anchor is measured from the top-left corner of the icon. MapLibre anchors the image bottom to the point and then moves the image 5 px down, so the pin tip lands on the location.
With noShadow, the same marker returns 64 × 84 px, or 32 × 42 CSS px. The anchor is then the image bottom: iconAnchor: [16, 42] in Leaflet and "icon-anchor": "bottom" without an offset in MapLibre. Recalculate the values whenever you change type, size, or the shadow.
Load places for the six categories
The examples show real places within 500 m of Dupont Circle in Washington, DC. One Places API request returns all six categories:
https://api.geoapify.com/v2/places?categories=catering.cafe,catering.restaurant,commercial.supermarket,healthcare.pharmacy,accommodation.hotel,leisure.park&filter=circle:-77.0434,38.9096,500&limit=200&apiKey=YOUR_API_KEY
categorieslists the six table keys, separated by commas.filter=circle:lon,lat,radiuslimits results to a 500 m circle around the centre of Dupont Circle. The coordinate order is longitude, latitude.limit=200returns all matches in one page. The default limit is 20. This area has 118 matches, solimit=100would return only the first 100. See How to Paginate Place Results for larger areas.
The response contained 118 places: 66 restaurants, 23 cafes, 15 hotels, 11 parks, 2 pharmacies, and 1 supermarket. Every 20 returned places cost 1 credit, so 118 places cost ⌈118 ÷ 20⌉ = 6 credits (Places API pricing).
Reduced response (one place per category; most properties and the other 112 features removed):
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"name": "Java House",
"address_line2": "1645 Q Street Northwest, Washington, DC 20009, United States of America",
"categories": ["catering", "catering.cafe", "catering.cafe.coffee", "catering.cafe.coffee_shop"]
},
"geometry": { "type": "Point", "coordinates": [-77.0381551, 38.9112644] }
},
{
"type": "Feature",
"properties": {
"name": "Thai Chef",
"address_line2": "1712 Connecticut Avenue Northwest, Washington, DC 20009, United States of America",
"categories": ["catering", "catering.restaurant", "catering.restaurant.thai"]
},
"geometry": { "type": "Point", "coordinates": [-77.0457725, 38.913047] }
},
{
"type": "Feature",
"properties": {
"name": "Safeway",
"address_line2": "1701 Corcoran Street Northwest, Washington, DC 20009, United States of America",
"categories": ["building", "building.commercial", "commercial", "commercial.supermarket"]
},
"geometry": { "type": "Point", "coordinates": [-77.0389509, 38.9120417] }
},
{
"type": "Feature",
"properties": {
"name": "CVS Pharmacy",
"address_line2": "1637 P Street Northwest, Washington, DC 20036, United States of America",
"categories": ["commercial.health_and_beauty", "commercial.health_and_beauty.pharmacy", "healthcare", "healthcare.pharmacy"]
},
"geometry": { "type": "Point", "coordinates": [-77.0382365, 38.9098401] }
},
{
"type": "Feature",
"properties": {
"name": "The Royal Sonesta Washington DC Dupont Circle",
"address_line2": "2121 P Street Northwest, Washington, DC 20037, United States of America",
"categories": ["accommodation", "accommodation.hotel", "internet_access", "internet_access.free"]
},
"geometry": { "type": "Point", "coordinates": [-77.0474131, 38.9098133] }
},
{
"type": "Feature",
"properties": {
"name": "Dupont Circle",
"address_line2": "Washington, DC 20036, United States of America",
"categories": ["leisure", "leisure.park", "national_park"]
},
"geometry": { "type": "Point", "coordinates": [-77.043438, 38.9096464] }
}
]
}
Match each place to a marker category
properties.categories is an array. It contains the parent category, the requested category, more specific subcategories, and unrelated tags such as building.commercial or wheelchair.yes. Do not use the first array item as the marker category: for CVS Pharmacy, the first item is commercial.health_and_beauty.
Instead, search the array for the table keys in table order. The markerCategory() function from marker-set.js returns the first table key that the place contains, or other. When a place matches several table keys, the table order decides which marker it gets.
Eight of the 118 places have no name (six small parks and two restaurants). Show the category label instead of an empty title.
Store the computed values on each feature so that Leaflet and MapLibre GL use the same data. This guide keeps the loader in load-places.js:
import { GEOAPIFY_API_KEY, MARKER_STYLES, markerCategory } from "./marker-set.js";
const PLACE_CATEGORIES = Object.keys(MARKER_STYLES).filter((key) => key !== "other");
export async function loadPlaces() {
const params = new URLSearchParams({
categories: PLACE_CATEGORIES.join(","),
filter: "circle:-77.0434,38.9096,500",
limit: "200",
apiKey: GEOAPIFY_API_KEY
});
const response = await fetch(`https://api.geoapify.com/v2/places?${params}`);
if (!response.ok) {
throw new Error(`Places API request failed: ${response.status}`);
}
const places = await response.json();
for (const feature of places.features) {
const category = markerCategory(feature.properties.categories);
feature.properties.category = category;
feature.properties.title = feature.properties.name ?? MARKER_STYLES[category].label;
}
return places;
}
URLSearchParams encodes the commas and the colon in categories and filter (%2C, %3A). The Places API accepts both the encoded and the plain form.
Show the marker set with Leaflet
Create one L.icon per category and reuse it for every marker of that category. A Map caches the icons, so the 118 Dupont Circle places use six icon objects and six image URLs. The browser downloads each URL once and reuses it for every marker that shares it.
This example assumes map is an initialized Leaflet map and imports the modules from the previous steps.
import { MARKER_ANCHOR, MARKER_SIZE, MARKER_STYLES, markerUrl } from "./marker-set.js";
import { loadPlaces } from "./load-places.js";
const iconCache = new Map();
function categoryIcon(category) {
if (!iconCache.has(category)) {
iconCache.set(category, L.icon({
iconUrl: markerUrl(category),
iconSize: MARKER_SIZE, // 64 × 94 px PNG ÷ scaleFactor 2 = 32 × 47 CSS px
iconAnchor: MARKER_ANCHOR, // [16, 42]: pin tip, 5 px above the image bottom
popupAnchor: [0, -42] // open popups at the top of the pin
}));
}
return iconCache.get(category);
}
function popupContent(properties) {
// Use textContent: place names are data, not HTML.
const content = document.createElement("div");
const title = document.createElement("strong");
title.textContent = properties.title;
content.append(title, document.createElement("br"), MARKER_STYLES[properties.category].label);
return content;
}
const places = await loadPlaces();
const placesLayer = L.geoJSON(places, {
// GeoJSON stores [longitude, latitude]; L.geoJSON converts it to a LatLng.
pointToLayer: (feature, latlng) => {
const { category, title } = feature.properties;
return L.marker(latlng, {
icon: categoryIcon(category),
alt: `${MARKER_STYLES[category].label}: ${title}`
});
},
onEachFeature: (feature, marker) => marker.bindPopup(popupContent(feature.properties))
}).addTo(map);
map.fitBounds(placesLayer.getBounds(), { padding: [24, 24] });
- Library behaviour: Leaflet creates one
<img>element per marker and positions itsiconAnchorpoint on the location.iconSizescales the 64 × 94 px image down to 32 × 47 CSS px. - Coordinate order: Leaflet marker methods use
[latitude, longitude].L.geoJSONreads the[longitude, latitude]GeoJSON coordinates and converts them for you. Swap the order yourself only when you callL.marker()with raw values. - Accessibility:
altgives each marker an accessible name with the category label. Leaflet markers are keyboard-focusable by default, and Enter opens the popup. - Many markers: Each marker is a DOM element. For several hundred places or more, use a clustering plugin or the MapLibre GL symbol layer.
Show the marker set as a MapLibre GL symbol layer
Load each category marker into the map style once with map.loadImage() and map.addImage(). Then draw all places with one symbol layer whose icon-image is chosen from the feature's category property with a match expression.
This example assumes map is an initialized MapLibre GL map and imports the modules from the previous steps.
import { MARKER_SHADOW, MARKER_STYLES, markerUrl } from "./marker-set.js";
import { loadPlaces } from "./load-places.js";
const CATEGORIES = Object.keys(MARKER_STYLES);
async function addMarkerImages() {
const images = await Promise.all(CATEGORIES.map(async (category) => {
try {
return { category, data: (await map.loadImage(markerUrl(category))).data };
} catch {
return { category, data: null };
}
}));
const fallback = images.find(({ category, data }) => category === "other" && data)?.data
?? images.find(({ data }) => data)?.data;
if (!fallback) throw new Error("No marker images could be loaded");
images.forEach(({ category, data }) => {
// The 64 × 94 px image is drawn at 32 × 47 CSS px.
map.addImage(`marker-${category}`, data ?? fallback, { pixelRatio: 2 });
});
}
map.on("load", async () => {
const [places] = await Promise.all([loadPlaces(), addMarkerImages()]);
map.addSource("places", { type: "geojson", data: places });
map.addLayer({
id: "place-markers",
type: "symbol",
source: "places",
layout: {
// ["match", ["get", "category"], "catering.cafe", "marker-catering.cafe", ..., "marker-other"]
"icon-image": [
"match", ["get", "category"],
...CATEGORIES.filter((key) => key !== "other").flatMap((key) => [key, `marker-${key}`]),
"marker-other"
],
"icon-anchor": "bottom",
// Move the image down so the pin tip, not the shadow, is on the location.
"icon-offset": [0, MARKER_SHADOW],
"icon-allow-overlap": true
}
});
map.on("click", "place-markers", (event) => {
const { category, title } = event.features[0].properties;
new maplibregl.Popup({ anchor: "bottom", offset: [0, -42] })
.setLngLat(event.features[0].geometry.coordinates)
.setText(`${title} (${MARKER_STYLES[category].label})`)
.addTo(map);
});
map.on("mouseenter", "place-markers", () => { map.getCanvas().style.cursor = "pointer"; });
map.on("mouseleave", "place-markers", () => { map.getCanvas().style.cursor = ""; });
});
- Library behaviour: MapLibre GL draws all 118 markers in one WebGL layer from seven images in the style's image sprite. No DOM element is created per place.
pixelRatio: 2: tells MapLibre that the image has two pixels per CSS pixel. Without it, the markers render at 64 × 94 CSS px, twice the intended size.- Anchor:
icon-anchor: "bottom"places the image bottom on the point;icon-offset: [0, 5]moves it 5 px down so the pin tip is on the point. The offset is multiplied byicon-size, so it stays correct when you scale the icons. - Fallback: the last
matchvalue,marker-other, applies to anycategorythat is not listed. If an image fails to load, its places use theotherimage, or another available category image ifotheralso fails. Every category ID is registered, so one failed image does not hide its places. - Overlap:
icon-allow-overlap: trueshows every marker, even where markers overlap. Remove it to let MapLibre hide colliding markers, or addsymbol-sort-keyto control which ones stay on top. - Coordinate order: GeoJSON coordinates and
setLngLat()use[longitude, latitude]. - Cross-origin images:
map.loadImage()fetches the PNG with a CORS request, because MapLibre must read the pixels. The Map Marker API returnsAccess-Control-Allow-Origin: *, so no extra option is needed. When you self-host the PNGs on another domain, send the same header from that server.
Prepare the marker set for production
A marker set is a small, fixed group of images that many markers reuse. Treat it as a static asset: generate it once, cache or self-host it, and keep its size, anchor, and fallback in sync.
- Request one image per category, not per marker. Each Map Marker API call costs 1 credit (pricing). The 118 Dupont Circle places need six or seven marker URLs, not 118. Avoid per-marker parameters such as a different
textfor every place: that turns a seven-image set into one request per place. - Store and self-host the PNGs. The Map Marker API pricing allows you to store generated icons without restrictions and to redistribute them, for example as part of your application or website, at no extra charge. Download the seven images once, serve them from your own static assets (for example
/assets/markers/[email protected]), and keep the table and URL template in the repository so you can regenerate the set. Self-hosted images also keep the API key out of every marker URL. - Use the browser cache when you load images from the API. The API response tested for this guide included
Cache-Control: public, max-age=3600. Identical URLs are downloaded once per page and reused for an hour; different parameter order or colour formatting creates a different URL. Build all URLs with the samemarkerUrl()function. - Keep the display size and anchor with the set. Every image in the set is 64 × 94 px. If you change
type,size,scaleFactor, or the shadow, measure the new PNGs and updateMARKER_SIZE,MARKER_ANCHOR, andMARKER_SHADOWtogether. - Check every icon after a change. An unknown icon name returns HTTP 200 with an empty white circle. Look at the generated images, or compare them with a marker requested without
icon, before you publish them. - Do not depend on colour alone. Keep one distinct icon per category, add a legend that shows each marker image with its label, and put the category label in popups and accessible names.
- Serve self-hosted images with CORS when they are on another domain. MapLibre GL reads image pixels and needs
Access-Control-Allow-Originon cross-origin images. Leaflet only displays an<img>element and does not need CORS. - Re-add MapLibre images after a style change.
map.setStyle()replaces the style, including images added withaddImage(). Add the marker images again after the new style loads, or load them on demand in astyleimagemissingevent handler. - Handle failures. If the Places API request fails or returns no features, show a message instead of an empty map. If a marker image fails to load, MapLibre logs a missing image and skips those symbols; catch the
loadImage()error and register the fallback image under the failed category ID. - Add attribution. On the Free plan, add the Geoapify attribution for generated marker icons. Material and Lucide icons need no extra icon attribution; Font Awesome Free icons require CC BY 4.0 attribution. Place data from the Places API also requires OpenStreetMap attribution. See Marker Icon API attribution.
- Protect browser API keys. Restrict keys used in the browser to your application's origins in Geoapify MyProjects.
Category markers with an AI coding agent
Copy this prompt into Codex, Claude Code, Cursor, or another coding agent to add category markers with a consistent Geoapify marker set to your map.
Add category markers to the map in my project with a consistent marker icon set from the Geoapify Map Marker API.
Before you start
- Inspect the existing project first. Reuse its current framework, map library (Leaflet or MapLibre GL), components, styling, HTTP client, and conventions. Make the smallest necessary changes.
- Ask only what the project does not answer: which place categories to show and where the places come from (Geoapify Places API or existing data with a category field), and whether marker PNGs should be loaded from the API or self-hosted as static assets.
- Read the Geoapify API key from an environment variable. Never hardcode, print, or log the key.
Marker set
- Create one category table: category key → label, icon name, and colour. Use one icon library for all categories (iconType=material, awesome, or lucide) and add an "other" fallback entry.
- Build every marker URL with one helper: https://api.geoapify.com/v2/icon/ with the same type, size, scaleFactor=2, iconType, contentSize, strokeColor, and shadowColor for all categories. Vary only icon, color, and contentColor (same value as color). Use URLSearchParams so # is encoded as %23.
- Choose colours with at least 3:1 contrast against white and avoid colour as the only cue: keep a distinct icon per category, add a legend with icons and labels, and use the label in popups and accessible names.
- An unknown icon name returns HTTP 200 with an empty circle, not an error. Generate the set once and check every image visually.
Size and anchor
- Request the PNGs and measure their pixel size. Display size = PNG size ÷ scaleFactor. For type=awesome and size=42, the PNG is 64 × 94 px, the display size is 32 × 47 CSS px, and the 5 px shadow sits below the pin tip.
- Leaflet: one cached L.icon per category with iconSize [32, 47] and iconAnchor [16, 42]. Do not create one icon per marker.
- MapLibre GL: map.loadImage + map.addImage(id, data, { pixelRatio: 2 }) per category, one symbol layer with icon-image from a match expression on the category property and "marker-other" as the default, icon-anchor "bottom", and icon-offset [0, 5]. Re-add the images after a style change.
Places data
- If the places come from the Places API, request the table categories with a filter and a limit that covers the area. Map each place to the first table key found in properties.categories (not the first array item); unmatched places use "other". Use the category label when a place has no name.
- Handle request errors, empty results, and failed image loads (fall back to the "other" marker).
- Request each marker image once per category, never once per place. Each Map Marker API call costs 1 credit, and generated icons can be stored and self-hosted.
Review the generated code against this guide. Check that only icon and color change between categories, that the display size and anchor match the measured PNG, that unmatched places get the fallback marker, and that the number of marker image requests equals the number of categories, not the number of places.