Compare commits

...

40 Commits

Author SHA1 Message Date
15fcc68f76 Add leaflet to mapper 2026-04-16 22:45:12 +00:00
3ad9ec9b3d Add Laundry Room air sensors 2026-04-16 22:43:55 +00:00
4a19599162 feat: Calculate and display average pace
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-17 20:19:08 +00:00
1003de33f2 fix: Center search result on time slider to allow panning
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-17 19:08:44 +00:00
aa40a3b1c1 feat: Cancel search request when menu is closed
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-17 19:05:50 +00:00
c8b9d2b8bd feat: Cycle included area segments by recency
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-17 19:02:13 +00:00
6df3446fca feat: Cycle excluded segments by recency on Exclude area clicks
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-17 18:56:38 +00:00
24a65b7f79 feat: Display total polyline distance in menu with unit conversion
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-17 18:50:44 +00:00
2ef752dc75 feat: Add include area button to narrow time range by selection
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-17 18:46:03 +00:00
f1938509d7 feat: Add 'Show points' checkbox to display polyline points
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-17 18:43:32 +00:00
7b07a8049b fix: Prevent map render on empty data 2025-08-17 18:43:30 +00:00
316ea7bf86 feat: allow merging of adjacent search results 2025-08-15 20:25:02 +00:00
109c877e5d fix: close submenu on search result selection 2025-08-15 20:19:11 +00:00
e53287be96 fix: prevent drawn items from disappearing during data load 2025-08-15 20:13:54 +00:00
9683110604 refactor: move search result inline styles to CSS file 2025-08-15 20:06:46 +00:00
0c107f86b6 feat: Set time range and highlight selected search result 2025-08-15 20:02:46 +00:00
7ec2a638a2 feat: add short label for Quarter duration 2025-08-15 20:02:32 +00:00
53acb33a56 feat: add 'Quarter' duration option 2025-08-15 19:52:01 +00:00
0a7f29e1d0 feat: Sort and scroll search results, preserving scroll position 2025-08-15 19:41:51 +00:00
5bc64bec13 feat: Use 24-hour format for search result times 2025-08-15 19:37:41 +00:00
9807187bc7 fix: Group search results by year and use abbreviated month 2025-08-15 19:35:19 +00:00
18c74bedf1 feat: group search results by month and year 2025-08-15 19:31:39 +00:00
e1fcd77180 feat: display area search results in misc submenu 2025-08-15 19:16:48 +00:00
360252151a feat: add loading state to search area button 2025-08-15 19:10:29 +00:00
dc9872ebb8 refactor: remove unused end and duration API parameters 2025-08-15 19:10:25 +00:00
8cdbb94878 feat: add 'Search area' button to query time in drawn zones 2025-08-15 17:28:58 +00:00
de7d9e45b9 fix: correct rounding for exclude area slider calculation 2025-08-15 02:43:22 +00:00
d0a5461073 feat: add button to exclude drawn areas from time range 2025-08-15 02:36:29 +00:00
1a3c11b5bb feat: add rectangle drawing controls to the map 2025-08-15 02:29:48 +00:00
25e57edf39 refactor: Update reset button text to "Reset page" 2025-08-15 02:29:40 +00:00
b4b840bf9c style: color disabled menu buttons 2025-08-15 01:48:43 +00:00
84b3ca1efd Valid share range adjustments 2025-08-15 01:47:27 +00:00
7572520c96 fix: Simplify datepicker validation to ignore duration 2025-08-15 01:29:25 +00:00
fa53b50fbf feat: restrict date navigation within shared link range 2025-08-15 01:18:18 +00:00
a568bf2f57 feat: default start date picker to current start date 2025-08-15 00:27:29 +00:00
d9f539f314 fix: preserve existing URL parameters 2025-08-15 00:22:50 +00:00
72c56c8245 Add button to recentre view 2025-08-15 00:21:30 +00:00
04f64a0fe4 fix: Prefix share signature data with 'owntracks-' 2025-08-15 00:02:28 +00:00
7bad27402a feat: add share range button to copy shareable URL 2025-08-14 23:36:40 +00:00
3e6782529d Fix midnight 2025-08-14 23:27:52 +00:00
5 changed files with 783 additions and 90 deletions

View File

@@ -730,6 +730,7 @@ function Graphs({end, duration}) {
<Air name='Living Room Air' sensorName='Living Room' end={end} duration={duration} />
<Air name='Kitchen Air' sensorName='Kitchen' end={end} duration={duration} />
<Air name='Bedroom Air' sensorName='Bedroom' end={end} duration={duration} />
<Air name='Laundry Room Air' sensorName='Laundry Room' end={end} duration={duration} />
<Temperature name='Outside Temperature' sensorName='Outside' end={end} duration={duration} yDomain={[-40, 40]} showHumidity={false} showFreezingLine={true} />
<Temperature name='Bedroom Temperature' sensorName='Bedroom' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<Temperature name='Nook Temperature' sensorName='Nook' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
@@ -743,6 +744,7 @@ function Graphs({end, duration}) {
<Lux name='Living Room Lux' sensorName='Living Room' end={end} duration={duration} />
<Lux name='Kitchen Lux' sensorName='Kitchen' end={end} duration={duration} />
<Lux name='Bedroom Lux' sensorName='Bedroom' end={end} duration={duration} />
<Lux name='Laundry Room Lux' sensorName='Laundry Room' end={end} duration={duration} />
<Soil name='Dumb Cane Soil Moisture' sensorName='Dumb Cane' end={end} duration={duration} />
<Soil name='Kitchen Pothos Soil Moisture' sensorName='Kitchen Pothos' end={end} duration={duration} />
<Soil name='Dracaena Soil Moisture' sensorName='Dracaena' end={end} duration={duration} />

View File

@@ -8,6 +8,7 @@
"@testing-library/user-event": "^12.1.10",
"axios": "^0.21.1",
"leaflet": "^1.9.4",
"leaflet-draw": "^1.0.4",
"leaflet-polylinedecorator": "^1.6.0",
"moment": "^2.29.1",
"moment-timezone": "^0.5.34",
@@ -16,6 +17,7 @@
"react-dom": "^17.0.2",
"react-is": "^17.0.2",
"react-leaflet": "^4.2.1",
"react-leaflet-draw": "^0.20.6",
"react-range-slider-input": "^3.0.7",
"react-scripts": "4.0.3",
"web-vitals": "^1.0.1"

View File

@@ -94,6 +94,10 @@ h2 {
background-color: #666;
}
.menu button:disabled {
color: #ce7e7e;
}
.submenu button {
background-color: #666;
}
@@ -167,6 +171,15 @@ h2 {
font-family: sans-serif;
}
.datepicker .rdtPicker .rdtDay.rdtDisabled {
color: #ce7e7e !important;
}
.datepicker .rdtPicker .rdtDay.rdtNew,
.datepicker .rdtPicker .rdtDay.rdtOld {
color: #c8c8c8;
}
.datepicker th:hover,
.datepicker td:hover {
background-color: #999!important;
@@ -193,3 +206,85 @@ h2 {
.submenu-group button {
flex-grow: 1;
}
.search-results-container {
max-height: 50vh;
overflow-y: auto;
}
.search-results-group-header {
color: white;
margin: 0.5em 0 0.25em;
font-size: 1em;
font-weight: normal;
text-align: center;
}
.search-results-empty {
color: white;
text-align: center;
padding: 1em 0;
}
.submenu button.active {
background-color: #4a4a4a;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 10000;
display: flex;
justify-content: center;
align-items: center;
color: white;
}
.search-result-row {
display: flex;
align-items: center;
position: relative;
}
.merge-button-wrapper {
flex-shrink: 0;
width: 2em;
position: relative;
align-self: stretch;
}
.merge-button {
position: absolute;
bottom: 0;
left: 50%;
transform: translate(-50%, 50%);
z-index: 10;
background-color: #888;
border: 1px solid #aaa;
border-radius: 50%;
color: white;
cursor: pointer;
font-size: 0.8rem;
line-height: 1;
height: 1.5em;
width: 1.5em;
padding: 0;
min-width: 0;
}
.merge-button:hover {
background-color: #999;
}
.search-result-button-wrapper {
flex-grow: 1;
}
.search-result-button-wrapper button {
width: 100%;
text-align: center;
}

View File

@@ -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: 'Year', win: '2h', full: '2 hour', delta: [1, 'years'], format: 'M/D', num: 4380, secs: 7200},
{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) => {
@@ -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-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);
@@ -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 ?
{!showMap ? (
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}>
@@ -258,18 +267,88 @@ function Map({end, duration, slider, mapState, setMapState, setSubmenu, showDire
</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} 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,
}}
/>
</FeatureGroup>
</MapContainer>
</div>
)}
</div>
);
}
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)})</span>
{rangeTime(range[0])} - {rangeTime(range[1])} <span style={{ whiteSpace: 'nowrap' }}>({rangeDelta(range)})</span>{totalDistance && <span style={{ whiteSpace: 'nowrap' }}> ({totalDistance})</span>}{averagePace && <span style={{ whiteSpace: 'nowrap' }}> ({averagePace})</span>}
</div>}
<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}
/>
</div>
@@ -428,9 +855,77 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, s
{submenu === 'misc' &&
<>
<div className='submenu-header'>
<h2>Misc</h2>
<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 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}</h3>
{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;
</button>
)}
</div>
<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)}
</button>
</div>
</div>
);
})}
</div>
));
})()
) : (
<p className="search-results-empty">No results found.</p>
)}
</div>
) : (
<>
<label className="submenu-checkbox-label">
<input
type="checkbox"
@@ -439,20 +934,37 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, s
/>
Show direction
</label>
<button onClick={resetToDefaults}>Reset to defaults</button>
<label className="submenu-checkbox-label">
<input
type="checkbox"
checked={showPoints}
onChange={e => setShowPoints(e.target.checked)}
/>
Show points
</label>
<button onClick={recentreView}>Recentre view</button>
<button onClick={excludeArea}>Exclude area</button>
<button onClick={includeArea}>Include 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()}>&lt;</button>
<button onClick={() => prev()} disabled={isPrevDisabled}>&lt;</button>
<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')}
</button>
<button
@@ -469,7 +981,7 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, s
{(duration.shortLen || duration.len)} / {duration.win}
</button>
<button onClick={() => next()}>&gt;</button>
<button onClick={() => next()} disabled={isNextDisabled}>&gt;</button>
</div>
</div>
);
@@ -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}
/>
</div>
);

View File

@@ -7308,6 +7308,11 @@ last-call-webpack-plugin@^3.0.0:
lodash "^4.17.5"
webpack-sources "^1.1.0"
leaflet-draw@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/leaflet-draw/-/leaflet-draw-1.0.4.tgz#45be92f378ed253e7202fdeda1fcc71885198d46"
integrity sha512-rsQ6saQO5ST5Aj6XRFylr5zvarWgzWnrg46zQ1MEOEIHsppdC/8hnN8qMoFvACsPvTioAuysya/TVtog15tyAQ==
leaflet-polylinedecorator@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/leaflet-polylinedecorator/-/leaflet-polylinedecorator-1.6.0.tgz#9ef79fd1b5302d67b72efe959a8ecd2553f27266"
@@ -7398,6 +7403,11 @@ locate-path@^5.0.0:
dependencies:
p-locate "^4.1.0"
lodash-es@^4.17.15:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
lodash._reinterpolate@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
@@ -9527,6 +9537,14 @@ react-is@^18.0.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
react-leaflet-draw@^0.20.6:
version "0.20.6"
resolved "https://registry.yarnpkg.com/react-leaflet-draw/-/react-leaflet-draw-0.20.6.tgz#ba3ee41fea14d87ca610df9d248156367f2e921e"
integrity sha512-mGypDjJNrrnVpfKfGYovNBuJZXSk39ClOdUJe/5dB5Cj3f2BGQlY9txyV4UmUxZCbc96aq+FMwrGZeM4BokhHQ==
dependencies:
fast-deep-equal "^3.1.3"
lodash-es "^4.17.15"
react-leaflet@^4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/react-leaflet/-/react-leaflet-4.2.1.tgz#c300e9eccaf15cb40757552e181200aa10b94780"