Map Support Plugin

This section shows how to implement a plugin able to access the native api of the mapping library to create custom supports. MapStore provides the most common map events and supports out of the box but in some project there could be the need to add more functionalities to the map.

Below an example on how to create a simple animated marker using the plugin system

Setup a plugin

Create a new plugin that will allows us to select two different map type by selecting the value from a dropdown, the options will be: OpenLayers and Cesium

The plugin will be included in the js/plugins/ directory with the name AnimatedMarker.jsx

// js/plugins/AnimatedMarker.jsx
import React from 'react';
import { createPlugin } from '@mapstore/utils/PluginsUtils';
import Select from 'react-select';
import { changeMapType } from '@mapstore/actions/maptype';
import { mapTypeSelector } from '@mapstore/selectors/maptype';
import { createSelector } from 'reselect';
import { connect } from 'react-redux';

function AnimatedMarkerPanel({
    mapType,
    onChangeMapType
}) {
    return (
        <div
            style={{
                position: 'absolute',
                width: 400,
                padding: 4,
                margin: 8,
                display: 'flex',
                flexDirection: 'column',
                zIndex: 100,
                background: '#ffffff',
                left: 50
            }}
        >
            <Select
                value={mapType}
                clearable={false}
                onChange={({ value } = {}) => onChangeMapType(value)}
                options={[
                    {
                        value: 'openlayers',
                        label: 'Openlayers'
                    },
                    {
                        value: 'cesium',
                        label: 'Cesium'
                    }
                ]}
            />
        </div>
    );
}

const ConnectedAnimatedMarkerPanel = connect(
    createSelector(
        [
            mapTypeSelector
        ],
        (mapType) => ({
            mapType
        })),
    {
        onChangeMapType: changeMapType
    }
)(AnimatedMarkerPanel);

export default createPlugin('AnimatedMarker', {
    component: ConnectedAnimatedMarkerPanel
});

Include the plugin inside the plugins.js config

// js/plugins.js
import AnimatedMarkerPlugin from '@js/plugins/AnimatedMarker';
import productPlugins from '@mapstore/product/plugins.js';

export default {
    requires: {
        ...productPlugins.requires
    },
    plugins: {
        ...productPlugins.plugins,
        // here you might have other custom plugins
        AnimatedMarkerPlugin
    }
};

Configure the plugin in the configs/localConfig.json in a page that include the Map plugin, for example in the custom-viewer showed in the previous section

// configs/localConfig.json
{
    // ... ,
    "plugins": {
        // ... ,
         "custom-viewer": [
            {
                "name": "Map"
            },
            {
                "name": "AnimatedMarker"
            }
        ]
    }
}

This process is similar to the one explained in the plugin development section. The current results will be a select floating on the map that allow to change map type. This select input has the only purpose to facilitate the development and compare the different map types.

Configure a support

Every plugin can export a containers properties where is possible to pass components/tools/action to other plugin based on the container implementation. The Map plugin allows to configure support tool component that can have access to the native map libraries

Here an example of empty support injected in the Map plugin

// js/plugins/AnimatedMarker.jsx
import React from 'react';
import { createPlugin } from '@mapstore/utils/PluginsUtils';
import Select from 'react-select';
import { changeMapType } from '@mapstore/actions/maptype';
import { mapTypeSelector } from '@mapstore/selectors/maptype';
import { createSelector } from 'reselect';
import { connect } from 'react-redux';

const animatedMarkerSupports = {};

function AnimatedMarkerSupport({
    mapType,
    ...props
}) {
    const Support = animatedMarkerSupports[mapType];
    return Support ? <Support {...props} /> : null;
}

function AnimatedMarkerPanel({
    mapType,
    onChangeMapType
}) {
    return (
        <div
            style={{
                position: 'absolute',
                width: 400,
                padding: 4,
                margin: 8,
                display: 'flex',
                flexDirection: 'column',
                zIndex: 100,
                background: '#ffffff'
            }}
        >
            <Select
                value={mapType}
                clearable={false}
                onChange={({ value } = {}) => onChangeMapType(value)}
                options={[
                    {
                        value: 'openlayers',
                        label: 'Openlayers'
                    },
                    {
                        value: 'cesium',
                        label: 'Cesium'
                    }
                ]}
            />
        </div>
    );
}

const ConnectedAnimatedMarkerPanel = connect(
    createSelector(
        [
            mapTypeSelector
        ],
        (mapType) => ({
            mapType
        })),
    {
        onChangeMapType: changeMapType
    }
)(AnimatedMarkerPanel);

export default createPlugin('AnimatedMarker', {
    component: ConnectedAnimatedMarkerPanel,
    containers: {
        Map: {
            Tool: AnimatedMarkerSupport,
            name: 'AnimatedMarker'
        }
    }
});

The Map container configuration accepts two keys:

  • name, the name of the support

  • Tool, a react component used for the support implementation. This component will get two special props:
    • mapType, the current map type in use

    • map, the native library instance

With this configuration we are now able to implement the support we need

Implement a support in react

This example will show an animated marker similar to the one in the OpenLayers examples, Marker Animation .

Create a component at js/components/map/openlayers/AnimatedMarkerSupport.jsx with the following content for the OpenLayers library:

// js/components/map/openlayers/AnimatedMarkerSupport.jsx
import { useEffect } from 'react';
import GeoJSON from 'ol/format/GeoJSON';
import VectorSource from 'ol/source/Vector';
import VectorLayer from 'ol/layer/Vector';
import Feature from 'ol/Feature';
import Point from 'ol/geom/Point';
import {
    Circle as CircleStyle,
    Fill,
    Stroke,
    Style
} from 'ol/style';

function AnimatedMarkerSupport({
    map,
    speed = 60,
    feature
}) {
    useEffect(() => {

        let coordinates;
        let now = new Date().getTime();
        let vectorLayer;
        let positionStyle;

        function movePoint(event) {
            const vectorContext = event.vectorContext;
            const frameState = event.frameState;
            const elapsedTime = frameState.time - now;
            const index = Math.round(speed * elapsedTime / 1000);
            if (index >= coordinates.length) {
                now = new Date().getTime();
            }
            const currentPoint = new Point(coordinates[index % coordinates.length]);
            const currentFeature = new Feature(currentPoint);
            vectorContext.drawFeature(currentFeature, positionStyle);
            map.render();
        }

        if (feature) {

            const route = new GeoJSON().readFeature(feature, {
                dataProjection: 'EPSG:4326',
                featureProjection: 'EPSG:3857'
            });

            const position = new Feature({
                type: 'marker',
                geometry: new Point(route.getGeometry().getFirstCoordinate())
            });

            positionStyle = new Style({
                image: new CircleStyle({
                    radius: 7,
                    fill: new Fill({
                        color: 'black'
                    }),
                    stroke: new Stroke({
                        color: 'white',
                        width: 2
                    })
                })
            });

            vectorLayer = new VectorLayer({
                source: new VectorSource({
                    features: [route, position]
                }),
                style: (olFeature) => {
                    if (olFeature.get('type') === 'marker') {
                        return positionStyle;
                    }
                    return new Style({
                        stroke: new Stroke({
                            width: 6,
                            color: 'red'
                        })
                    });
                }
            });

            map.addLayer(vectorLayer);
            coordinates = route.getGeometry().getCoordinates();
            position.setGeometry(null);
            vectorLayer.on('postcompose', movePoint);
        }
        return () => {
            if (vectorLayer) {
                vectorLayer.un('postcompose', movePoint);
                map.removeLayer(vectorLayer);
            }
        };
    }, [feature, speed]);

    return null;
}

export default AnimatedMarkerSupport;

In the snippet above we are initialize the map events on every feature or speed change

Create another component at js/components/map/cesium/AnimatedMarkerSupport.jsx with the following content for the Cesium library:

// js/components/map/cesium/AnimatedMarkerSupport.jsx
import { useEffect } from 'react';
import * as Cesium from 'cesium';

function AnimatedMarkerSupport({
    map,
    speed = 60,
    feature
}) {
    useEffect(() => {
        let dataSource;
        let entities;
        let startTime = new Date().getTime();
        let lastTime = startTime;
        let removing = false;
        const clock = map.clock;
        function movePoint() {
            const elapsedTime = lastTime - startTime;
            const index = Math.round(speed * elapsedTime / 1000);
            const coordinates = entities[0].polyline.positions.getValue();
            if (index >= coordinates.length) {
                startTime = new Date().getTime();
            }
            const currentPosition = coordinates[index % coordinates.length];
            entities[1].position = currentPosition;
            lastTime = new Date().getTime();
            map.scene.requestRender();
        }
        if (feature) {
            Cesium.GeoJsonDataSource.load({
                type: 'FeatureCollection',
                features: [feature, {
                    type: 'Feature',
                    properties: {},
                    geometry: {
                        type: 'Point',
                        coordinates: feature?.geometry?.coordinates[0]
                    }
                }]
            }, {
                stroke: Cesium.Color.RED,
                strokeWidth: 6
            }).then((newDataSource) => {
                if (!removing) {
                    dataSource = newDataSource;
                    entities = dataSource?.entities?.values;
                    entities[1].billboard = new Cesium.BillboardGraphics({
                        image: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAJlJREFUOE+tk7sNAjEQRN+0QiNcBlUQIEFRIAioAjIohGtl0J7OyDgB22y2Xs3zfkVhtpfABhiAxRwegTtwkfTIJcod2wdgV0IL/yhpn97eANtXYPVFnMI3SetwJsCPP5fsKRPNNUd9LTYE4ARsW9TAOQDPrNu1nDEArlV9jPEfgO4SupsYq9s+xu5FSh3tWuUM0n5MGaTqnF8jHk9pS1JsJgAAAABJRU5ErkJggg==',
                        color: Cesium.Color.BLACK,
                        disableDepthTestDistance: Number.POSITIVE_INFINITY
                    });
                    map.dataSources.add(dataSource);
                    clock.onTick.addEventListener(movePoint);
                }
            });
        }
        return () => {
            removing = true;
            if (dataSource && map?.dataSources) {
                map.dataSources.remove(dataSource);
                clock.onTick.removeEventListener(movePoint);
            }
        };
    }, [feature, speed]);
    return null;
}

export default AnimatedMarkerSupport;

Now we have similar implementation that show a point moving on a line string feature

Once we created this two component we could include them in the plugin support using the lazy functionality from react. This allow to reduce the main bundle size adding the mapping libraries dependencies only when requested

// js/plugins/AnimatedMarker.jsx
import React, { lazy, Suspense } from 'react';
import { createPlugin } from '@mapstore/utils/PluginsUtils';
import Select from 'react-select';
import { changeMapType } from '@mapstore/actions/maptype';
import { mapTypeSelector } from '@mapstore/selectors/maptype';
import { createSelector } from 'reselect';
import { connect } from 'react-redux';

const animatedMarkerSupports = {
    openlayers: lazy(() => import('@js/components/map/openlayers/AnimatedMarkerSupport.jsx')),
    cesium: lazy(() => import('@js/components/map/cesium/AnimatedMarkerSupport.jsx'))
};

function AnimatedMarkerSupport({
    mapType,
    ...props
}) {
    const Support = animatedMarkerSupports[mapType];
    return Support ? <Suspense fallback={<div/>}><Support {...props} /></Suspense> : null;
}

function AnimatedMarkerPanel({
    mapType,
    onChangeMapType
}) {
    return (
        <div
            style={{
                position: 'absolute',
                width: 400,
                padding: 4,
                margin: 8,
                display: 'flex',
                flexDirection: 'column',
                zIndex: 100,
                background: '#ffffff'
            }}
        >
            <Select
                value={mapType}
                clearable={false}
                onChange={({ value } = {}) => onChangeMapType(value)}
                options={[
                    {
                        value: 'openlayers',
                        label: 'Openlayers'
                    },
                    {
                        value: 'cesium',
                        label: 'Cesium'
                    }
                ]}
            />
        </div>
    );
}

const ConnectedAnimatedMarkerPanel = connect(
    createSelector(
        [
            mapTypeSelector
        ],
        (mapType) => ({
            mapType
        })),
    {
        onChangeMapType: changeMapType
    }
)(AnimatedMarkerPanel);

export default createPlugin('AnimatedMarker', {
    component: ConnectedAnimatedMarkerPanel,
    containers: {
        Map: {
            Tool: AnimatedMarkerSupport,
            name: 'AnimatedMarker'
        }
    }
});

Finally we can include a sample line string feature from the railroad-sample.json file. (the railroad data has been download from the Natural Earth page)

Download the railroad-sample.json and copy it in the configs/ folder

Add the request to the Support component so we can test the animation on a line string feature

// js/plugins/AnimatedMarker.jsx
import React, { useEffect, useState, lazy, Suspense } from 'react';
import { createPlugin } from '@mapstore/utils/PluginsUtils';
import Select from 'react-select';
import { changeMapType } from '@mapstore/actions/maptype';
import { mapTypeSelector } from '@mapstore/selectors/maptype';
import { createSelector } from 'reselect';
import { connect } from 'react-redux';
import axios from '@mapstore/libs/ajax';

const animatedMarkerSupports = {
    openlayers: lazy(() => import('@js/components/map/openlayers/AnimatedMarkerSupport.jsx')),
    cesium: lazy(() => import('@js/components/map/cesium/AnimatedMarkerSupport.jsx'))
};

function AnimatedMarkerSupport({
    mapType,
    ...props
}) {
    const [feature, setFeature] = useState();
    useEffect(() => {
        axios.get('configs/railroad-sample.json')
            .then(({ data }) => {
                setFeature(data.features[0]);
            });
    }, []);
    const Support = animatedMarkerSupports[mapType];
    return Support ? <Suspense fallback={<div/>}><Support {...props} feature={feature} /></Suspense> : null;
}

function AnimatedMarkerPanel({
    mapType,
    onChangeMapType
}) {
    return (
        <div
            style={{
                position: 'absolute',
                width: 400,
                padding: 4,
                margin: 8,
                display: 'flex',
                flexDirection: 'column',
                zIndex: 100,
                background: '#ffffff'
            }}
        >
            <Select
                value={mapType}
                clearable={false}
                onChange={({ value } = {}) => onChangeMapType(value)}
                options={[
                    {
                        value: 'openlayers',
                        label: 'Openlayers'
                    },
                    {
                        value: 'cesium',
                        label: 'Cesium'
                    }
                ]}
            />
        </div>
    );
}

const ConnectedAnimatedMarkerPanel = connect(
    createSelector(
        [
            mapTypeSelector
        ],
        (mapType) => ({
            mapType
        })),
    {
        onChangeMapType: changeMapType
    }
)(AnimatedMarkerPanel);

export default createPlugin('AnimatedMarker', {
    component: ConnectedAnimatedMarkerPanel,
    containers: {
        Map: {
            Tool: AnimatedMarkerSupport,
            name: 'AnimatedMarker'
        }
    }
});

The final plugin will show a read line with a black point moving over it for the OpenLayers and Cesium map types.