This is the documentation of version 0.64.0. Go to the latest version, 1.0.0-rc.5.

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:

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');

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' } }] } },
});

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

Feature IDs

show gives every feature matching feature.id and properties.id:

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 } },
    },
});

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:

  1. The images are registered again.
  2. 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.
  3. Each source's last shown data is shown again.
  4. 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:

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] } },
            ],
        },
    },
});

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 }]

API reference

CustomGeoJSONModule · customGeoJSONKnobCatalogue · mapFonts

Related guides and examples

Related examples

Related guides