diff options
Diffstat (limited to 'modern/src')
-rw-r--r-- | modern/src/App.js | 36 | ||||
-rw-r--r-- | modern/src/DevicesList.js | 2 | ||||
-rw-r--r-- | modern/src/MainPage.js | 13 | ||||
-rw-r--r-- | modern/src/MainToolbar.js | 9 | ||||
-rw-r--r-- | modern/src/RemoveDialog.js | 2 | ||||
-rw-r--r-- | modern/src/common/preferences.js | 21 | ||||
-rw-r--r-- | modern/src/map/AccuracyMap.js | 53 | ||||
-rw-r--r-- | modern/src/map/CurrentLocationMap.js | 21 | ||||
-rw-r--r-- | modern/src/map/CurrentPositionsMap.js | 11 | ||||
-rw-r--r-- | modern/src/map/Map.js | 103 | ||||
-rw-r--r-- | modern/src/map/PositionsMap.js | 52 | ||||
-rw-r--r-- | modern/src/map/ReplayPathMap.js | 59 | ||||
-rw-r--r-- | modern/src/map/StatusView.js | 2 | ||||
-rw-r--r-- | modern/src/map/mapStyles.js | 52 | ||||
-rw-r--r-- | modern/src/map/mapUtil.js | 24 | ||||
-rw-r--r-- | modern/src/map/switcher/switcher.css | 40 | ||||
-rw-r--r-- | modern/src/map/switcher/switcher.js | 79 | ||||
-rw-r--r-- | modern/src/reactHelper.js | 6 | ||||
-rw-r--r-- | modern/src/reports/FilterForm.js | 88 | ||||
-rw-r--r-- | modern/src/reports/ReplayPage.js | 121 |
20 files changed, 685 insertions, 109 deletions
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; |