aboutsummaryrefslogtreecommitdiff
path: root/src/other
diff options
context:
space:
mode:
authorAnton Tananaev <anton@traccar.org>2024-04-06 09:22:10 -0700
committerAnton Tananaev <anton@traccar.org>2024-04-06 09:22:10 -0700
commitf418231b6b2f5e030a0d2dcc390c314602b1f740 (patch)
tree10326adf3792bc2697e06bb5f2b8ef2a8f7e55fe /src/other
parentb392a4af78e01c8e0f50aad5468e9583675b24be (diff)
downloadtrackermap-web-f418231b6b2f5e030a0d2dcc390c314602b1f740.tar.gz
trackermap-web-f418231b6b2f5e030a0d2dcc390c314602b1f740.tar.bz2
trackermap-web-f418231b6b2f5e030a0d2dcc390c314602b1f740.zip
Move modern to the top
Diffstat (limited to 'src/other')
-rw-r--r--src/other/EventPage.jsx108
-rw-r--r--src/other/GeofencesList.jsx54
-rw-r--r--src/other/GeofencesPage.jsx142
-rw-r--r--src/other/NetworkPage.jsx122
-rw-r--r--src/other/PositionPage.jsx110
-rw-r--r--src/other/ReplayPage.jsx233
6 files changed, 769 insertions, 0 deletions
diff --git a/src/other/EventPage.jsx b/src/other/EventPage.jsx
new file mode 100644
index 00000000..c8d84d5e
--- /dev/null
+++ b/src/other/EventPage.jsx
@@ -0,0 +1,108 @@
+import React, { useCallback, useState } from 'react';
+
+import {
+ Typography, AppBar, Toolbar, IconButton,
+} from '@mui/material';
+import makeStyles from '@mui/styles/makeStyles';
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import { useNavigate, useParams } from 'react-router-dom';
+import { useEffectAsync } from '../reactHelper';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import MapView from '../map/core/MapView';
+import MapCamera from '../map/MapCamera';
+import MapPositions from '../map/MapPositions';
+import MapGeofence from '../map/MapGeofence';
+import StatusCard from '../common/components/StatusCard';
+import { formatNotificationTitle } from '../common/util/formatter';
+
+const useStyles = makeStyles(() => ({
+ root: {
+ height: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ toolbar: {
+ zIndex: 1,
+ },
+ mapContainer: {
+ flexGrow: 1,
+ },
+}));
+
+const EventPage = () => {
+ const classes = useStyles();
+ const navigate = useNavigate();
+ const t = useTranslation();
+
+ const { id } = useParams();
+
+ const [event, setEvent] = useState();
+ const [position, setPosition] = useState();
+ const [showCard, setShowCard] = useState(false);
+
+ const formatType = (event) => formatNotificationTitle(t, {
+ type: event.type,
+ attributes: {
+ alarms: event.attributes.alarm,
+ },
+ });
+
+ const onMarkerClick = useCallback((positionId) => {
+ setShowCard(!!positionId);
+ }, [setShowCard]);
+
+ useEffectAsync(async () => {
+ if (id) {
+ const response = await fetch(`/api/events/${id}`);
+ if (response.ok) {
+ setEvent(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ }
+ }, [id]);
+
+ useEffectAsync(async () => {
+ if (event && event.positionId) {
+ const response = await fetch(`/api/positions?id=${event.positionId}`);
+ if (response.ok) {
+ const positions = await response.json();
+ if (positions.length > 0) {
+ setPosition(positions[0]);
+ }
+ } else {
+ throw Error(await response.text());
+ }
+ }
+ }, [event]);
+
+ return (
+ <div className={classes.root}>
+ <AppBar color="inherit" position="static" className={classes.toolbar}>
+ <Toolbar>
+ <IconButton color="inherit" edge="start" sx={{ mr: 2 }} onClick={() => navigate('/')}>
+ <ArrowBackIcon />
+ </IconButton>
+ <Typography variant="h6">{event && formatType(event)}</Typography>
+ </Toolbar>
+ </AppBar>
+ <div className={classes.mapContainer}>
+ <MapView>
+ <MapGeofence />
+ {position && <MapPositions positions={[position]} onClick={onMarkerClick} titleField="fixTime" />}
+ </MapView>
+ {position && <MapCamera latitude={position.latitude} longitude={position.longitude} />}
+ {position && showCard && (
+ <StatusCard
+ deviceId={position.deviceId}
+ position={position}
+ onClose={() => setShowCard(false)}
+ disableActions
+ />
+ )}
+ </div>
+ </div>
+ );
+};
+
+export default EventPage;
diff --git a/src/other/GeofencesList.jsx b/src/other/GeofencesList.jsx
new file mode 100644
index 00000000..d26eff09
--- /dev/null
+++ b/src/other/GeofencesList.jsx
@@ -0,0 +1,54 @@
+import React, { Fragment } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import makeStyles from '@mui/styles/makeStyles';
+import {
+ Divider, List, ListItemButton, ListItemText,
+} from '@mui/material';
+
+import { geofencesActions } from '../store';
+import CollectionActions from '../settings/components/CollectionActions';
+import { useCatchCallback } from '../reactHelper';
+
+const useStyles = makeStyles(() => ({
+ list: {
+ maxHeight: '100%',
+ overflow: 'auto',
+ },
+ icon: {
+ width: '25px',
+ height: '25px',
+ filter: 'brightness(0) invert(1)',
+ },
+}));
+
+const GeofencesList = ({ onGeofenceSelected }) => {
+ const classes = useStyles();
+ const dispatch = useDispatch();
+
+ const items = useSelector((state) => state.geofences.items);
+
+ const refreshGeofences = useCatchCallback(async () => {
+ const response = await fetch('/api/geofences');
+ if (response.ok) {
+ dispatch(geofencesActions.refresh(await response.json()));
+ } else {
+ throw Error(await response.text());
+ }
+ }, [dispatch]);
+
+ return (
+ <List className={classes.list}>
+ {Object.values(items).map((item, index, list) => (
+ <Fragment key={item.id}>
+ <ListItemButton key={item.id} onClick={() => onGeofenceSelected(item.id)}>
+ <ListItemText primary={item.name} />
+ <CollectionActions itemId={item.id} editPath="/settings/geofence" endpoint="geofences" setTimestamp={refreshGeofences} />
+ </ListItemButton>
+ {index < list.length - 1 ? <Divider /> : null}
+ </Fragment>
+ ))}
+ </List>
+ );
+};
+
+export default GeofencesList;
diff --git a/src/other/GeofencesPage.jsx b/src/other/GeofencesPage.jsx
new file mode 100644
index 00000000..a27a6dca
--- /dev/null
+++ b/src/other/GeofencesPage.jsx
@@ -0,0 +1,142 @@
+import React, { useState } from 'react';
+import { useDispatch } from 'react-redux';
+import {
+ Divider, Typography, IconButton, useMediaQuery, Toolbar,
+} from '@mui/material';
+import Tooltip from '@mui/material/Tooltip';
+import makeStyles from '@mui/styles/makeStyles';
+import { useTheme } from '@mui/material/styles';
+import Drawer from '@mui/material/Drawer';
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import UploadFileIcon from '@mui/icons-material/UploadFile';
+import { useNavigate } from 'react-router-dom';
+import MapView from '../map/core/MapView';
+import MapCurrentLocation from '../map/MapCurrentLocation';
+import MapGeofenceEdit from '../map/draw/MapGeofenceEdit';
+import GeofencesList from './GeofencesList';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import MapGeocoder from '../map/geocoder/MapGeocoder';
+import { errorsActions } from '../store';
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ height: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ content: {
+ flexGrow: 1,
+ overflow: 'hidden',
+ display: 'flex',
+ flexDirection: 'row',
+ [theme.breakpoints.down('sm')]: {
+ flexDirection: 'column-reverse',
+ },
+ },
+ drawer: {
+ zIndex: 1,
+ },
+ drawerPaper: {
+ position: 'relative',
+ [theme.breakpoints.up('sm')]: {
+ width: theme.dimensions.drawerWidthTablet,
+ },
+ [theme.breakpoints.down('sm')]: {
+ height: theme.dimensions.drawerHeightPhone,
+ },
+ },
+ mapContainer: {
+ flexGrow: 1,
+ },
+ title: {
+ flexGrow: 1,
+ },
+ fileInput: {
+ display: 'none',
+ },
+}));
+
+const GeofencesPage = () => {
+ const theme = useTheme();
+ const classes = useStyles();
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+ const t = useTranslation();
+
+ const isPhone = useMediaQuery(theme.breakpoints.down('sm'));
+
+ const [selectedGeofenceId, setSelectedGeofenceId] = useState();
+
+ const handleFile = (event) => {
+ const files = Array.from(event.target.files);
+ const [file] = files;
+ const reader = new FileReader();
+ reader.onload = async () => {
+ const xml = new DOMParser().parseFromString(reader.result, 'text/xml');
+ const segment = xml.getElementsByTagName('trkseg')[0];
+ const coordinates = Array.from(segment.getElementsByTagName('trkpt'))
+ .map((point) => `${point.getAttribute('lat')} ${point.getAttribute('lon')}`)
+ .join(', ');
+ const area = `LINESTRING (${coordinates})`;
+ const newItem = { name: t('sharedGeofence'), area };
+ try {
+ const response = await fetch('/api/geofences', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(newItem),
+ });
+ if (response.ok) {
+ const item = await response.json();
+ navigate(`/settings/geofence/${item.id}`);
+ } else {
+ throw Error(await response.text());
+ }
+ } catch (error) {
+ dispatch(errorsActions.push(error.message));
+ }
+ };
+ reader.onerror = (event) => {
+ dispatch(errorsActions.push(event.target.error));
+ };
+ reader.readAsText(file);
+ };
+
+ return (
+ <div className={classes.root}>
+ <div className={classes.content}>
+ <Drawer
+ className={classes.drawer}
+ anchor={isPhone ? 'bottom' : 'left'}
+ variant="permanent"
+ classes={{ paper: classes.drawerPaper }}
+ >
+ <Toolbar>
+ <IconButton edge="start" sx={{ mr: 2 }} onClick={() => navigate(-1)}>
+ <ArrowBackIcon />
+ </IconButton>
+ <Typography variant="h6" className={classes.title}>{t('sharedGeofences')}</Typography>
+ <label htmlFor="upload-gpx">
+ <input accept=".gpx" id="upload-gpx" type="file" className={classes.fileInput} onChange={handleFile} />
+ <IconButton edge="end" component="span" onClick={() => {}}>
+ <Tooltip title={t('sharedUpload')}>
+ <UploadFileIcon />
+ </Tooltip>
+ </IconButton>
+ </label>
+ </Toolbar>
+ <Divider />
+ <GeofencesList onGeofenceSelected={setSelectedGeofenceId} />
+ </Drawer>
+ <div className={classes.mapContainer}>
+ <MapView>
+ <MapGeofenceEdit selectedGeofenceId={selectedGeofenceId} />
+ </MapView>
+ <MapCurrentLocation />
+ <MapGeocoder />
+ </div>
+ </div>
+ </div>
+ );
+};
+
+export default GeofencesPage;
diff --git a/src/other/NetworkPage.jsx b/src/other/NetworkPage.jsx
new file mode 100644
index 00000000..9dc00c61
--- /dev/null
+++ b/src/other/NetworkPage.jsx
@@ -0,0 +1,122 @@
+import React, { useState } from 'react';
+import { useSelector } from 'react-redux';
+
+import {
+ Typography, Container, Paper, AppBar, Toolbar, IconButton, Table, TableHead, TableRow, TableCell, TableBody,
+} from '@mui/material';
+import makeStyles from '@mui/styles/makeStyles';
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import { useNavigate, useParams } from 'react-router-dom';
+import { useEffectAsync } from '../reactHelper';
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ height: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ content: {
+ overflow: 'auto',
+ paddingTop: theme.spacing(2),
+ paddingBottom: theme.spacing(2),
+ display: 'flex',
+ flexDirection: 'column',
+ gap: theme.spacing(2),
+ },
+}));
+
+const NetworkPage = () => {
+ const classes = useStyles();
+ const navigate = useNavigate();
+
+ const { positionId } = useParams();
+
+ const [item, setItem] = useState({});
+
+ useEffectAsync(async () => {
+ if (positionId) {
+ const response = await fetch(`/api/positions?id=${positionId}`);
+ if (response.ok) {
+ const positions = await response.json();
+ if (positions.length > 0) {
+ setItem(positions[0]);
+ }
+ } else {
+ throw Error(await response.text());
+ }
+ }
+ }, [positionId]);
+
+ const deviceName = useSelector((state) => {
+ if (item) {
+ const device = state.devices.items[item.deviceId];
+ if (device) {
+ return device.name;
+ }
+ }
+ return null;
+ });
+
+ return (
+ <div className={classes.root}>
+ <AppBar position="sticky" color="inherit">
+ <Toolbar>
+ <IconButton color="inherit" edge="start" sx={{ mr: 2 }} onClick={() => navigate(-1)}>
+ <ArrowBackIcon />
+ </IconButton>
+ <Typography variant="h6">
+ {deviceName}
+ </Typography>
+ </Toolbar>
+ </AppBar>
+ <div className={classes.content}>
+ <Container maxWidth="sm">
+ <Paper>
+ <Table>
+ <TableHead>
+ <TableRow>
+ <TableCell>MCC</TableCell>
+ <TableCell>MNC</TableCell>
+ <TableCell>LAC</TableCell>
+ <TableCell>CID</TableCell>
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {(item.network?.cellTowers || []).map((cell) => (
+ <TableRow key={cell.cellId}>
+ <TableCell>{cell.mobileCountryCode}</TableCell>
+ <TableCell>{cell.mobileNetworkCode}</TableCell>
+ <TableCell>{cell.locationAreaCode}</TableCell>
+ <TableCell>{cell.cellId}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </Paper>
+ </Container>
+ <Container maxWidth="sm">
+ <Paper>
+ <Table>
+ <TableHead>
+ <TableRow>
+ <TableCell>MAC</TableCell>
+ <TableCell>RSSI</TableCell>
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {(item.network?.wifiAccessPoints || []).map((wifi) => (
+ <TableRow key={wifi.macAddress}>
+ <TableCell>{wifi.macAddress}</TableCell>
+ <TableCell>{wifi.signalStrength}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </Paper>
+ </Container>
+ </div>
+ </div>
+ );
+};
+
+export default NetworkPage;
diff --git a/src/other/PositionPage.jsx b/src/other/PositionPage.jsx
new file mode 100644
index 00000000..f253cd2c
--- /dev/null
+++ b/src/other/PositionPage.jsx
@@ -0,0 +1,110 @@
+import React, { useState } from 'react';
+import { useSelector } from 'react-redux';
+
+import {
+ Typography, Container, Paper, AppBar, Toolbar, IconButton, Table, TableHead, TableRow, TableCell, TableBody,
+} from '@mui/material';
+import makeStyles from '@mui/styles/makeStyles';
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import { useNavigate, useParams } from 'react-router-dom';
+import { useEffectAsync } from '../reactHelper';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PositionValue from '../common/components/PositionValue';
+import usePositionAttributes from '../common/attributes/usePositionAttributes';
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ height: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ content: {
+ overflow: 'auto',
+ paddingTop: theme.spacing(2),
+ paddingBottom: theme.spacing(2),
+ },
+}));
+
+const PositionPage = () => {
+ const classes = useStyles();
+ const navigate = useNavigate();
+ const t = useTranslation();
+
+ const positionAttributes = usePositionAttributes(t);
+
+ const { id } = useParams();
+
+ const [item, setItem] = useState();
+
+ useEffectAsync(async () => {
+ if (id) {
+ const response = await fetch(`/api/positions?id=${id}`);
+ if (response.ok) {
+ const positions = await response.json();
+ if (positions.length > 0) {
+ setItem(positions[0]);
+ }
+ } else {
+ throw Error(await response.text());
+ }
+ }
+ }, [id]);
+
+ const deviceName = useSelector((state) => {
+ if (item) {
+ const device = state.devices.items[item.deviceId];
+ if (device) {
+ return device.name;
+ }
+ }
+ return null;
+ });
+
+ return (
+ <div className={classes.root}>
+ <AppBar position="sticky" color="inherit">
+ <Toolbar>
+ <IconButton color="inherit" edge="start" sx={{ mr: 2 }} onClick={() => navigate(-1)}>
+ <ArrowBackIcon />
+ </IconButton>
+ <Typography variant="h6">
+ {deviceName}
+ </Typography>
+ </Toolbar>
+ </AppBar>
+ <div className={classes.content}>
+ <Container maxWidth="sm">
+ <Paper>
+ <Table>
+ <TableHead>
+ <TableRow>
+ <TableCell>{t('stateName')}</TableCell>
+ <TableCell>{t('sharedName')}</TableCell>
+ <TableCell>{t('stateValue')}</TableCell>
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {item && Object.getOwnPropertyNames(item).filter((it) => it !== 'attributes').map((property) => (
+ <TableRow key={property}>
+ <TableCell>{property}</TableCell>
+ <TableCell><strong>{positionAttributes[property]?.name || property}</strong></TableCell>
+ <TableCell><PositionValue position={item} property={property} /></TableCell>
+ </TableRow>
+ ))}
+ {item && Object.getOwnPropertyNames(item.attributes).map((attribute) => (
+ <TableRow key={attribute}>
+ <TableCell>{attribute}</TableCell>
+ <TableCell><strong>{positionAttributes[attribute]?.name || attribute}</strong></TableCell>
+ <TableCell><PositionValue position={item} attribute={attribute} /></TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </Paper>
+ </Container>
+ </div>
+ </div>
+ );
+};
+
+export default PositionPage;
diff --git a/src/other/ReplayPage.jsx b/src/other/ReplayPage.jsx
new file mode 100644
index 00000000..1050b976
--- /dev/null
+++ b/src/other/ReplayPage.jsx
@@ -0,0 +1,233 @@
+import React, {
+ useState, useEffect, useRef, useCallback,
+} from 'react';
+import {
+ IconButton, Paper, Slider, Toolbar, Typography,
+} from '@mui/material';
+import makeStyles from '@mui/styles/makeStyles';
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import TuneIcon from '@mui/icons-material/Tune';
+import DownloadIcon from '@mui/icons-material/Download';
+import PlayArrowIcon from '@mui/icons-material/PlayArrow';
+import PauseIcon from '@mui/icons-material/Pause';
+import FastForwardIcon from '@mui/icons-material/FastForward';
+import FastRewindIcon from '@mui/icons-material/FastRewind';
+import { useNavigate } from 'react-router-dom';
+import { useSelector } from 'react-redux';
+import MapView from '../map/core/MapView';
+import MapRoutePath from '../map/MapRoutePath';
+import MapRoutePoints from '../map/MapRoutePoints';
+import MapPositions from '../map/MapPositions';
+import { formatTime } from '../common/util/formatter';
+import ReportFilter from '../reports/components/ReportFilter';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import { useCatch } from '../reactHelper';
+import MapCamera from '../map/MapCamera';
+import MapGeofence from '../map/MapGeofence';
+import StatusCard from '../common/components/StatusCard';
+import { usePreference } from '../common/util/preferences';
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ height: '100%',
+ },
+ sidebar: {
+ display: 'flex',
+ flexDirection: 'column',
+ position: 'fixed',
+ zIndex: 3,
+ left: 0,
+ top: 0,
+ margin: theme.spacing(1.5),
+ width: theme.dimensions.drawerWidthDesktop,
+ [theme.breakpoints.down('md')]: {
+ width: '100%',
+ margin: 0,
+ },
+ },
+ title: {
+ flexGrow: 1,
+ },
+ slider: {
+ width: '100%',
+ },
+ controls: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ formControlLabel: {
+ height: '100%',
+ width: '100%',
+ paddingRight: theme.spacing(1),
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ content: {
+ display: 'flex',
+ flexDirection: 'column',
+ padding: theme.spacing(2),
+ [theme.breakpoints.down('md')]: {
+ margin: theme.spacing(1),
+ },
+ [theme.breakpoints.up('md')]: {
+ marginTop: theme.spacing(1),
+ },
+ },
+}));
+
+const ReplayPage = () => {
+ const t = useTranslation();
+ const classes = useStyles();
+ const navigate = useNavigate();
+ const timerRef = useRef();
+
+ const hours12 = usePreference('twelveHourFormat');
+
+ const defaultDeviceId = useSelector((state) => state.devices.selectedId);
+
+ const [positions, setPositions] = useState([]);
+ const [index, setIndex] = useState(0);
+ const [selectedDeviceId, setSelectedDeviceId] = useState(defaultDeviceId);
+ const [showCard, setShowCard] = useState(false);
+ const [from, setFrom] = useState();
+ const [to, setTo] = useState();
+ const [expanded, setExpanded] = useState(true);
+ const [playing, setPlaying] = useState(false);
+
+ const deviceName = useSelector((state) => {
+ if (selectedDeviceId) {
+ const device = state.devices.items[selectedDeviceId];
+ if (device) {
+ return device.name;
+ }
+ }
+ return null;
+ });
+
+ useEffect(() => {
+ if (playing && positions.length > 0) {
+ timerRef.current = setInterval(() => {
+ setIndex((index) => index + 1);
+ }, 500);
+ } else {
+ clearInterval(timerRef.current);
+ }
+
+ return () => clearInterval(timerRef.current);
+ }, [playing, positions]);
+
+ useEffect(() => {
+ if (index >= positions.length - 1) {
+ clearInterval(timerRef.current);
+ setPlaying(false);
+ }
+ }, [index, positions]);
+
+ const onPointClick = useCallback((_, index) => {
+ setIndex(index);
+ }, [setIndex]);
+
+ const onMarkerClick = useCallback((positionId) => {
+ setShowCard(!!positionId);
+ }, [setShowCard]);
+
+ const handleSubmit = useCatch(async ({ deviceId, from, to }) => {
+ setSelectedDeviceId(deviceId);
+ setFrom(from);
+ setTo(to);
+ const query = new URLSearchParams({ deviceId, from, to });
+ const response = await fetch(`/api/positions?${query.toString()}`);
+ if (response.ok) {
+ setIndex(0);
+ const positions = await response.json();
+ setPositions(positions);
+ if (positions.length) {
+ setExpanded(false);
+ } else {
+ throw Error(t('sharedNoData'));
+ }
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ const handleDownload = () => {
+ const query = new URLSearchParams({ deviceId: selectedDeviceId, from, to });
+ window.location.assign(`/api/positions/kml?${query.toString()}`);
+ };
+
+ return (
+ <div className={classes.root}>
+ <MapView>
+ <MapGeofence />
+ <MapRoutePath positions={positions} />
+ <MapRoutePoints positions={positions} onClick={onPointClick} />
+ {index < positions.length && (
+ <MapPositions positions={[positions[index]]} onClick={onMarkerClick} titleField="fixTime" />
+ )}
+ </MapView>
+ <MapCamera positions={positions} />
+ <div className={classes.sidebar}>
+ <Paper elevation={3} square>
+ <Toolbar>
+ <IconButton edge="start" sx={{ mr: 2 }} onClick={() => navigate(-1)}>
+ <ArrowBackIcon />
+ </IconButton>
+ <Typography variant="h6" className={classes.title}>{t('reportReplay')}</Typography>
+ {!expanded && (
+ <>
+ <IconButton onClick={handleDownload}>
+ <DownloadIcon />
+ </IconButton>
+ <IconButton edge="end" onClick={() => setExpanded(true)}>
+ <TuneIcon />
+ </IconButton>
+ </>
+ )}
+ </Toolbar>
+ </Paper>
+ <Paper className={classes.content} square>
+ {!expanded ? (
+ <>
+ <Typography variant="subtitle1" align="center">{deviceName}</Typography>
+ <Slider
+ className={classes.slider}
+ max={positions.length - 1}
+ step={null}
+ marks={positions.map((_, index) => ({ value: index }))}
+ value={index}
+ onChange={(_, index) => setIndex(index)}
+ />
+ <div className={classes.controls}>
+ {`${index + 1}/${positions.length}`}
+ <IconButton onClick={() => setIndex((index) => index - 1)} disabled={playing || index <= 0}>
+ <FastRewindIcon />
+ </IconButton>
+ <IconButton onClick={() => setPlaying(!playing)} disabled={index >= positions.length - 1}>
+ {playing ? <PauseIcon /> : <PlayArrowIcon /> }
+ </IconButton>
+ <IconButton onClick={() => setIndex((index) => index + 1)} disabled={playing || index >= positions.length - 1}>
+ <FastForwardIcon />
+ </IconButton>
+ {formatTime(positions[index].fixTime, 'seconds', hours12)}
+ </div>
+ </>
+ ) : (
+ <ReportFilter handleSubmit={handleSubmit} fullScreen showOnly />
+ )}
+ </Paper>
+ </div>
+ {showCard && index < positions.length && (
+ <StatusCard
+ deviceId={selectedDeviceId}
+ position={positions[index]}
+ onClose={() => setShowCard(false)}
+ disableActions
+ />
+ )}
+ </div>
+ );
+};
+
+export default ReplayPage;