@@ -1,6 +1,7 @@
import React , { useState , useEffect } from 'react' ;
import React , { useState , useEffect , useRef , useMemo } from 'react' ;
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 'react-datetime/css/react-datetime.css' ;
import axios from 'axios' ;
@@ -16,7 +17,7 @@ import 'react-range-slider-input/dist/style.css';
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' , 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 } ,
] ;
@@ -38,7 +39,7 @@ const parseSlider = (end, duration, slider) => {
//async function sha256(source) {
// const sourceBytes = new TextEncoder().encode(source);
// const digest = await crypto.subtle.digest('SHA-25 6', sourceBytes);
// 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('');
//}
@@ -70,14 +71,161 @@ function useSensor(measurement, name, 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 ] ;
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 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 ) => {
e . preventDefault ( ) ;
@@ -91,13 +239,16 @@ function Map({end, duration, slider}) {
< p > Loading ... < / p >
:
coords . length ?
< MapContainer center = { coords [ coords . length - 1 ] } zoom = { 13 } scrollWheelZoom = { true } style = { { width : '100%' , height : 'calc(100vh - 2.5rem)' } } >
< TileLayer
attribution = '© <a href="https://www.openstre etm ap.org/copyright">OpenStreetMap</a> contributors'
url = 'https://maptiles.p.rapidapi.com/en/map/v1/{z}/{x}/{y}.png?rapidapi-key=4375b0b1d8msh0c9e7fa3efb9adfp1769dfjsnd603a0387fea'
/ >
< Polyline pathOptions = { { color : 'blue' } } positions = { coords } / >
< / M a p C o n t a i n e r >
(
< MapContainer center = { mapState . center || [ 0 , 0 ] } zoom = { mapState . zoom } scrollWheelZoom = { true } style = { { width : '100%' , height : 'calc(100vh - 2.5rem)' } } >
< MapViewManager coords = { coords } mapState = { mapState } s etM apState = { setMapState } loading = { loading } setSubmenu = { setSubmenu } / >
< TileLayer
attribution = '© <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 } / >
< / M a p C o n t a i n e r >
)
:
< >
< p > No data < / p >
@@ -112,8 +263,7 @@ function Map({end, duration, slider}) {
) ;
}
function Menu ( { duration , setDuration , end , setEnd , slider , setSlider } ) {
const [ submenu , setSubmenu ] = useState ( false ) ;
function Menu ( { duration , setDuration , end , setEnd , slider , setSlider , submenu , setSubmenu , showDirection , setShowDirection }) {
const [ showRange , setShowRange ] = useState ( false ) ;
const chooseDuration = ( x ) => {
@@ -135,12 +285,20 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider}) {
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 ] ) ;
} ;
@@ -157,6 +315,10 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider}) {
setEnd ( prevEnd => moment ( prevEnd ) . subtract ( ... duration . delta ) ) ;
}
const resetToDefaults = ( ) => {
window . location . href = window . location . pathname ;
} ;
const range = parseSlider ( end , duration , slider ) ;
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 (
< div className = 'menu' >
{ ( showRange || ! ! submenu ) && < div className = 'range' >
{ rangeTime ( range [ 0 ] ) } - { rangeTime ( range [ 1 ] ) }
{ rangeTime ( range [ 0 ] ) } - { rangeTime ( range [ 1 ] ) } < span style = { { whiteSpace : 'nowrap' } } > ( { rangeDelta ( range ) } ) < / s p a n >
< / d i v > }
< div className = 'time-slider' >
@@ -203,9 +397,18 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider}) {
/ >
< / d i v >
< button onClick = { chooseNow } > Jump to Now < / b u t t o n >
< button onClick = { rangeStart } > Shift to Range Start < / b u t t o n >
< button onClick = { rangeEnd } > Shift to Range End < / b u t t o n >
< div className = 'submenu-actions' >
< div className = 'submenu-group' >
< span > Jump to : < / s p a n >
< button onClick = { chooseNow } > Now < / b u t t o n >
< button onClick = { chooseMidnight } > Midnight < / b u t t o n >
< / d i v >
< div className = 'submenu-group' >
< span > Shift to : < / s p a n >
< button onClick = { rangeStart } > Range Start < / b u t t o n >
< button onClick = { rangeEnd } > Range End < / b u t t o n >
< / d i v >
< / d i v >
< / >
}
@@ -221,6 +424,24 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider}) {
) }
< / >
}
{ submenu === 'misc' &&
< >
< div className = 'submenu-header' >
< h2 > Misc < / h 2 >
< button onClick = { ( ) => setSubmenu ( false ) } > × < / b u t t o n >
< / d i v >
< label className = "submenu-checkbox-label" >
< input
type = "checkbox"
checked = { showDirection }
onChange = { e => setShowDirection ( e . target . checked ) }
/ >
Show direction
< / l a b e l >
< button onClick = { resetToDefaults } > Reset to defaults < / b u t t o n >
< / >
}
< / d i v >
}
@@ -231,14 +452,21 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider}) {
onClick = { ( ) => setSubmenu ( 'end' ) }
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' ) }
< / b u t t o n >
< button
onClick = { ( ) => setSubmenu ( 'misc' ) }
className = { submenu === 'misc' ? 'active' : '' }
>
☰
< / b u t t o n >
< button
onClick = { ( ) => setSubmenu ( 'duration' ) }
className = { submenu === 'duration' ? 'active' : '' }
>
{ duration . len } / { duration . win }
{ ( duration . shortLen || duration . len ) } / { duration . win }
< / b u t t o n >
< button onClick = { ( ) => next ( ) } > & gt ; < / b u t t o n >
@@ -248,9 +476,60 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider}) {
}
function App ( ) {
const [ duration , setDuration ] = useState ( dur ations [ 0 ] ) ;
const [ end , setEnd ] = useState ( moment ( ) ) ;
const [ slider , setSlider ] = useState ( [ 0 , duration . num ] ) ;
const params = new URLSearchParams ( window . loc ation. 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 >
@@ -261,12 +540,20 @@ function App() {
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 }
/ >
< / d i v >
) ;