This is the documentation of version 1.0.0-rc.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, …). 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:

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

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. For 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

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

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:

  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, empty and with the configuration create was given.
  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) => { /* ... */ });

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:

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