Custom map hotspots

Open in CodeSandbox

This example shows a railway map based on OpenRailwayMap.

The map downloads data from the server in the form of standard tiles, and also uses hotspots.

These areas allow you to get additional information: if you click on any map object (for example, a station or train station), details will appear in a pop—up window - information about the object that the map automatically requests from the server.

The data was obtained using the Overpass Turbo service.

The following query was used to get railway station data:

[out:json][timeout:25];
{{geocodeArea:{{City}}}}->.searchArea;
(
  node["railway"="station"](area.searchArea);
  node["railway"="halt"](area.searchArea);
  node["railway"="tram_stop"](area.searchArea);
);
out geom;

Where {{City}} should be replaced with the name of the city where you want to perform the search.

import {setValue} from '../common';
import {dataSourceProps, layerProps} from '../layer';
import {LOCATION, ZOOM_RANGE} from '../variables';

window.map = null;

main();
async function main() {
  // Waiting for all api elements to be loaded
  await mappable.ready;
  const {WebMercator} = await mappable.import('@mappable-world/mappable-web-mercator-projection');
  const {MMapPopupMarker} = await mappable.import('@mappable-world/mappable-default-ui-theme');
  const {MMap, MMapDefaultSchemeLayer, MMapTileDataSource, MMapLayer, MMapListener, MMapDefaultFeaturesLayer} =
    mappable;

  const projection = new WebMercator();

  // Initialize the map
  map = new MMap(
    // Pass the link to the HTMLElement of the container
    document.getElementById('app'),
    // Pass the map initialization location and the Mercator projection used to represent the Earth's surface on a plane
    {location: LOCATION, showScaleInCopyrights: true, zoomRange: ZOOM_RANGE, projection},
    [
      // Adding our own data source
      new MMapTileDataSource(dataSourceProps),
      // Adding a layer that will display data from `dataSource`
      new MMapLayer(layerProps),
      // Add a map scheme layer
      new MMapDefaultSchemeLayer({}),
      new MMapDefaultFeaturesLayer({})
    ]
  );

  let currentProperties = {};

  const infoPopup = new MMapPopupMarker({
    coordinates: [0, 0],
    blockBehaviors: true,
    content: () => {
      const container = document.createElement('div');
      container.classList.add('info');
      const jsonElement = document.createElement('pre');

      const {name, '@id': id} = currentProperties as {name: string; '@id': string};
      const preparedProperties = {id, name};

      jsonElement.textContent = JSON.stringify(preparedProperties, null, 2);
      container.appendChild(jsonElement);
      return container;
    },
    position: 'top'
  });

  const listener = new MMapListener({
    layer: layerProps.id,
    onClick: (object, {coordinates}) => {
      map.removeChild(infoPopup);
      if (object && object.type === 'hotspot') {
        currentProperties = object.entity.properties;
        console.log(currentProperties);
        infoPopup.update({coordinates});
        map.addChild(infoPopup);
        setValue(currentProperties);
      }
    },
    onActionStart: () => {
      map.removeChild(infoPopup);
    }
  });
  map.addChild(listener);
}
import type {DomEventHandler, LngLat} from '@mappable-world/mappable-types';
import {setValue} from '../common';
import {dataSourceProps, layerProps} from '../layer';
import {LOCATION, ZOOM_RANGE} from '../variables';

window.map = null;

main();
async function main() {
  // For each object in the JS API, there is a React counterpart
  // To use the React version of the API, include the module @mappable-world/mappable-reactify
  const [mappableReact] = await Promise.all([mappable.import('@mappable-world/mappable-reactify'), mappable.ready]);
  const reactify = mappableReact.reactify.bindTo(React, ReactDOM);
  const {WebMercator} = await mappable.import('@mappable-world/mappable-web-mercator-projection');
  const {MMapPopupMarker} = reactify.module(await mappable.import('@mappable-world/mappable-default-ui-theme'));
  const {MMap, MMapDefaultSchemeLayer, MMapTileDataSource, MMapLayer, MMapListener, MMapDefaultFeaturesLayer} =
    reactify.module(mappable);

  const projection = new WebMercator();
  function App() {
    const [showPopup, setShowPopup] = React.useState(false);
    const [currentProperties, setCurrentProperties] = React.useState({});
    const [popupCoordinates, setPopupCoordinates] = React.useState<LngLat>([0, 0]);

    const onCLickMap: DomEventHandler = React.useCallback(
      (object, {coordinates}) =>
        React.startTransition(() => {
          setShowPopup(false);

          if (object && object.type === 'hotspot') {
            setCurrentProperties(object.entity.properties);
            console.log(object.entity.properties);
            setPopupCoordinates(coordinates);
            setShowPopup(true);
            setValue(object.entity.properties);
          }
        }),
      [currentProperties]
    );

    const onActionStartMap = React.useCallback(() => {
      setShowPopup(false);
    }, []);

    const createPopupContent = React.useCallback(() => {
      const {name, '@id': id} = currentProperties as {name: string; '@id': string};
      const preparedProperties = {id, name};

      return (
        <div className="info">
          <pre>{JSON.stringify(preparedProperties, null, 2)}</pre>
        </div>
      );
    }, [currentProperties]);

    return (
      // Initialize the map and pass initialization location and the Mercator projection used to represent the Earth's surface on a plane
      <MMap
        location={reactify.useDefault(LOCATION)}
        showScaleInCopyrights={true}
        zoomRange={ZOOM_RANGE}
        projection={projection}
        ref={(x) => (map = x)}
      >
        {/* Adding our own data source */}
        <MMapTileDataSource {...dataSourceProps} />
        {/* Adding a layer that will display data from `dataSource`s */}
        <MMapLayer {...layerProps} />
        <MMapDefaultSchemeLayer />
        <MMapDefaultFeaturesLayer />

        <MMapListener layer={layerProps.id} onClick={onCLickMap} onActionStart={onActionStartMap} />

        {showPopup && (
          <MMapPopupMarker
            coordinates={popupCoordinates}
            content={createPopupContent}
            position="top"
            blockBehaviors={true}
          />
        )}
      </MMap>
    );
  }

  ReactDOM.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
    document.getElementById('app')
  );
}
import type {DomEventHandler, LngLat} from '@mappable-world/mappable-types';
import {setValue} from '../common';
import {dataSourceProps, layerProps} from '../layer';
import {LOCATION, ZOOM_RANGE} from '../variables';

window.map = null;

async function main() {
  // For each object in the JS API, there is a Vue counterpart
  // To use the Vue version of the API, include the module @mappable-world/mappable-vuefy
  const [mappableVue] = await Promise.all([mappable.import('@mappable-world/mappable-vuefy'), mappable.ready]);
  const vuefy = mappableVue.vuefy.bindTo(Vue);
  const {WebMercator} = await mappable.import('@mappable-world/mappable-web-mercator-projection');
  const {MMapPopupMarker} = vuefy.module(await mappable.import('@mappable-world/mappable-default-ui-theme'));
  const {MMap, MMapDefaultSchemeLayer, MMapTileDataSource, MMapLayer, MMapListener, MMapDefaultFeaturesLayer} =
    vuefy.module(mappable);

  const app = Vue.createApp({
    components: {
      MMap,
      MMapDefaultSchemeLayer,
      MMapTileDataSource,
      MMapLayer,
      MMapListener,
      MMapDefaultFeaturesLayer,
      MMapPopupMarker
    },
    setup() {
      const refMap = (ref) => {
        window.map = ref?.entity;
      };

      const showPopup = Vue.ref(false);
      const currentProperties = Vue.ref({});
      const jsonCurrentProperties = Vue.computed(() => {
        const {name, '@id': id} = currentProperties.value as {name: string; '@id': string};
        const preparedProperties = {id, name};

        return JSON.stringify(preparedProperties, null, 2);
      });
      const popupCoordinates = Vue.ref<LngLat>([0, 0]);
      const projection = new WebMercator();

      const onCLickMap: DomEventHandler = (object, {coordinates}) => {
        showPopup.value = false;

        if (object && object.type === 'hotspot') {
          currentProperties.value = object.entity.properties;
          console.log(object.entity.properties);
          popupCoordinates.value = coordinates;
          showPopup.value = true;
          setValue(object.entity.properties);
        }
      };

      const onActionStartMap = () => {
        showPopup.value = false;
      };

      return {
        LOCATION,
        ZOOM_RANGE,
        refMap,
        showPopup,
        jsonCurrentProperties,
        popupCoordinates,
        projection,
        dataSourceProps,
        layerProps,
        onCLickMap,
        onActionStartMap
      };
    },
    template: `
            <!--Initialize the map and pass initialization location and the Mercator projection used to represent the Earth's surface on a plane-->
            <MMap :location="LOCATION" :projection="projection" :showScaleInCopyrights="true" :zoomRange="ZOOM_RANGE" :ref="refMap">
                <!--Adding our own data source-->
                <MMapTileDataSource v-bind="dataSourceProps" />
                <!--Adding a layer that will display data from \`dataSource\`s-->
                <MMapLayer v-bind="layerProps" />
                <MMapDefaultSchemeLayer />
                <MMapDefaultFeaturesLayer />

                <MMapListener :layer="layerProps.id", :onClick="onCLickMap" :onActionStart="onActionStartMap" />

                <MMapPopupMarker
                    v-if="showPopup"
                    :coordinates="popupCoordinates"
                    position="top"
                    :blockBehaviors="true">
                    <template #content>
                        <div class="info">
                            <pre>{{ jsonCurrentProperties }}</pre>
                        </div>
                    </template>
                </MMapPopupMarker>
            </MMap>`
  });
  app.mount('#app');
}
main();
import type {MMapLayerProps, MMapTileDataSourceProps} from '@mappable-world/mappable-types';
import {fetchHotspotData, DEFAULT_TILE_SIZE} from './tile-data-server';

export const dataSourceProps: MMapTileDataSourceProps = {
  id: 'custom',
  copyrights: ['© OpenRailwayMap contributors'],
  raster: {
    type: 'ground',
    size: DEFAULT_TILE_SIZE,
    /*
        fetchTile is called to get data for displaying a custom tile
        This method can be of several variants:
        1) x y z placeholders for tile coordinates
        2) method that returns final url
        3) method that fetches tile manually

        In this example, we use option 1
        */
    fetchTile: 'https://tiles.openrailwaymap.org/standard/z/x/y.png',
    fetchHotspots: async (x, y, zoom) => {
      const hotspots = await fetchHotspotData(x, y, zoom);
      return hotspots;
    }
  },
  zoomRange: {min: 0, max: 19},
  clampMapZoom: true
};
/*
    A text identifier is used to link the data source and the layer.
    Be careful, the identifier for the data source is set in the id field,
    and the source field is used when transferring to the layer
*/
export const layerProps: MMapLayerProps = {
  id: 'customLayer',
  source: 'custom',
  type: 'ground',
  options: {
    raster: {
      awaitAllTilesOnFirstDisplay: true
    }
  }
};
import {BBox, FeatureCollection, Point} from '@turf/helpers';
import type {
  Hotspot,
  LngLatBounds,
  PixelCoordinates,
  WorldBounds,
  WorldCoordinates
} from '@mappable-world/mappable-types';
import type {WebMercator} from '@mappable-world/mappable-web-mercator-projection';
import {GEOJSON_URL} from './variables';

let featureCollection: FeatureCollection<Point>;
let projection: WebMercator;

export const DEFAULT_TILE_SIZE = 256;
const DEFAULT_HOTSPOT_RADIUS = 26;

const serverReady: Promise<void> = Promise.all([
  fetch(GEOJSON_URL).then((response) => response.json()),
  mappable.import('@mappable-world/mappable-web-mercator-projection'),
  mappable.ready
]).then(([geojson, {WebMercator}]) => {
  featureCollection = geojson;
  projection = new WebMercator();
});

/**
 * Simulating the processing of a request to a real backend
 * @param tx tile x number
 * @param ty tile y number
 * @param zoom map zoom
 */
export async function fetchHotspotData(tx: number, ty: number, zoom: number): Promise<Hotspot[]> {
  await serverReady;
  const tileWorldBounds = tileToWorld(tx, ty, zoom);
  const tileLngLatBounds = tileWorldBounds.map((worldCoordinate) =>
    projection.fromWorldCoordinates(worldCoordinate)
  ) as LngLatBounds;
  const tileFeatureCollection: FeatureCollection<Point> = getPointsInBounds(featureCollection, tileLngLatBounds);

  return getRendererHotspots(tileFeatureCollection, tileLngLatBounds); // function create renderer hotspots (with pixel coordinates)
}

/**
 * Convert tile coordinates to special world coordinates.
 * It's not a real some standard, but it's used in our JS API.
 */
function tileToWorld(tx: number, ty: number, tz: number): WorldBounds {
  const ntiles = 2 ** tz;
  const ts = (1 / ntiles) * 2;

  const x = (tx / ntiles) * 2 - 1;
  const y = -((ty / ntiles) * 2 - 1);

  return [
    {x, y},
    {x: x + ts, y: y - ts}
  ];
}

/**
 * Get points in bounds.
 * @param featureCollection - feature collection with points
 * @param bounds - lnglat bounds
 * @returns feature collection with points in bounds
 */
function getPointsInBounds(
  featureCollection: FeatureCollection<Point>,
  bounds: LngLatBounds
): FeatureCollection<Point> {
  const tilePolygon = turf.bboxPolygon(bounds.flat() as BBox);
  return turf.pointsWithinPolygon(featureCollection, tilePolygon) as FeatureCollection<Point>;
}

function getRendererHotspots(
  tileFeatureCollection: FeatureCollection<Point>,
  tileLngLatBounds: LngLatBounds
): Hotspot[] {
  const [topLeftLngLat, bottomRightLngLat] = tileLngLatBounds;

  return tileFeatureCollection.features.map<Hotspot>((feature) => {
    const {geometry, properties} = feature;
    const center: PixelCoordinates = {
      x:
        ((geometry.coordinates[0] - topLeftLngLat[0]) / (bottomRightLngLat[0] - topLeftLngLat[0])) *
        DEFAULT_TILE_SIZE,
      y:
        ((geometry.coordinates[1] - topLeftLngLat[1]) / (bottomRightLngLat[1] - topLeftLngLat[1])) * DEFAULT_TILE_SIZE
    };

    return {
      type: 'rendered',
      feature: {id: feature.id.toString(), type: feature.type, properties, geometry},
      geometry: {
        type: 'Polygon',
        coordinates: [
          [
            {x: center.x - DEFAULT_HOTSPOT_RADIUS, y: center.y - DEFAULT_HOTSPOT_RADIUS},
            {x: center.x + DEFAULT_HOTSPOT_RADIUS, y: center.y - DEFAULT_HOTSPOT_RADIUS},
            {x: center.x + DEFAULT_HOTSPOT_RADIUS, y: center.y + DEFAULT_HOTSPOT_RADIUS},
            {x: center.x - DEFAULT_HOTSPOT_RADIUS, y: center.y + DEFAULT_HOTSPOT_RADIUS}
          ]
        ]
      }
    };
  });
}