diff options
Diffstat (limited to 'src/map/core')
-rw-r--r-- | src/map/core/MapView.jsx | 123 | ||||
-rw-r--r-- | src/map/core/mapUtil.js | 105 | ||||
-rw-r--r-- | src/map/core/preloadImages.js | 78 | ||||
-rw-r--r-- | src/map/core/useMapStyles.js | 259 |
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]); +}; |