Files
sensors/mapper/src/App.js

921 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef, useMemo } from 'react';
import * as leaflet from 'leaflet';
import 'leaflet-polylinedecorator';
import { MapContainer, TileLayer, useMap, useMapEvents, FeatureGroup } from 'react-leaflet';
import { EditControl } from 'react-leaflet-draw';
import Datetime from 'react-datetime';
import 'react-datetime/css/react-datetime.css';
import axios from 'axios';
import moment from 'moment-timezone';
import RangeSlider from 'react-range-slider-input';
import './App.css';
import 'leaflet/dist/leaflet.css';
import 'react-range-slider-input/dist/style.css';
import 'leaflet-draw/dist/leaflet.draw.css';
// num: number of steps per duration
// secs: number of seconds per step
const durations = [
{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: 2, len: 'Month', shortLen: 'Mth', win: '10m', full: '10 min', delta: [1, 'months'], format: 'D', num: 4380, secs: 600},
{id: 3, len: 'Quarter', shortLen: 'Qtr', win: '30m', full: '30 min', delta: [3, 'months'], format: 'M/D', num: 4380, secs: 1800},
{id: 4, len: 'Year', win: '2h', full: '2 hour', delta: [1, 'years'], format: 'M/D', num: 4380, secs: 7200},
];
const parseSlider = (end, duration, slider) => {
//console.log(slider);
// good luck remembering how this works
const lowOffset = slider[0] * duration.secs - duration.num * duration.secs;
const highOffset = slider[1] * duration.secs - duration.num * duration.secs;
const low = moment.unix(end.unix() + lowOffset);
const high = moment.unix(end.unix() + highOffset);
const lowStr = low.utc().format('YYYY-MM-DDTHH:mm:ss[Z]');
const highStr = high.utc().format('YYYY-MM-DDTHH:mm:ss[Z]');
//console.log(lowStr, highStr);
return [lowStr, highStr];
};
async function sha256(source) {
const sourceBytes = new TextEncoder().encode(source);
const digest = await crypto.subtle.digest('SHA-256', sourceBytes);
const resultBytes = [...new Uint8Array(digest)];
return resultBytes.map(x => x.toString(16).padStart(2, '0')).join('');
}
function useSensor(measurement, name, end, duration) {
const [data, setData] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
const get = async() => {
setLoading(true);
try {
const api_key = localStorage.getItem('api_key');
const urlParams = new URLSearchParams(window.location.search);
const shareStart = urlParams.get('shareStart');
const shareEnd = urlParams.get('shareEnd');
const shareSig = urlParams.get('shareSig');
const params = { end: end.unix(), duration: duration.len.toLowerCase(), window: duration.win, api_key: api_key };
if (shareStart && shareEnd && shareSig) {
params.shareStart = shareStart;
params.shareEnd = shareEnd;
params.shareSig = shareSig;
}
const res = await axios.get(
'https://sensors-api.dns.t0.vc/history/'+measurement+'/'+name,
{ params: params },
);
setData((d) => (res.data));
setLoading(false);
} catch (error) {
console.log(error);
}
};
get();
}, [end, duration]);
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];
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({data, loading, end, duration, slider, mapState, setMapState, setSubmenu, showDirection, setDrawnItems}) {
const range = useMemo(() => parseSlider(end, duration, slider), [end, duration, slider]);
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) => {
e.preventDefault();
const api_key = e.target[0].value;
localStorage.setItem('api_key', api_key);
}
const onCreated = (e) => {
const { layer } = e;
setDrawnItems(items => [...items, {id: layer._leaflet_id, bounds: layer.getBounds()}]);
};
const onEdited = (e) => {
const { layers } = e;
layers.eachLayer(layer => {
setDrawnItems(items => items.map(item =>
item.id === layer._leaflet_id ? { ...item, bounds: layer.getBounds() } : item
));
});
};
const onDeleted = (e) => {
const { layers } = e;
const deletedIds = [];
layers.eachLayer(layer => deletedIds.push(layer._leaflet_id));
setDrawnItems(items => items.filter(item => !deletedIds.includes(item.id)));
};
const showMap = Array.isArray(data);
return (
<div className='container'>
{!showMap ? (
loading ? (
<p>Loading...</p>
) : (
<>
<p>No data</p>
<form onSubmit={handleSubmit}>
<p>
<input placeholder='API key' />
</p>
</form>
</>
)
) : (
<div style={{ position: 'relative', width: '100%', height: 'calc(100vh - 2.5rem)' }}>
{loading && (
<div className="loading-overlay">
<p>Loading...</p>
</div>
)}
<MapContainer center={mapState.center || [0, 0]} zoom={mapState.zoom} scrollWheelZoom={true} style={{ width: '100%', height: '100%' }}>
<MapViewManager coords={coords} mapState={mapState} setMapState={setMapState} loading={loading} setSubmenu={setSubmenu} />
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url='https://maptiles.p.rapidapi.com/en/map/v1/{z}/{x}/{y}.png?rapidapi-key=4375b0b1d8msh0c9e7fa3efb9adfp1769dfjsnd603a0387fea'
/>
<PolylineWithArrows coords={coords} showDirection={showDirection} />
<FeatureGroup>
<EditControl
position="topright"
onCreated={onCreated}
onEdited={onEdited}
onDeleted={onDeleted}
draw={{
rectangle: true,
polyline: false,
polygon: false,
circle: false,
marker: false,
circlemarker: false,
}}
/>
</FeatureGroup>
</MapContainer>
</div>
)}
</div>
);
}
function Menu({data, duration, setDuration, end, setEnd, slider, setSlider, submenu, setSubmenu, showDirection, setShowDirection, setMapState, shareStart, shareEnd, drawnItems}) {
const [showRange, setShowRange] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState(null);
const [activeSearchResult, setActiveSearchResult] = useState(null);
const scrollContainerRef = useRef(null);
const scrollPositionRef = useRef(0);
useEffect(() => {
const container = scrollContainerRef.current;
if (container) {
// Restore scroll position when results are shown
container.scrollTop = scrollPositionRef.current;
}
return () => {
// Save scroll position when results are hidden
if (container) {
scrollPositionRef.current = container.scrollTop;
}
};
}, [searchResults]);
const handleSliderChange = (newSliderValue) => {
setActiveSearchResult(null);
setSlider(newSliderValue);
};
const chooseDuration = (x) => {
setSubmenu(false);
setSlider([0, x.num]);
setDuration(x);
setActiveSearchResult(null);
};
const chooseEnd = (x) => {
setSubmenu(false);
const newEnd = x.add(...duration.delta);
setSlider([0, duration.num]);
setEnd(newEnd);
setActiveSearchResult(null);
};
const chooseNow = (x) => {
setSubmenu(false);
setSlider([0, duration.num]);
setEnd(moment());
setActiveSearchResult(null);
};
const chooseMidnight = () => {
setSubmenu(false);
setSlider([0, duration.num]);
setEnd(prevEnd => moment(prevEnd).startOf('day'));
setActiveSearchResult(null);
};
const rangeStart = (x) => {
setSubmenu(false);
setEnd(moment(range[0]).add(...duration.delta));
setSlider([0, duration.num]);
setActiveSearchResult(null);
};
const rangeEnd = (x) => {
setSubmenu(false);
setEnd(moment(range[1]));
setSlider([0, duration.num]);
setActiveSearchResult(null);
};
const next = () => {
setSubmenu(false);
setSlider([0, duration.num]);
setEnd(prevEnd => moment(prevEnd).add(...duration.delta));
setActiveSearchResult(null);
}
const prev = () => {
setSubmenu(false);
setSlider([0, duration.num]);
setEnd(prevEnd => moment(prevEnd).subtract(...duration.delta));
setActiveSearchResult(null);
}
const resetToDefaults = () => {
window.location.href = window.location.pathname;
};
const recentreView = () => {
setMapState(prev => ({ ...prev, center: null }));
setSubmenu(false);
};
const excludeArea = () => {
const drawnRectangles = drawnItems.map(item => item.bounds);
if (!drawnRectangles.length || !data || !Array.isArray(data)) {
if (!drawnRectangles.length) alert("Please draw one or more rectangles on the map first.");
setSubmenu(false);
return;
}
const isInsideExclusionZone = (lat, lon) => {
for (const rect of drawnRectangles) {
if (rect.contains([lat, lon])) {
return true;
}
}
return false;
};
const goodSegments = [];
let currentSegment = null;
for (const point of data) {
if (!point || typeof point.lat !== 'number' || typeof point.lon !== 'number' || !point.time) {
continue;
}
const isInside = isInsideExclusionZone(point.lat, point.lon);
if (!isInside) {
if (!currentSegment) {
currentSegment = { start: point.time, end: point.time };
} else {
currentSegment.end = point.time;
}
} else {
if (currentSegment) {
goodSegments.push(currentSegment);
currentSegment = null;
}
}
}
if (currentSegment) {
goodSegments.push(currentSegment);
}
if (!goodSegments.length) {
alert("No time ranges found outside the selected area(s).");
setSubmenu(false);
return;
}
let longestSegment = goodSegments[0];
for (let i = 1; i < goodSegments.length; i++) {
const durationCurrent = moment(longestSegment.end).diff(moment(longestSegment.start));
const durationNew = moment(goodSegments[i].end).diff(moment(goodSegments[i].start));
if (durationNew > durationCurrent) {
longestSegment = goodSegments[i];
}
}
const startUnix = moment(longestSegment.start).unix();
const endUnix = moment(longestSegment.end).unix();
const endOfWindowUnix = end.unix();
const newSliderStart = Math.floor((startUnix - endOfWindowUnix) / duration.secs + duration.num);
const newSliderEnd = Math.ceil((endUnix - endOfWindowUnix) / duration.secs + duration.num);
const clampedStart = Math.max(0, newSliderStart);
const clampedEnd = Math.min(duration.num, newSliderEnd);
setSlider([clampedStart, clampedEnd]);
setSubmenu(false);
};
const searchArea = async () => {
const drawnRectangles = drawnItems.map(item => item.bounds);
if (!drawnRectangles.length) {
alert("Please draw one or more rectangles on the map first.");
return;
}
const areas = drawnRectangles.map(bounds => ({
northEast: bounds.getNorthEast(),
southWest: bounds.getSouthWest(),
}));
setIsSearching(true);
try {
const api_key = localStorage.getItem('api_key');
const params = {
api_key: api_key,
};
const res = await axios.post(
'https://sensors-api.dns.t0.vc/search/owntracks/OwnTracks',
{ areas: areas },
{ params: params }
);
setActiveSearchResult(null);
setSearchResults(res.data);
} catch (error) {
console.error('Error during area search:', error);
alert('An error occurred during the search.');
} finally {
setIsSearching(false);
}
};
const selectSearchResult = (result) => {
const resultStart = moment.unix(result.start);
const resultEnd = moment.unix(result.end);
const resultDurationSeconds = result.end - result.start;
// Find the best duration that fits the search result
let bestDuration = durations.find(d => resultDurationSeconds <= moment.duration(...d.delta).asSeconds());
if (!bestDuration) {
bestDuration = durations[durations.length - 1]; // Default to the largest duration if none fit
}
setDuration(bestDuration);
// Set the end of the window to be the end of the search result for simplicity
const newEnd = resultEnd;
setEnd(newEnd);
// Calculate the new slider positions based on the new duration and end time
const newSliderStart = (resultStart.unix() - newEnd.unix()) / bestDuration.secs + bestDuration.num;
const newSliderEnd = (resultEnd.unix() - newEnd.unix()) / bestDuration.secs + bestDuration.num;
// Clamp values to be within the slider's bounds [0, duration.num]
const clampedStart = Math.max(0, Math.floor(newSliderStart));
const clampedEnd = Math.min(bestDuration.num, Math.ceil(newSliderEnd));
setSlider([clampedStart, clampedEnd]);
setActiveSearchResult({ start: result.start, end: result.end });
setSubmenu(false);
};
const range = parseSlider(end, duration, slider);
const startDate = moment(end).subtract(...duration.delta);
const isPrevDisabled = shareStart ? moment(startDate).isBefore(shareStart) : false;
const isNextDisabled = shareEnd ? moment(end).add(1, 'day').isAfter(shareEnd) : false;
const isValidDate = (current) => {
if (!shareStart || !shareEnd) {
return true;
}
const proposedDate = moment(current).startOf('day');
const isAfterOrOnShareStart = proposedDate.isSameOrAfter(shareStart, 'day');
const isBeforeOrOnShareEnd = proposedDate.isBefore(shareEnd, 'day');
return isAfterOrOnShareStart && isBeforeOrOnShareEnd;
};
const shareRange = async () => {
const shareStart = moment(range[0]).unix();
const shareEnd = moment(range[1]).unix();
const apiKey = localStorage.getItem('api_key');
if (!apiKey) {
alert('API key is not set. Cannot create a shareable link.');
return;
}
const dataToSign = `owntracks-${shareStart}-${shareEnd}-${apiKey}`;
const shareSig = await sha256(dataToSign);
const params = new URLSearchParams(window.location.search);
params.set('shareStart', shareStart);
params.set('shareEnd', shareEnd);
params.set('shareSig', shareSig);
const shareUrl = `${window.location.origin}${window.location.pathname}?${params.toString()}`;
try {
await navigator.clipboard.writeText(shareUrl);
alert('Shareable URL copied to clipboard!');
} catch (err) {
console.error('Failed to copy: ', err);
alert('Failed to copy URL to clipboard.');
}
setSubmenu(false);
};
const rangeTime = (x) => {
if (new Date().getTimezoneOffset()) { // non-librewolf browser
return moment(x).format('lll'); // default to browser's TZ
} else {
return moment(x).tz('America/Edmonton').format('lll');
}
};
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 (
<div className='menu'>
{(showRange || !!submenu) && <div className='range'>
{rangeTime(range[0])} - {rangeTime(range[1])} <span style={{ whiteSpace: 'nowrap' }}>({rangeDelta(range)})</span>
</div>}
<div className='time-slider'>
<RangeSlider
min={0}
max={duration.num}
value={slider}
onInput={handleSliderChange}
onThumbDragStart={() => setShowRange(true)}
onThumbDragEnd={() => setShowRange(false)}
onRangeDragStart={() => setShowRange(true)}
onRangeDragEnd={() => setShowRange(false)}
/>
</div>
{!!submenu &&
<div className='submenu'>
{submenu === 'end' &&
<>
<div className='submenu-header'>
<h2>Choose start date:</h2>
<button onClick={() => setSubmenu(false)}>×</button>
</div>
<div className='datepicker'>
<Datetime
input={false}
timeFormat={false}
onChange={(x) => chooseEnd(x)}
value={startDate}
isValidDate={isValidDate}
/>
</div>
<div className='submenu-actions'>
<div className='submenu-group'>
<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>
</>
}
{submenu === 'duration' &&
<>
<div className='submenu-header'>
<h2>Choose duration:</h2>
<button onClick={() => setSubmenu(false)}>×</button>
</div>
{durations.map(x =>
<button key={x.id} onClick={() => chooseDuration(x)}>Last {x.len} ({x.full} data)</button>
)}
</>
}
{submenu === 'misc' &&
<>
<div className='submenu-header'>
<h2>{searchResults ? 'Search Results' : 'Misc'}</h2>
{searchResults ? (
<button onClick={() => { setSearchResults(null); setActiveSearchResult(null); }}>&lt; Back</button>
) : (
<button onClick={() => setSubmenu(false)}>×</button>
)}
</div>
{searchResults ? (
<div ref={scrollContainerRef} className="search-results-container">
{searchResults.length > 0 ? (
(() => {
const sortedResults = [...searchResults].sort((a, b) => b.start - a.start);
const groupedResults = sortedResults.reduce((acc, result) => {
const groupKey = moment.unix(result.start).format('YYYY');
if (!acc[groupKey]) {
acc[groupKey] = [];
}
acc[groupKey].push(result);
return acc;
}, {});
const formatShortTime = (unixTimestamp) => {
const m = moment.unix(unixTimestamp);
if (new Date().getTimezoneOffset()) {
return m.format('MMM D, HH:mm');
} else {
return m.tz('America/Edmonton').format('MMM D, HH:mm');
}
};
return Object.entries(groupedResults)
.sort(([yearA], [yearB]) => yearB - yearA)
.map(([groupKey, results]) => (
<div key={groupKey}>
<h3 className="search-results-group-header">{groupKey}</h3>
{results.map((result, index) => (
<button
key={index}
onClick={() => selectSearchResult(result)}
className={activeSearchResult && activeSearchResult.start === result.start && activeSearchResult.end === result.end ? 'active' : ''}
>
{formatShortTime(result.start)} - {formatShortTime(result.end)}
</button>
))}
</div>
));
})()
) : (
<p className="search-results-empty">No results found.</p>
)}
</div>
) : (
<>
<label className="submenu-checkbox-label">
<input
type="checkbox"
checked={showDirection}
onChange={e => setShowDirection(e.target.checked)}
/>
Show direction
</label>
<button onClick={recentreView}>Recentre view</button>
<button onClick={excludeArea}>Exclude area</button>
<button onClick={searchArea} disabled={isSearching}>
{isSearching ? 'Searching...' : 'Search area'}
</button>
<button onClick={shareRange}>Share range</button>
<button onClick={resetToDefaults}>Reset page</button>
</>
)}
</>
}
</div>
}
<div className='menu-container'>
<button onClick={() => prev()} disabled={isPrevDisabled}>&lt;</button>
<button
onClick={() => setSubmenu('end')}
className={submenu === 'end' ? 'active' : ''}
>
{startDate.format('dd MMM DD')}
</button>
<button
onClick={() => setSubmenu('misc')}
className={submenu === 'misc' ? 'active' : ''}
>
</button>
<button
onClick={() => setSubmenu('duration')}
className={submenu === 'duration' ? 'active' : ''}
>
{(duration.shortLen || duration.len)} / {duration.win}
</button>
<button onClick={() => next()} disabled={isNextDisabled}>&gt;</button>
</div>
</div>
);
}
function App() {
const params = new URLSearchParams(window.location.search);
const initialDurationId = params.get('duration');
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 shareStartParam = params.get('shareStart');
const shareEndParam = params.get('shareEnd');
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 [drawnItems, setDrawnItems] = useState([]);
const [data, loading] = useSensor('owntracks', 'OwnTracks', end, duration);
const shareStart = shareStartParam ? moment.unix(shareStartParam) : null;
const shareEnd = shareEndParam ? moment.unix(shareEndParam) : null;
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(window.location.search);
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 (
<div>
<Menu
duration={duration}
setDuration={setDuration}
end={end}
setEnd={setEnd}
slider={slider}
setSlider={setSlider}
submenu={submenu}
setSubmenu={setSubmenu}
showDirection={showDirection}
setShowDirection={setShowDirection}
setMapState={setMapState}
shareStart={shareStart}
shareEnd={shareEnd}
data={data}
drawnItems={drawnItems}
/>
<Map
end={end}
duration={duration}
slider={slider}
mapState={mapState}
setMapState={setMapState}
setSubmenu={setSubmenu}
showDirection={showDirection}
data={data}
loading={loading}
setDrawnItems={setDrawnItems}
/>
</div>
);
}
export default App;