diff options
Diffstat (limited to 'modern/src/map/core')
-rw-r--r-- | modern/src/map/core/Map.js | 143 | ||||
-rw-r--r-- | modern/src/map/core/mapStyles.js | 55 | ||||
-rw-r--r-- | modern/src/map/core/mapUtil.js | 90 |
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)); |