Compare commits

..

51 Commits

Author SHA1 Message Date
tanner 34f21791cc Catch bad OwnTrack data, add WH51 soil sensor 2026-06-07 00:04:19 +00:00
tanner cd547a15e6 Add quarter time period, Laundry Room air 2026-04-16 22:46:38 +00:00
tanner 900e31de9d perf: Filter search results in DB and use time-based gap detection
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-04-16 22:46:38 +00:00
tanner 6d1a1e7c78 fix: Recalculate visit ranges using point-based gap threshold
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-04-16 22:46:38 +00:00
tanner c959321c7b perf: Optimize search with InfluxDB geo-filtering; detect time gaps
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-04-16 22:46:38 +00:00
tanner 182c42de88 feat: Implement search API for owntracks geo-fence time ranges
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-04-16 22:46:38 +00:00
tanner d809c33f87 fix: Handle OPTIONS requests for search route to prevent CORS duplication
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-04-16 22:46:38 +00:00
tanner 502c18f434 fix: Remove duplicate CORS handling from aiohttp app
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-04-16 22:46:38 +00:00
tanner a37f446375 fix: Enable CORS for /search route
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-04-16 22:46:38 +00:00
tanner 6f80297ac7 feat: Implement search API handler stub with logging
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-04-16 22:46:38 +00:00
tanner aa3c10fab8 feat: Add P2 Pro scale sensor and secure history sharing 2026-04-16 22:46:38 +00:00
tanner 15fcc68f76 Add leaflet to mapper 2026-04-16 22:45:12 +00:00
tanner 3ad9ec9b3d Add Laundry Room air sensors 2026-04-16 22:43:55 +00:00
tanner 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
tanner 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
tanner 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
tanner 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
tanner 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
tanner 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
tanner 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
tanner 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
tanner 7b07a8049b fix: Prevent map render on empty data 2025-08-17 18:43:30 +00:00
tanner 316ea7bf86 feat: allow merging of adjacent search results 2025-08-15 20:25:02 +00:00
tanner 109c877e5d fix: close submenu on search result selection 2025-08-15 20:19:11 +00:00
tanner e53287be96 fix: prevent drawn items from disappearing during data load 2025-08-15 20:13:54 +00:00
tanner 9683110604 refactor: move search result inline styles to CSS file 2025-08-15 20:06:46 +00:00
tanner 0c107f86b6 feat: Set time range and highlight selected search result 2025-08-15 20:02:46 +00:00
tanner 7ec2a638a2 feat: add short label for Quarter duration 2025-08-15 20:02:32 +00:00
tanner 53acb33a56 feat: add 'Quarter' duration option 2025-08-15 19:52:01 +00:00
tanner 0a7f29e1d0 feat: Sort and scroll search results, preserving scroll position 2025-08-15 19:41:51 +00:00
tanner 5bc64bec13 feat: Use 24-hour format for search result times 2025-08-15 19:37:41 +00:00
tanner 9807187bc7 fix: Group search results by year and use abbreviated month 2025-08-15 19:35:19 +00:00
tanner 18c74bedf1 feat: group search results by month and year 2025-08-15 19:31:39 +00:00
tanner e1fcd77180 feat: display area search results in misc submenu 2025-08-15 19:16:48 +00:00
tanner 360252151a feat: add loading state to search area button 2025-08-15 19:10:29 +00:00
tanner dc9872ebb8 refactor: remove unused end and duration API parameters 2025-08-15 19:10:25 +00:00
tanner 8cdbb94878 feat: add 'Search area' button to query time in drawn zones 2025-08-15 17:28:58 +00:00
tanner de7d9e45b9 fix: correct rounding for exclude area slider calculation 2025-08-15 02:43:22 +00:00
tanner d0a5461073 feat: add button to exclude drawn areas from time range 2025-08-15 02:36:29 +00:00
tanner 1a3c11b5bb feat: add rectangle drawing controls to the map 2025-08-15 02:29:48 +00:00
tanner 25e57edf39 refactor: Update reset button text to "Reset page" 2025-08-15 02:29:40 +00:00
tanner b4b840bf9c style: color disabled menu buttons 2025-08-15 01:48:43 +00:00
tanner 84b3ca1efd Valid share range adjustments 2025-08-15 01:47:27 +00:00
tanner 7572520c96 fix: Simplify datepicker validation to ignore duration 2025-08-15 01:29:25 +00:00
tanner fa53b50fbf feat: restrict date navigation within shared link range 2025-08-15 01:18:18 +00:00
tanner a568bf2f57 feat: default start date picker to current start date 2025-08-15 00:27:29 +00:00
tanner d9f539f314 fix: preserve existing URL parameters 2025-08-15 00:22:50 +00:00
tanner 72c56c8245 Add button to recentre view 2025-08-15 00:21:30 +00:00
tanner 04f64a0fe4 fix: Prefix share signature data with 'owntracks-' 2025-08-15 00:02:28 +00:00
tanner 7bad27402a feat: add share range button to copy shareable URL 2025-08-14 23:36:40 +00:00
tanner 3e6782529d Fix midnight 2025-08-14 23:27:52 +00:00
6 changed files with 967 additions and 106 deletions
+2
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} />
+184 -16
View File
@@ -23,6 +23,7 @@ import aiomqtt
from datetime import datetime, timedelta
import pytz
TIMEZONE = pytz.timezone('America/Edmonton')
import hashlib
app = web.Application()
http_session = None
@@ -104,6 +105,13 @@ class Sensor():
return str(before) != str(after)
def check_cooldown(self):
if self.last_update and self.skip_cooldown and time.time() - self.last_update < self.skip_cooldown:
# ignore data point
return True
else:
return False
def log(self):
if not self.value:
return
@@ -112,7 +120,7 @@ class Sensor():
logging.debug('Skipping writing %s, data hasn\'t changed', self)
return
if self.last_update and self.skip_cooldown and time.time() - self.last_update < self.skip_cooldown:
if self.check_cooldown():
logging.debug('Skipping writing %s, cooldown limit', self)
return
@@ -261,6 +269,32 @@ class SoilSensor(Sensor):
except TypeError:
pass
class WH51SoilSensor(Sensor):
type_ = 'soil'
bad_keys = [
'model',
'mic',
'battery_ok',
]
update_period = 60*30
class P2ProScaleSensor(Sensor):
type_ = 'scale'
skip_cooldown = 60.0 * 60 * 18 # 18 hours
def check_cooldown(self):
if 'weight' not in self.value:
return False
skip = super().check_cooldown()
if skip:
controller_message('Cooldown skipping scale weight: ' + str(self.value['weight']))
return skip
class SolarSensor(Sensor):
type_ = 'solar'
@@ -326,7 +360,7 @@ async def poll_sensors():
await sensor.poll()
sensor.check_update()
await asyncio.sleep(1)
await asyncio.sleep(5)
async def process_data(data):
sensor = sensors.get(data['id'])
@@ -374,19 +408,32 @@ async def fetch_mqtt():
async def owntracks(request):
data = await request.json()
logging.debug('Web data: %s', str(data))
try:
data = await request.json()
logging.debug('Web data: %s', str(data))
if data.get('_type', '') == 'location':
data['id'] = data['topic'].split('/')[-1]
data['timestamp'] = datetime.utcfromtimestamp(data['tst'])
if 'inregions' in data:
data['inregions'] = ','.join(data['inregions'])
await process_data(data)
else:
logging.info('Not a location, skipping.')
if data.get('_type', '') == 'location':
data['id'] = data['topic'].split('/')[-1]
data['timestamp'] = datetime.utcfromtimestamp(data['tst'])
if 'inregions' in data:
data['inregions'] = ','.join(data['inregions'])
await process_data(data)
else:
logging.info('Not a location, skipping.')
return web.Response()
return web.json_response({'status': 'ok'}, status=200)
except BaseException as e:
logging.exception('Problem with OwnTracks data: {} - {}'.format(e.__class__.__name__, str(e)))
try:
logging.info('Data: %s', str(data))
except:
pass
return web.json_response({'error': str(e)}, status=500)
def share_sha256(measurement, share_start, share_end, api_key):
s = f'{measurement}-{share_start}-{share_end}-{api_key}'
return hashlib.sha256(s.encode()).hexdigest()
async def history(request):
api_key = request.rel_url.query.get('api_key', '')
@@ -395,6 +442,14 @@ async def history(request):
measurement = request.match_info.get('measurement')
name = request.match_info.get('name')
share_start = request.rel_url.query.get('shareStart', '')
share_end = request.rel_url.query.get('shareEnd', '')
share_sig = request.rel_url.query.get('shareSig', '')
share_authed = share_sig == share_sha256(measurement, share_start, share_end, settings.SENSORS_API_KEY)
authed = authed or share_authed
if not authed and measurement in ['owntracks', 'sleep']:
return web.json_response([])
@@ -423,6 +478,9 @@ async def history(request):
elif duration == 'month':
start = end - timedelta(days=30)
window = '1d'
elif duration == 'quarter':
start = end - timedelta(days=90)
window = '1d'
elif duration == 'year':
start = end - timedelta(days=365)
window = '1d'
@@ -430,7 +488,7 @@ async def history(request):
raise
window = request.rel_url.query.get('window', window)
if window not in ['1m', '3m', '10m', '1h', '2h', '1d', '7d', '30d']:
if window not in ['1m', '3m', '10m', '30m', '1h', '2h', '1d', '7d', '30d']:
raise
if name == 'Water':
@@ -443,6 +501,13 @@ async def history(request):
start = int(start.timestamp())
end = int(end.timestamp())
if share_authed:
if start <= int(share_start):
start = int(share_start)
if end >= int(share_end):
end = int(share_end)
if measurement == 'temperature':
client = sensors_client
q = 'select mean("temperature_C") as temperature_C, mean("humidity") as humidity from temperature where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(linear)'.format(name, start, end, window)
@@ -482,6 +547,99 @@ async def history(request):
return web.json_response(result)
async def search(request):
api_key = request.rel_url.query.get('api_key', '')
authed = api_key == settings.SENSORS_API_KEY
measurement = request.match_info.get('measurement')
name = request.match_info.get('name')
if not authed:
return web.json_response([])
if name not in [x.name for x in sensors.sensors]:
raise
if measurement != 'owntracks':
return web.json_response({'error': 'not implemented for this measurement'}, status=400)
try:
post_data = await request.json()
except json.JSONDecodeError:
return web.json_response({'error': 'invalid json'}, status=400)
params = request.rel_url.query
logging.info('Search request: meas=%s, name=%s, params=%s, data=%s',
measurement, name, params, post_data)
areas = post_data.get('areas')
if not areas or not isinstance(areas, list):
return web.json_response({'error': 'invalid areas format'}, status=400)
try:
for area in areas:
_ = area['southWest']['lat']
_ = area['southWest']['lng']
_ = area['northEast']['lat']
_ = area['northEast']['lng']
except (KeyError, TypeError):
return web.json_response({'error': 'invalid area format in areas list'}, status=400)
client = sensors_client
where_clauses = []
for area in areas:
sw = area['southWest']
ne = area['northEast']
where_clauses.append(f'("lat" >= {sw["lat"]} and "lat" <= {ne["lat"]} and "lon" >= {sw["lng"]} and "lon" <= {ne["lng"]})')
full_where_clause = ' or '.join(where_clauses)
q = f'select "lat", "lon" from owntracks where "acc" < 100 and "name" = \'{name}\' and ({full_where_clause}) order by time asc'
points = list(client.query(q).get_points())
ranges = []
current_range = None
last_point_dt = None
# Use a 12-hour gap to distinguish between separate visits
GAP_THRESHOLD_HOURS = 12
for point in points:
point_time_str = point['time']
if '.' in point_time_str:
point_dt = datetime.strptime(point_time_str, '%Y-%m-%dT%H:%M:%S.%fZ')
else:
point_dt = datetime.strptime(point_time_str, '%Y-%m-%dT%H:%M:%SZ')
if current_range is None:
current_range = {'start': point_dt, 'end': point_dt}
else:
time_diff_hours = (point_dt - last_point_dt).total_seconds() / 3600
if time_diff_hours > GAP_THRESHOLD_HOURS:
ranges.append({
'start': int(current_range['start'].timestamp()),
'end': int(current_range['end'].timestamp())
})
current_range = {'start': point_dt, 'end': point_dt}
else:
current_range['end'] = point_dt
last_point_dt = point_dt
if current_range is not None:
ranges.append({
'start': int(current_range['start'].timestamp()),
'end': int(current_range['end'].timestamp())
})
return web.json_response(ranges)
async def options_handler(request):
return web.Response()
async def latest(request):
result = dict()
api_key = request.rel_url.query.get('api_key', '')
@@ -535,16 +693,22 @@ if __name__ == '__main__':
app.router.add_get('/', index)
app.router.add_post('/owntracks', owntracks)
app.router.add_get('/history/{measurement}/{name}', history)
app.router.add_post('/search/{measurement}/{name}', search)
app.router.add_route('OPTIONS', '/search/{measurement}/{name}', options_handler)
app.router.add_get('/latest', latest)
# serial, name
# API look up is done by name
# when retiring / reassigning a serial, change it to something impossible ie. 9999
sensors.add(ThermostatSensor('thermostat2', '192.168.69.152', 'Venstar'))
sensors.add(ERTSCMSensor('31005493', 'Water'))
sensors.add(ERTSCMSensor('78628180', 'Gas'))
sensors.add(OwnTracksSensor('owntracks1', 'OwnTracks'))
sensors.add(AirSensor('air1', 'Living Room'))
sensors.add(AirSensor('air9999', 'Living Room'))
sensors.add(AirSensor('air1', 'Laundry Room'))
sensors.add(AirSensor('air2', 'Bedroom'))
sensors.add(AirSensor('air3', 'Kitchen'))
sensors.add(Acurite606TX('185', 'Outside', 0.0))
sensors.add(Acurite606TX('252', 'Outside', 0.0))
sensors.add(AcuRite6002RM('999999', 'Seeds', 0.0)) # A
sensors.add(AcuRite6002RM('999998', 'Misc', 0.7, -1.0)) # A
sensors.add(AcuRite6002RM('12516', 'Basement', 0.7, -1.0)) # A
@@ -553,6 +717,10 @@ if __name__ == '__main__':
sensors.add(SleepSensor('sleep1', 'Bedroom'))
sensors.add(SolarSensor('solar', 'Solar'))
sensors.add(SoilSensor('soil1', 'Dumb Cane'))
sensors.add(SoilSensor('soil2', 'Kitchen Pothos'))
sensors.add(SoilSensor('soil3', 'Dracaena'))
sensors.add(WH51SoilSensor('0fafff', 'Side Garden'))
sensors.add(P2ProScaleSensor('scale1', 'Master Bathroom'))
sensors.add(QotMotionSensor('qot_dc3c', 'Bedroom'))
sensors.add(QotMotionSensor('qot_88c3', 'Lower Stairs Hi'))
+2
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"
+95
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;
}
+666 -90
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 ?
<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>
)
:
{!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>
</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,31 +855,116 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, s
{submenu === 'misc' &&
<>
<div className='submenu-header'>
<h2>Misc</h2>
<button onClick={() => setSubmenu(false)}>×</button>
<h2>{searchResults ? 'Search Results' : 'Misc'}</h2>
{searchResults ? (
<button onClick={() => { setSearchResults(null); setActiveSearchResult(null); }}>&lt; Back</button>
) : (
<button onClick={() => setSubmenu(false)}>×</button>
)}
</div>
<label className="submenu-checkbox-label">
<input
type="checkbox"
checked={showDirection}
onChange={e => setShowDirection(e.target.checked)}
/>
Show direction
</label>
<button onClick={resetToDefaults}>Reset to defaults</button>
{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"
checked={showDirection}
onChange={e => setShowDirection(e.target.checked)}
/>
Show direction
</label>
<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>
);
+18
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"