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