Custom GeoJSON module
CustomGeoJSONModule draws your own GeoJSON with MapLibre layer specs you write (circle, heatmap, fill, fill-extrusion, line or symbol). It 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, and draws them exactly as you specify. 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. - For non-GeoJSON sources such as vector or raster tiles, use raw MapLibre (which to use when).
Sources and layers
A module manages one or more named sources, each a MapLibre GeoJSON source plus the layers that draw it. Use the names 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');createthrows unlesssourceshas at least one source and each source at least one layer. It also throws on the source nameson,offandwhere, whicheventskeeps for its methods.- A layer is a MapLibre
LayerSpecificationwithoutsource, which the module assigns. - IDs are optional. The module generates missing ones 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 you reorder the array. - Several instances can share one map. Each gets its own
instanceIndex, so generated IDs never collide.
Typed sources
To type show, clear, getShown and events per source, declare each source’s payload as a generic on create:
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, so later layers draw on top. This draws 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 mapFonts. It lists MAP_REGULAR_FONT, MAP_MEDIUM_FONT, MAP_BOLD_FONT and MAP_ITALIC_FONT, plus the style’s serif and italic faces, and the TomTom glyph endpoint serves only those. The serif and italic faces lack some scripts, so end a stack that names one with the Noto-* face of the same weight. Without a text-font, MapLibre falls back to a local browser font and logs Unable to load glyph range.
Layer placement
As on every other module, beforeLayerConfig sets where layers go (Appearance vocabulary). It takes 'top', the default, or a mapStyleLayerIDs key such as 'lowestLabel', which inserts the layer 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'sawait 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, either another layer of this module or one another module added.- A target the map lacks puts the layer on top instead of failing, as with a mistyped
layerID, or'lowestRoadLine'and'lowestBuilding'onstreetSatellite, which has neither. - Placement survives
setStyleandapplyConfig, and changing it moves the layer.
Showing and clearing data
await module.show(newFeatureCollection, 'points'); // replace one source's dataawait module.show(sameData); // no name: every sourceconst { points } = module.getShown(); // per sourceawait module.clear('points'); // one sourceawait module.clear(); // every sourceshowwithout 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. A layer whose spec sets
layout.visibility: 'none'stays hidden. 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. If both are set and differ,
feature.idwins. - If neither is set, both take the feature’s index in the array, or the next number no other feature holds.
Event lookup reads properties.id, otherwise feature.id. MapLibre drops promoteId on a clustered source, so 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 } }, },});image- WhataddImagetakes:HTMLImageElement,ImageBitmap,ImageData,StyleImageInterfaceor{ width, height, data }. The module doesn’t load URLs or raw SVG strings, so pass a loadedHTMLImageElement.options-addImage’sPartial<StyleImageMetadata>(pixelRatio,sdf,stretchX,stretchY,content), forwarded verbatim.- An id something else already added to the map is skipped, so several modules may register the same icon.
applyConfigadds new images and replaces one the module added when you pass a newimageoroptions, but never removes one dropped fromimages.
Clustering
cluster forwards MapLibre’s cluster options per source. It is fixed at creation, so 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 isn’t among the shown features. Instead, a handler receives the rendered MapLibre feature for it, with cluster: true, cluster_id and point_count in its properties:
module.events.incidents.on('click', (feature) => { const properties = feature.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, empty and with the configurationcreatewas given. - 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 handlermodule.events.points.off('click'); // every click handler on the sourceLifecycle 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) => { /* ... */ });Each feature a handler listens to gets an eventState that your layers can style by. The module’s event state methods act on every source. For ids that repeat across sources, call the same methods on a source’s scope to act on that source alone:
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 then doesn’t also fire a Places marker underneath, and the cursor doesn’t flicker. User events covers precision modes, hover delays and layer order.
Updating layers at runtime
applyConfig and updateConfig change the layer specs of existing sources, diffed by layer id:
- An id in both sets updates its paint, layout, filter, zoom range, and position if its
beforeLayerConfigchanged. - A new id adds a layer.
- A missing id removes its layer.
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 and each source’s
sourceIDare fixed at creation. Adding or dropping a source, or changing itssourceIDorcluster, needs a new module.applyConfigthrows on a changed name orsourceID, and changes nothing. resetConfig()returns to the configurationcreatewas given, since the module has no defaults of its own.getConfig()is neverundefined.CustomGeoJSONModuleSerializableConfigis the configuration minusimages, the part that survives JSON (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 });If you change those layers’ properties with setPaintProperty yourself, a change to the module’s configuration replaces only the properties your new layer specs set differently, and a style switch drops what you wrote. See Writing to a module’s layers yourself.
Every setting as data
customGeoJSONKnobCatalogue lists the settings a single control can hold, with kind, description and default. You can read it with no map loaded:
import { customGeoJSONKnobCatalogue } from '@tomtom-org/maps-sdk/map';
customGeoJSONKnobCatalogue;// [{ id: 'visible', kind: 'toggle', description: '…', default: true }]visibleis the only entry, because your own layer specs style everything else.- It leaves out each source’s
layers(passed to MapLibre untouched),cluster(fixed at creation) andsourceID, and the module’simages,beforeLayerConfigandevents. customGeoJSONKnobIdslists the ids,CustomGeoJSONKnobIdis their union andCustomGeoJSONKnobValueOf<ID>is 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 - Show your own GeoJSON as places — Your own GeoJSON as places, with no custom module at all
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