Compare commits
51 Commits
25d6a8757b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 34f21791cc | |||
| cd547a15e6 | |||
| 900e31de9d | |||
| 6d1a1e7c78 | |||
| c959321c7b | |||
| 182c42de88 | |||
| d809c33f87 | |||
| 502c18f434 | |||
| a37f446375 | |||
| 6f80297ac7 | |||
| aa3c10fab8 | |||
| 15fcc68f76 | |||
| 3ad9ec9b3d | |||
| 4a19599162 | |||
| 1003de33f2 | |||
| aa40a3b1c1 | |||
| c8b9d2b8bd | |||
| 6df3446fca | |||
| 24a65b7f79 | |||
| 2ef752dc75 | |||
| f1938509d7 | |||
| 7b07a8049b | |||
| 316ea7bf86 | |||
| 109c877e5d | |||
| e53287be96 | |||
| 9683110604 | |||
| 0c107f86b6 | |||
| 7ec2a638a2 | |||
| 53acb33a56 | |||
| 0a7f29e1d0 | |||
| 5bc64bec13 | |||
| 9807187bc7 | |||
| 18c74bedf1 | |||
| e1fcd77180 | |||
| 360252151a | |||
| dc9872ebb8 | |||
| 8cdbb94878 | |||
| de7d9e45b9 | |||
| d0a5461073 | |||
| 1a3c11b5bb | |||
| 25e57edf39 | |||
| b4b840bf9c | |||
| 84b3ca1efd | |||
| 7572520c96 | |||
| fa53b50fbf | |||
| a568bf2f57 | |||
| d9f539f314 | |||
| 72c56c8245 | |||
| 04f64a0fe4 | |||
| 7bad27402a | |||
| 3e6782529d |
@@ -730,6 +730,7 @@ function Graphs({end, duration}) {
|
|||||||
<Air name='Living Room Air' sensorName='Living Room' end={end} duration={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='Kitchen Air' sensorName='Kitchen' end={end} duration={duration} />
|
||||||
<Air name='Bedroom Air' sensorName='Bedroom' 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='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='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} />
|
<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='Living Room Lux' sensorName='Living Room' end={end} duration={duration} />
|
||||||
<Lux name='Kitchen Lux' sensorName='Kitchen' 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='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='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='Kitchen Pothos Soil Moisture' sensorName='Kitchen Pothos' end={end} duration={duration} />
|
||||||
<Soil name='Dracaena Soil Moisture' sensorName='Dracaena' end={end} duration={duration} />
|
<Soil name='Dracaena Soil Moisture' sensorName='Dracaena' end={end} duration={duration} />
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import aiomqtt
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import pytz
|
import pytz
|
||||||
TIMEZONE = pytz.timezone('America/Edmonton')
|
TIMEZONE = pytz.timezone('America/Edmonton')
|
||||||
|
import hashlib
|
||||||
|
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
http_session = None
|
http_session = None
|
||||||
@@ -104,6 +105,13 @@ class Sensor():
|
|||||||
|
|
||||||
return str(before) != str(after)
|
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):
|
def log(self):
|
||||||
if not self.value:
|
if not self.value:
|
||||||
return
|
return
|
||||||
@@ -112,7 +120,7 @@ class Sensor():
|
|||||||
logging.debug('Skipping writing %s, data hasn\'t changed', self)
|
logging.debug('Skipping writing %s, data hasn\'t changed', self)
|
||||||
return
|
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)
|
logging.debug('Skipping writing %s, cooldown limit', self)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -261,6 +269,32 @@ class SoilSensor(Sensor):
|
|||||||
except TypeError:
|
except TypeError:
|
||||||
pass
|
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):
|
class SolarSensor(Sensor):
|
||||||
type_ = 'solar'
|
type_ = 'solar'
|
||||||
|
|
||||||
@@ -326,7 +360,7 @@ async def poll_sensors():
|
|||||||
await sensor.poll()
|
await sensor.poll()
|
||||||
sensor.check_update()
|
sensor.check_update()
|
||||||
|
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
async def process_data(data):
|
async def process_data(data):
|
||||||
sensor = sensors.get(data['id'])
|
sensor = sensors.get(data['id'])
|
||||||
@@ -374,19 +408,32 @@ async def fetch_mqtt():
|
|||||||
|
|
||||||
|
|
||||||
async def owntracks(request):
|
async def owntracks(request):
|
||||||
data = await request.json()
|
try:
|
||||||
logging.debug('Web data: %s', str(data))
|
data = await request.json()
|
||||||
|
logging.debug('Web data: %s', str(data))
|
||||||
|
|
||||||
if data.get('_type', '') == 'location':
|
if data.get('_type', '') == 'location':
|
||||||
data['id'] = data['topic'].split('/')[-1]
|
data['id'] = data['topic'].split('/')[-1]
|
||||||
data['timestamp'] = datetime.utcfromtimestamp(data['tst'])
|
data['timestamp'] = datetime.utcfromtimestamp(data['tst'])
|
||||||
if 'inregions' in data:
|
if 'inregions' in data:
|
||||||
data['inregions'] = ','.join(data['inregions'])
|
data['inregions'] = ','.join(data['inregions'])
|
||||||
await process_data(data)
|
await process_data(data)
|
||||||
else:
|
else:
|
||||||
logging.info('Not a location, skipping.')
|
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):
|
async def history(request):
|
||||||
api_key = request.rel_url.query.get('api_key', '')
|
api_key = request.rel_url.query.get('api_key', '')
|
||||||
@@ -395,6 +442,14 @@ async def history(request):
|
|||||||
measurement = request.match_info.get('measurement')
|
measurement = request.match_info.get('measurement')
|
||||||
name = request.match_info.get('name')
|
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']:
|
if not authed and measurement in ['owntracks', 'sleep']:
|
||||||
return web.json_response([])
|
return web.json_response([])
|
||||||
|
|
||||||
@@ -423,6 +478,9 @@ async def history(request):
|
|||||||
elif duration == 'month':
|
elif duration == 'month':
|
||||||
start = end - timedelta(days=30)
|
start = end - timedelta(days=30)
|
||||||
window = '1d'
|
window = '1d'
|
||||||
|
elif duration == 'quarter':
|
||||||
|
start = end - timedelta(days=90)
|
||||||
|
window = '1d'
|
||||||
elif duration == 'year':
|
elif duration == 'year':
|
||||||
start = end - timedelta(days=365)
|
start = end - timedelta(days=365)
|
||||||
window = '1d'
|
window = '1d'
|
||||||
@@ -430,7 +488,7 @@ async def history(request):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
window = request.rel_url.query.get('window', window)
|
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
|
raise
|
||||||
|
|
||||||
if name == 'Water':
|
if name == 'Water':
|
||||||
@@ -443,6 +501,13 @@ async def history(request):
|
|||||||
start = int(start.timestamp())
|
start = int(start.timestamp())
|
||||||
end = int(end.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':
|
if measurement == 'temperature':
|
||||||
client = sensors_client
|
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)
|
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)
|
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):
|
async def latest(request):
|
||||||
result = dict()
|
result = dict()
|
||||||
api_key = request.rel_url.query.get('api_key', '')
|
api_key = request.rel_url.query.get('api_key', '')
|
||||||
@@ -535,16 +693,22 @@ if __name__ == '__main__':
|
|||||||
app.router.add_get('/', index)
|
app.router.add_get('/', index)
|
||||||
app.router.add_post('/owntracks', owntracks)
|
app.router.add_post('/owntracks', owntracks)
|
||||||
app.router.add_get('/history/{measurement}/{name}', history)
|
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)
|
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(ThermostatSensor('thermostat2', '192.168.69.152', 'Venstar'))
|
||||||
sensors.add(ERTSCMSensor('31005493', 'Water'))
|
sensors.add(ERTSCMSensor('31005493', 'Water'))
|
||||||
sensors.add(ERTSCMSensor('78628180', 'Gas'))
|
sensors.add(ERTSCMSensor('78628180', 'Gas'))
|
||||||
sensors.add(OwnTracksSensor('owntracks1', 'OwnTracks'))
|
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('air2', 'Bedroom'))
|
||||||
sensors.add(AirSensor('air3', 'Kitchen'))
|
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('999999', 'Seeds', 0.0)) # A
|
||||||
sensors.add(AcuRite6002RM('999998', 'Misc', 0.7, -1.0)) # A
|
sensors.add(AcuRite6002RM('999998', 'Misc', 0.7, -1.0)) # A
|
||||||
sensors.add(AcuRite6002RM('12516', 'Basement', 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(SleepSensor('sleep1', 'Bedroom'))
|
||||||
sensors.add(SolarSensor('solar', 'Solar'))
|
sensors.add(SolarSensor('solar', 'Solar'))
|
||||||
sensors.add(SoilSensor('soil1', 'Dumb Cane'))
|
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_dc3c', 'Bedroom'))
|
||||||
sensors.add(QotMotionSensor('qot_88c3', 'Lower Stairs Hi'))
|
sensors.add(QotMotionSensor('qot_88c3', 'Lower Stairs Hi'))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"@testing-library/user-event": "^12.1.10",
|
"@testing-library/user-event": "^12.1.10",
|
||||||
"axios": "^0.21.1",
|
"axios": "^0.21.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet-draw": "^1.0.4",
|
||||||
"leaflet-polylinedecorator": "^1.6.0",
|
"leaflet-polylinedecorator": "^1.6.0",
|
||||||
"moment": "^2.29.1",
|
"moment": "^2.29.1",
|
||||||
"moment-timezone": "^0.5.34",
|
"moment-timezone": "^0.5.34",
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-is": "^17.0.2",
|
"react-is": "^17.0.2",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-leaflet-draw": "^0.20.6",
|
||||||
"react-range-slider-input": "^3.0.7",
|
"react-range-slider-input": "^3.0.7",
|
||||||
"react-scripts": "4.0.3",
|
"react-scripts": "4.0.3",
|
||||||
"web-vitals": "^1.0.1"
|
"web-vitals": "^1.0.1"
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ h2 {
|
|||||||
background-color: #666;
|
background-color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu button:disabled {
|
||||||
|
color: #ce7e7e;
|
||||||
|
}
|
||||||
|
|
||||||
.submenu button {
|
.submenu button {
|
||||||
background-color: #666;
|
background-color: #666;
|
||||||
}
|
}
|
||||||
@@ -167,6 +171,15 @@ h2 {
|
|||||||
font-family: sans-serif;
|
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 th:hover,
|
||||||
.datepicker td:hover {
|
.datepicker td:hover {
|
||||||
background-color: #999!important;
|
background-color: #999!important;
|
||||||
@@ -193,3 +206,85 @@ h2 {
|
|||||||
.submenu-group button {
|
.submenu-group button {
|
||||||
flex-grow: 1;
|
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
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import * as leaflet from 'leaflet';
|
import * as leaflet from 'leaflet';
|
||||||
import 'leaflet-polylinedecorator';
|
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 Datetime from 'react-datetime';
|
||||||
import 'react-datetime/css/react-datetime.css';
|
import 'react-datetime/css/react-datetime.css';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -10,6 +11,7 @@ import RangeSlider from 'react-range-slider-input';
|
|||||||
import './App.css';
|
import './App.css';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import 'react-range-slider-input/dist/style.css';
|
import 'react-range-slider-input/dist/style.css';
|
||||||
|
import 'leaflet-draw/dist/leaflet.draw.css';
|
||||||
|
|
||||||
|
|
||||||
// num: number of steps per duration
|
// 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: 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: 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: 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) => {
|
const parseSlider = (end, duration, slider) => {
|
||||||
@@ -37,12 +40,12 @@ const parseSlider = (end, duration, slider) => {
|
|||||||
return [lowStr, highStr];
|
return [lowStr, highStr];
|
||||||
};
|
};
|
||||||
|
|
||||||
//async function sha256(source) {
|
async function sha256(source) {
|
||||||
// const sourceBytes = new TextEncoder().encode(source);
|
const sourceBytes = new TextEncoder().encode(source);
|
||||||
// const digest = await crypto.subtle.digest('SHA-26', sourceBytes);
|
const digest = await crypto.subtle.digest('SHA-256', sourceBytes);
|
||||||
// const resultBytes = [...new Uint8Array(digest)];
|
const resultBytes = [...new Uint8Array(digest)];
|
||||||
// return resultBytes.map(x => x.toString(16).padStart(2, '0')).join('');
|
return resultBytes.map(x => x.toString(16).padStart(2, '0')).join('');
|
||||||
//}
|
}
|
||||||
|
|
||||||
function useSensor(measurement, name, end, duration) {
|
function useSensor(measurement, name, end, duration) {
|
||||||
const [data, setData] = useState(false);
|
const [data, setData] = useState(false);
|
||||||
@@ -52,8 +55,19 @@ function useSensor(measurement, name, end, duration) {
|
|||||||
const get = async() => {
|
const get = async() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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 };
|
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(
|
const res = await axios.get(
|
||||||
'https://sensors-api.dns.t0.vc/history/'+measurement+'/'+name,
|
'https://sensors-api.dns.t0.vc/history/'+measurement+'/'+name,
|
||||||
{ params: params },
|
{ params: params },
|
||||||
@@ -130,10 +144,11 @@ function MapViewManager({ coords, mapState, setMapState, loading, setSubmenu })
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PolylineWithArrows({ coords, showDirection }) {
|
function PolylineWithArrows({ coords, showDirection, showPoints }) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const polylineRef = useRef(null);
|
const polylineRef = useRef(null);
|
||||||
const decoratorRef = useRef(null);
|
const decoratorRef = useRef(null);
|
||||||
|
const pointsLayerRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (polylineRef.current) {
|
if (polylineRef.current) {
|
||||||
@@ -142,6 +157,9 @@ function PolylineWithArrows({ coords, showDirection }) {
|
|||||||
if (decoratorRef.current) {
|
if (decoratorRef.current) {
|
||||||
map.removeLayer(decoratorRef.current);
|
map.removeLayer(decoratorRef.current);
|
||||||
}
|
}
|
||||||
|
if (pointsLayerRef.current) {
|
||||||
|
map.removeLayer(pointsLayerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
if (coords && coords.length > 1) {
|
if (coords && coords.length > 1) {
|
||||||
const polyline = leaflet.polyline(coords, { color: 'blue' });
|
const polyline = leaflet.polyline(coords, { color: 'blue' });
|
||||||
@@ -171,6 +189,21 @@ function PolylineWithArrows({ coords, showDirection }) {
|
|||||||
} else {
|
} else {
|
||||||
decoratorRef.current = null;
|
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 () => {
|
return () => {
|
||||||
@@ -180,52 +213,16 @@ function PolylineWithArrows({ coords, showDirection }) {
|
|||||||
if (decoratorRef.current) {
|
if (decoratorRef.current) {
|
||||||
map.removeLayer(decoratorRef.current);
|
map.removeLayer(decoratorRef.current);
|
||||||
}
|
}
|
||||||
|
if (pointsLayerRef.current) {
|
||||||
|
map.removeLayer(pointsLayerRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [coords, map, showDirection]);
|
}, [coords, map, showDirection, showPoints]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Map({end, duration, slider, mapState, setMapState, setSubmenu, showDirection}) {
|
function Map({data, loading, coords, mapState, setMapState, setSubmenu, showDirection, showPoints, setDrawnItems}) {
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -233,23 +230,35 @@ function Map({end, duration, slider, mapState, setMapState, setSubmenu, showDire
|
|||||||
localStorage.setItem('api_key', api_key);
|
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 (
|
return (
|
||||||
<div className='container'>
|
<div className='container'>
|
||||||
{loading ?
|
{!showMap ? (
|
||||||
<p>Loading...</p>
|
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} />
|
|
||||||
</MapContainer>
|
|
||||||
)
|
|
||||||
:
|
|
||||||
<>
|
<>
|
||||||
<p>No data</p>
|
<p>No data</p>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
@@ -258,18 +267,88 @@ function Map({end, duration, slider, mapState, setMapState, setSubmenu, showDire
|
|||||||
</p>
|
</p>
|
||||||
</form>
|
</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='© <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>
|
</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 [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) => {
|
const chooseDuration = (x) => {
|
||||||
setSubmenu(false);
|
setSubmenu(false);
|
||||||
setSlider([0, x.num]);
|
setSlider([0, x.num]);
|
||||||
setDuration(x);
|
setDuration(x);
|
||||||
|
setActiveSearchResult(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const chooseEnd = (x) => {
|
const chooseEnd = (x) => {
|
||||||
@@ -277,49 +356,395 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, s
|
|||||||
const newEnd = x.add(...duration.delta);
|
const newEnd = x.add(...duration.delta);
|
||||||
setSlider([0, duration.num]);
|
setSlider([0, duration.num]);
|
||||||
setEnd(newEnd);
|
setEnd(newEnd);
|
||||||
|
setActiveSearchResult(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const chooseNow = (x) => {
|
const chooseNow = (x) => {
|
||||||
setSubmenu(false);
|
setSubmenu(false);
|
||||||
setSlider([0, duration.num]);
|
setSlider([0, duration.num]);
|
||||||
setEnd(moment());
|
setEnd(moment());
|
||||||
|
setActiveSearchResult(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const chooseMidnight = () => {
|
const chooseMidnight = () => {
|
||||||
setSubmenu(false);
|
setSubmenu(false);
|
||||||
setSlider([0, duration.num]);
|
setSlider([0, duration.num]);
|
||||||
setEnd(moment().startOf('day'));
|
setEnd(prevEnd => moment(prevEnd).startOf('day'));
|
||||||
|
setActiveSearchResult(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rangeStart = (x) => {
|
const rangeStart = (x) => {
|
||||||
setSubmenu(false);
|
setSubmenu(false);
|
||||||
setEnd(moment(range[0]).add(...duration.delta));
|
setEnd(moment(range[0]).add(...duration.delta));
|
||||||
setSlider([0, duration.num]);
|
setSlider([0, duration.num]);
|
||||||
|
setActiveSearchResult(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rangeEnd = (x) => {
|
const rangeEnd = (x) => {
|
||||||
setSubmenu(false);
|
setSubmenu(false);
|
||||||
setEnd(moment(range[1]));
|
setEnd(moment(range[1]));
|
||||||
setSlider([0, duration.num]);
|
setSlider([0, duration.num]);
|
||||||
|
setActiveSearchResult(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
setSubmenu(false);
|
setSubmenu(false);
|
||||||
setSlider([0, duration.num]);
|
setSlider([0, duration.num]);
|
||||||
setEnd(prevEnd => moment(prevEnd).add(...duration.delta));
|
setEnd(prevEnd => moment(prevEnd).add(...duration.delta));
|
||||||
|
setActiveSearchResult(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const prev = () => {
|
const prev = () => {
|
||||||
setSubmenu(false);
|
setSubmenu(false);
|
||||||
setSlider([0, duration.num]);
|
setSlider([0, duration.num]);
|
||||||
setEnd(prevEnd => moment(prevEnd).subtract(...duration.delta));
|
setEnd(prevEnd => moment(prevEnd).subtract(...duration.delta));
|
||||||
|
setActiveSearchResult(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetToDefaults = () => {
|
const resetToDefaults = () => {
|
||||||
window.location.href = window.location.pathname;
|
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 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) => {
|
const rangeTime = (x) => {
|
||||||
if (new Date().getTimezoneOffset()) { // non-librewolf browser
|
if (new Date().getTimezoneOffset()) { // non-librewolf browser
|
||||||
@@ -364,7 +789,7 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, s
|
|||||||
return (
|
return (
|
||||||
<div className='menu'>
|
<div className='menu'>
|
||||||
{(showRange || !!submenu) && <div className='range'>
|
{(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>}
|
||||||
|
|
||||||
<div className='time-slider'>
|
<div className='time-slider'>
|
||||||
@@ -372,7 +797,7 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, s
|
|||||||
min={0}
|
min={0}
|
||||||
max={duration.num}
|
max={duration.num}
|
||||||
value={slider}
|
value={slider}
|
||||||
onInput={setSlider}
|
onInput={handleSliderChange}
|
||||||
onThumbDragStart={() => setShowRange(true)}
|
onThumbDragStart={() => setShowRange(true)}
|
||||||
onThumbDragEnd={() => setShowRange(false)}
|
onThumbDragEnd={() => setShowRange(false)}
|
||||||
onRangeDragStart={() => setShowRange(true)}
|
onRangeDragStart={() => setShowRange(true)}
|
||||||
@@ -394,6 +819,8 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, s
|
|||||||
input={false}
|
input={false}
|
||||||
timeFormat={false}
|
timeFormat={false}
|
||||||
onChange={(x) => chooseEnd(x)}
|
onChange={(x) => chooseEnd(x)}
|
||||||
|
value={startDate}
|
||||||
|
isValidDate={isValidDate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -428,31 +855,116 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, s
|
|||||||
{submenu === 'misc' &&
|
{submenu === 'misc' &&
|
||||||
<>
|
<>
|
||||||
<div className='submenu-header'>
|
<div className='submenu-header'>
|
||||||
<h2>Misc</h2>
|
<h2>{searchResults ? 'Search Results' : 'Misc'}</h2>
|
||||||
<button onClick={() => setSubmenu(false)}>×</button>
|
{searchResults ? (
|
||||||
|
<button onClick={() => { setSearchResults(null); setActiveSearchResult(null); }}>< Back</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setSubmenu(false)}>×</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label className="submenu-checkbox-label">
|
{searchResults ? (
|
||||||
<input
|
<div ref={scrollContainerRef} className="search-results-container">
|
||||||
type="checkbox"
|
{searchResults.length > 0 ? (
|
||||||
checked={showDirection}
|
(() => {
|
||||||
onChange={e => setShowDirection(e.target.checked)}
|
const groupedResults = searchResults.reduce((acc, result) => {
|
||||||
/>
|
const groupKey = moment.unix(result.start).format('YYYY');
|
||||||
Show direction
|
if (!acc[groupKey]) {
|
||||||
</label>
|
acc[groupKey] = [];
|
||||||
<button onClick={resetToDefaults}>Reset to defaults</button>
|
}
|
||||||
|
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)}
|
||||||
|
>
|
||||||
|
↓↑
|
||||||
|
</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>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div className='menu-container'>
|
<div className='menu-container'>
|
||||||
<button onClick={() => prev()}><</button>
|
<button onClick={() => prev()} disabled={isPrevDisabled}><</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSubmenu('end')}
|
onClick={() => setSubmenu('end')}
|
||||||
className={submenu === 'end' ? 'active' : ''}
|
className={submenu === 'end' ? 'active' : ''}
|
||||||
>
|
>
|
||||||
{moment(end).subtract(duration.delta[0], duration.delta[1]).format('dd MMM DD')}
|
{startDate.format('dd MMM DD')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -469,7 +981,7 @@ function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, s
|
|||||||
{(duration.shortLen || duration.len)} / {duration.win}
|
{(duration.shortLen || duration.len)} / {duration.win}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button onClick={() => next()}>></button>
|
<button onClick={() => next()} disabled={isNextDisabled}>></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -484,6 +996,9 @@ function App() {
|
|||||||
const initialLng = params.get('lng');
|
const initialLng = params.get('lng');
|
||||||
const initialZoom = params.get('zoom');
|
const initialZoom = params.get('zoom');
|
||||||
const initialShowDirection = params.get('showDirection') === 'true';
|
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 initialDuration = (initialDurationId && durations[parseInt(initialDurationId, 10)]) ? durations[parseInt(initialDurationId, 10)] : durations[0];
|
||||||
const initialEnd = initialEndTimestamp ? moment.unix(initialEndTimestamp) : moment();
|
const initialEnd = initialEndTimestamp ? moment.unix(initialEndTimestamp) : moment();
|
||||||
@@ -498,6 +1013,53 @@ function App() {
|
|||||||
});
|
});
|
||||||
const [submenu, setSubmenu] = useState(false);
|
const [submenu, setSubmenu] = useState(false);
|
||||||
const [showDirection, setShowDirection] = useState(initialShowDirection);
|
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);
|
const isInitialMount = useRef(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -511,13 +1073,16 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = setTimeout(() => {
|
const handler = setTimeout(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams(window.location.search);
|
||||||
params.set('duration', duration.id);
|
params.set('duration', duration.id);
|
||||||
params.set('end', end.unix());
|
params.set('end', end.unix());
|
||||||
params.set('slider', slider.join(','));
|
params.set('slider', slider.join(','));
|
||||||
if (showDirection) {
|
if (showDirection) {
|
||||||
params.set('showDirection', 'true');
|
params.set('showDirection', 'true');
|
||||||
}
|
}
|
||||||
|
if (showPoints) {
|
||||||
|
params.set('showPoints', 'true');
|
||||||
|
}
|
||||||
if (mapState.center) {
|
if (mapState.center) {
|
||||||
params.set('lat', mapState.center[0].toFixed(5));
|
params.set('lat', mapState.center[0].toFixed(5));
|
||||||
params.set('lng', mapState.center[1].toFixed(5));
|
params.set('lng', mapState.center[1].toFixed(5));
|
||||||
@@ -529,7 +1094,7 @@ function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
clearTimeout(handler);
|
clearTimeout(handler);
|
||||||
};
|
};
|
||||||
}, [duration, end, slider, mapState, showDirection]);
|
}, [duration, end, slider, mapState, showDirection, showPoints]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -544,16 +1109,27 @@ function App() {
|
|||||||
setSubmenu={setSubmenu}
|
setSubmenu={setSubmenu}
|
||||||
showDirection={showDirection}
|
showDirection={showDirection}
|
||||||
setShowDirection={setShowDirection}
|
setShowDirection={setShowDirection}
|
||||||
|
showPoints={showPoints}
|
||||||
|
setShowPoints={setShowPoints}
|
||||||
|
setMapState={setMapState}
|
||||||
|
shareStart={shareStart}
|
||||||
|
shareEnd={shareEnd}
|
||||||
|
data={data}
|
||||||
|
drawnItems={drawnItems}
|
||||||
|
coords={coords}
|
||||||
|
pointsInRange={pointsInRange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Map
|
<Map
|
||||||
end={end}
|
|
||||||
duration={duration}
|
|
||||||
slider={slider}
|
|
||||||
mapState={mapState}
|
mapState={mapState}
|
||||||
setMapState={setMapState}
|
setMapState={setMapState}
|
||||||
setSubmenu={setSubmenu}
|
setSubmenu={setSubmenu}
|
||||||
showDirection={showDirection}
|
showDirection={showDirection}
|
||||||
|
showPoints={showPoints}
|
||||||
|
data={data}
|
||||||
|
loading={loading}
|
||||||
|
setDrawnItems={setDrawnItems}
|
||||||
|
coords={coords}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7308,6 +7308,11 @@ last-call-webpack-plugin@^3.0.0:
|
|||||||
lodash "^4.17.5"
|
lodash "^4.17.5"
|
||||||
webpack-sources "^1.1.0"
|
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:
|
leaflet-polylinedecorator@^1.6.0:
|
||||||
version "1.6.0"
|
version "1.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/leaflet-polylinedecorator/-/leaflet-polylinedecorator-1.6.0.tgz#9ef79fd1b5302d67b72efe959a8ecd2553f27266"
|
resolved "https://registry.yarnpkg.com/leaflet-polylinedecorator/-/leaflet-polylinedecorator-1.6.0.tgz#9ef79fd1b5302d67b72efe959a8ecd2553f27266"
|
||||||
@@ -7398,6 +7403,11 @@ locate-path@^5.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate "^4.1.0"
|
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:
|
lodash._reinterpolate@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
|
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"
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
||||||
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
|
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:
|
react-leaflet@^4.2.1:
|
||||||
version "4.2.1"
|
version "4.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-leaflet/-/react-leaflet-4.2.1.tgz#c300e9eccaf15cb40757552e181200aa10b94780"
|
resolved "https://registry.yarnpkg.com/react-leaflet/-/react-leaflet-4.2.1.tgz#c300e9eccaf15cb40757552e181200aa10b94780"
|
||||||
|
|||||||
Reference in New Issue
Block a user