aboutsummaryrefslogtreecommitdiff
path: root/modern
diff options
context:
space:
mode:
Diffstat (limited to 'modern')
-rw-r--r--modern/package.json1
-rw-r--r--modern/src/App.js36
-rw-r--r--modern/src/DevicesList.js2
-rw-r--r--modern/src/MainPage.js13
-rw-r--r--modern/src/MainToolbar.js9
-rw-r--r--modern/src/RemoveDialog.js2
-rw-r--r--modern/src/common/preferences.js21
-rw-r--r--modern/src/map/AccuracyMap.js53
-rw-r--r--modern/src/map/CurrentLocationMap.js21
-rw-r--r--modern/src/map/CurrentPositionsMap.js11
-rw-r--r--modern/src/map/Map.js103
-rw-r--r--modern/src/map/PositionsMap.js52
-rw-r--r--modern/src/map/ReplayPathMap.js59
-rw-r--r--modern/src/map/StatusView.js2
-rw-r--r--modern/src/map/mapStyles.js52
-rw-r--r--modern/src/map/mapUtil.js24
-rw-r--r--modern/src/map/switcher/switcher.css40
-rw-r--r--modern/src/map/switcher/switcher.js79
-rw-r--r--modern/src/reactHelper.js6
-rw-r--r--modern/src/reports/FilterForm.js88
-rw-r--r--modern/src/reports/ReplayPage.js121
21 files changed, 686 insertions, 109 deletions
diff --git a/modern/package.json b/modern/package.json
index bd1dd33e..e5438b53 100644
--- a/modern/package.json
+++ b/modern/package.json
@@ -9,6 +9,7 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.56",
"@reduxjs/toolkit": "^1.4.0",
+ "@turf/turf": "^5.1.6",
"mapbox-gl": "^1.12.0",
"moment": "^2.28.0",
"ol": "^6.4.3",
diff --git a/modern/src/App.js b/modern/src/App.js
index 6ea74297..f4de837b 100644
--- a/modern/src/App.js
+++ b/modern/src/App.js
@@ -15,26 +15,38 @@ import GroupsPage from './settings/GroupsPage';
import GroupPage from './settings/GroupPage';
import PositionPage from './PositionPage';
import EventReportPage from './reports/EventReportPage';
+import ReplayPage from './reports/ReplayPage';
+import { useSelector } from 'react-redux';
+import { LinearProgress } from '@material-ui/core';
const App = () => {
+ const initialized = useSelector(state => !!state.session.server && !!state.session.user);
+
return (
<>
<CssBaseline />
<SocketController />
<Switch>
- <Route exact path='/' component={MainPage} />
<Route exact path='/login' component={LoginPage} />
- <Route exact path='/position/:id?' component={PositionPage} />
- <Route exact path='/user/:id?' component={UserPage} />
- <Route exact path='/device/:id?' component={DevicePage} />
- <Route exact path='/reports/route' component={RouteReportPage} />
- <Route exact path='/settings/notifications' component={NotificationsPage} />
- <Route exact path='/settings/notification/:id?' component={NotificationPage} />
- <Route exact path='/settings/groups' component={GroupsPage} />
- <Route exact path='/settings/group/:id?' component={GroupPage} />
- <Route exact path='/admin/server' component={ServerPage} />
- <Route exact path='/admin/users' component={UsersPage} />
- <Route exact path='/reports/event' component={EventReportPage} />
+ <Route>
+ {!initialized ? (<LinearProgress />) : (
+ <Switch>
+ <Route exact path='/' component={MainPage} />
+ <Route exact path='/replay' component={ReplayPage} />
+ <Route exact path='/position/:id?' component={PositionPage} />
+ <Route exact path='/user/:id?' component={UserPage} />
+ <Route exact path='/device/:id?' component={DevicePage} />
+ <Route exact path='/reports/route' component={RouteReportPage} />
+ <Route exact path='/settings/notifications' component={NotificationsPage} />
+ <Route exact path='/settings/notification/:id?' component={NotificationPage} />
+ <Route exact path='/settings/groups' component={GroupsPage} />
+ <Route exact path='/settings/group/:id?' component={GroupPage} />
+ <Route exact path='/admin/server' component={ServerPage} />
+ <Route exact path='/admin/users' component={UsersPage} />
+ <Route exact path='/reports/event' component={EventReportPage} />
+ </Switch>
+ )}
+ </Route>
</Switch>
</>
);
diff --git a/modern/src/DevicesList.js b/modern/src/DevicesList.js
index 15badcb9..976fd84a 100644
--- a/modern/src/DevicesList.js
+++ b/modern/src/DevicesList.js
@@ -47,7 +47,7 @@ const DeviceView = ({ updateTimestamp, onMenuClick }) => {
<ListItem button key={item.id} onClick={() => dispatch(devicesActions.select(item))}>
<ListItemAvatar>
<Avatar>
- <img className={classes.icon} src={`images/icon/${item.category || 'default'}.svg`} />
+ <img className={classes.icon} src={`images/icon/${item.category || 'default'}.svg`} alt="" />
</Avatar>
</ListItemAvatar>
<ListItemText primary={item.name} secondary={item.uniqueId} />
diff --git a/modern/src/MainPage.js b/modern/src/MainPage.js
index adb86b25..b6b5044c 100644
--- a/modern/src/MainPage.js
+++ b/modern/src/MainPage.js
@@ -1,15 +1,15 @@
import React from 'react';
-import { useSelector } from 'react-redux';
import { isWidthUp, makeStyles, withWidth } from '@material-ui/core';
import Drawer from '@material-ui/core/Drawer';
import ContainerDimensions from 'react-container-dimensions';
-import LinearProgress from '@material-ui/core/LinearProgress';
import DevicesList from './DevicesList';
import MainToolbar from './MainToolbar';
import Map from './map/Map';
-import PositionsMap from './map/PositionsMap';
import SelectedDeviceMap from './map/SelectedDeviceMap';
+import AccuracyMap from './map/AccuracyMap';
import GeofenceMap from './map/GeofenceMap';
+import CurrentPositionsMap from './map/CurrentPositionsMap';
+import CurrentLocationMap from './map/CurrentLocationMap';
const useStyles = makeStyles(theme => ({
root: {
@@ -41,10 +41,9 @@ const useStyles = makeStyles(theme => ({
}));
const MainPage = ({ width }) => {
- const initialized = useSelector(state => !!state.session.server && !!state.session.user);
const classes = useStyles();
- return !initialized ? (<LinearProgress />) : (
+ return (
<div className={classes.root}>
<MainToolbar />
<div className={classes.content}>
@@ -57,8 +56,10 @@ const MainPage = ({ width }) => {
<div className={classes.mapContainer}>
<ContainerDimensions>
<Map>
+ <CurrentLocationMap />
<GeofenceMap />
- <PositionsMap />
+ <AccuracyMap />
+ <CurrentPositionsMap />
<SelectedDeviceMap />
</Map>
</ContainerDimensions>
diff --git a/modern/src/MainToolbar.js b/modern/src/MainToolbar.js
index 897fd37e..b853dc47 100644
--- a/modern/src/MainToolbar.js
+++ b/modern/src/MainToolbar.js
@@ -29,6 +29,7 @@ import NotificationsActiveIcon from '@material-ui/icons/NotificationsActive';
import FormatListBulletedIcon from '@material-ui/icons/FormatListBulleted';
import TrendingUpIcon from '@material-ui/icons/TrendingUp';
import FolderIcon from '@material-ui/icons/Folder';
+import ReplayIcon from '@material-ui/icons/Replay';
import t from './common/localization';
const useStyles = makeStyles(theme => ({
@@ -78,7 +79,7 @@ const MainToolbar = () => {
</IconButton>
<Typography variant="h6" color="inherit" className={classes.flex}>
Traccar
- </Typography>
+ </Typography>
<Button color="inherit" onClick={handleLogout}>{t('loginLogout')}</Button>
</Toolbar>
</AppBar>
@@ -96,6 +97,12 @@ const MainToolbar = () => {
</ListItemIcon>
<ListItemText primary={t('mapTitle')} />
</ListItem>
+ <ListItem button onClick={() => history.push('/replay')}>
+ <ListItemIcon>
+ <ReplayIcon />
+ </ListItemIcon>
+ <ListItemText primary={t('reportReplay')} />
+ </ListItem>
</List>
<Divider />
<List
diff --git a/modern/src/RemoveDialog.js b/modern/src/RemoveDialog.js
index 8e7d97f4..bbcfb226 100644
--- a/modern/src/RemoveDialog.js
+++ b/modern/src/RemoveDialog.js
@@ -8,7 +8,7 @@ import DialogContentText from '@material-ui/core/DialogContentText';
const RemoveDialog = ({ open, endpoint, itemId, onResult }) => {
const handleRemove = async () => {
- const response = await fetch(`/api/${endpoint}/${itemId}`, { method: 'DELETE' })
+ const response = await fetch(`/api/${endpoint}/${itemId}`, { method: 'DELETE' });
if (response.ok) {
onResult(true);
}
diff --git a/modern/src/common/preferences.js b/modern/src/common/preferences.js
new file mode 100644
index 00000000..24fe389a
--- /dev/null
+++ b/modern/src/common/preferences.js
@@ -0,0 +1,21 @@
+import { useSelector } from 'react-redux';
+
+export const usePreference = (key, defaultValue) => {
+ return useSelector(state => {
+ if (state.session.server.forceSettings) {
+ return state.session.server[key] || state.session.user[key] || defaultValue;
+ } else {
+ return state.session.user[key] || state.session.server[key] || defaultValue;
+ }
+ });
+};
+
+export const useAttributePreference = (key, defaultValue) => {
+ return useSelector(state => {
+ if (state.session.server.forceSettings) {
+ return state.session.server.attributes[key] || state.session.user.attributes[key] || defaultValue;
+ } else {
+ return state.session.user.attributes[key] || state.session.server.attributes[key] || defaultValue;
+ }
+ });
+};
diff --git a/modern/src/map/AccuracyMap.js b/modern/src/map/AccuracyMap.js
new file mode 100644
index 00000000..e81fc8fb
--- /dev/null
+++ b/modern/src/map/AccuracyMap.js
@@ -0,0 +1,53 @@
+import { useEffect } from 'react';
+import { useSelector } from 'react-redux';
+import circle from '@turf/circle';
+
+import { map } from './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': id,
+ 'type': 'fill',
+ 'filter': [
+ 'all',
+ ['==', '$type', 'Polygon'],
+ ],
+ 'paint': {
+ 'fill-color':'#3bb2d0',
+ 'fill-outline-color':'#3bb2d0',
+ 'fill-opacity':0.25,
+ },
+ });
+
+ return () => {
+ map.removeLayer(id);
+ map.removeSource(id);
+ };
+ }, []);
+
+ useEffect(() => {
+ map.getSource(id).setData(positions);
+ }, [positions]);
+
+ return null;
+}
+
+export default AccuracyMap;
diff --git a/modern/src/map/CurrentLocationMap.js b/modern/src/map/CurrentLocationMap.js
new file mode 100644
index 00000000..31e6e285
--- /dev/null
+++ b/modern/src/map/CurrentLocationMap.js
@@ -0,0 +1,21 @@
+import mapboxgl from 'mapbox-gl';
+import { useEffect } from 'react';
+import { map } from './Map';
+
+const CurrentLocationMap = () => {
+ useEffect(() => {
+ const control = new mapboxgl.GeolocateControl({
+ positionOptions: {
+ enableHighAccuracy: true,
+ timeout: 5000,
+ },
+ trackUserLocation: true,
+ });
+ map.addControl(control);
+ return () => map.removeControl(control);
+ }, []);
+
+ return null;
+}
+
+export default CurrentLocationMap;
diff --git a/modern/src/map/CurrentPositionsMap.js b/modern/src/map/CurrentPositionsMap.js
new file mode 100644
index 00000000..0bfe4fcd
--- /dev/null
+++ b/modern/src/map/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 => Object.values(state.positions.items));
+ return (<PositionsMap positions={positions} />);
+}
+
+export default CurrentPositionsMap;
diff --git a/modern/src/map/Map.js b/modern/src/map/Map.js
index fec8d501..8a43e97b 100644
--- a/modern/src/map/Map.js
+++ b/modern/src/map/Map.js
@@ -1,8 +1,13 @@
import 'mapbox-gl/dist/mapbox-gl.css';
+import './switcher/switcher.css';
import mapboxgl from 'mapbox-gl';
+import { SwitcherControl } from './switcher/switcher';
import React, { useRef, useLayoutEffect, useEffect, useState } from 'react';
import { deviceCategories } from '../common/deviceCategories';
import { loadIcon, loadImage } from './mapUtil';
+import { styleCarto, styleMapbox, styleOsm } from './mapStyles';
+import t from '../common/localization';
+import { useAttributePreference } from '../common/preferences';
const element = document.createElement('div');
element.style.width = '100%';
@@ -10,55 +15,83 @@ element.style.height = '100%';
export const map = new mapboxgl.Map({
container: element,
- style: {
- version: 8,
- sources: {
- osm: {
- type: 'raster',
- tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
- tileSize: 256,
- attribution: '© <a target="_top" rel="noopener" href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
- },
- },
- glyphs: 'https://cdn.traccar.com/map/fonts/{fontstack}/{range}.pbf',
- layers: [{
- id: 'osm',
- type: 'raster',
- source: 'osm',
- }],
- },
+ style: styleOsm(),
});
-map.addControl(new mapboxgl.NavigationControl());
+let ready = false;
+const readyListeners = new Set();
-let readyListeners = [];
+const addReadyListener = listener => {
+ readyListeners.add(listener);
+ listener(ready);
+};
-const onMapReady = listener => {
- if (!readyListeners) {
- listener();
- } else {
- readyListeners.push(listener);
- }
+const removeReadyListener = listener => {
+ readyListeners.delete(listener);
};
-map.on('load', async () => {
+const updateReadyValue = value => {
+ ready = value;
+ readyListeners.forEach(listener => listener(value));
+};
+
+const initMap = async () => {
const background = await loadImage('images/background.svg');
await Promise.all(deviceCategories.map(async category => {
- const imageData = await loadIcon(category, background, `images/icon/${category}.svg`);
- map.addImage(category, imageData, { pixelRatio: window.devicePixelRatio });
+ if (!map.hasImage(category)) {
+ const imageData = await loadIcon(category, background, `images/icon/${category}.svg`);
+ map.addImage(category, imageData, { pixelRatio: window.devicePixelRatio });
+ }
}));
- if (readyListeners) {
- readyListeners.forEach(listener => listener());
- readyListeners = null;
- }
-});
+ updateReadyValue(true);
+};
+
+map.on('load', initMap);
+
+map.addControl(new mapboxgl.NavigationControl({
+ showCompass: false,
+}));
+
+map.addControl(new SwitcherControl(
+ [
+ { title: t('mapOsm'), uri: styleOsm() },
+ { title: t('mapCarto'), uri: styleCarto() },
+ { title: t('mapMapboxStreets'), uri: styleMapbox('streets-v11') },
+ { title: t('mapMapboxOutdoors'), uri: styleMapbox('outdoors-v11') },
+ { title: t('mapMapboxSatellite'), uri: styleMapbox('satellite-v9') },
+ ],
+ t('mapOsm'),
+ () => updateReadyValue(false),
+ () => {
+ const waiting = () => {
+ if (!map.loaded()) {
+ setTimeout(waiting, 100);
+ } else {
+ initMap();
+ }
+ };
+ waiting();
+ },
+));
const Map = ({ children }) => {
const containerEl = useRef(null);
const [mapReady, setMapReady] = useState(false);
-
- useEffect(() => onMapReady(() => setMapReady(true)), []);
+
+ const mapboxAccessToken = useAttributePreference('mapboxAccessToken');
+
+ useEffect(() => {
+ mapboxgl.accessToken = mapboxAccessToken;
+ }, [mapboxAccessToken]);
+
+ useEffect(() => {
+ const listener = ready => setMapReady(ready);
+ addReadyListener(listener);
+ return () => {
+ removeReadyListener(listener);
+ };
+ }, []);
useLayoutEffect(() => {
const currentEl = containerEl.current;
diff --git a/modern/src/map/PositionsMap.js b/modern/src/map/PositionsMap.js
index 6a7a68bb..fa7b431a 100644
--- a/modern/src/map/PositionsMap.js
+++ b/modern/src/map/PositionsMap.js
@@ -1,4 +1,4 @@
-import React, { useEffect } from 'react';
+import React, { useCallback, useEffect } from 'react';
import ReactDOM from 'react-dom';
import mapboxgl from 'mapbox-gl';
import { Provider, useSelector } from 'react-redux';
@@ -8,36 +8,25 @@ import store from '../store';
import { useHistory } from 'react-router-dom';
import StatusView from './StatusView';
-const PositionsMap = () => {
+const PositionsMap = ({ positions }) => {
const id = 'positions';
const history = useHistory();
+ const devices = useSelector(state => state.devices.items);
- const createFeature = (state, position) => {
- const device = state.devices.items[position.deviceId] || null;
+ const createFeature = (devices, position) => {
+ const device = devices[position.deviceId] || null;
return {
deviceId: position.deviceId,
name: device ? device.name : '',
- category: device && device.category || 'default',
+ category: device && (device.category || 'default'),
}
};
- const positions = useSelector(state => ({
- type: 'FeatureCollection',
- features: Object.values(state.positions.items).map(position => ({
- type: 'Feature',
- geometry: {
- type: 'Point',
- coordinates: [position.longitude, position.latitude]
- },
- properties: createFeature(state, position),
- })),
- }));
-
const onMouseEnter = () => map.getCanvas().style.cursor = 'pointer';
const onMouseLeave = () => map.getCanvas().style.cursor = '';
- const onClick = event => {
+ const onClickCallback = useCallback(event => {
const feature = event.features[0];
let coordinates = feature.geometry.coordinates.slice();
while (Math.abs(event.lngLat.lng - coordinates[0]) > 180) {
@@ -59,12 +48,15 @@ const PositionsMap = () => {
.setDOMContent(placeholder)
.setLngLat(coordinates)
.addTo(map);
- };
+ }, [history]);
useEffect(() => {
map.addSource(id, {
'type': 'geojson',
- 'data': positions,
+ 'data': {
+ type: 'FeatureCollection',
+ features: [],
+ }
});
map.addLayer({
'id': id,
@@ -88,23 +80,33 @@ const PositionsMap = () => {
map.on('mouseenter', id, onMouseEnter);
map.on('mouseleave', id, onMouseLeave);
- map.on('click', id, onClick);
+ map.on('click', id, onClickCallback);
return () => {
Array.from(map.getContainer().getElementsByClassName('mapboxgl-popup')).forEach(el => el.remove());
map.off('mouseenter', id, onMouseEnter);
map.off('mouseleave', id, onMouseLeave);
- map.off('click', id, onClick);
+ map.off('click', id, onClickCallback);
map.removeLayer(id);
map.removeSource(id);
};
- }, []);
+ }, [onClickCallback]);
useEffect(() => {
- map.getSource(id).setData(positions);
- }, [positions]);
+ map.getSource(id).setData({
+ type: 'FeatureCollection',
+ features: positions.map(position => ({
+ type: 'Feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [position.longitude, position.latitude],
+ },
+ properties: createFeature(devices, position),
+ }))
+ });
+ }, [devices, positions]);
return null;
}
diff --git a/modern/src/map/ReplayPathMap.js b/modern/src/map/ReplayPathMap.js
new file mode 100644
index 00000000..feab0718
--- /dev/null
+++ b/modern/src/map/ReplayPathMap.js
@@ -0,0 +1,59 @@
+import mapboxgl from 'mapbox-gl';
+import { useEffect } from 'react';
+import { map } from './Map';
+
+const ReplayPathMap = ({ positions }) => {
+ const id = 'replay';
+
+ useEffect(() => {
+ map.addSource(id, {
+ 'type': 'geojson',
+ 'data': {
+ type: 'Feature',
+ geometry: {
+ type: 'LineString',
+ coordinates: [],
+ },
+ },
+ });
+ map.addLayer({
+ 'source': id,
+ 'id': id,
+ 'type': 'line',
+ 'layout': {
+ 'line-join': 'round',
+ 'line-cap': 'round',
+ },
+ 'paint': {
+ 'line-color': '#333',
+ 'line-width': 5,
+ },
+ });
+
+ return () => {
+ map.removeLayer(id);
+ map.removeSource(id);
+ };
+ }, []);
+
+ useEffect(() => {
+ const coordinates = positions.map(item => [item.longitude, item.latitude]);
+ map.getSource(id).setData({
+ type: 'Feature',
+ geometry: {
+ type: 'LineString',
+ coordinates: coordinates,
+ },
+ });
+ if (coordinates.length) {
+ const bounds = coordinates.reduce((bounds, item) => bounds.extend(item), new mapboxgl.LngLatBounds(coordinates[0], coordinates[0]));
+ map.fitBounds(bounds, {
+ padding: { top: 50, bottom: 250, left: 25, right: 25 },
+ });
+ }
+ }, [positions]);
+
+ return null;
+}
+
+export default ReplayPathMap;
diff --git a/modern/src/map/StatusView.js b/modern/src/map/StatusView.js
index 3a304263..ae049af1 100644
--- a/modern/src/map/StatusView.js
+++ b/modern/src/map/StatusView.js
@@ -22,7 +22,7 @@ const StatusView = ({ deviceId, onShowDetails }) => {
{position.attributes.batteryLevel &&
<><b>{t('positionBattery')}:</b> {formatPosition(position.attributes.batteryLevel, 'batteryLevel')}<br /></>
}
- <a href="#" onClick={handleClick}>{t('sharedShowDetails')}</a>
+ <a href="/" onClick={handleClick}>{t('sharedShowDetails')}</a>
</>
);
};
diff --git a/modern/src/map/mapStyles.js b/modern/src/map/mapStyles.js
new file mode 100644
index 00000000..ff323cb3
--- /dev/null
+++ b/modern/src/map/mapStyles.js
@@ -0,0 +1,52 @@
+export const styleCustom = (url, attribution) => ({
+ version: 8,
+ sources: {
+ osm: {
+ type: 'raster',
+ tiles: [url],
+ attribution: attribution,
+ },
+ },
+ 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}`;
diff --git a/modern/src/map/mapUtil.js b/modern/src/map/mapUtil.js
index 71d7b3c9..15f16202 100644
--- a/modern/src/map/mapUtil.js
+++ b/modern/src/map/mapUtil.js
@@ -27,30 +27,6 @@ export const loadIcon = async (key, background, url) => {
return context.getImageData(0, 0, canvas.width, canvas.height);
};
-export const calculateBounds = features => {
- if (features && features.length) {
- const first = features[0].geometry.coordinates;
- const bounds = [[...first], [...first]];
- for (let feature of features) {
- const longitude = feature.geometry.coordinates[0]
- const latitude = feature.geometry.coordinates[1]
- if (longitude < bounds[0][0]) {
- bounds[0][0] = longitude;
- } else if (longitude > bounds[1][0]) {
- bounds[1][0] = longitude;
- }
- if (latitude < bounds[0][1]) {
- bounds[0][1] = latitude;
- } else if (latitude > bounds[1][1]) {
- bounds[1][1] = latitude;
- }
- }
- return bounds;
- } else {
- return null;
- }
-}
-
export const reverseCoordinates = it => {
if (!it) {
return it;
diff --git a/modern/src/map/switcher/switcher.css b/modern/src/map/switcher/switcher.css
new file mode 100644
index 00000000..6c8fdb4e
--- /dev/null
+++ b/modern/src/map/switcher/switcher.css
@@ -0,0 +1,40 @@
+.mapboxgl-style-list
+{
+ display: none;
+}
+
+.mapboxgl-ctrl-group .mapboxgl-style-list button
+{
+ background: none;
+ border: none;
+ cursor: pointer;
+ display: block;
+ font-size: 14px;
+ padding: 8px 8px 6px;
+ text-align: right;
+ width: 100%;
+ height: auto;
+}
+
+.mapboxgl-style-list button.active
+{
+ font-weight: bold;
+}
+
+.mapboxgl-style-list button:hover
+{
+ background-color: rgba(0, 0, 0, 0.05);
+}
+
+.mapboxgl-style-list button + button
+{
+ border-top: 1px solid #ddd;
+}
+
+.mapboxgl-style-switcher
+{
+ background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iaXNvLTg4NTktMSI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJDYXBhXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB3aWR0aD0iNTQuODQ5cHgiIGhlaWdodD0iNTQuODQ5cHgiIHZpZXdCb3g9IjAgMCA1NC44NDkgNTQuODQ5IiBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCA1NC44NDkgNTQuODQ5OyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PGc+PGc+PGc+PHBhdGggZD0iTTU0LjQ5NywzOS42MTRsLTEwLjM2My00LjQ5bC0xNC45MTcsNS45NjhjLTAuNTM3LDAuMjE0LTEuMTY1LDAuMzE5LTEuNzkzLDAuMzE5Yy0wLjYyNywwLTEuMjU0LTAuMTA0LTEuNzktMC4zMThsLTE0LjkyMS01Ljk2OEwwLjM1MSwzOS42MTRjLTAuNDcyLDAuMjAzLTAuNDY3LDAuNTI0LDAuMDEsMC43MTZMMjYuNTYsNTAuODFjMC40NzcsMC4xOTEsMS4yNTEsMC4xOTEsMS43MjksMEw1NC40ODgsNDAuMzNDNTQuOTY0LDQwLjEzOSw1NC45NjksMzkuODE3LDU0LjQ5NywzOS42MTR6Ii8+PHBhdGggZD0iTTU0LjQ5NywyNy41MTJsLTEwLjM2NC00LjQ5MWwtMTQuOTE2LDUuOTY2Yy0wLjUzNiwwLjIxNS0xLjE2NSwwLjMyMS0xLjc5MiwwLjMyMWMtMC42MjgsMC0xLjI1Ni0wLjEwNi0xLjc5My0wLjMyMWwtMTQuOTE4LTUuOTY2TDAuMzUxLDI3LjUxMmMtMC40NzIsMC4yMDMtMC40NjcsMC41MjMsMC4wMSwwLjcxNkwyNi41NiwzOC43MDZjMC40NzcsMC4xOSwxLjI1MSwwLjE5LDEuNzI5LDBsMjYuMTk5LTEwLjQ3OUM1NC45NjQsMjguMDM2LDU0Ljk2OSwyNy43MTYsNTQuNDk3LDI3LjUxMnoiLz48cGF0aCBkPSJNMC4zNjEsMTYuMTI1bDEzLjY2Miw1LjQ2NWwxMi41MzcsNS4wMTVjMC40NzcsMC4xOTEsMS4yNTEsMC4xOTEsMS43MjksMGwxMi41NDEtNS4wMTZsMTMuNjU4LTUuNDYzYzAuNDc3LTAuMTkxLDAuNDgtMC41MTEsMC4wMS0wLjcxNkwyOC4yNzcsNC4wNDhjLTAuNDcxLTAuMjA0LTEuMjM2LTAuMjA0LTEuNzA4LDBMMC4zNTEsMTUuNDFDLTAuMTIxLDE1LjYxNC0wLjExNiwxNS45MzUsMC4zNjEsMTYuMTI1eiIvPjwvZz48L2c+PC9nPjxnPjwvZz48Zz48L2c+PGc+PC9nPjxnPjwvZz48Zz48L2c+PGc+PC9nPjxnPjwvZz48Zz48L2c+PGc+PC9nPjxnPjwvZz48Zz48L2c+PGc+PC9nPjxnPjwvZz48Zz48L2c+PGc+PC9nPjwvc3ZnPg==);
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: 70%;
+}
diff --git a/modern/src/map/switcher/switcher.js b/modern/src/map/switcher/switcher.js
new file mode 100644
index 00000000..ff9fbe97
--- /dev/null
+++ b/modern/src/map/switcher/switcher.js
@@ -0,0 +1,79 @@
+export class SwitcherControl {
+
+ constructor(styles, defaultStyle, beforeSwitch, afterSwitch) {
+ this.styles = styles;
+ this.defaultStyle = defaultStyle;
+ this.beforeSwitch = beforeSwitch;
+ this.afterSwitch = afterSwitch;
+ this.onDocumentClick = this.onDocumentClick.bind(this);
+ }
+
+ getDefaultPosition() {
+ return 'top-right';
+ }
+
+ onAdd(map) {
+ this.map = map;
+ this.controlContainer = document.createElement('div');
+ this.controlContainer.classList.add('mapboxgl-ctrl');
+ this.controlContainer.classList.add('mapboxgl-ctrl-group');
+ this.mapStyleContainer = document.createElement('div');
+ this.styleButton = document.createElement('button');
+ this.styleButton.type = 'button';
+ this.mapStyleContainer.classList.add('mapboxgl-style-list');
+ for (const style of this.styles) {
+ const styleElement = document.createElement('button');
+ styleElement.type = 'button';
+ styleElement.innerText = style.title;
+ styleElement.classList.add(style.title.replace(/[^a-z0-9-]/gi, '_'));
+ styleElement.dataset.uri = JSON.stringify(style.uri);
+ styleElement.addEventListener('click', event => {
+ const srcElement = event.srcElement;
+ if (srcElement.classList.contains('active')) {
+ return;
+ }
+ this.beforeSwitch();
+ this.map.setStyle(JSON.parse(srcElement.dataset.uri));
+ this.afterSwitch();
+ this.mapStyleContainer.style.display = 'none';
+ this.styleButton.style.display = 'block';
+ const elms = this.mapStyleContainer.getElementsByClassName('active');
+ while (elms[0]) {
+ elms[0].classList.remove('active');
+ }
+ srcElement.classList.add('active');
+ });
+ if (style.title === this.defaultStyle) {
+ styleElement.classList.add('active');
+ }
+ this.mapStyleContainer.appendChild(styleElement);
+ }
+ this.styleButton.classList.add('mapboxgl-ctrl-icon');
+ this.styleButton.classList.add('mapboxgl-style-switcher');
+ this.styleButton.addEventListener('click', () => {
+ this.styleButton.style.display = 'none';
+ this.mapStyleContainer.style.display = 'block';
+ });
+ document.addEventListener('click', this.onDocumentClick);
+ this.controlContainer.appendChild(this.styleButton);
+ this.controlContainer.appendChild(this.mapStyleContainer);
+ return this.controlContainer;
+ }
+
+ onRemove() {
+ if (!this.controlContainer || !this.controlContainer.parentNode || !this.map || !this.styleButton) {
+ return;
+ }
+ this.styleButton.removeEventListener('click', this.onDocumentClick);
+ this.controlContainer.parentNode.removeChild(this.controlContainer);
+ document.removeEventListener('click', this.onDocumentClick);
+ this.map = undefined;
+ }
+
+ onDocumentClick(event) {
+ if (this.controlContainer && !this.controlContainer.contains(event.target) && this.mapStyleContainer && this.styleButton) {
+ this.mapStyleContainer.style.display = 'none';
+ this.styleButton.style.display = 'block';
+ }
+ }
+}
diff --git a/modern/src/reactHelper.js b/modern/src/reactHelper.js
index 9286c577..b0eb0169 100644
--- a/modern/src/reactHelper.js
+++ b/modern/src/reactHelper.js
@@ -7,10 +7,10 @@ export const usePrevious = value => {
ref.current = value;
});
return ref.current;
-}
+};
export const useEffectAsync = (effect, deps) => {
useEffect(() => {
effect();
- }, deps);
-}
+ }, deps); // eslint-disable-line react-hooks/exhaustive-deps
+};
diff --git a/modern/src/reports/FilterForm.js b/modern/src/reports/FilterForm.js
new file mode 100644
index 00000000..86339d2d
--- /dev/null
+++ b/modern/src/reports/FilterForm.js
@@ -0,0 +1,88 @@
+import React, { useEffect, useState } from 'react';
+import { FormControl, InputLabel, Select, MenuItem, TextField } from '@material-ui/core';
+import t from '../common/localization';
+import { useSelector } from 'react-redux';
+import moment from 'moment';
+
+const FilterForm = ({ deviceId, setDeviceId, from, setFrom, to, setTo }) => {
+ const devices = useSelector(state => Object.values(state.devices.items));
+
+ const [period, setPeriod] = useState('today');
+
+ useEffect(() => {
+ switch (period) {
+ default:
+ case 'today':
+ setFrom(moment().startOf('day'));
+ setTo(moment().endOf('day'));
+ break;
+ case 'yesterday':
+ setFrom(moment().subtract(1, 'day').startOf('day'));
+ setTo(moment().subtract(1, 'day').endOf('day'));
+ break;
+ case 'thisWeek':
+ setFrom(moment().startOf('week'));
+ setTo(moment().endOf('week'));
+ break;
+ case 'previousWeek':
+ setFrom(moment().subtract(1, 'week').startOf('week'));
+ setTo(moment().subtract(1, 'week').endOf('week'));
+ break;
+ case 'thisMonth':
+ setFrom(moment().startOf('month'));
+ setTo(moment().endOf('month'));
+ break;
+ case 'previousMonth':
+ setFrom(moment().subtract(1, 'month').startOf('month'));
+ setTo(moment().subtract(1, 'month').endOf('month'));
+ break;
+ }
+ }, [period, setFrom, setTo]);
+
+ return (
+ <>
+ <FormControl variant='filled' margin='normal' fullWidth>
+ <InputLabel>{t('reportDevice')}</InputLabel>
+ <Select value={deviceId || ''} onChange={e => setDeviceId(e.target.value)}>
+ {devices.map((device) => (
+ <MenuItem key={device.id} value={device.id}>{device.name}</MenuItem>
+ ))}
+ </Select>
+ </FormControl>
+ <FormControl variant='filled' margin='normal' fullWidth>
+ <InputLabel>{t('reportPeriod')}</InputLabel>
+ <Select value={period} onChange={e => setPeriod(e.target.value)}>
+ <MenuItem key='today' value='today'>{t('reportToday')}</MenuItem>
+ <MenuItem key='yesterday' value='yesterday'>{t('reportYesterday')}</MenuItem>
+ <MenuItem key='thisWeek' value='thisWeek'>{t('reportThisWeek')}</MenuItem>
+ <MenuItem key='previousWeek' value='previousWeek'>{t('reportPreviousWeek')}</MenuItem>
+ <MenuItem key='thisMonth' value='thisMonth'>{t('reportThisMonth')}</MenuItem>
+ <MenuItem key='previousMonth' value='previousMonth'>{t('reportPreviousMonth')}</MenuItem>
+ <MenuItem key='custom' value='custom'>{t('reportCustom')}</MenuItem>
+ </Select>
+ </FormControl>
+ {period === 'custom' &&
+ <TextField
+ margin='normal'
+ variant='filled'
+ label={t('reportFrom')}
+ type='datetime-local'
+ value={from.format(moment.HTML5_FMT.DATETIME_LOCAL)}
+ onChange={e => setFrom(moment(e.target.value, moment.HTML5_FMT.DATETIME_LOCAL))}
+ fullWidth />
+ }
+ {period === 'custom' &&
+ <TextField
+ margin='normal'
+ variant='filled'
+ label={t('reportTo')}
+ type='datetime-local'
+ value={to.format(moment.HTML5_FMT.DATETIME_LOCAL)}
+ onChange={(e) => setTo(moment(e.target.value, moment.HTML5_FMT.DATETIME_LOCAL))}
+ fullWidth />
+ }
+ </>
+ );
+}
+
+export default FilterForm;
diff --git a/modern/src/reports/ReplayPage.js b/modern/src/reports/ReplayPage.js
new file mode 100644
index 00000000..6b84d4d9
--- /dev/null
+++ b/modern/src/reports/ReplayPage.js
@@ -0,0 +1,121 @@
+import React, { useState } from 'react';
+import { Accordion, AccordionDetails, AccordionSummary, Button, Container, FormControl, makeStyles, Paper, Slider, Tooltip, Typography } from '@material-ui/core';
+import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
+import MainToolbar from '../MainToolbar';
+import Map from '../map/Map';
+import t from '../common/localization';
+import FilterForm from './FilterForm';
+import ReplayPathMap from '../map/ReplayPathMap';
+import PositionsMap from '../map/PositionsMap';
+import { formatPosition } from '../common/formatter';
+
+const useStyles = makeStyles(theme => ({
+ root: {
+ height: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ controlPanel: {
+ position: 'absolute',
+ bottom: theme.spacing(5),
+ left: '50%',
+ transform: 'translateX(-50%)',
+ },
+ controlContent: {
+ padding: theme.spacing(2),
+ marginBottom: theme.spacing(2),
+ },
+ configForm: {
+ display: 'flex',
+ flexDirection: 'column',
+ },
+}));
+
+const TimeLabel = ({ children, open, value }) => {
+ return (
+ <Tooltip open={open} enterTouchDelay={0} placement="top" title={value}>
+ {children}
+ </Tooltip>
+ );
+};
+
+const ReplayPage = () => {
+ const classes = useStyles();
+
+ const [expanded, setExpanded] = useState(true);
+
+ const [deviceId, setDeviceId] = useState();
+ const [from, setFrom] = useState();
+ const [to, setTo] = useState();
+
+ const [positions, setPositions] = useState([]);
+
+ const [index, setIndex] = useState(0);
+
+ const handleShow = async () => {
+ const query = new URLSearchParams({
+ deviceId,
+ from: from.toISOString(),
+ to: to.toISOString(),
+ });
+ const response = await fetch(`/api/positions?${query.toString()}`, { headers: { 'Accept': 'application/json' } });
+ if (response.ok) {
+ setIndex(0);
+ setPositions(await response.json());
+ setExpanded(false);
+ }
+ };
+
+ return (
+ <div className={classes.root}>
+ <MainToolbar />
+ <Map>
+ <ReplayPathMap positions={positions} />
+ {index < positions.length &&
+ <PositionsMap positions={[positions[index]]} />
+ }
+ </Map>
+ <Container maxWidth="sm" className={classes.controlPanel}>
+ {!!positions.length &&
+ <Paper className={classes.controlContent}>
+ <Slider
+ max={positions.length - 1}
+ step={null}
+ marks={positions.map((_, index) => ({ value: index }))}
+ value={index}
+ onChange={(_, index) => setIndex(index)}
+ valueLabelDisplay="auto"
+ valueLabelFormat={i => i < positions.length ? formatPosition(positions[i], 'fixTime') : ''}
+ ValueLabelComponent={TimeLabel}
+ />
+ </Paper>
+ }
+ <div>
+ <Accordion expanded={expanded} onChange={() => setExpanded(!expanded)}>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography align='center'>
+ {t('reportConfigure')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.configForm}>
+ <FilterForm
+ deviceId={deviceId}
+ setDeviceId={setDeviceId}
+ from={from}
+ setFrom={setFrom}
+ to={to}
+ setTo={setTo} />
+ <FormControl margin='normal' fullWidth>
+ <Button type='button' color='primary' variant='contained' disabled={!deviceId} onClick={handleShow}>
+ {t('reportShow')}
+ </Button>
+ </FormControl>
+ </AccordionDetails>
+ </Accordion>
+ </div>
+ </Container>
+ </div>
+ );
+}
+
+export default ReplayPage;