diff options
author | Anton Tananaev <anton@traccar.org> | 2022-05-08 11:29:16 -0700 |
---|---|---|
committer | Anton Tananaev <anton@traccar.org> | 2022-05-08 11:29:16 -0700 |
commit | 15d7000b16e90db68f52931cc16dfa3ebbd74114 (patch) | |
tree | ac336a1ea285fdbc536f71556e473767d6e3c8e2 /modern/src/map/main | |
parent | be0409f7e855cecceb4e38db611afd3beca255ff (diff) | |
download | trackermap-web-15d7000b16e90db68f52931cc16dfa3ebbd74114.tar.gz trackermap-web-15d7000b16e90db68f52931cc16dfa3ebbd74114.tar.bz2 trackermap-web-15d7000b16e90db68f52931cc16dfa3ebbd74114.zip |
Reorganize map folder
Diffstat (limited to 'modern/src/map/main')
-rw-r--r-- | modern/src/map/main/AccuracyMap.js | 55 | ||||
-rw-r--r-- | modern/src/map/main/CurrentPositionsMap.js | 11 | ||||
-rw-r--r-- | modern/src/map/main/DefaultCameraMap.js | 52 | ||||
-rw-r--r-- | modern/src/map/main/GeofenceMap.js | 84 | ||||
-rw-r--r-- | modern/src/map/main/LiveRoutesMap.js | 80 | ||||
-rw-r--r-- | modern/src/map/main/PoiMap.js | 72 | ||||
-rw-r--r-- | modern/src/map/main/SelectedDeviceMap.js | 30 | ||||
-rw-r--r-- | modern/src/map/main/StatusCard.js | 134 |
8 files changed, 518 insertions, 0 deletions
diff --git a/modern/src/map/main/AccuracyMap.js b/modern/src/map/main/AccuracyMap.js new file mode 100644 index 00000000..870df38f --- /dev/null +++ b/modern/src/map/main/AccuracyMap.js @@ -0,0 +1,55 @@ +import { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import circle from '@turf/circle'; + +import { map } from '../core/Map'; + +const AccuracyMap = () => { + const id = 'accuracy'; + + const positions = useSelector((state) => ({ + type: 'FeatureCollection', + features: Object.values(state.positions.items).filter((position) => position.accuracy > 0).map((position) => circle([position.longitude, position.latitude], position.accuracy * 0.001)), + })); + + useEffect(() => { + map.addSource(id, { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: [], + }, + }); + map.addLayer({ + source: id, + id, + type: 'fill', + filter: [ + 'all', + ['==', '$type', 'Polygon'], + ], + paint: { + 'fill-color': '#3bb2d0', + 'fill-outline-color': '#3bb2d0', + 'fill-opacity': 0.25, + }, + }); + + return () => { + if (map.getLayer(id)) { + map.removeLayer(id); + } + if (map.getSource(id)) { + map.removeSource(id); + } + }; + }, []); + + useEffect(() => { + map.getSource(id).setData(positions); + }, [positions]); + + return null; +}; + +export default AccuracyMap; diff --git a/modern/src/map/main/CurrentPositionsMap.js b/modern/src/map/main/CurrentPositionsMap.js new file mode 100644 index 00000000..80795497 --- /dev/null +++ b/modern/src/map/main/CurrentPositionsMap.js @@ -0,0 +1,11 @@ +import React, { } from 'react'; +import { useSelector } from 'react-redux'; + +import PositionsMap from '../PositionsMap'; + +const CurrentPositionsMap = () => { + const positions = useSelector((state) => state.positions.items); + return (<PositionsMap positions={Object.values(positions)} />); +}; + +export default CurrentPositionsMap; diff --git a/modern/src/map/main/DefaultCameraMap.js b/modern/src/map/main/DefaultCameraMap.js new file mode 100644 index 00000000..e8baadc4 --- /dev/null +++ b/modern/src/map/main/DefaultCameraMap.js @@ -0,0 +1,52 @@ +import maplibregl from 'maplibre-gl'; +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { usePreference } from '../../common/preferences'; +import { map } from '../core/Map'; + +const DefaultCameraMap = () => { + const selectedDeviceId = useSelector((state) => state.devices.selectedId); + const positions = useSelector((state) => state.positions.items); + + const defaultLatitude = usePreference('latitude'); + const defaultLongitude = usePreference('longitude'); + const defaultZoom = usePreference('zoom'); + + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + if (selectedDeviceId) { + setInitialized(true); + } else if (!initialized) { + if (defaultLatitude && defaultLongitude) { + map.jumpTo({ + center: [defaultLongitude, defaultLatitude], + zoom: defaultZoom, + }); + setInitialized(true); + } else { + const coordinates = Object.values(positions).map((item) => [item.longitude, item.latitude]); + if (coordinates.length > 1) { + const bounds = coordinates.reduce((bounds, item) => bounds.extend(item), new maplibregl.LngLatBounds(coordinates[0], coordinates[0])); + const canvas = map.getCanvas(); + map.fitBounds(bounds, { + duration: 0, + padding: Math.min(canvas.width, canvas.height) * 0.1, + }); + setInitialized(true); + } else if (coordinates.length) { + const [individual] = coordinates; + map.jumpTo({ + center: individual, + zoom: Math.max(map.getZoom(), 10), + }); + setInitialized(true); + } + } + } + }, [selectedDeviceId, initialized, defaultLatitude, defaultLongitude, defaultZoom, positions]); + + return null; +}; + +export default DefaultCameraMap; diff --git a/modern/src/map/main/GeofenceMap.js b/modern/src/map/main/GeofenceMap.js new file mode 100644 index 00000000..c0912cb2 --- /dev/null +++ b/modern/src/map/main/GeofenceMap.js @@ -0,0 +1,84 @@ +import { useEffect } from 'react'; +import { useSelector } from 'react-redux'; + +import { map } from '../core/Map'; +import { geofenceToFeature } from '../core/mapUtil'; + +const GeofenceMap = () => { + const id = 'geofences'; + + const geofences = useSelector((state) => state.geofences.items); + + useEffect(() => { + map.addSource(id, { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: [], + }, + }); + map.addLayer({ + source: id, + id: 'geofences-fill', + type: 'fill', + filter: [ + 'all', + ['==', '$type', 'Polygon'], + ], + paint: { + 'fill-color': '#3bb2d0', + 'fill-outline-color': '#3bb2d0', + 'fill-opacity': 0.1, + }, + }); + map.addLayer({ + source: id, + id: 'geofences-line', + type: 'line', + paint: { + 'line-color': '#3bb2d0', + 'line-width': 2, + }, + }); + map.addLayer({ + source: id, + id: 'geofences-title', + type: 'symbol', + layout: { + 'text-field': '{name}', + 'text-font': ['Roboto Regular'], + 'text-size': 12, + }, + paint: { + 'text-halo-color': 'white', + 'text-halo-width': 1, + }, + }); + + return () => { + if (map.getLayer('geofences-fill')) { + map.removeLayer('geofences-fill'); + } + if (map.getLayer('geofences-line')) { + map.removeLayer('geofences-line'); + } + if (map.getLayer('geofences-title')) { + map.removeLayer('geofences-title'); + } + if (map.getSource(id)) { + map.removeSource(id); + } + }; + }, []); + + useEffect(() => { + map.getSource(id).setData({ + type: 'FeatureCollection', + features: Object.values(geofences).map(geofenceToFeature), + }); + }, [geofences]); + + return null; +}; + +export default GeofenceMap; diff --git a/modern/src/map/main/LiveRoutesMap.js b/modern/src/map/main/LiveRoutesMap.js new file mode 100644 index 00000000..5c629d86 --- /dev/null +++ b/modern/src/map/main/LiveRoutesMap.js @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; + +import { useSelector } from 'react-redux'; +import { map } from '../core/Map'; +import { usePrevious } from '../../reactHelper'; + +const LiveRoutesMap = () => { + const id = 'liveRoute'; + + const selectedDeviceId = useSelector((state) => state.devices.selectedId); + const currentDeviceId = usePrevious(selectedDeviceId); + + const position = useSelector((state) => state.positions.items[selectedDeviceId]); + + const [route, setRoute] = useState([]); + + useEffect(() => { + map.addSource(id, { + type: 'geojson', + data: { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [], + }, + }, + }); + map.addLayer({ + source: id, + id, + type: 'line', + layout: { + 'line-join': 'round', + 'line-cap': 'round', + }, + paint: { + 'line-color': '#3bb2d0', + 'line-width': 2, + }, + }); + + return () => { + if (map.getLayer(id)) { + map.removeLayer(id); + } + if (map.getSource(id)) { + map.removeSource(id); + } + }; + }, []); + + useEffect(() => { + if (selectedDeviceId !== currentDeviceId) { + if (!selectedDeviceId) { + setRoute([]); + } else if (position) { + setRoute([position]); + } + } else if (position) { + const last = route.at(-1); + if (!last || (last.latitude !== position.latitude && last.longitude !== position.longitude)) { + setRoute([...route.slice(-9), position]); + } + } + }, [selectedDeviceId, currentDeviceId, position, route]); + + useEffect(() => { + map.getSource(id).setData({ + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: route.map((item) => [item.longitude, item.latitude]), + }, + }); + }, [route]); + + return null; +}; + +export default LiveRoutesMap; diff --git a/modern/src/map/main/PoiMap.js b/modern/src/map/main/PoiMap.js new file mode 100644 index 00000000..4635148a --- /dev/null +++ b/modern/src/map/main/PoiMap.js @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react'; +import { kml } from '@tmcw/togeojson'; + +import { map } from '../core/Map'; +import { useEffectAsync } from '../../reactHelper'; +import { usePreference } from '../../common/preferences'; + +const PoiMap = () => { + const id = 'poi'; + + const poiLayer = usePreference('poiLayer'); + + const [data, setData] = useState(null); + + useEffectAsync(async () => { + if (poiLayer) { + const file = await fetch(poiLayer); + const dom = new DOMParser().parseFromString(await file.text(), 'text/xml'); + setData(kml(dom)); + } + }, [poiLayer]); + + useEffect(() => { + if (data) { + map.addSource(id, { + type: 'geojson', + data, + }); + map.addLayer({ + source: id, + id: 'poi-point', + type: 'circle', + paint: { + 'circle-radius': 5, + 'circle-color': '#3bb2d0', + }, + }); + map.addLayer({ + source: id, + id: 'poi-title', + type: 'symbol', + layout: { + 'text-field': '{name}', + 'text-anchor': 'bottom', + 'text-offset': [0, -0.5], + 'text-font': ['Roboto Regular'], + 'text-size': 12, + }, + paint: { + 'text-halo-color': 'white', + 'text-halo-width': 1, + }, + }); + return () => { + if (map.getLayer('poi-point')) { + map.removeLayer('poi-point'); + } + if (map.getLayer('poi-title')) { + map.removeLayer('poi-title'); + } + if (map.getSource(id)) { + map.removeSource(id); + } + }; + } + return null; + }, [data]); + + return null; +}; + +export default PoiMap; diff --git a/modern/src/map/main/SelectedDeviceMap.js b/modern/src/map/main/SelectedDeviceMap.js new file mode 100644 index 00000000..b5392b70 --- /dev/null +++ b/modern/src/map/main/SelectedDeviceMap.js @@ -0,0 +1,30 @@ +import { useEffect } from 'react'; + +import { useSelector } from 'react-redux'; +import dimensions from '../../theme/dimensions'; +import { map } from '../core/Map'; +import { usePrevious } from '../../reactHelper'; +import usePersistedState from '../../common/usePersistedState'; + +const SelectedDeviceMap = () => { + const selectedDeviceId = useSelector((state) => state.devices.selectedId); + const previousDeviceId = usePrevious(selectedDeviceId); + + const position = useSelector((state) => state.positions.items[selectedDeviceId]); + + const [mapFollow] = usePersistedState('mapFollow', false); + + useEffect(() => { + if ((selectedDeviceId !== previousDeviceId || mapFollow) && position) { + map.easeTo({ + center: [position.longitude, position.latitude], + zoom: Math.max(map.getZoom(), 10), + offset: [0, -dimensions.popupMapOffset / 2], + }); + } + }); + + return null; +}; + +export default SelectedDeviceMap; diff --git a/modern/src/map/main/StatusCard.js b/modern/src/map/main/StatusCard.js new file mode 100644 index 00000000..d431e647 --- /dev/null +++ b/modern/src/map/main/StatusCard.js @@ -0,0 +1,134 @@ +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { + makeStyles, Button, Card, CardContent, Typography, CardActions, CardHeader, IconButton, Avatar, Table, TableBody, TableRow, TableCell, TableContainer, +} from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import ReplayIcon from '@material-ui/icons/Replay'; +import PublishIcon from '@material-ui/icons/Publish'; +import EditIcon from '@material-ui/icons/Edit'; +import DeleteIcon from '@material-ui/icons/Delete'; + +import { useTranslation } from '../../LocalizationProvider'; +import { formatStatus } from '../../common/formatter'; +import RemoveDialog from '../../RemoveDialog'; +import PositionValue from '../../components/PositionValue'; +import dimensions from '../../theme/dimensions'; +import { useDeviceReadonly, useReadonly } from '../../common/permissions'; + +const useStyles = makeStyles((theme) => ({ + card: { + width: dimensions.popupMaxWidth, + }, + negative: { + color: theme.palette.colors.negative, + }, + icon: { + width: '25px', + height: '25px', + filter: 'brightness(0) invert(1)', + }, + table: { + '& .MuiTableCell-sizeSmall': { + paddingLeft: theme.spacing(0.5), + paddingRight: theme.spacing(0.5), + }, + }, + cell: { + borderBottom: 'none', + }, +})); + +const StatusRow = ({ name, content }) => { + const classes = useStyles(); + + return ( + <TableRow> + <TableCell className={classes.cell}> + <Typography variant="body2">{name}</Typography> + </TableCell> + <TableCell className={classes.cell}> + <Typography variant="body2" color="textSecondary">{content}</Typography> + </TableCell> + </TableRow> + ); +}; + +const StatusCard = ({ deviceId, onClose }) => { + const classes = useStyles(); + const history = useHistory(); + const t = useTranslation(); + + const readonly = useReadonly(); + const deviceReadonly = useDeviceReadonly(); + + const device = useSelector((state) => state.devices.items[deviceId]); + const position = useSelector((state) => state.positions.items[deviceId]); + + const [removeDialogShown, setRemoveDialogShown] = useState(false); + + return ( + <> + {device && ( + <Card elevation={3} className={classes.card}> + <CardHeader + avatar={( + <Avatar> + <img className={classes.icon} src={`images/icon/${device.category || 'default'}.svg`} alt="" /> + </Avatar> + )} + action={( + <IconButton onClick={onClose}> + <CloseIcon /> + </IconButton> + )} + title={device.name} + subheader={formatStatus(device.status, t)} + /> + {position && ( + <CardContent> + <TableContainer> + <Table size="small" classes={{ root: classes.table }}> + <TableBody> + <StatusRow name={t('positionSpeed')} content={<PositionValue position={position} property="speed" />} /> + <StatusRow name={t('positionAddress')} content={<PositionValue position={position} property="address" />} /> + {position.attributes.odometer + ? <StatusRow name={t('positionOdometer')} content={<PositionValue position={position} attribute="odometer" />} /> + : <StatusRow name={t('deviceTotalDistance')} content={<PositionValue position={position} attribute="totalDistance" />} />} + <StatusRow name={t('positionCourse')} content={<PositionValue position={position} property="course" />} /> + </TableBody> + </Table> + </TableContainer> + </CardContent> + )} + <CardActions disableSpacing> + <Button onClick={() => history.push(`/position/${position.id}`)} disabled={!position} color="secondary"> + {t('sharedInfoTitle')} + </Button> + <IconButton onClick={() => history.push('/replay')} disabled={!position}> + <ReplayIcon /> + </IconButton> + <IconButton onClick={() => history.push(`/command/${deviceId}`)} disabled={readonly}> + <PublishIcon /> + </IconButton> + <IconButton onClick={() => history.push(`/device/${deviceId}`)} disabled={deviceReadonly}> + <EditIcon /> + </IconButton> + <IconButton onClick={() => setRemoveDialogShown(true)} disabled={deviceReadonly} className={classes.negative}> + <DeleteIcon /> + </IconButton> + </CardActions> + </Card> + )} + <RemoveDialog + open={removeDialogShown} + endpoint="devices" + itemId={deviceId} + onResult={() => setRemoveDialogShown(false)} + /> + </> + ); +}; + +export default StatusCard; |