aboutsummaryrefslogtreecommitdiff
path: root/modern/src/map/main
diff options
context:
space:
mode:
authorAnton Tananaev <anton@traccar.org>2022-05-08 11:29:16 -0700
committerAnton Tananaev <anton@traccar.org>2022-05-08 11:29:16 -0700
commit15d7000b16e90db68f52931cc16dfa3ebbd74114 (patch)
treeac336a1ea285fdbc536f71556e473767d6e3c8e2 /modern/src/map/main
parentbe0409f7e855cecceb4e38db611afd3beca255ff (diff)
downloadtrackermap-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.js55
-rw-r--r--modern/src/map/main/CurrentPositionsMap.js11
-rw-r--r--modern/src/map/main/DefaultCameraMap.js52
-rw-r--r--modern/src/map/main/GeofenceMap.js84
-rw-r--r--modern/src/map/main/LiveRoutesMap.js80
-rw-r--r--modern/src/map/main/PoiMap.js72
-rw-r--r--modern/src/map/main/SelectedDeviceMap.js30
-rw-r--r--modern/src/map/main/StatusCard.js134
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;