aboutsummaryrefslogtreecommitdiff
path: root/modern/src/map/core
diff options
context:
space:
mode:
Diffstat (limited to 'modern/src/map/core')
-rw-r--r--modern/src/map/core/Map.js143
-rw-r--r--modern/src/map/core/mapStyles.js55
-rw-r--r--modern/src/map/core/mapUtil.js90
3 files changed, 288 insertions, 0 deletions
diff --git a/modern/src/map/core/Map.js b/modern/src/map/core/Map.js
new file mode 100644
index 00000000..b763dca2
--- /dev/null
+++ b/modern/src/map/core/Map.js
@@ -0,0 +1,143 @@
+import 'maplibre-gl/dist/maplibre-gl.css';
+import '../switcher/switcher.css';
+import maplibregl from 'maplibre-gl';
+import React, {
+ useRef, useLayoutEffect, useEffect, useState,
+} from 'react';
+import { SwitcherControl } from '../switcher/switcher';
+import deviceCategories from '../../common/deviceCategories';
+import { prepareIcon, loadImage } from './mapUtil';
+import {
+ styleCarto, styleLocationIq, styleMapbox, styleMapTiler, styleOsm,
+} from './mapStyles';
+import { useAttributePreference } from '../../common/preferences';
+import palette from '../../theme/palette';
+import { useTranslation } from '../../LocalizationProvider';
+import usePersistedState, { savePersistedState } from '../../common/usePersistedState';
+
+const element = document.createElement('div');
+element.style.width = '100%';
+element.style.height = '100%';
+element.style.boxSizing = 'initial';
+
+export const map = new maplibregl.Map({
+ container: element,
+});
+
+let ready = false;
+const readyListeners = new Set();
+
+const addReadyListener = (listener) => {
+ readyListeners.add(listener);
+ listener(ready);
+};
+
+const removeReadyListener = (listener) => {
+ readyListeners.delete(listener);
+};
+
+const updateReadyValue = (value) => {
+ ready = value;
+ readyListeners.forEach((listener) => listener(value));
+};
+
+const initMap = async () => {
+ if (ready) return;
+ if (!map.hasImage('background')) {
+ const background = await loadImage('/images/background.svg');
+ map.addImage('background', await prepareIcon(background), {
+ pixelRatio: window.devicePixelRatio,
+ });
+ await Promise.all(deviceCategories.map(async (category) => {
+ const results = [];
+ ['positive', 'negative', 'neutral'].forEach((color) => {
+ results.push(loadImage(`/images/icon/${category}.svg`).then((icon) => {
+ map.addImage(`${category}-${color}`, prepareIcon(background, icon, palette.colors[color]), {
+ pixelRatio: window.devicePixelRatio,
+ });
+ }));
+ });
+ await Promise.all(results);
+ }));
+ }
+ updateReadyValue(true);
+};
+
+map.addControl(new maplibregl.NavigationControl({
+ showCompass: false,
+}));
+
+const switcher = new SwitcherControl(
+ () => updateReadyValue(false),
+ (layerId) => savePersistedState('mapLayer', layerId),
+ () => {
+ map.once('styledata', () => {
+ const waiting = () => {
+ if (!map.loaded()) {
+ setTimeout(waiting, 33);
+ } else {
+ initMap();
+ }
+ };
+ waiting();
+ });
+ },
+);
+
+map.addControl(switcher);
+
+const Map = ({ children }) => {
+ const containerEl = useRef(null);
+ const t = useTranslation();
+
+ const [mapReady, setMapReady] = useState(false);
+
+ const [defaultMapLayer] = usePersistedState('mapLayer', 'locationIqStreets');
+ const mapboxAccessToken = useAttributePreference('mapboxAccessToken');
+ const mapTilerKey = useAttributePreference('mapTilerKey');
+ const locationIqKey = useAttributePreference('locationIqKey', 'pk.0f147952a41c555a5b70614039fd148b');
+
+ useEffect(() => {
+ maplibregl.accessToken = mapboxAccessToken;
+ }, [mapboxAccessToken]);
+
+ useEffect(() => {
+ switcher.updateStyles([
+ { id: 'locationIqStreets', title: t('mapLocationIqStreets'), uri: styleLocationIq('streets', locationIqKey) },
+ { id: 'locationIqEarth', title: t('mapLocationIqEarth'), uri: styleLocationIq('earth', locationIqKey) },
+ { id: 'locationIqHybrid', title: t('mapLocationIqHybrid'), uri: styleLocationIq('hybrid', locationIqKey) },
+ { id: 'osm', title: t('mapOsm'), uri: styleOsm() },
+ { id: 'carto', title: t('mapCarto'), uri: styleCarto() },
+ { id: 'mapboxStreets', title: t('mapMapboxStreets'), uri: styleMapbox('streets-v11') },
+ { id: 'mapboxOutdoors', title: t('mapMapboxOutdoors'), uri: styleMapbox('outdoors-v11') },
+ { id: 'mapboxSatellite', title: t('mapMapboxSatellite'), uri: styleMapbox('satellite-v9') },
+ { id: 'mapTilerBasic', title: t('mapMapTilerBasic'), uri: styleMapTiler('basic', mapTilerKey) },
+ { id: 'mapTilerHybrid', title: t('mapMapTilerHybrid'), uri: styleMapTiler('hybrid', mapTilerKey) },
+ ], defaultMapLayer);
+ }, [t, locationIqKey, mapTilerKey, defaultMapLayer]);
+
+ useEffect(() => {
+ const listener = (ready) => setMapReady(ready);
+ addReadyListener(listener);
+ return () => {
+ removeReadyListener(listener);
+ };
+ }, []);
+
+ useLayoutEffect(() => {
+ const currentEl = containerEl.current;
+ currentEl.appendChild(element);
+ map.resize();
+ return () => {
+ currentEl.removeChild(element);
+ };
+ }, [containerEl]);
+
+ return (
+ <div style={{ width: '100%', height: '100%' }} ref={containerEl}>
+ {mapReady && children}
+ </div>
+ );
+};
+
+export default Map;
diff --git a/modern/src/map/core/mapStyles.js b/modern/src/map/core/mapStyles.js
new file mode 100644
index 00000000..86813a13
--- /dev/null
+++ b/modern/src/map/core/mapStyles.js
@@ -0,0 +1,55 @@
+export const styleCustom = (url, attribution) => ({
+ version: 8,
+ sources: {
+ osm: {
+ type: 'raster',
+ tiles: [url],
+ attribution,
+ tileSize: 256,
+ },
+ },
+ glyphs: 'https://cdn.traccar.com/map/fonts/{fontstack}/{range}.pbf',
+ layers: [{
+ id: 'osm',
+ type: 'raster',
+ source: 'osm',
+ }],
+});
+
+export const styleOsm = () => styleCustom(
+ 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
+ '© <a target="_top" rel="noopener" href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
+);
+
+export const styleCarto = () => ({
+ version: 8,
+ sources: {
+ 'raster-tiles': {
+ type: 'raster',
+ tiles: [
+ 'https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png',
+ 'https://b.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png',
+ 'https://c.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png',
+ 'https://d.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png',
+ ],
+ tileSize: 256,
+ attribution: '© <a target="_top" rel="noopener" href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, © <a target="_top" rel="noopener" href="https://carto.com/attribution">CARTO</a>',
+ },
+ },
+ glyphs: 'https://cdn.traccar.com/map/fonts/{fontstack}/{range}.pbf',
+ layers: [
+ {
+ id: 'simple-tiles',
+ type: 'raster',
+ source: 'raster-tiles',
+ minzoom: 0,
+ maxzoom: 22,
+ },
+ ],
+});
+
+export const styleMapbox = (style) => `mapbox://styles/mapbox/${style}`;
+
+export const styleMapTiler = (style, key) => `https://api.maptiler.com/maps/${style}/style.json?key=${key}`;
+
+export const styleLocationIq = (style, key) => `https://tiles.locationiq.com/v3/${style}/vector.json?key=${key}`;
diff --git a/modern/src/map/core/mapUtil.js b/modern/src/map/core/mapUtil.js
new file mode 100644
index 00000000..2aa86c68
--- /dev/null
+++ b/modern/src/map/core/mapUtil.js
@@ -0,0 +1,90 @@
+import { parse, stringify } from 'wellknown';
+import circle from '@turf/circle';
+
+export const loadImage = (url) => new Promise((imageLoaded) => {
+ const image = new Image();
+ image.onload = () => imageLoaded(image);
+ image.src = url;
+});
+
+const canvasTintImage = (image, color) => {
+ const canvas = document.createElement('canvas');
+ canvas.width = image.width * devicePixelRatio;
+ canvas.height = image.height * devicePixelRatio;
+ canvas.style.width = `${image.width}px`;
+ canvas.style.height = `${image.height}px`;
+
+ const context = canvas.getContext('2d');
+
+ context.save();
+ context.fillStyle = color;
+ context.globalAlpha = 1;
+ context.fillRect(0, 0, canvas.width, canvas.height);
+ context.globalCompositeOperation = 'destination-atop';
+ context.globalAlpha = 1;
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
+ context.restore();
+
+ return canvas;
+};
+
+export const prepareIcon = (background, icon, color) => {
+ const canvas = document.createElement('canvas');
+ canvas.width = background.width * devicePixelRatio;
+ canvas.height = background.height * devicePixelRatio;
+ canvas.style.width = `${background.width}px`;
+ canvas.style.height = `${background.height}px`;
+
+ const context = canvas.getContext('2d');
+ context.drawImage(background, 0, 0, canvas.width, canvas.height);
+
+ if (icon) {
+ const iconRatio = 0.5;
+ const imageWidth = canvas.width * iconRatio;
+ const imageHeight = canvas.height * iconRatio;
+ if (navigator.userAgent.indexOf('Firefox') > 0) {
+ context.drawImage(icon, (canvas.width - imageWidth) / 2, (canvas.height - imageHeight) / 2, imageWidth, imageHeight);
+ } else {
+ context.drawImage(canvasTintImage(icon, color), (canvas.width - imageWidth) / 2, (canvas.height - imageHeight) / 2, imageWidth, imageHeight);
+ }
+ }
+
+ return context.getImageData(0, 0, canvas.width, canvas.height);
+};
+
+export const reverseCoordinates = (it) => {
+ if (!it) {
+ return it;
+ } if (Array.isArray(it)) {
+ if (it.length === 2 && !Number.isNaN(it[0]) && !Number.isNaN(it[1])) {
+ return [it[1], it[0]];
+ }
+ return it.map((it) => reverseCoordinates(it));
+ }
+ return {
+ ...it,
+ coordinates: reverseCoordinates(it.coordinates),
+ };
+};
+
+export const geofenceToFeature = (item) => {
+ if (item.area.indexOf('CIRCLE') > -1) {
+ const coordinates = item.area.replace(/CIRCLE|\(|\)|,/g, ' ').trim().split(/ +/);
+ const options = { steps: 32, units: 'meters' };
+ const polygon = circle([Number(coordinates[1]), Number(coordinates[0])], Number(coordinates[2]), options);
+ return {
+ id: item.id,
+ type: 'Feature',
+ geometry: polygon.geometry,
+ properties: { name: item.name },
+ };
+ }
+ return {
+ id: item.id,
+ type: 'Feature',
+ geometry: reverseCoordinates(parse(item.area)),
+ properties: { name: item.name },
+ };
+};
+
+export const geometryToArea = (geometry) => stringify(reverseCoordinates(geometry));