@@ -1,7 +1,8 @@
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 { 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' ;
@@ -10,6 +11,7 @@ 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
@@ -18,7 +20,8 @@ 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 : 'Yea r' , win : '2h ' , full : '2 hour ' , delta : [ 1 , 'year s' ] , format : 'M/D' , num : 4380 , secs : 72 00} ,
{ id : 3 , len : 'Quarter' , shortLen : 'Qt r' , win : '30m ' , full : '30 min ' , delta : [ 3 , 'month s' ] , format : 'M/D' , num : 4380 , secs : 18 00} ,
{ id : 4 , len : 'Year' , win : '2h' , full : '2 hour' , delta : [ 1 , 'years' ] , format : 'M/D' , num : 4380 , secs : 7200 } ,
] ;
const parseSlider = ( end , duration , slider ) => {
@@ -37,12 +40,12 @@ const parseSlider = (end, duration, slider) => {
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('');
//}
async function sha256( source) {
const sourceBytes = new TextEncoder ( ) . encode( source) ;
const digest = await crypto. subtle. digest( 'SHA-25 6', 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 ) ;
@@ -52,8 +55,19 @@ function useSensor(measurement, name, end, duration) {
const get = async ( ) => {
setLoading ( true ) ;
try {
const api _key = localStorage . getItem ( 'api_key' , 'null' );
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 } ,
@@ -130,10 +144,11 @@ function MapViewManager({ coords, mapState, setMapState, loading, setSubmenu })
return null ;
}
function PolylineWithArrows ( { coords , showDirection } ) {
function PolylineWithArrows ( { coords , showDirection , showPoints } ) {
const map = useMap ( ) ;
const polylineRef = useRef ( null ) ;
const decoratorRef = useRef ( null ) ;
const pointsLayerRef = useRef ( null ) ;
useEffect ( ( ) => {
if ( polylineRef . current ) {
@@ -142,6 +157,9 @@ function PolylineWithArrows({ coords, showDirection }) {
if ( decoratorRef . current ) {
map . removeLayer ( decoratorRef . current ) ;
}
if ( pointsLayerRef . current ) {
map . removeLayer ( pointsLayerRef . current ) ;
}
if ( coords && coords . length > 1 ) {
const polyline = leaflet . polyline ( coords , { color : 'blue' } ) ;
@@ -171,6 +189,21 @@ function PolylineWithArrows({ coords, showDirection }) {
} else {
decoratorRef . current = null ;
}
if ( showPoints ) {
const points = coords . map ( coord => leaflet . circleMarker ( coord , {
color : 'red' ,
radius : 3 ,
weight : 1 ,
fillColor : 'red' ,
fillOpacity : 1
} ) ) ;
const pointsLayer = leaflet . layerGroup ( points ) ;
pointsLayerRef . current = pointsLayer ;
map . addLayer ( pointsLayer ) ;
} else {
pointsLayerRef . current = null ;
}
}
return ( ) => {
@@ -180,52 +213,16 @@ function PolylineWithArrows({ coords, showDirection }) {
if ( decoratorRef . current ) {
map . removeLayer ( decoratorRef . current ) ;
}
if ( pointsLayerRef . current ) {
map . removeLayer ( pointsLayerRef . current ) ;
}
} ;
} , [ coords , map , showDirection ] ) ;
} , [ coords , map , showDirection , showPoints ]) ;
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 ] ) ;
function Map ( { data , loading , coords , mapState , setMapState , setSubmenu , showDirection , showPoints , setDrawnItems }) {
const handleSubmit = ( e ) => {
e . preventDefault ( ) ;
@@ -233,23 +230,35 @@ function Map({end, duration, slider, mapState, setMapState, setSubmenu, showDire
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 ) && data . length > 0 ;
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 = '© <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 >
)
:
{ ! showMap ? (
loading ? (
< p > Loading ... < / p >
) : (
< >
< p > No data < / p >
< form onSubmit = { handleSubmit } >
@@ -258,18 +267,88 @@ function Map({end, duration, slider, mapState, setMapState, setSubmenu, showDire
< / p >
< / f o r m >
< / >
}
)
) : (
< div style = { { position : 'relative' , width : '100%' , height : 'calc(100vh - 2.5rem)' } } >
{ loading && (
< div className = "loading-overlay" >
< p > Loading ... < / p >
< / d i v >
) }
< 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 = '© <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 } showPoints = { showPoints } / >
< FeatureGroup >
< EditControl
position = "topright"
onCreated = { onCreated }
onEdited = { onEdited }
onDeleted = { onDeleted }
draw = { {
rectangle : true ,
polyline : false ,
polygon : false ,
circle : false ,
marker : false ,
circlemarker : false ,
} }
/ >
< / F e a t u r e G r o u p >
< / M a p C o n t a i n e r >
< / d i v >
) }
< / d i v >
) ;
}
function Menu ( { duration , setDuration , end , setEnd , slider , setSlider , submenu , setSubmenu , showDirection , setShowDirection } ) {
function Menu ( { data , duration, setDuration , end , setEnd , slider , setSlider , submenu , setSubmenu , showDirection , setShowDirection , showPoints , setShowPoints , setMapState , shareStart , shareEnd , drawnItems , coords , pointsInRange }) {
const [ showRange , setShowRange ] = useState ( false ) ;
const [ isSearching , setIsSearching ] = useState ( false ) ;
const [ searchResults , setSearchResults ] = useState ( null ) ;
const [ activeSearchResult , setActiveSearchResult ] = useState ( null ) ;
const cancelTokenSourceRef = useRef ( null ) ;
const scrollContainerRef = useRef ( null ) ;
const scrollPositionRef = useRef ( 0 ) ;
const [ lastDrawnItemsForExclusion , setLastDrawnItemsForExclusion ] = useState ( null ) ;
const [ exclusionCycleIndex , setExclusionCycleIndex ] = useState ( 0 ) ;
const [ lastDrawnItemsForInclusion , setLastDrawnItemsForInclusion ] = useState ( null ) ;
const [ inclusionCycleIndex , setInclusionCycleIndex ] = useState ( 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 ] ) ;
useEffect ( ( ) => {
if ( ! submenu && isSearching && cancelTokenSourceRef . current ) {
cancelTokenSourceRef . current . cancel ( 'Search cancelled because menu was closed.' ) ;
}
} , [ submenu , isSearching ] ) ;
const handleSliderChange = ( newSliderValue ) => {
setActiveSearchResult ( null ) ;
setSlider ( newSliderValue ) ;
} ;
const chooseDuration = ( x ) => {
setSubmenu ( false ) ;
setSlider ( [ 0 , x . num ] ) ;
setDuration ( x ) ;
setActiveSearchResult ( null ) ;
} ;
const chooseEnd = ( x ) => {
@@ -277,49 +356,395 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, s
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 ( moment ( ) . startOf ( 'day' ) ) ;
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 ;
}
goodSegments . sort ( ( a , b ) => moment ( b . end ) . diff ( moment ( a . end ) ) ) ;
const drawnItemsKey = JSON . stringify ( drawnItems . map ( item => item . bounds . toBBoxString ( ) ) . sort ( ) ) ;
let newIndex = 0 ;
if ( lastDrawnItemsForExclusion === drawnItemsKey ) {
newIndex = ( exclusionCycleIndex + 1 ) % goodSegments . length ;
}
setLastDrawnItemsForExclusion ( drawnItemsKey ) ;
setExclusionCycleIndex ( newIndex ) ;
const segmentToSelect = goodSegments [ newIndex ] ;
const startUnix = moment ( segmentToSelect . start ) . unix ( ) ;
const endUnix = moment ( segmentToSelect . 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 includeArea = ( ) => {
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 isInsideInclusionZone = ( 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 = isInsideInclusionZone ( 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 data points found inside the selected area(s)." ) ;
setSubmenu ( false ) ;
return ;
}
goodSegments . sort ( ( a , b ) => moment ( b . end ) . diff ( moment ( a . end ) ) ) ;
const drawnItemsKey = JSON . stringify ( drawnItems . map ( item => item . bounds . toBBoxString ( ) ) . sort ( ) ) ;
let newIndex = 0 ;
if ( lastDrawnItemsForInclusion === drawnItemsKey ) {
newIndex = ( inclusionCycleIndex + 1 ) % goodSegments . length ;
}
setLastDrawnItemsForInclusion ( drawnItemsKey ) ;
setInclusionCycleIndex ( newIndex ) ;
const segmentToSelect = goodSegments [ newIndex ] ;
const startUnix = moment ( segmentToSelect . start ) . unix ( ) ;
const endUnix = moment ( segmentToSelect . 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 ) ;
cancelTokenSourceRef . current = axios . CancelToken . source ( ) ;
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 ,
cancelToken : cancelTokenSourceRef . current . token ,
}
) ;
setActiveSearchResult ( null ) ;
const sortedData = res . data . sort ( ( a , b ) => b . start - a . start ) ;
setSearchResults ( sortedData ) ;
} catch ( error ) {
if ( axios . isCancel ( error ) ) {
// Request was canceled, do nothing
} else {
console . error ( 'Error during area search:' , error ) ;
alert ( 'An error occurred during the search.' ) ;
}
} finally {
setIsSearching ( false ) ;
cancelTokenSourceRef . current = null ;
}
} ;
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 ) ;
// Center the result in the window to allow panning
const resultCenterUnix = ( result . start + result . end ) / 2 ;
const windowDurationSeconds = bestDuration . num * bestDuration . secs ;
const newEndUnix = resultCenterUnix + windowDurationSeconds / 2 ;
const newEnd = moment . unix ( newEndUnix ) ;
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 mergeSearchResults = ( index ) => {
const newResults = [ ... searchResults ] ;
const item1 = newResults [ index ] ;
const item2 = newResults [ index + 1 ] ;
const mergedItem = {
start : item2 . start ,
end : item1 . end ,
} ;
newResults . splice ( index , 2 , mergedItem ) ;
setSearchResults ( newResults ) ;
} ;
const { totalDistance , averagePace } = useMemo ( ( ) => {
if ( ! coords || coords . length < 2 ) {
return { totalDistance : null , averagePace : null } ;
}
let distance = 0 ;
for ( let i = 0 ; i < coords . length - 1 ; i ++ ) {
const p1 = leaflet . latLng ( coords [ i ] ) ;
const p2 = leaflet . latLng ( coords [ i + 1 ] ) ;
distance += p1 . distanceTo ( p2 ) ;
}
const firstPointTime = moment ( pointsInRange [ 0 ] . time ) ;
const lastPointTime = moment ( pointsInRange [ pointsInRange . length - 1 ] . time ) ;
const durationSeconds = lastPointTime . diff ( firstPointTime , 'seconds' ) ;
let pace = null ;
const distanceKm = distance / 1000 ;
if ( distanceKm > 0.01 && durationSeconds > 0 ) {
const paceSecondsPerKm = durationSeconds / distanceKm ;
if ( paceSecondsPerKm < 3600 ) { // cap at 60min/km
const paceMinutes = Math . floor ( paceSecondsPerKm / 60 ) ;
const paceSeconds = Math . round ( paceSecondsPerKm % 60 ) ;
pace = ` ${ paceMinutes } : ${ paceSeconds . toString ( ) . padStart ( 2 , '0' ) } /km ` ;
}
}
let distanceStr ;
if ( distance < 1000 ) {
distanceStr = ` ${ distance . toFixed ( 0 ) } m ` ;
} else {
distanceStr = ` ${ distanceKm . toFixed ( 2 ) } km ` ;
}
return { totalDistance : distanceStr , averagePace : pace } ;
} , [ coords , pointsInRange ] ) ;
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
@@ -364,7 +789,7 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, s
return (
< div className = 'menu' >
{ ( showRange || ! ! submenu ) && < div className = 'range' >
{ rangeTime ( range [ 0 ] ) } - { rangeTime ( range [ 1 ] ) } < span style = { { whiteSpace : 'nowrap' } } > ( { rangeDelta ( range ) } ) < / s p a n >
{ rangeTime ( range [ 0 ] ) } - { rangeTime ( range [ 1 ] ) } < span style = { { whiteSpace : 'nowrap' } } > ( { rangeDelta ( range ) } ) < / s p a n > { t o t a l D i s t a n c e & & < s p a n s t y l e = { { w h i t e S p a c e : ' n o w r a p ' } } > ( { t o t a l D i s t a n c e } ) < / s p a n > } { a v e r a g e P a c e & & < s p a n s t y l e = { { w h i t e S p a c e : ' n o w r a p ' } } > ( { a v e r a g e P a c e } ) < / s p a n > }
< / d i v > }
< div className = 'time-slider' >
@@ -372,7 +797,7 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, s
min = { 0 }
max = { duration . num }
value = { slider }
onInput = { setSlider }
onInput = { handleSliderChange }
onThumbDragStart = { ( ) => setShowRange ( true ) }
onThumbDragEnd = { ( ) => setShowRange ( false ) }
onRangeDragStart = { ( ) => setShowRange ( true ) }
@@ -394,6 +819,8 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, s
input = { false }
timeFormat = { false }
onChange = { ( x ) => chooseEnd ( x ) }
value = { startDate }
isValidDate = { isValidDate }
/ >
< / d i v >
@@ -428,31 +855,116 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, s
{ submenu === 'misc' &&
< >
< div className = 'submenu-header' >
< h2 > Misc < / h 2 >
< button onClick = { ( ) => setSubmenu ( false ) } > × < / b u t t o n >
< h2 > { searchResults ? 'Search Results' : 'Misc' } < / h 2 >
{ searchResults ? (
< button onClick = { ( ) => { setSearchResults ( null ) ; setActiveSearchResult ( null ) ; } } > & lt ; Back < / b u t t o n >
) : (
< 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 >
{ searchResults ? (
< div ref = { scrollContainerRef } className = "search-results-container" >
{ searchResults . length > 0 ? (
( ( ) => {
const groupedResults = searchResults . 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 } < / h 3 >
{ results . map ( ( result , indexInYear ) => {
const absoluteIndex = searchResults . findIndex ( r => r . start === result . start && r . end === result . end ) ;
const isLastResultOverall = absoluteIndex === searchResults . length - 1 ;
return (
< div className = "search-result-row" key = { ` ${ result . start } - ${ result . end } ` } >
< div className = "merge-button-wrapper" >
{ ! isLastResultOverall && (
< button
className = "merge-button"
title = "Merge with next item"
onClick = { ( ) => mergeSearchResults ( absoluteIndex ) }
>
& darr ; & uarr ;
< / b u t t o n >
) }
< / d i v >
< div className = "search-result-button-wrapper" >
< button
onClick = { ( ) => selectSearchResult ( result ) }
className = { activeSearchResult && activeSearchResult . start === result . start && activeSearchResult . end === result . end ? 'active' : '' }
>
{ formatShortTime ( result . start ) } - { formatShortTime ( result . end ) }
< / b u t t o n >
< / d i v >
< / d i v >
) ;
} ) }
< / d i v >
) ) ;
} ) ( )
) : (
< p className = "search-results-empty" > No results found . < / p >
) }
< / 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 >
< label className = "submenu-checkbox-label" >
< input
type = "checkbox"
checked = { showPoints }
onChange = { e => setShowPoints ( e . target . checked ) }
/ >
Show points
< / l a b e l >
< button onClick = { recentreView } > Recentre view < / b u t t o n >
< button onClick = { excludeArea } > Exclude area < / b u t t o n >
< button onClick = { includeArea } > Include area < / b u t t o n >
< button onClick = { searchArea } disabled = { isSearching } >
{ isSearching ? 'Searching...' : 'Search area' }
< / b u t t o n >
< button onClick = { shareRange } > Share range < / b u t t o n >
< button onClick = { resetToDefaults } > Reset page < / b u t t o n >
< / >
) }
< / >
}
< / d i v >
}
< div className = 'menu-container' >
< button onClick = { ( ) => prev ( ) } > & lt ; < / b u t t o n >
< button onClick = { ( ) => prev ( ) } disabled = { isPrevDisabled } > & lt ; < / b u t t o n >
< button
onClick = { ( ) => setSubmenu ( 'end' ) }
className = { submenu === 'end' ? 'active' : '' }
>
{ moment ( end ) . subtract ( duration . delta [ 0 ] , duration . delta [ 1 ] ) . format ( 'dd MMM DD' ) }
{ startDate . format ( 'dd MMM DD' ) }
< / b u t t o n >
< button
@@ -469,7 +981,7 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, s
{ ( duration . shortLen || duration . len ) } / { duration . win }
< / b u t t o n >
< button onClick = { ( ) => next ( ) } > & gt ; < / b u t t o n >
< button onClick = { ( ) => next ( ) } disabled = { isNextDisabled } > & gt ; < / b u t t o n >
< / d i v >
< / d i v >
) ;
@@ -484,6 +996,9 @@ function App() {
const initialLng = params . get ( 'lng' ) ;
const initialZoom = params . get ( 'zoom' ) ;
const initialShowDirection = params . get ( 'showDirection' ) === 'true' ;
const initialShowPoints = params . get ( 'showPoints' ) === '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 ( ) ;
@@ -498,6 +1013,53 @@ function App() {
} ) ;
const [ submenu , setSubmenu ] = useState ( false ) ;
const [ showDirection , setShowDirection ] = useState ( initialShowDirection ) ;
const [ showPoints , setShowPoints ] = useState ( initialShowPoints ) ;
const [ drawnItems , setDrawnItems ] = useState ( [ ] ) ;
const [ data , loading ] = useSensor ( 'owntracks' , 'OwnTracks' , end , duration ) ;
const range = useMemo ( ( ) => parseSlider ( end , duration , slider ) , [ end , duration , slider ] ) ;
const pointsInRange = 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 , time } ) ;
}
}
return result ;
} , [ data , range ] ) ;
const coords = useMemo ( ( ) => pointsInRange . map ( p => [ p . lat , p . lon ] ) , [ pointsInRange ] ) ;
const shareStart = shareStartParam ? moment . unix ( shareStartParam ) : null ;
const shareEnd = shareEndParam ? moment . unix ( shareEndParam ) : null ;
const isInitialMount = useRef ( true ) ;
useEffect ( ( ) => {
@@ -511,13 +1073,16 @@ function App() {
useEffect ( ( ) => {
const handler = setTimeout ( ( ) => {
const params = new URLSearchParams ( ) ;
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 ( showPoints ) {
params . set ( 'showPoints' , 'true' ) ;
}
if ( mapState . center ) {
params . set ( 'lat' , mapState . center [ 0 ] . toFixed ( 5 ) ) ;
params . set ( 'lng' , mapState . center [ 1 ] . toFixed ( 5 ) ) ;
@@ -529,7 +1094,7 @@ function App() {
return ( ) => {
clearTimeout ( handler ) ;
} ;
} , [ duration , end , slider , mapState , showDirection ] ) ;
} , [ duration , end , slider , mapState , showDirection , showPoints ]) ;
return (
< div >
@@ -544,16 +1109,27 @@ function App() {
setSubmenu = { setSubmenu }
showDirection = { showDirection }
setShowDirection = { setShowDirection }
showPoints = { showPoints }
setShowPoints = { setShowPoints }
setMapState = { setMapState }
shareStart = { shareStart }
shareEnd = { shareEnd }
data = { data }
drawnItems = { drawnItems }
coords = { coords }
pointsInRange = { pointsInRange }
/ >
< Map
end = { end }
duration = { duration }
slider = { slider }
mapState = { mapState }
setMapState = { setMapState }
setSubmenu = { setSubmenu }
showDirection = { showDirection }
showPoints = { showPoints }
data = { data }
loading = { loading }
setDrawnItems = { setDrawnItems }
coords = { coords }
/ >
< / d i v >
) ;