Files
sensors/mapper/src/App.js

563 lines
17 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 } from 'react-leaflet';
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';
// 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: '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-26', 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', 'null');
const params = { end: end.unix(), duration: duration.len.toLowerCase(), window: duration.win, api_key: api_key };
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({end, duration, slider, mapState, setMapState, setSubmenu, showDirection}) {
const [data, loading] = useSensor('owntracks', 'OwnTracks', end, duration);
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);
}
return (
<div className='container'>
{loading ?
<p>Loading...</p>
:
coords.length ?
(
<MapContainer center={mapState.center || [0, 0]} zoom={mapState.zoom} scrollWheelZoom={true} style={{ width: '100%', height: 'calc(100vh - 2.5rem)' }}>
<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} />
</MapContainer>
)
:
<>
<p>No data</p>
<form onSubmit={handleSubmit}>
<p>
<input placeholder='API key' />
</p>
</form>
</>
}
</div>
);
}
function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, setSubmenu, showDirection, setShowDirection}) {
const [showRange, setShowRange] = useState(false);
const chooseDuration = (x) => {
setSubmenu(false);
setSlider([0, x.num]);
setDuration(x);
};
const chooseEnd = (x) => {
setSubmenu(false);
const newEnd = x.add(...duration.delta);
setSlider([0, duration.num]);
setEnd(newEnd);
};
const chooseNow = (x) => {
setSubmenu(false);
setSlider([0, duration.num]);
setEnd(moment());
};
const chooseMidnight = () => {
setSubmenu(false);
setSlider([0, duration.num]);
setEnd(moment().startOf('day'));
};
const rangeStart = (x) => {
setSubmenu(false);
setEnd(moment(range[0]).add(...duration.delta));
setSlider([0, duration.num]);
};
const rangeEnd = (x) => {
setSubmenu(false);
setEnd(moment(range[1]));
setSlider([0, duration.num]);
};
const next = () => {
setSubmenu(false);
setSlider([0, duration.num]);
setEnd(prevEnd => moment(prevEnd).add(...duration.delta));
}
const prev = () => {
setSubmenu(false);
setSlider([0, duration.num]);
setEnd(prevEnd => moment(prevEnd).subtract(...duration.delta));
}
const resetToDefaults = () => {
window.location.href = window.location.pathname;
};
const range = parseSlider(end, duration, slider);
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={setSlider}
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)}
/>
</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>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 className='menu-container'>
<button onClick={() => prev()}>&lt;</button>
<button
onClick={() => setSubmenu('end')}
className={submenu === 'end' ? 'active' : ''}
>
{moment(end).subtract(duration.delta[0], duration.delta[1]).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()}>&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 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 (
<div>
<Menu
duration={duration}
setDuration={setDuration}
end={end}
setEnd={setEnd}
slider={slider}
setSlider={setSlider}
submenu={submenu}
setSubmenu={setSubmenu}
showDirection={showDirection}
setShowDirection={setShowDirection}
/>
<Map
end={end}
duration={duration}
slider={slider}
mapState={mapState}
setMapState={setMapState}
setSubmenu={setSubmenu}
showDirection={showDirection}
/>
</div>
);
}
export default App;