aboutsummaryrefslogtreecommitdiff
path: root/src/map/core
diff options
context:
space:
mode:
Diffstat (limited to 'src/map/core')
-rw-r--r--src/map/core/MapView.jsx123
-rw-r--r--src/map/core/mapUtil.js105
-rw-r--r--src/map/core/preloadImages.js78
-rw-r--r--src/map/core/useMapStyles.js259
4 files changed, 565 insertions, 0 deletions
diff --git a/src/map/core/MapView.jsx b/src/map/core/MapView.jsx
new file mode 100644
index 00000000..35b3a65a
--- /dev/null
+++ b/src/map/core/MapView.jsx
@@ -0,0 +1,123 @@
+import 'maplibre-gl/dist/maplibre-gl.css';
+import maplibregl from 'maplibre-gl';
+import React, {
+ useRef, useLayoutEffect, useEffect, useState,
+} from 'react';
+import { SwitcherControl } from '../switcher/switcher';
+import { useAttributePreference, usePreference } from '../../common/util/preferences';
+import usePersistedState, { savePersistedState } from '../../common/util/usePersistedState';
+import { mapImages } from './preloadImages';
+import useMapStyles from './useMapStyles';
+
+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,
+ attributionControl: false,
+});
+
+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')) {
+ Object.entries(mapImages).forEach(([key, value]) => {
+ map.addImage(key, value, {
+ pixelRatio: window.devicePixelRatio,
+ });
+ });
+ }
+ updateReadyValue(true);
+};
+
+map.addControl(new maplibregl.NavigationControl());
+
+const switcher = new SwitcherControl(
+ () => updateReadyValue(false),
+ (styleId) => savePersistedState('selectedMapStyle', styleId),
+ () => {
+ map.once('styledata', () => {
+ const waiting = () => {
+ if (!map.loaded()) {
+ setTimeout(waiting, 33);
+ } else {
+ initMap();
+ }
+ };
+ waiting();
+ });
+ },
+);
+
+map.addControl(switcher);
+
+const MapView = ({ children }) => {
+ const containerEl = useRef(null);
+
+ const [mapReady, setMapReady] = useState(false);
+
+ const mapStyles = useMapStyles();
+ const activeMapStyles = useAttributePreference('activeMapStyles', 'locationIqStreets,osm,carto');
+ const [defaultMapStyle] = usePersistedState('selectedMapStyle', usePreference('map', 'locationIqStreets'));
+ const mapboxAccessToken = useAttributePreference('mapboxAccessToken');
+ const maxZoom = useAttributePreference('web.maxZoom');
+
+ useEffect(() => {
+ if (maxZoom) {
+ map.setMaxZoom(maxZoom);
+ }
+ }, [maxZoom]);
+
+ useEffect(() => {
+ maplibregl.accessToken = mapboxAccessToken;
+ }, [mapboxAccessToken]);
+
+ useEffect(() => {
+ const filteredStyles = mapStyles.filter((s) => s.available && activeMapStyles.includes(s.id));
+ const styles = filteredStyles.length ? filteredStyles : mapStyles.filter((s) => s.id === 'osm');
+ switcher.updateStyles(styles, defaultMapStyle);
+ }, [mapStyles, defaultMapStyle]);
+
+ 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 MapView;
diff --git a/src/map/core/mapUtil.js b/src/map/core/mapUtil.js
new file mode 100644
index 00000000..8dcded2c
--- /dev/null
+++ b/src/map/core/mapUtil.js
@@ -0,0 +1,105 @@
+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;
+ 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 && typeof it[0] === 'number' && typeof it[1] === 'number') {
+ return [it[1], it[0]];
+ }
+ return it.map((it) => reverseCoordinates(it));
+ }
+ return {
+ ...it,
+ coordinates: reverseCoordinates(it.coordinates),
+ };
+};
+
+export const geofenceToFeature = (theme, item) => {
+ let geometry;
+ 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);
+ geometry = polygon.geometry;
+ } else {
+ geometry = reverseCoordinates(parse(item.area));
+ }
+ return {
+ id: item.id,
+ type: 'Feature',
+ geometry,
+ properties: {
+ name: item.name,
+ color: item.attributes.color || theme.palette.geometry.main,
+ },
+ };
+};
+
+export const geometryToArea = (geometry) => stringify(reverseCoordinates(geometry));
+
+export const findFonts = (map) => {
+ const fontSet = new Set();
+ const { layers } = map.getStyle();
+ layers?.forEach?.((layer) => {
+ layer.layout?.['text-font']?.forEach?.(fontSet.add, fontSet);
+ });
+ const availableFonts = [...fontSet];
+ const regularFont = availableFonts.find((it) => it.includes('Regular'));
+ if (regularFont) {
+ return [regularFont];
+ }
+ const anyFont = availableFonts.find(Boolean);
+ if (anyFont) {
+ return [anyFont];
+ }
+ return ['Roboto Regular'];
+};
diff --git a/src/map/core/preloadImages.js b/src/map/core/preloadImages.js
new file mode 100644
index 00000000..a0056d4c
--- /dev/null
+++ b/src/map/core/preloadImages.js
@@ -0,0 +1,78 @@
+import { grey } from '@mui/material/colors';
+import createPalette from '@mui/material/styles/createPalette';
+import { loadImage, prepareIcon } from './mapUtil';
+
+import arrowSvg from '../../resources/images/arrow.svg';
+import directionSvg from '../../resources/images/direction.svg';
+import backgroundSvg from '../../resources/images/background.svg';
+import animalSvg from '../../resources/images/icon/animal.svg';
+import bicycleSvg from '../../resources/images/icon/bicycle.svg';
+import boatSvg from '../../resources/images/icon/boat.svg';
+import busSvg from '../../resources/images/icon/bus.svg';
+import carSvg from '../../resources/images/icon/car.svg';
+import camperSvg from '../../resources/images/icon/camper.svg';
+import craneSvg from '../../resources/images/icon/crane.svg';
+import defaultSvg from '../../resources/images/icon/default.svg';
+import helicopterSvg from '../../resources/images/icon/helicopter.svg';
+import motorcycleSvg from '../../resources/images/icon/motorcycle.svg';
+import offroadSvg from '../../resources/images/icon/offroad.svg';
+import personSvg from '../../resources/images/icon/person.svg';
+import pickupSvg from '../../resources/images/icon/pickup.svg';
+import planeSvg from '../../resources/images/icon/plane.svg';
+import scooterSvg from '../../resources/images/icon/scooter.svg';
+import shipSvg from '../../resources/images/icon/ship.svg';
+import tractorSvg from '../../resources/images/icon/tractor.svg';
+import trainSvg from '../../resources/images/icon/train.svg';
+import tramSvg from '../../resources/images/icon/tram.svg';
+import trolleybusSvg from '../../resources/images/icon/trolleybus.svg';
+import truckSvg from '../../resources/images/icon/truck.svg';
+import vanSvg from '../../resources/images/icon/van.svg';
+
+export const mapIcons = {
+ animal: animalSvg,
+ bicycle: bicycleSvg,
+ boat: boatSvg,
+ bus: busSvg,
+ car: carSvg,
+ camper: camperSvg,
+ crane: craneSvg,
+ default: defaultSvg,
+ helicopter: helicopterSvg,
+ motorcycle: motorcycleSvg,
+ offroad: offroadSvg,
+ person: personSvg,
+ pickup: pickupSvg,
+ plane: planeSvg,
+ scooter: scooterSvg,
+ ship: shipSvg,
+ tractor: tractorSvg,
+ train: trainSvg,
+ tram: tramSvg,
+ trolleybus: trolleybusSvg,
+ truck: truckSvg,
+ van: vanSvg,
+};
+
+export const mapIconKey = (category) => (mapIcons.hasOwnProperty(category) ? category : 'default');
+
+export const mapImages = {};
+
+const mapPalette = createPalette({
+ neutral: { main: grey[500] },
+});
+
+export default async () => {
+ const background = await loadImage(backgroundSvg);
+ mapImages.background = await prepareIcon(background);
+ mapImages.direction = await prepareIcon(await loadImage(directionSvg));
+ mapImages.arrow = await prepareIcon(await loadImage(arrowSvg));
+ await Promise.all(Object.keys(mapIcons).map(async (category) => {
+ const results = [];
+ ['info', 'success', 'error', 'neutral'].forEach((color) => {
+ results.push(loadImage(mapIcons[category]).then((icon) => {
+ mapImages[`${category}-${color}`] = prepareIcon(background, icon, mapPalette[color].main);
+ }));
+ });
+ await Promise.all(results);
+ }));
+};
diff --git a/src/map/core/useMapStyles.js b/src/map/core/useMapStyles.js
new file mode 100644
index 00000000..7c3412b5
--- /dev/null
+++ b/src/map/core/useMapStyles.js
@@ -0,0 +1,259 @@
+import { useMemo } from 'react';
+import { useSelector } from 'react-redux';
+import { useTranslation } from '../../common/components/LocalizationProvider';
+import { useAttributePreference } from '../../common/util/preferences';
+
+const styleCustom = ({ tiles, minZoom, maxZoom, attribution }) => {
+ const source = {
+ type: 'raster',
+ tiles,
+ attribution,
+ tileSize: 256,
+ minzoom: minZoom,
+ maxzoom: maxZoom,
+ };
+ Object.keys(source).forEach((key) => source[key] === undefined && delete source[key]);
+ return {
+ version: 8,
+ sources: {
+ custom: source,
+ },
+ glyphs: 'https://cdn.traccar.com/map/fonts/{fontstack}/{range}.pbf',
+ layers: [{
+ id: 'custom',
+ type: 'raster',
+ source: 'custom',
+ }],
+ };
+};
+
+export default () => {
+ const t = useTranslation();
+
+ const mapTilerKey = useAttributePreference('mapTilerKey');
+ const locationIqKey = useAttributePreference('locationIqKey') || 'pk.0f147952a41c555a5b70614039fd148b';
+ const bingMapsKey = useAttributePreference('bingMapsKey');
+ const tomTomKey = useAttributePreference('tomTomKey');
+ const hereKey = useAttributePreference('hereKey');
+ const mapboxAccessToken = useAttributePreference('mapboxAccessToken');
+ const customMapUrl = useSelector((state) => state.session.server.mapUrl);
+
+ return useMemo(() => [
+ {
+ id: 'locationIqStreets',
+ title: t('mapLocationIqStreets'),
+ style: `https://tiles.locationiq.com/v3/streets/vector.json?key=${locationIqKey}`,
+ available: true,
+ },
+ {
+ id: 'locationIqDark',
+ title: t('mapLocationIqDark'),
+ style: `https://tiles.locationiq.com/v3/dark/vector.json?key=${locationIqKey}`,
+ available: true,
+ },
+ {
+ id: 'osm',
+ title: t('mapOsm'),
+ style: styleCustom({
+ tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
+ maxZoom: 19,
+ attribution: '© <a target="_top" rel="noopener" href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
+ }),
+ available: true,
+ },
+ {
+ id: 'openTopoMap',
+ title: t('mapOpenTopoMap'),
+ style: styleCustom({
+ tiles: ['a', 'b', 'c'].map((i) => `https://${i}.tile.opentopomap.org/{z}/{x}/{y}.png`),
+ maxZoom: 17,
+ }),
+ available: true,
+ },
+ {
+ id: 'carto',
+ title: t('mapCarto'),
+ style: styleCustom({
+ tiles: ['a', 'b', 'c', 'd'].map((i) => `https://${i}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png`),
+ maxZoom: 22,
+ attribution: '© <a target="_top" rel="noopener" href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, © <a target="_top" rel="noopener" href="https://carto.com/attribution">CARTO</a>',
+ }),
+ available: true,
+ },
+ {
+ id: 'googleRoad',
+ title: t('mapGoogleRoad'),
+ style: styleCustom({
+ tiles: [0, 1, 2, 3].map((i) => `https://mt${i}.google.com/vt/lyrs=m&hl=en&x={x}&y={y}&z={z}&s=Ga`),
+ maxZoom: 20,
+ attribution: '© Google',
+ }),
+ available: true,
+ },
+ {
+ id: 'googleSatellite',
+ title: t('mapGoogleSatellite'),
+ style: styleCustom({
+ tiles: [0, 1, 2, 3].map((i) => `https://mt${i}.google.com/vt/lyrs=s&hl=en&x={x}&y={y}&z={z}&s=Ga`),
+ maxZoom: 20,
+ attribution: '© Google',
+ }),
+ available: true,
+ },
+ {
+ id: 'googleHybrid',
+ title: t('mapGoogleHybrid'),
+ style: styleCustom({
+ tiles: [0, 1, 2, 3].map((i) => `https://mt${i}.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}&s=Ga`),
+ maxZoom: 20,
+ attribution: '© Google',
+ }),
+ available: true,
+ },
+ {
+ id: 'mapTilerBasic',
+ title: t('mapMapTilerBasic'),
+ style: `https://api.maptiler.com/maps/basic/style.json?key=${mapTilerKey}`,
+ available: !!mapTilerKey,
+ attribute: 'mapTilerKey',
+ },
+ {
+ id: 'mapTilerHybrid',
+ title: t('mapMapTilerHybrid'),
+ style: `https://api.maptiler.com/maps/hybrid/style.json?key=${mapTilerKey}`,
+ available: !!mapTilerKey,
+ attribute: 'mapTilerKey',
+ },
+ {
+ id: 'bingRoad',
+ title: t('mapBingRoad'),
+ style: styleCustom({
+ tiles: [0, 1, 2, 3].map((i) => `https://t${i}.ssl.ak.dynamic.tiles.virtualearth.net/comp/ch/{quadkey}?mkt=en-US&it=G,L&shading=hill&og=1885&n=z`),
+ maxZoom: 21,
+ }),
+ available: !!bingMapsKey,
+ attribute: 'bingMapsKey',
+ },
+ {
+ id: 'bingAerial',
+ title: t('mapBingAerial'),
+ style: styleCustom({
+ tiles: [0, 1, 2, 3].map((i) => `https://ecn.t${i}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=12327`),
+ maxZoom: 19,
+ }),
+ available: !!bingMapsKey,
+ attribute: 'bingMapsKey',
+ },
+ {
+ id: 'bingHybrid',
+ title: t('mapBingHybrid'),
+ style: styleCustom({
+ tiles: [0, 1, 2, 3].map((i) => `https://t${i}.ssl.ak.dynamic.tiles.virtualearth.net/comp/ch/{quadkey}?mkt=en-US&it=A,G,L&og=1885&n=z`),
+ maxZoom: 19,
+ }),
+ available: !!bingMapsKey,
+ attribute: 'bingMapsKey',
+ },
+ {
+ id: 'tomTomBasic',
+ title: t('mapTomTomBasic'),
+ style: `https://api.tomtom.com/map/1/style/20.0.0-8/basic_main.json?key=${tomTomKey}`,
+ available: !!tomTomKey,
+ attribute: 'tomTomKey',
+ },
+ {
+ id: 'hereBasic',
+ title: t('mapHereBasic'),
+ style: `https://assets.vector.hereapi.com/styles/berlin/base/mapbox/tilezen?apikey=${hereKey}`,
+ available: !!hereKey,
+ attribute: 'hereKey',
+ },
+ {
+ id: 'hereHybrid',
+ title: t('mapHereHybrid'),
+ style: styleCustom({
+ tiles: [1, 2, 3, 4].map((i) => `https://${i}.aerial.maps.ls.hereapi.com/maptile/2.1/maptile/newest/hybrid.day/{z}/{x}/{y}/256/png8?apiKey=${hereKey}`),
+ maxZoom: 20,
+ }),
+ available: !!hereKey,
+ attribute: 'hereKey',
+ },
+ {
+ id: 'hereSatellite',
+ title: t('mapHereSatellite'),
+ style: styleCustom({
+ tiles: [1, 2, 3, 4].map((i) => `https://${i}.aerial.maps.ls.hereapi.com/maptile/2.1/maptile/newest/satellite.day/{z}/{x}/{y}/256/png8?apiKey=${hereKey}`),
+ maxZoom: 19,
+ }),
+ available: !!hereKey,
+ attribute: 'hereKey',
+ },
+ {
+ id: 'autoNavi',
+ title: t('mapAutoNavi'),
+ style: styleCustom({
+ tiles: [1, 2, 3, 4].map((i) => `https://webrd0${i}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}`),
+ minZoom: 3,
+ maxZoom: 18,
+ }),
+ available: true,
+ },
+ {
+ id: 'ordnanceSurvey',
+ title: t('mapOrdnanceSurvey'),
+ style: 'https://api.os.uk/maps/vector/v1/vts/resources/styles?key=EAZ8p83u72FTGiLjLC2MsTAl1ko6XQHC',
+ transformRequest: (url) => ({
+ url: `${url}&srs=3857`,
+ }),
+ available: true,
+ },
+ {
+ id: 'mapboxStreets',
+ title: t('mapMapboxStreets'),
+ style: styleCustom({
+ tiles: [`https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/{z}/{x}/{y}?access_token=${mapboxAccessToken}`],
+ maxZoom: 22,
+ }),
+ available: !!mapboxAccessToken,
+ attribute: 'mapboxAccessToken',
+ },
+ {
+ id: 'mapboxStreetsDark',
+ title: t('mapMapboxStreetsDark'),
+ style: styleCustom({
+ tiles: [`https://api.mapbox.com/styles/v1/mapbox/dark-v11/tiles/{z}/{x}/{y}?access_token=${mapboxAccessToken}`],
+ maxZoom: 22,
+ }),
+ available: !!mapboxAccessToken,
+ attribute: 'mapboxAccessToken',
+ },
+ {
+ id: 'mapboxOutdoors',
+ title: t('mapMapboxOutdoors'),
+ style: styleCustom({
+ tiles: [`https://api.mapbox.com/styles/v1/mapbox/outdoors-v11/tiles/{z}/{x}/{y}?access_token=${mapboxAccessToken}`],
+ maxZoom: 22,
+ }),
+ available: !!mapboxAccessToken,
+ attribute: 'mapboxAccessToken',
+ },
+ {
+ id: 'mapboxSatelliteStreet',
+ title: t('mapMapboxSatellite'),
+ style: styleCustom({
+ tiles: [`https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v11/tiles/{z}/{x}/{y}?access_token=${mapboxAccessToken}`],
+ maxZoom: 22,
+ }),
+ available: !!mapboxAccessToken,
+ attribute: 'mapboxAccessToken',
+ },
+ {
+ id: 'custom',
+ title: t('mapCustom'),
+ style: styleCustom({
+ tiles: [customMapUrl],
+ }),
+ available: !!customMapUrl,
+ },
+ ], [t, mapTilerKey, locationIqKey, bingMapsKey, tomTomKey, hereKey, mapboxAccessToken, customMapUrl]);
+};