Compare commits
35 Commits
578bed681a
...
25d6a8757b
Author | SHA1 | Date | |
---|---|---|---|
25d6a8757b | |||
b585a39dd0 | |||
a3c7f85302 | |||
646ca1268e | |||
562c7cb6eb | |||
cb8129cbba | |||
502ae2b982 | |||
1f744216ec | |||
ec7fbed514 | |||
3d927c18ce | |||
f309c0af00 | |||
7e0eddaf38 | |||
1875d7b4e7 | |||
959e1d85d0 | |||
2be0dd1c3d | |||
0708301396 | |||
fbc15bb371 | |||
ca3202f9b7 | |||
435db835e9 | |||
87e706c223 | |||
478dca185e | |||
b295c3fef0 | |||
13b35e1c00 | |||
2adc0a9fcb | |||
0a02db9a8d | |||
bdc2921bc0 | |||
9dd772839b | |||
4bc88e5ce9 | |||
6c7dff2d8f | |||
21cec132a7 | |||
51031e7b20 | |||
81880a6a0a | |||
44dcc1b8ad | |||
17b1f979a9 | |||
00d9ee362f |
@@ -8,6 +8,7 @@
|
|||||||
"@testing-library/user-event": "^12.1.10",
|
"@testing-library/user-event": "^12.1.10",
|
||||||
"axios": "^0.21.1",
|
"axios": "^0.21.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet-polylinedecorator": "^1.6.0",
|
||||||
"moment": "^2.29.1",
|
"moment": "^2.29.1",
|
||||||
"moment-timezone": "^0.5.34",
|
"moment-timezone": "^0.5.34",
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
|
@@ -60,6 +60,7 @@ h2 {
|
|||||||
|
|
||||||
.submenu h2 {
|
.submenu h2 {
|
||||||
color: white;
|
color: white;
|
||||||
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submenu-header {
|
.submenu-header {
|
||||||
@@ -82,6 +83,7 @@ h2 {
|
|||||||
color: white;
|
color: white;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
font-family: sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu button:hover {
|
.menu button:hover {
|
||||||
@@ -96,6 +98,67 @@ h2 {
|
|||||||
background-color: #666;
|
background-color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.submenu-checkbox-label {
|
||||||
|
/* Make it look like a button */
|
||||||
|
background-color: #666;
|
||||||
|
height: 2.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: sans-serif;
|
||||||
|
|
||||||
|
/* Center content */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-checkbox-label:hover {
|
||||||
|
background-color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-checkbox-label input[type="checkbox"] {
|
||||||
|
/* Reset default styles */
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
background-color: transparent;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
/* Custom checkbox style */
|
||||||
|
font: inherit;
|
||||||
|
color: currentColor;
|
||||||
|
width: 0.75em;
|
||||||
|
height: 0.75em;
|
||||||
|
border: 0.1em solid currentColor;
|
||||||
|
border-radius: 0;
|
||||||
|
transform: translateY(-0.075em);
|
||||||
|
|
||||||
|
/* For the checkmark */
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix for Firefox Mobile rendering a black background on checked state,
|
||||||
|
especially when extensions like Dark Reader are active. */
|
||||||
|
.submenu-checkbox-label input[type="checkbox"]:checked {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-checkbox-label input[type="checkbox"]::before {
|
||||||
|
content: "";
|
||||||
|
width: 0.75em;
|
||||||
|
height: 0.75em;
|
||||||
|
transform: scale(0);
|
||||||
|
transition: 120ms transform ease-in-out;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-checkbox-label input[type="checkbox"]:checked::before {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
.datepicker .rdtPicker {
|
.datepicker .rdtPicker {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -109,3 +172,24 @@ h2 {
|
|||||||
background-color: #999!important;
|
background-color: #999!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.submenu-actions {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-group span {
|
||||||
|
color: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-group button {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import * as leaflet from 'leaflet';
|
import * as leaflet from 'leaflet';
|
||||||
import { MapContainer, Polyline, TileLayer, useMap } from 'react-leaflet';
|
import 'leaflet-polylinedecorator';
|
||||||
|
import { MapContainer, TileLayer, useMap, useMapEvents } from 'react-leaflet';
|
||||||
import Datetime from 'react-datetime';
|
import Datetime from 'react-datetime';
|
||||||
import 'react-datetime/css/react-datetime.css';
|
import 'react-datetime/css/react-datetime.css';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -16,7 +17,7 @@ import 'react-range-slider-input/dist/style.css';
|
|||||||
const durations = [
|
const durations = [
|
||||||
{id: 0, len: 'Day', win: '1m', full: '1 min', delta: [1, 'days'], format: 'HH', num: 1440, secs: 60},
|
{id: 0, len: 'Day', win: '1m', full: '1 min', delta: [1, 'days'], format: 'HH', num: 1440, secs: 60},
|
||||||
{id: 1, len: 'Week', win: '3m', full: '3 min', delta: [7, 'days'], format: 'HH', num: 3360, secs: 180},
|
{id: 1, len: 'Week', win: '3m', full: '3 min', delta: [7, 'days'], format: 'HH', num: 3360, secs: 180},
|
||||||
{id: 2, len: 'Month', win: '10m', full: '10 min', delta: [1, 'months'], format: 'D', num: 4380, secs: 600},
|
{id: 2, len: 'Month', shortLen: 'Mth', win: '10m', full: '10 min', delta: [1, 'months'], format: 'D', num: 4380, secs: 600},
|
||||||
{id: 3, len: 'Year', win: '2h', full: '2 hour', delta: [1, 'years'], format: 'M/D', num: 4380, secs: 7200},
|
{id: 3, len: 'Year', win: '2h', full: '2 hour', delta: [1, 'years'], format: 'M/D', num: 4380, secs: 7200},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ const parseSlider = (end, duration, slider) => {
|
|||||||
|
|
||||||
//async function sha256(source) {
|
//async function sha256(source) {
|
||||||
// const sourceBytes = new TextEncoder().encode(source);
|
// const sourceBytes = new TextEncoder().encode(source);
|
||||||
// const digest = await crypto.subtle.digest('SHA-256', sourceBytes);
|
// const digest = await crypto.subtle.digest('SHA-26', sourceBytes);
|
||||||
// const resultBytes = [...new Uint8Array(digest)];
|
// const resultBytes = [...new Uint8Array(digest)];
|
||||||
// return resultBytes.map(x => x.toString(16).padStart(2, '0')).join('');
|
// return resultBytes.map(x => x.toString(16).padStart(2, '0')).join('');
|
||||||
//}
|
//}
|
||||||
@@ -70,14 +71,161 @@ function useSensor(measurement, name, end, duration) {
|
|||||||
return [data, loading];
|
return [data, loading];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function MapViewManager({ coords, mapState, setMapState, loading, setSubmenu }) {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
// Effect 1: Handle map events (pan/zoom) from the user
|
||||||
|
useMapEvents({
|
||||||
|
mousedown: () => setSubmenu(false),
|
||||||
|
zoomstart: () => setSubmenu(false),
|
||||||
|
moveend: () => {
|
||||||
|
const center = map.getCenter();
|
||||||
|
const newZoom = map.getZoom();
|
||||||
|
const newCenter = [center.lat, center.lng];
|
||||||
|
|
||||||
function Map({end, duration, slider}) {
|
setMapState(prevState => {
|
||||||
|
// A small tolerance for floating point comparisons
|
||||||
|
const tolerance = 1e-5;
|
||||||
|
if (!prevState.center) {
|
||||||
|
return { zoom: newZoom, center: newCenter };
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoomChanged = prevState.zoom !== newZoom;
|
||||||
|
const centerChanged = Math.abs(prevState.center[0] - newCenter[0]) > tolerance ||
|
||||||
|
Math.abs(prevState.center[1] - newCenter[1]) > tolerance;
|
||||||
|
|
||||||
|
if (zoomChanged || centerChanged) {
|
||||||
|
return { zoom: newZoom, center: newCenter };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If nothing changed, return the previous state to prevent a re-render
|
||||||
|
return prevState;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Effect 2: Handle programmatic view changes (refitting or setting from state)
|
||||||
|
useEffect(() => {
|
||||||
|
// Don't do anything while loading new data
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
// Case A: A refit is needed (signaled by null center)
|
||||||
|
if (mapState.center === null && coords.length > 0) {
|
||||||
|
const bounds = leaflet.latLngBounds(coords);
|
||||||
|
if (bounds.isValid()) {
|
||||||
|
map.fitBounds(bounds);
|
||||||
|
// After fitting, the 'moveend' event will fire and update the state naturally.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Case B: A center is set in the state, ensure map is synced
|
||||||
|
else if (mapState.center) {
|
||||||
|
const currentCenter = map.getCenter();
|
||||||
|
const currentZoom = map.getZoom();
|
||||||
|
if (currentCenter.lat !== mapState.center[0] || currentCenter.lng !== mapState.center[1] || currentZoom !== mapState.zoom) {
|
||||||
|
map.setView(mapState.center, mapState.zoom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [coords, mapState, loading, map, setMapState]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolylineWithArrows({ coords, showDirection }) {
|
||||||
|
const map = useMap();
|
||||||
|
const polylineRef = useRef(null);
|
||||||
|
const decoratorRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (polylineRef.current) {
|
||||||
|
map.removeLayer(polylineRef.current);
|
||||||
|
}
|
||||||
|
if (decoratorRef.current) {
|
||||||
|
map.removeLayer(decoratorRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coords && coords.length > 1) {
|
||||||
|
const polyline = leaflet.polyline(coords, { color: 'blue' });
|
||||||
|
polylineRef.current = polyline;
|
||||||
|
map.addLayer(polyline);
|
||||||
|
|
||||||
|
if (showDirection) {
|
||||||
|
const decorator = leaflet.polylineDecorator(polyline, {
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
offset: 25,
|
||||||
|
repeat: 100,
|
||||||
|
symbol: leaflet.Symbol.arrowHead({
|
||||||
|
pixelSize: 10,
|
||||||
|
polygon: false,
|
||||||
|
pathOptions: {
|
||||||
|
stroke: true,
|
||||||
|
color: 'blue',
|
||||||
|
weight: 3
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
decoratorRef.current = decorator;
|
||||||
|
map.addLayer(decorator);
|
||||||
|
} else {
|
||||||
|
decoratorRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (polylineRef.current) {
|
||||||
|
map.removeLayer(polylineRef.current);
|
||||||
|
}
|
||||||
|
if (decoratorRef.current) {
|
||||||
|
map.removeLayer(decoratorRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [coords, map, showDirection]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Map({end, duration, slider, mapState, setMapState, setSubmenu, showDirection}) {
|
||||||
const [data, loading] = useSensor('owntracks', 'OwnTracks', end, duration);
|
const [data, loading] = useSensor('owntracks', 'OwnTracks', end, duration);
|
||||||
|
|
||||||
const range = parseSlider(end, duration, slider);
|
const range = useMemo(() => parseSlider(end, duration, slider), [end, duration, slider]);
|
||||||
|
|
||||||
const coords = data.length ? data.filter(x => !range || (x.time >= range[0] && x.time <= range[1])).map(({ lat, lon }) => [lat, lon]).filter(([lat, lon]) => lat !== null || lon !== null) : [];
|
const coords = useMemo(() => {
|
||||||
|
// 1. Guard against invalid top-level data
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
const [startTime, endTime] = range;
|
||||||
|
|
||||||
|
// 2. Loop through the data
|
||||||
|
for (const point of data) {
|
||||||
|
// 3. Guard against malformed points
|
||||||
|
if (!point || typeof point !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lat, lon, time } = point;
|
||||||
|
|
||||||
|
// 4. Guard against invalid time
|
||||||
|
if (typeof time !== 'string' || time.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Guard against invalid coordinates (null, undefined, NaN, non-number)
|
||||||
|
if (typeof lat !== 'number' || typeof lon !== 'number' || !isFinite(lat) || !isFinite(lon)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Now that all data is known to be valid, filter by time
|
||||||
|
if (time >= startTime && time <= endTime) {
|
||||||
|
result.push([lat, lon]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [data, range]);
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -91,13 +239,16 @@ function Map({end, duration, slider}) {
|
|||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
:
|
:
|
||||||
coords.length ?
|
coords.length ?
|
||||||
<MapContainer center={coords[coords.length-1]} zoom={13} scrollWheelZoom={true} style={{ width: '100%', height: 'calc(100vh - 2.5rem)' }}>
|
(
|
||||||
<TileLayer
|
<MapContainer center={mapState.center || [0, 0]} zoom={mapState.zoom} scrollWheelZoom={true} style={{ width: '100%', height: 'calc(100vh - 2.5rem)' }}>
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
<MapViewManager coords={coords} mapState={mapState} setMapState={setMapState} loading={loading} setSubmenu={setSubmenu} />
|
||||||
url='https://maptiles.p.rapidapi.com/en/map/v1/{z}/{x}/{y}.png?rapidapi-key=4375b0b1d8msh0c9e7fa3efb9adfp1769dfjsnd603a0387fea'
|
<TileLayer
|
||||||
/>
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
<Polyline pathOptions={{color: 'blue'}} positions={coords} />
|
url='https://maptiles.p.rapidapi.com/en/map/v1/{z}/{x}/{y}.png?rapidapi-key=4375b0b1d8msh0c9e7fa3efb9adfp1769dfjsnd603a0387fea'
|
||||||
</MapContainer>
|
/>
|
||||||
|
<PolylineWithArrows coords={coords} showDirection={showDirection} />
|
||||||
|
</MapContainer>
|
||||||
|
)
|
||||||
:
|
:
|
||||||
<>
|
<>
|
||||||
<p>No data</p>
|
<p>No data</p>
|
||||||
@@ -112,8 +263,7 @@ function Map({end, duration, slider}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Menu({duration, setDuration, end, setEnd, slider, setSlider}) {
|
function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, setSubmenu, showDirection, setShowDirection}) {
|
||||||
const [submenu, setSubmenu] = useState(false);
|
|
||||||
const [showRange, setShowRange] = useState(false);
|
const [showRange, setShowRange] = useState(false);
|
||||||
|
|
||||||
const chooseDuration = (x) => {
|
const chooseDuration = (x) => {
|
||||||
@@ -135,12 +285,20 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider}) {
|
|||||||
setEnd(moment());
|
setEnd(moment());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const chooseMidnight = () => {
|
||||||
|
setSubmenu(false);
|
||||||
|
setSlider([0, duration.num]);
|
||||||
|
setEnd(moment().startOf('day'));
|
||||||
|
};
|
||||||
|
|
||||||
const rangeStart = (x) => {
|
const rangeStart = (x) => {
|
||||||
|
setSubmenu(false);
|
||||||
setEnd(moment(range[0]).add(...duration.delta));
|
setEnd(moment(range[0]).add(...duration.delta));
|
||||||
setSlider([0, duration.num]);
|
setSlider([0, duration.num]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rangeEnd = (x) => {
|
const rangeEnd = (x) => {
|
||||||
|
setSubmenu(false);
|
||||||
setEnd(moment(range[1]));
|
setEnd(moment(range[1]));
|
||||||
setSlider([0, duration.num]);
|
setSlider([0, duration.num]);
|
||||||
};
|
};
|
||||||
@@ -157,6 +315,10 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider}) {
|
|||||||
setEnd(prevEnd => moment(prevEnd).subtract(...duration.delta));
|
setEnd(prevEnd => moment(prevEnd).subtract(...duration.delta));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetToDefaults = () => {
|
||||||
|
window.location.href = window.location.pathname;
|
||||||
|
};
|
||||||
|
|
||||||
const range = parseSlider(end, duration, slider);
|
const range = parseSlider(end, duration, slider);
|
||||||
|
|
||||||
const rangeTime = (x) => {
|
const rangeTime = (x) => {
|
||||||
@@ -167,10 +329,42 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider}) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rangeDelta = (range) => {
|
||||||
|
const start = moment(range[0]);
|
||||||
|
const end = moment(range[1]);
|
||||||
|
const diff = moment.duration(end.diff(start));
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
const years = diff.years();
|
||||||
|
if (years > 0) parts.push(`${years} year${years > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
const months = diff.months();
|
||||||
|
if (months > 0) parts.push(`${months} month${months > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
const days = diff.days();
|
||||||
|
if (days > 0) parts.push(`${days} day${days > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
const hours = diff.hours();
|
||||||
|
if (hours > 0) parts.push(`${hours} hour${hours > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
const minutes = diff.minutes();
|
||||||
|
if (minutes > 0) parts.push(`${minutes} minute${minutes > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
const seconds = diff.seconds();
|
||||||
|
if (seconds > 0) parts.push(`${seconds} second${seconds > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return '0 seconds';
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='menu'>
|
<div className='menu'>
|
||||||
{(showRange || !!submenu) && <div className='range'>
|
{(showRange || !!submenu) && <div className='range'>
|
||||||
{rangeTime(range[0])} - {rangeTime(range[1])}
|
{rangeTime(range[0])} - {rangeTime(range[1])} <span style={{ whiteSpace: 'nowrap' }}>({rangeDelta(range)})</span>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
<div className='time-slider'>
|
<div className='time-slider'>
|
||||||
@@ -203,9 +397,18 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider}) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onClick={chooseNow}>Jump to Now</button>
|
<div className='submenu-actions'>
|
||||||
<button onClick={rangeStart}>Shift to Range Start</button>
|
<div className='submenu-group'>
|
||||||
<button onClick={rangeEnd}>Shift to Range End</button>
|
<span>Jump to:</span>
|
||||||
|
<button onClick={chooseNow}>Now</button>
|
||||||
|
<button onClick={chooseMidnight}>Midnight</button>
|
||||||
|
</div>
|
||||||
|
<div className='submenu-group'>
|
||||||
|
<span>Shift to:</span>
|
||||||
|
<button onClick={rangeStart}>Range Start</button>
|
||||||
|
<button onClick={rangeEnd}>Range End</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +424,24 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider}) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{submenu === 'misc' &&
|
||||||
|
<>
|
||||||
|
<div className='submenu-header'>
|
||||||
|
<h2>Misc</h2>
|
||||||
|
<button onClick={() => setSubmenu(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<label className="submenu-checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showDirection}
|
||||||
|
onChange={e => setShowDirection(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Show direction
|
||||||
|
</label>
|
||||||
|
<button onClick={resetToDefaults}>Reset to defaults</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,14 +452,21 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider}) {
|
|||||||
onClick={() => setSubmenu('end')}
|
onClick={() => setSubmenu('end')}
|
||||||
className={submenu === 'end' ? 'active' : ''}
|
className={submenu === 'end' ? 'active' : ''}
|
||||||
>
|
>
|
||||||
{moment(end).subtract(duration.delta[0], duration.delta[1]).format('ddd MMM DD')}
|
{moment(end).subtract(duration.delta[0], duration.delta[1]).format('dd MMM DD')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSubmenu('misc')}
|
||||||
|
className={submenu === 'misc' ? 'active' : ''}
|
||||||
|
>
|
||||||
|
☰
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSubmenu('duration')}
|
onClick={() => setSubmenu('duration')}
|
||||||
className={submenu === 'duration' ? 'active' : ''}
|
className={submenu === 'duration' ? 'active' : ''}
|
||||||
>
|
>
|
||||||
{duration.len} / {duration.win}
|
{(duration.shortLen || duration.len)} / {duration.win}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button onClick={() => next()}>></button>
|
<button onClick={() => next()}>></button>
|
||||||
@@ -248,9 +476,60 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [duration, setDuration] = useState(durations[0]);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const [end, setEnd] = useState(moment());
|
const initialDurationId = params.get('duration');
|
||||||
const [slider, setSlider] = useState([0, duration.num]);
|
const initialEndTimestamp = params.get('end');
|
||||||
|
const initialSliderValue = params.get('slider');
|
||||||
|
const initialLat = params.get('lat');
|
||||||
|
const initialLng = params.get('lng');
|
||||||
|
const initialZoom = params.get('zoom');
|
||||||
|
const initialShowDirection = params.get('showDirection') === 'true';
|
||||||
|
|
||||||
|
const initialDuration = (initialDurationId && durations[parseInt(initialDurationId, 10)]) ? durations[parseInt(initialDurationId, 10)] : durations[0];
|
||||||
|
const initialEnd = initialEndTimestamp ? moment.unix(initialEndTimestamp) : moment();
|
||||||
|
const initialSlider = initialSliderValue ? initialSliderValue.split(',').map(Number) : [0, initialDuration.num];
|
||||||
|
|
||||||
|
const [duration, setDuration] = useState(initialDuration);
|
||||||
|
const [end, setEnd] = useState(initialEnd);
|
||||||
|
const [slider, setSlider] = useState(initialSlider);
|
||||||
|
const [mapState, setMapState] = useState({
|
||||||
|
center: (initialLat && initialLng) ? [parseFloat(initialLat), parseFloat(initialLng)] : null,
|
||||||
|
zoom: initialZoom ? parseInt(initialZoom, 10) : 13,
|
||||||
|
});
|
||||||
|
const [submenu, setSubmenu] = useState(false);
|
||||||
|
const [showDirection, setShowDirection] = useState(initialShowDirection);
|
||||||
|
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
} else {
|
||||||
|
// Reset map center to trigger a refit when new data arrives
|
||||||
|
setMapState(prev => ({ ...prev, center: null }));
|
||||||
|
}
|
||||||
|
}, [end, duration]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('duration', duration.id);
|
||||||
|
params.set('end', end.unix());
|
||||||
|
params.set('slider', slider.join(','));
|
||||||
|
if (showDirection) {
|
||||||
|
params.set('showDirection', 'true');
|
||||||
|
}
|
||||||
|
if (mapState.center) {
|
||||||
|
params.set('lat', mapState.center[0].toFixed(5));
|
||||||
|
params.set('lng', mapState.center[1].toFixed(5));
|
||||||
|
params.set('zoom', mapState.zoom);
|
||||||
|
}
|
||||||
|
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(handler);
|
||||||
|
};
|
||||||
|
}, [duration, end, slider, mapState, showDirection]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -261,12 +540,20 @@ function App() {
|
|||||||
setEnd={setEnd}
|
setEnd={setEnd}
|
||||||
slider={slider}
|
slider={slider}
|
||||||
setSlider={setSlider}
|
setSlider={setSlider}
|
||||||
|
submenu={submenu}
|
||||||
|
setSubmenu={setSubmenu}
|
||||||
|
showDirection={showDirection}
|
||||||
|
setShowDirection={setShowDirection}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Map
|
<Map
|
||||||
end={end}
|
end={end}
|
||||||
duration={duration}
|
duration={duration}
|
||||||
slider={slider}
|
slider={slider}
|
||||||
|
mapState={mapState}
|
||||||
|
setMapState={setMapState}
|
||||||
|
setSubmenu={setSubmenu}
|
||||||
|
showDirection={showDirection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@@ -7308,6 +7308,18 @@ last-call-webpack-plugin@^3.0.0:
|
|||||||
lodash "^4.17.5"
|
lodash "^4.17.5"
|
||||||
webpack-sources "^1.1.0"
|
webpack-sources "^1.1.0"
|
||||||
|
|
||||||
|
leaflet-polylinedecorator@^1.6.0:
|
||||||
|
version "1.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/leaflet-polylinedecorator/-/leaflet-polylinedecorator-1.6.0.tgz#9ef79fd1b5302d67b72efe959a8ecd2553f27266"
|
||||||
|
integrity sha512-kn3krmZRetgvN0wjhgYL8kvyLS0tUogAl0vtHuXQnwlYNjbl7aLQpkoFUo8UB8gVZoB0dhI4Tb55VdTJAcYzzQ==
|
||||||
|
dependencies:
|
||||||
|
leaflet-rotatedmarker "^0.2.0"
|
||||||
|
|
||||||
|
leaflet-rotatedmarker@^0.2.0:
|
||||||
|
version "0.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/leaflet-rotatedmarker/-/leaflet-rotatedmarker-0.2.0.tgz#4467f49f98d1bfd56959bd9c6705203dd2601277"
|
||||||
|
integrity sha512-yc97gxLXwbZa+Gk9VCcqI0CkvIBC9oNTTjFsHqq4EQvANrvaboib4UdeQLyTnEqDpaXHCqzwwVIDHtvz2mUiDg==
|
||||||
|
|
||||||
leaflet@^1.9.4:
|
leaflet@^1.9.4:
|
||||||
version "1.9.4"
|
version "1.9.4"
|
||||||
resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d"
|
resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d"
|
||||||
|
Reference in New Issue
Block a user