Custom GeoJSON module
CustomGeoJSONModule draws your own GeoJSON with MapLibre layer specs you write (circle, heatmap, fill, line, symbol, …), and adds the SDK lifecycle on top: style-change restoration, module events and per-source user events. The demo below draws one data set as a heatmap and as clickable icons:
- It owns the sources and layers you declare, drawn exactly as you specify them. It never restyles the base map, which the Styling Foundations module owns, and no styling knob reaches your layers (Who owns what).
- Use it for any GeoJSON that
PlacesModuleorGeometriesModuledoesn't fit, even a single layer: the layer spec is the onemap.mapLibreMap.addLayer(...)takes, so you lose no styling freedom and write no restoration code. - Use raw MapLibre for non-GeoJSON sources such as vector or raster tiles (which to use when).
Sources and layers
A module manages one or more named sources, each one MapLibre GeoJSON source plus the layers that draw it. The names address data in show(data, name), clear(name) and events.<name>.
import { CustomGeoJSONModule } from '@tomtom-org/maps-sdk/map';
const module = await CustomGeoJSONModule.create(map, {
sources: {
points: {
layers: [{ type: 'circle', paint: { 'circle-radius': 4, 'circle-color': '#0a3653' } }],
},
},
});
await module.show(myFeatureCollection, 'points');
sourcesis required, with at least one source; each source needs at least one layer.createthrows otherwise, and on the source nameson,offandwhere, whicheventskeeps for its methods.- A layer is a MapLibre
LayerSpecificationwithoutsource: the module assigns it. - IDs are optional. Omitted, they are generated from the instance index and array position:
- source:
custom-geojson-${instanceIndex}-${sourceName} - layer:
${sourceID}-layer-${layerIndex}
- source:
- Give explicit
ids to layers you change at runtime or reach from outside the module: generated ones shift when the array is reordered. - Several instances can share one map: each gets its own
instanceIndex, so generated IDs never collide.
Typed sources
Declare each source's payload as a generic on create to type show, clear, getShown and events per source:
import type { FeatureCollection, Point, Polygon } from 'geojson';
type Sources = {
heatmap: FeatureCollection<Point>;
buildings: FeatureCollection<Polygon, { name: string }>;
};
const module = await CustomGeoJSONModule.create<Sources>(map, {
sources: {
heatmap: { layers: [{ type: 'heatmap', paint: { 'heatmap-radius': 12 } }] },
buildings: { layers: [{ type: 'fill', paint: { 'fill-color': '#5a5' } }] },
},
});
await module.show(heatmapData, 'heatmap'); // FeatureCollection<Point>
await module.show(buildingData, 'buildings'); // FeatureCollection<Polygon, { name: string }>
Without the generic, every source takes a FeatureCollection.
Multiple layers per source
Array order is MapLibre draw order: later layers draw on top. A dot with a label under it:
import { CustomGeoJSONModule, MAP_REGULAR_FONT } from '@tomtom-org/maps-sdk/map';
const module = await CustomGeoJSONModule.create(map, {
sources: {
markers: {
layers: [
{ type: 'circle', paint: { 'circle-radius': 6, 'circle-color': '#0a3653' } },
{
type: 'symbol',
layout: {
'text-field': ['get', 'name'],
'text-font': [MAP_REGULAR_FONT],
'text-offset': [0, 1.2],
'text-anchor': 'top',
'text-optional': true,
},
paint: { 'text-color': '#0a3653', 'text-halo-color': '#fff', 'text-halo-width': 1.5 },
},
],
},
},
});
Give every text layer a text-font from MAP_REGULAR_FONT, MAP_MEDIUM_FONT, MAP_BOLD_FONT or MAP_ITALIC_FONT (mapFonts lists all four). The TomTom glyph endpoint serves only those: without one, MapLibre falls back to a local browser font and logs Unable to load glyph range.
Layer placement
Layers go where beforeLayerConfig says, as on every other module (Appearance vocabulary): 'top', the default, or a mapStyleLayerIDs key such as 'lowestLabel', the layer inserted below that style layer.
import { CustomGeoJSONModule } from '@tomtom-org/maps-sdk/map';
const module = await CustomGeoJSONModule.create(map, {
beforeLayerConfig: 'lowestLabel', // every layer, unless it names its own
sources: {
zones: {
layers: [
{ id: 'zones-fill', type: 'fill', paint: { 'fill-color': '#2A5BD7', 'fill-opacity': 0.35 } },
{ type: 'line', paint: { 'line-color': '#2A5BD7', 'line-width': 2 } },
{ type: 'circle', beforeLayerConfig: 'top', paint: { 'circle-radius': 4 } },
],
},
},
});
// Below a layer outside the style, such as another module's
await CustomGeoJSONModule.create(map, {
sources: { heat: { layers: [{ type: 'heatmap', beforeLayerConfig: { layerID: 'zones-fill' } }] } },
});
- The module's
beforeLayerConfigplaces every layer; a layer's own wins over it. 'lowestLabel'keeps every label above lines and polygons; points usually stay on top.{ layerID }goes below a layer the style lacks: another layer of this module, or one another module added.- A target the map lacks puts the layer on top rather than failing: a mistyped
layerID, or'lowestRoadLine'and'lowestBuilding'onsatellite, which has neither. - It survives
setStyleandapplyConfig: a changed placement moves the layer.
Showing and clearing data
await module.show(newFeatureCollection, 'points'); // replace one source's data
await module.show(sameData); // no name: every source
const { points } = module.getShown(); // per source
await module.clear('points'); // one source
await module.clear(); // every source
showwithout a name writes every source, for one data set drawn several ways (a heatmap and markers over the same points).- A source's layers draw while it holds features and the module is visible.
setVisible(false)hides every layer of every source and keeps the data:showandclearleave a hidden module hidden, andsetVisible(true)draws what the sources hold.
Feature IDs
show gives every feature matching feature.id and properties.id:
- Your id on either field is kept, and copied to the other.
- With neither, both take the feature's index in the array.
Event lookup goes through feature.id, and MapLibre drops promoteId on a clustered source: without this, clustered points would all have feature.id === undefined and every click would resolve to the first one.
Custom images
A symbol layer's icon-image must exist on the map before the layer draws, and setStyle clears every image added with map.mapLibreMap.addImage. Declare them in images instead: the module registers them before it creates any source or layer, and again after every style change.
const module = await CustomGeoJSONModule.create(map, {
sources: {
markers: {
layers: [
{
type: 'symbol',
layout: { 'icon-image': 'my-marker', 'text-field': ['get', 'name'], 'text-font': [MAP_REGULAR_FONT] },
},
],
},
},
images: {
'my-marker': { image: myImageBitmap, options: { pixelRatio: 2 } },
},
});
imagetakes whataddImagetakes:HTMLImageElement,ImageBitmap,ImageData,StyleImageInterfaceor{ width, height, data }. URLs and raw SVG strings are not loaded for you: pass a loadedHTMLImageElement.optionsisaddImage'sPartial<StyleImageMetadata>(pixelRatio,sdf,stretchX,stretchY,content), forwarded verbatim.- An id already on the map is skipped, so several modules may register the same icon.
applyConfigadds new images and never removes one dropped fromimages.
Clustering
cluster forwards MapLibre's cluster options per source, and is fixed at creation: recreate the module to change it.
const module = await CustomGeoJSONModule.create(map, {
sources: {
incidents: {
cluster: { cluster: true, clusterRadius: 50, clusterMaxZoom: 14 },
layers: [
{ type: 'circle', filter: ['has', 'point_count'], paint: { 'circle-radius': 18 } },
{ type: 'symbol', filter: ['!', ['has', 'point_count']], layout: { 'icon-image': 'marker' } },
],
},
},
});
A cluster is synthetic, so it is not among the shown features, and a click handler's first argument is undefined for it. The third argument holds the rendered MapLibre features, with cluster: true, cluster_id and point_count:
module.events.incidents.on('click', (feature, _lngLat, features) => {
const properties = feature?.properties ?? features[0]?.properties ?? {};
if (properties.cluster) {
showCluster(properties.point_count);
} else {
showIncident(properties);
}
});
Style-change restoration
On map.setStyle(...), with no StyleChangeHandler of your own:
- The
imagesare registered again. - Sources and layers stay on the map under the same IDs and draw through the switch; a clean switch (
resetState: true) adds them to the new style afresh. - Each source's last shown data is shown again.
- Event handlers keep firing.
Events
Each source has its own user events, module.events.<name>, over that source's layers alone:
const unsubscribe = module.events.points.on('click', (feature, lngLat, features) => {
console.log(feature.properties, lngLat);
});
module.events.points.on('hover', (feature) => { /* ... */ });
unsubscribe(); // one handler
module.events.points.off('click'); // every click handler on the source
Lifecycle events belong to the module. shown-features names the source show wrote:
module.events.on('shown-features', ({ sourceName, data }) => {
console.log(`${data.features.length} features on ${String(sourceName)}`);
});
module.events.on('config-change', (config) => { /* ... */ });
A feature a handler covers takes an eventState your layers can style by. The module's event state methods act on every source; the same methods on a source's scope act on that source alone, for ids that repeat across sources:
module.events.points.setEventState({ id: 'depot-1', state: 'click' });
const { click = [] } = module.events.points.getEventStates();
Registering through module.events rather than map.mapLibreMap.on(...) lets the SDK arbitrate between modules: a click on your layer does not also fire a Places marker underneath, and the cursor does not flicker. Precision modes, hover delays and layer order are in User events.
Updating layers at runtime
applyConfig and updateConfig change the layer specs of existing sources, diffed by layer id:
- Updated: an id in both sets — paint, layout, filter, zoom range, and position when its
beforeLayerConfigchanged. - Added: a new id.
- Removed: an id no longer there.
module.applyConfig({
sources: {
points: {
layers: [
{ id: 'points-dot', type: 'circle', paint: { 'circle-radius': 6, 'circle-color': '#c33' } },
{ id: 'points-label', type: 'symbol', layout: { 'text-field': ['get', 'name'], 'text-font': [MAP_REGULAR_FONT] } },
],
},
},
});
- Source names are fixed at creation: adding or dropping one, or changing
cluster, needs a new module. resetConfig()returns to the configurationcreatewas given, since the module has no defaults of its own;getConfig()is neverundefined.CustomGeoJSONModuleSerializableConfigis the part of the configuration that survives JSON: all butimages(Saving and replaying a configuration).
Reading the underlying IDs
sourceAndLayerIDs returns the resolved MapLibre IDs per source, for MapLibre calls such as queryRenderedFeatures or setPaintProperty:
const ids = module.sourceAndLayerIDs;
// { points: { sourceID: 'custom-geojson-0-points', layerIDs: ['custom-geojson-0-points-layer-0'] } }
const rendered = map.mapLibreMap.queryRenderedFeatures(undefined, { layers: ids.points.layerIDs });
Every setting as data
customGeoJSONKnobCatalogue lists the settings a single control can hold, with kind, description and default, readable with no map loaded:
import { customGeoJSONKnobCatalogue } from '@tomtom-org/maps-sdk/map';
customGeoJSONKnobCatalogue;
// [{ id: 'visible', kind: 'toggle', description: '…', default: true }]
visibleis the only entry: your own layer specs style everything else.- Not in it: each source's
layers(passed to MapLibre untouched),cluster(fixed at creation) andsourceID, and the module'simagesandevents. customGeoJSONKnobIdslists the ids,CustomGeoJSONKnobIdis their union andCustomGeoJSONKnobValueOf<ID>the plain value one takes. Entries share theKnobEntryshape of every module's catalogue, andsetKnob,getKnobandresetKnobtake them.
API reference
CustomGeoJSONModule · customGeoJSONKnobCatalogue · mapFonts
Related guides and examples
Related examples
- Bring Your Own Data: GeoJSON Heatmap with Custom Module — Heatmap and symbol layers with click and hover events, custom icons and style switching
- Bring Your Own Data: Clusters and Polygons — 20k clustered points and random polygons, both clickable
- Feature Event States — Triangles coloured by their
eventState - Bring Your Own Data: Yorkshire Heatmap — The same kind of heatmap through raw MapLibre and
PlacesModule
Related guides
- Places module — TomTom places, or compatible point data, with iconography and marker types
- Geometries module — Polygons with TomTom theming
- User events and Module events — The full event API
- Map styles —
setStyleand the style lifecycle