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: '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))); }; return (
{loading ?

Loading...

: coords.length ? ( ) : <>

No data

}
); } 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 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(prevEnd => moment(prevEnd).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 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 } ); setSearchResults(res.data); } catch (error) { console.error('Error during area search:', error); alert('An error occurred during the search.'); } finally { setIsSearching(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 (
{(showRange || !!submenu) &&
{rangeTime(range[0])} - {rangeTime(range[1])} ({rangeDelta(range)})
}
setShowRange(true)} onThumbDragEnd={() => setShowRange(false)} onRangeDragStart={() => setShowRange(true)} onRangeDragEnd={() => setShowRange(false)} />
{!!submenu &&
{submenu === 'end' && <>

Choose start date:

chooseEnd(x)} value={startDate} isValidDate={isValidDate} />
Jump to:
Shift to:
} {submenu === 'duration' && <>

Choose duration:

{durations.map(x => )} } {submenu === 'misc' && <>

{searchResults ? 'Search Results' : 'Misc'}

{searchResults ? ( ) : ( )}
{searchResults ? ( <> {searchResults.length > 0 ? ( searchResults.map((result, index) => ( )) ) : (

No results found.

)} ) : ( <> )} }
}
); } 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 (
); } export default App;