Compare commits

...

50 Commits

Author SHA1 Message Date
25d6a8757b Add leaflet-polylinedecorator 2025-08-14 23:17:17 +00:00
b585a39dd0 fix: Force transparent checkbox background to override extensions 2025-08-14 23:14:05 +00:00
a3c7f85302 fix: Set transparent background for checked checkbox on Firefox Mobile 2025-08-14 23:10:49 +00:00
646ca1268e style: apply sans-serif font to menu buttons 2025-08-14 23:09:21 +00:00
562c7cb6eb style: Refine header font and checkbox styles 2025-08-14 23:09:16 +00:00
cb8129cbba style: Restyle 'Show direction' checkbox to match menu buttons 2025-08-14 23:04:18 +00:00
502ae2b982 feat: add checkbox to toggle direction arrows 2025-08-14 23:01:02 +00:00
1f744216ec feat: add direction arrows to polyline 2025-08-14 22:56:59 +00:00
ec7fbed514 fix: shorten day of week format to two letters 2025-08-14 22:56:54 +00:00
3d927c18ce feat: add reset button and correct date logic 2025-08-14 22:39:15 +00:00
f309c0af00 fix: Use two-letter day format in menu 2025-08-14 22:36:36 +00:00
7e0eddaf38 fix: Correct month short name from Mon to Mth 2025-08-14 22:33:45 +00:00
1875d7b4e7 fix: abbreviate Month duration to prevent button text wrap 2025-08-14 22:26:05 +00:00
959e1d85d0 feat: add Misc submenu 2025-08-14 22:08:32 +00:00
2be0dd1c3d fix: preserve selected date when choosing midnight 2025-08-14 22:08:27 +00:00
0708301396 style: Reduce submenu header font size 2025-08-14 21:53:53 +00:00
fbc15bb371 feat: add jump to midnight button and rearrange submenu actions 2025-08-14 21:46:26 +00:00
ca3202f9b7 feat: Close submenu on map interaction 2025-08-14 21:42:20 +00:00
435db835e9 refactor: unify map view logic to fix centering and data bugs 2025-08-14 21:18:09 +00:00
87e706c223 fix: return null from FitBounds component 2025-08-14 21:14:33 +00:00
478dca185e fix: prevent map freeze and fix repositioning race condition 2025-08-14 21:12:50 +00:00
b295c3fef0 fix: wait for data to load before fitting map bounds 2025-08-14 20:59:14 +00:00
13b35e1c00 fix: Validate coordinate points before processing to prevent freeze 2025-08-14 20:55:38 +00:00
2adc0a9fcb perf: memoize coordinate processing to prevent UI freeze 2025-08-14 20:25:18 +00:00
0a02db9a8d fix: filter invalid coordinates to prevent UI freeze 2025-08-14 20:21:57 +00:00
bdc2921bc0 fix: Resolve race condition when refitting map on date change 2025-08-14 20:16:07 +00:00
9dd772839b feat: Refit map bounds on end or duration change 2025-08-14 20:12:09 +00:00
4bc88e5ce9 feat: Fit map to all points on initial load 2025-08-14 20:08:44 +00:00
6c7dff2d8f fix: prevent infinite loop by checking map state on moveend 2025-08-14 20:01:09 +00:00
21cec132a7 feat: encode map position and zoom in URL 2025-08-14 19:58:05 +00:00
51031e7b20 fix: debounce URL updates to prevent History API errors 2025-08-14 19:52:37 +00:00
81880a6a0a feat: Store view state in URL for shareable links 2025-08-14 19:49:16 +00:00
44dcc1b8ad fix: Prevent range delta from wrapping mid-text 2025-08-14 19:44:55 +00:00
17b1f979a9 feat: implement rangeDelta to display time range duration 2025-08-14 19:40:06 +00:00
00d9ee362f feat: add rangeDelta function 2025-08-14 19:39:56 +00:00
578bed681a Change temperature chart settings 2025-07-03 22:41:22 +00:00
816624ec44 refactor: Abstract temperature components into a single generic component 2025-07-03 22:41:22 +00:00
1052cf9bb9 refactor: abstract air quality components 2025-07-03 22:41:22 +00:00
3c8393b14c refactor: Rename SoilMoisture to Soil and hardcode sensorId 2025-07-03 22:41:22 +00:00
d5f5e08a3c refactor: Abstract Lux components into single component 2025-07-03 22:41:22 +00:00
f93e6d2323 feat: Add soil moisture graphs for Kitchen Pothos and Dracaena 2025-07-03 22:41:22 +00:00
24bada26a4 refactor: Abstract DumbCaneSoil to generic SoilMoisture component 2025-07-03 22:41:22 +00:00
4202e1a19d Add Dumb Cane soil sensor 2025-07-03 22:41:22 +00:00
ecd1dab005 Add soil sensors, cooldown skipping, make dupe skipping optional 2025-06-20 16:59:25 +00:00
54e169bdd2 Handle transform() exception 2025-06-20 16:59:25 +00:00
49f9ee120b Add Kitchen and Bedroom air / lux sensors 2025-05-13 19:17:13 +00:00
fa8f2cddb5 Fix bugs, add Qot motion sensors 2024-08-01 17:53:23 +00:00
7b15b39d5f Simplify components, add sha256() for later 2024-07-16 02:02:55 +00:00
e5dbb0af39 Allow shifting by time range 2024-07-16 01:30:23 +00:00
e549afce96 Add slider for time range 2024-07-15 21:41:09 +00:00
7 changed files with 742 additions and 446 deletions

1
.gitignore vendored
View File

@@ -104,3 +104,4 @@ ENV/
settings.py settings.py
*.csv *.csv
.aider*

View File

@@ -31,6 +31,7 @@ const units = {
'Setpoint': ' °C', 'Setpoint': ' °C',
'State': '', 'State': '',
'Lux': ' lx', 'Lux': ' lx',
'Soil': '',
}; };
function useSensor(measurement, name, end, duration) { function useSensor(measurement, name, end, duration) {
@@ -108,7 +109,7 @@ function ChartContainer({name, data, lastFormatter, loading, children, topMargin
return false; return false;
} }
const dataGood = (x) => !['undefined', 'null'].some(y => lastFormatter(x).includes(y)); const dataGood = (x) => !['undefined', 'null'].some(y => String(lastFormatter(x)).includes(y));
let last = null; let last = null;
if (data.length) { if (data.length) {
const data_end = data.slice(-2); const data_end = data.slice(-2);
@@ -195,12 +196,12 @@ function SolarPower({end, duration}) {
); );
} }
function OutsideTemperature({end, duration}) { function Temperature({name, sensorName, end, duration, yDomain, showHumidity, showFreezingLine}) {
const [data, loading, tickFormatter] = useSensor('temperature', 'Outside', end, duration); const [data, loading, tickFormatter] = useSensor('temperature', sensorName, end, duration);
return ( return (
<ChartContainer <ChartContainer
name='Outside Temperature' name={name}
data={data} data={data}
lastFormatter={(x) => x.temperature_C?.toFixed(1) + ' °C'} lastFormatter={(x) => x.temperature_C?.toFixed(1) + ' °C'}
loading={loading} loading={loading}
@@ -210,77 +211,35 @@ function OutsideTemperature({end, duration}) {
minTickGap={10} minTickGap={10}
tickFormatter={tickFormatter} tickFormatter={tickFormatter}
/> />
{showHumidity &&
<YAxis <YAxis
domain={[-40, 40]} yAxisId='right'
domain={[0, 100]}
orientation='right'
hide={true}
/>
}
<YAxis
yAxisId={showHumidity ? 'left' : undefined}
domain={yDomain}
/> />
<CartesianGrid strokeDasharray='3 3'/> <CartesianGrid strokeDasharray='3 3'/>
<Tooltip <Tooltip
formatter={v => v.toFixed(1) + ' °C'} formatter={(v, name) => v.toFixed(1) + units[name]}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')} labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': ' separator=': '
/> />
<ReferenceLine y={0} stroke='purple'> {showFreezingLine &&
<ReferenceLine yAxisId={showHumidity ? 'left' : undefined} y={0} stroke='purple'>
<Label value='Freezing' offset={7} position='bottom' /> <Label value='Freezing' offset={7} position='bottom' />
</ReferenceLine> </ReferenceLine>
<ReferenceLine x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
type='monotone'
dataKey='temperature_C'
name='Temperature'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ChartContainer>
);
} }
function NookTemperature({end, duration}) { <ReferenceLine yAxisId={showHumidity ? 'left' : undefined} x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
const [data, loading, tickFormatter] = useSensor('temperature', 'Nook', end, duration);
return (
<ChartContainer
name='Nook Temperature'
data={data}
lastFormatter={(x) => x.temperature_C?.toFixed(1) + ' °C'}
loading={loading}
>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
yAxisId='right'
domain={[0, 100]}
orientation='right'
hide={true}
/>
<YAxis
yAxisId='left'
domain={[15, 30]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={(v, name) => v.toFixed(1) + units[name]}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine yAxisId='left' y={0} stroke='blue'>
<Label value='Freezing' offset={7} position='bottom' />
</ReferenceLine>
<ReferenceLine yAxisId='left' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line <Line
yAxisId='left' yAxisId={showHumidity ? 'left' : undefined}
type='monotone' type='monotone'
dataKey='temperature_C' dataKey='temperature_C'
name='Temperature' name='Temperature'
@@ -289,7 +248,7 @@ function NookTemperature({end, duration}) {
dot={false} dot={false}
isAnimationActive={false} isAnimationActive={false}
/> />
{showHumidity &&
<Line <Line
yAxisId='right' yAxisId='right'
type='monotone' type='monotone'
@@ -300,257 +259,11 @@ function NookTemperature({end, duration}) {
dot={false} dot={false}
isAnimationActive={false} isAnimationActive={false}
/> />
}
</ChartContainer> </ChartContainer>
); );
} }
function BedroomTemperature({end, duration}) {
const [data, loading, tickFormatter] = useSensor('temperature', 'Bedroom', end, duration);
return (
<ChartContainer
name='Bedroom Temperature'
data={data}
lastFormatter={(x) => x.temperature_C?.toFixed(1) + ' °C'}
loading={loading}
>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
yAxisId='right'
domain={[0, 100]}
orientation='right'
hide={true}
/>
<YAxis
yAxisId='left'
domain={[15, 30]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={(v, name) => v.toFixed(1) + units[name]}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine yAxisId='left' y={0} stroke='blue'>
<Label value='Freezing' offset={7} position='bottom' />
</ReferenceLine>
<ReferenceLine yAxisId='left' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
yAxisId='left'
type='monotone'
dataKey='temperature_C'
name='Temperature'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
<Line
yAxisId='right'
type='monotone'
dataKey='humidity'
name='Humidity'
stroke='blue'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ChartContainer>
);
}
function BasementTemperature({end, duration}) {
const [data, loading, tickFormatter] = useSensor('temperature', 'Basement', end, duration);
return (
<ChartContainer
name='Basement Temperature'
data={data}
lastFormatter={(x) => x.temperature_C?.toFixed(1) + ' °C'}
loading={loading}
>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
yAxisId='right'
domain={[0, 100]}
orientation='right'
hide={true}
/>
<YAxis
yAxisId='left'
domain={[-40, 40]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={(v, name) => v.toFixed(1) + units[name]}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine yAxisId='left' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
type='monotone'
dataKey='temperature_C'
yAxisId='left'
name='Temperature'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
<Line
type='monotone'
dataKey='humidity'
yAxisId='right'
name='Humidity'
stroke='blue'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ChartContainer>
);
}
function MiscTemperature({end, duration}) {
const [data, loading, tickFormatter] = useSensor('temperature', 'Misc', end, duration);
return (
<ChartContainer
name='Misc Temperature'
data={data}
lastFormatter={(x) => x.temperature_C?.toFixed(1) + ' °C'}
loading={loading}
>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
yAxisId='right'
domain={[0, 100]}
orientation='right'
hide={true}
/>
<YAxis
yAxisId='left'
domain={[-40, 40]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={(v, name) => v.toFixed(1) + units[name]}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine yAxisId='left' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
type='monotone'
dataKey='temperature_C'
yAxisId='left'
name='Temperature'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
<Line
type='monotone'
dataKey='humidity'
yAxisId='right'
name='Humidity'
stroke='blue'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ChartContainer>
);
}
function SeedsTemperature({end, duration}) {
const [data, loading, tickFormatter] = useSensor('temperature', 'Seeds', end, duration);
return (
<ChartContainer
name='Garden Temperature'
data={data}
lastFormatter={(x) => x.temperature_C?.toFixed(1) + ' °C'}
loading={loading}
>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
yAxisId='right'
domain={[0, 100]}
orientation='right'
hide={true}
/>
<YAxis
yAxisId='left'
domain={[15, 30]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={(v, name) => v.toFixed(1) + units[name]}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine yAxisId='left' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
type='monotone'
dataKey='temperature_C'
yAxisId='left'
name='Temperature'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
<Line
type='monotone'
dataKey='humidity'
yAxisId='right'
name='Humidity'
stroke='blue'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ChartContainer>
);
}
function Thermostat({end, duration}) { function Thermostat({end, duration}) {
const [data, loading, tickFormatter] = useSensor('thermostat', 'Venstar', end, duration); const [data, loading, tickFormatter] = useSensor('thermostat', 'Venstar', end, duration);
@@ -780,12 +493,12 @@ function LivingRoomDust({end, duration}) {
); );
} }
function LivingRoomAir({end, duration}) { function Air({name, sensorName, end, duration}) {
const [data, loading, tickFormatter] = useSensor('air', 'Living Room', end, duration); const [data, loading, tickFormatter] = useSensor('air', sensorName, end, duration);
return ( return (
<ChartContainer <ChartContainer
name='Living Room Air' name={name}
data={data} data={data}
lastFormatter={(x) => x.max_p10?.toFixed(1) + ' ug/m³'} lastFormatter={(x) => x.max_p10?.toFixed(1) + ' ug/m³'}
loading={loading} loading={loading}
@@ -869,6 +582,8 @@ function LivingRoomAir({end, duration}) {
); );
} }
function BedroomSleep({end, duration}) { function BedroomSleep({end, duration}) {
const [data, loading, tickFormatter] = useSensor('sleep', 'Bedroom', end, duration); const [data, loading, tickFormatter] = useSensor('sleep', 'Bedroom', end, duration);
@@ -909,12 +624,12 @@ function BedroomSleep({end, duration}) {
); );
} }
function LivingRoomLux({end, duration}) { function Lux({name, sensorName, end, duration}) {
const [data, loading, tickFormatter] = useSensor('lux', 'Living Room', end, duration); const [data, loading, tickFormatter] = useSensor('lux', sensorName, end, duration);
return ( return (
<ChartContainer <ChartContainer
name='Living Room Lux' name={name}
data={data} data={data}
lastFormatter={(x) => x.lux?.toFixed(1) + ' lx'} lastFormatter={(x) => x.lux?.toFixed(1) + ' lx'}
loading={loading} loading={loading}
@@ -954,6 +669,51 @@ function LivingRoomLux({end, duration}) {
} }
function Soil({name, sensorName, end, duration}) {
const [data, loading, tickFormatter] = useSensor('soil', sensorName, end, duration);
return (
<ChartContainer
name={name}
data={data}
lastFormatter={(x) => x.soil?.toFixed(1)}
loading={loading}
>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
yAxisId='soil'
domain={[0, 1000]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={(v, name) => v.toFixed(1) + units[name]}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine yAxisId='soil' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
yAxisId='soil'
type='monotone'
dataKey='soil'
name='Soil'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ChartContainer>
);
}
function Graphs({end, duration}) { function Graphs({end, duration}) {
const api_key = localStorage.getItem('api_key', false); const api_key = localStorage.getItem('api_key', false);
@@ -967,18 +727,25 @@ function Graphs({end, duration}) {
<div className='container'> <div className='container'>
<SolarPower end={end} duration={duration} /> <SolarPower end={end} duration={duration} />
<LivingRoomDust end={end} duration={duration} /> <LivingRoomDust end={end} duration={duration} />
<LivingRoomAir end={end} duration={duration} /> <Air name='Living Room Air' sensorName='Living Room' end={end} duration={duration} />
<OutsideTemperature end={end} duration={duration} /> <Air name='Kitchen Air' sensorName='Kitchen' end={end} duration={duration} />
<BedroomTemperature end={end} duration={duration} /> <Air name='Bedroom Air' sensorName='Bedroom' end={end} duration={duration} />
<NookTemperature end={end} duration={duration} /> <Temperature name='Outside Temperature' sensorName='Outside' end={end} duration={duration} yDomain={[-40, 40]} showHumidity={false} showFreezingLine={true} />
<SeedsTemperature end={end} duration={duration} /> <Temperature name='Bedroom Temperature' sensorName='Bedroom' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<MiscTemperature end={end} duration={duration} /> <Temperature name='Nook Temperature' sensorName='Nook' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<BasementTemperature end={end} duration={duration} /> <Temperature name='Garden Temperature' sensorName='Seeds' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<Temperature name='Misc Temperature' sensorName='Misc' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<Temperature name='Basement Temperature' sensorName='Basement' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<Thermostat end={end} duration={duration} /> <Thermostat end={end} duration={duration} />
<Gas end={end} duration={duration} /> <Gas end={end} duration={duration} />
<Water end={end} duration={duration} /> <Water end={end} duration={duration} />
<BedroomSleep end={end} duration={duration} /> <BedroomSleep end={end} duration={duration} />
<LivingRoomLux 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='Bedroom Lux' sensorName='Bedroom' 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} />
{!!api_key || {!!api_key ||
<div> <div>

90
main.py
View File

@@ -33,6 +33,7 @@ solar_client = InfluxDBClient('localhost', 8086, database='solar2')
PORT = 6903 if PROD else 6904 PORT = 6903 if PROD else 6904
def controller_message(message): def controller_message(message):
logging.info('Sending controller message: %s', message)
payload = dict(home=message) payload = dict(home=message)
r = requests.post('https://tbot.tannercollin.com/message', data=payload, timeout=10) r = requests.post('https://tbot.tannercollin.com/message', data=payload, timeout=10)
if r.status_code == 200: if r.status_code == 200:
@@ -78,8 +79,10 @@ class Sensor():
value = {} value = {}
prev_value = {} prev_value = {}
bad_keys = [] bad_keys = []
last_update = time.time() last_update = None
update_period = None update_period = None
skip_if_hasnt_changed = False
skip_cooldown = 1.0
def __init__(self, id_, name): def __init__(self, id_, name):
self.id_ = id_ self.id_ = id_
@@ -102,11 +105,25 @@ class Sensor():
return str(before) != str(after) return str(before) != str(after)
def log(self): def log(self):
if not self.value or not self.changed(): if not self.value:
return
if not self.changed() and self.skip_if_hasnt_changed:
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:
logging.debug('Skipping writing %s, cooldown limit', self)
return return
data = self.value.copy() data = self.value.copy()
try:
self.transform(data) self.transform(data)
except BaseException as e:
logging.exception('Problem transforming sensor data: {} - {}'.format(e.__class__.__name__, str(e)))
logging.error('Data: %s', str(data))
return
for key in self.bad_keys: for key in self.bad_keys:
data.pop(key, None) data.pop(key, None)
@@ -133,16 +150,16 @@ class Sensor():
logging.info('Wrote %s data to InfluxDB: %s', self, data) logging.info('Wrote %s data to InfluxDB: %s', self, data)
def check_update(self): def check_update(self):
if self.update_period: if self.update_period and self.last_update:
if time.time() - self.last_update > self.update_period: if time.time() - self.last_update > self.update_period:
logging.warning('Missed expected update from %s.', self) logging.warning('Missed expected update from %s.', self)
self.last_update = time.time() self.last_update = time.time()
def update(self, data): def update(self, data):
self.last_update = time.time()
self.prev_value = self.value self.prev_value = self.value
self.value = data self.value = data
self.log() self.log()
self.last_update = time.time()
async def poll(self): async def poll(self):
return return
@@ -161,6 +178,7 @@ class ThermostatSensor(Sensor):
'dehum_setpoint' 'dehum_setpoint'
] ]
update_period = 300 update_period = 300
skip_if_hasnt_changed = True
def __init__(self, id_, ip, name): def __init__(self, id_, ip, name):
self.id_ = id_ self.id_ = id_
@@ -193,6 +211,7 @@ class OwnTracksSensor(Sensor):
'created_at', 'created_at',
] ]
update_period = 90 update_period = 90
skip_cooldown = False
class DustSensor(Sensor): class DustSensor(Sensor):
type_ = 'dust' type_ = 'dust'
@@ -230,6 +249,18 @@ class SleepSensor(Sensor):
except TypeError: except TypeError:
pass pass
class SoilSensor(Sensor):
type_ = 'soil'
update_period = 90
def transform(self, data):
for key, value in data.items():
# what happens if you do this to a timestamp?
try:
data[key] = float(round(value, 1))
except TypeError:
pass
class SolarSensor(Sensor): class SolarSensor(Sensor):
type_ = 'solar' type_ = 'solar'
@@ -249,7 +280,7 @@ class Acurite606TX(Sensor):
self.offset = offset self.offset = offset
def transform(self, data): def transform(self, data):
if data['battery_ok'] != 1: if data.get('battery_ok', None) != 1:
logging.error('%s battery not ok!', self) logging.error('%s battery not ok!', self)
data['temperature_C'] = float(data['temperature_C']) + self.offset data['temperature_C'] = float(data['temperature_C']) + self.offset
@@ -264,16 +295,29 @@ class AcuRite6002RM(Sensor):
update_period = 40 update_period = 40
offset = 0.0 offset = 0.0
def __init__(self, id_, name, offset=0.0): def __init__(self, id_, name, temp_offset=0.0, hum_offset=0.0):
self.id_ = id_ self.id_ = id_
self.name = name self.name = name
self.offset = offset self.temp_offset = temp_offset
self.hum_offset = hum_offset
def transform(self, data): def transform(self, data):
if data['battery_ok'] != 1: if data.get('battery_ok', None) != 1:
logging.error('%s battery not ok!', self) logging.error('%s battery not ok!', self)
data['temperature_C'] = float(data['temperature_C']) + self.offset
data['humidity'] = float(data['humidity']) data['temperature_C'] = float(data['temperature_C']) + self.temp_offset
data['humidity'] = float(data['humidity']) + self.hum_offset
class QotMotionSensor(Sensor):
type_ = 'qotmotion'
update_period = False
def transform(self, data):
split = data['data'].split(',')
data['battery'] = int(split[0])
data['boots'] = int(split[1])
data['motion'] = True # useful to distinguish if I eventually add a heartbeat
async def poll_sensors(): async def poll_sensors():
@@ -414,6 +458,9 @@ async def history(request):
elif measurement == 'air': elif measurement == 'air':
client = sensors_client client = sensors_client
q = 'select max("pm10") as max_p10, max("pm25") as max_p25, max("co2") as max_co2, max("voc_idx") as max_voc from air where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(linear)'.format(name, start, end, window) q = 'select max("pm10") as max_p10, max("pm25") as max_p25, max("co2") as max_co2, max("voc_idx") as max_voc from air where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(linear)'.format(name, start, end, window)
elif measurement == 'soil':
client = sensors_client
q = 'select mean("soil") as soil from soil where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(linear)'.format(name, start, end, window)
elif measurement == 'lux': elif measurement == 'lux':
client = sensors_client client = sensors_client
q = 'select mean("lux") as lux from air where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(linear)'.format(name, start, end, window) q = 'select mean("lux") as lux from air where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(linear)'.format(name, start, end, window)
@@ -495,13 +542,28 @@ if __name__ == '__main__':
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('air1', 'Living Room'))
sensors.add(Acurite606TX('59', 'Outside')) sensors.add(AirSensor('air2', 'Bedroom'))
sensors.add(AirSensor('air3', 'Kitchen'))
sensors.add(Acurite606TX('185', 'Outside', 0.0))
sensors.add(AcuRite6002RM('999999', 'Seeds', 0.0)) # A sensors.add(AcuRite6002RM('999999', 'Seeds', 0.0)) # A
sensors.add(AcuRite6002RM('5613', 'Misc', 0.0)) # A sensors.add(AcuRite6002RM('999998', 'Misc', 0.7, -1.0)) # A
sensors.add(AcuRite6002RM('5109', 'Nook', 0.4)) # B sensors.add(AcuRite6002RM('12516', 'Basement', 0.7, -1.0)) # A
sensors.add(AcuRite6002RM('11087', 'Bedroom', -0.3)) # C sensors.add(AcuRite6002RM('5109', 'Nook', 0.2, -1.0)) # B
sensors.add(AcuRite6002RM('11087', 'Bedroom', -0.7, 1.0)) # C
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(QotMotionSensor('qot_dc3c', 'Bedroom'))
sensors.add(QotMotionSensor('qot_88c3', 'Lower Stairs Hi'))
sensors.add(QotMotionSensor('qot_7c3c', 'Theatre'))
sensors.add(QotMotionSensor('qot_54e6', 'Lab'))
sensors.add(QotMotionSensor('qot_10f4', 'Office'))
sensors.add(QotMotionSensor('qot_74c3', 'Guest Bathroom'))
sensors.add(QotMotionSensor('qot_706f', 'Nook'))
sensors.add(QotMotionSensor('qot_8c1c', 'Kitchen S'))
sensors.add(QotMotionSensor('qot_a83b', 'Kitchen N'))
sensors.add(QotMotionSensor('qot_28c3', 'Side Entrance'))
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
a = loop.create_task(poll_sensors()).add_done_callback(task_died) a = loop.create_task(poll_sensors()).add_done_callback(task_died)

View File

@@ -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-polylinedecorator": "^1.6.0",
"moment": "^2.29.1", "moment": "^2.29.1",
"moment-timezone": "^0.5.34", "moment-timezone": "^0.5.34",
"react": "^18.0.0", "react": "^18.0.0",
@@ -15,6 +16,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-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"
}, },

View File

@@ -39,6 +39,16 @@ h2 {
z-index: 9999; z-index: 9999;
} }
.time-slider {
padding: 1em 0.5em;
}
.range {
color: white;
text-align: center;
padding: 0.5rem;
}
.submenu { .submenu {
background-color: #666; background-color: #666;
max-width: 40em; max-width: 40em;
@@ -50,6 +60,7 @@ h2 {
.submenu h2 { .submenu h2 {
color: white; color: white;
font-size: 1.1em;
} }
.submenu-header { .submenu-header {
@@ -72,6 +83,7 @@ h2 {
color: white; color: white;
border-radius: 0; border-radius: 0;
border: 0; border: 0;
font-family: sans-serif;
} }
.menu button:hover { .menu button:hover {
@@ -86,6 +98,67 @@ h2 {
background-color: #666; background-color: #666;
} }
.submenu-checkbox-label {
/* Make it look like a button */
background-color: #666;
height: 2.5rem;
font-size: 1.5rem;
color: white;
cursor: pointer;
font-family: sans-serif;
/* Center content */
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.submenu-checkbox-label:hover {
background-color: #999;
}
.submenu-checkbox-label input[type="checkbox"] {
/* Reset default styles */
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-color: transparent;
margin: 0;
/* Custom checkbox style */
font: inherit;
color: currentColor;
width: 0.75em;
height: 0.75em;
border: 0.1em solid currentColor;
border-radius: 0;
transform: translateY(-0.075em);
/* For the checkmark */
display: grid;
place-content: center;
}
/* Fix for Firefox Mobile rendering a black background on checked state,
especially when extensions like Dark Reader are active. */
.submenu-checkbox-label input[type="checkbox"]:checked {
background-color: transparent !important;
}
.submenu-checkbox-label input[type="checkbox"]::before {
content: "";
width: 0.75em;
height: 0.75em;
transform: scale(0);
transition: 120ms transform ease-in-out;
background-color: white;
}
.submenu-checkbox-label input[type="checkbox"]:checked::before {
transform: scale(1);
}
.datepicker .rdtPicker { .datepicker .rdtPicker {
background: none; background: none;
border: none; border: none;
@@ -99,3 +172,24 @@ h2 {
background-color: #999!important; background-color: #999!important;
} }
.submenu-actions {
padding-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.submenu-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.submenu-group span {
color: white;
flex-shrink: 0;
}
.submenu-group button {
flex-grow: 1;
}

View File

@@ -1,39 +1,49 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef, useMemo } from 'react';
import * as leaflet from 'leaflet'; import * as leaflet from 'leaflet';
import { MapContainer, Polyline, TileLayer, useMap } from 'react-leaflet'; import 'leaflet-polylinedecorator';
import { MapContainer, TileLayer, useMap, useMapEvents } from 'react-leaflet';
import Datetime from 'react-datetime'; import 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';
import moment from 'moment-timezone'; import moment from 'moment-timezone';
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';
let tzcache = {};
// num: number of steps per duration
// secs: number of seconds per step
const durations = [ const durations = [
{id: 0, len: 'Day', win: '1m', full: '1 min', delta: [1, 'days'], format: 'HH'}, {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'}, {id: 1, len: 'Week', win: '3m', full: '3 min', delta: [7, 'days'], format: 'HH', num: 3360, secs: 180},
{id: 2, len: 'Week', win: '10m', full: '10 min', delta: [7, 'days'], format: 'HH'}, {id: 2, len: 'Month', shortLen: 'Mth', win: '10m', full: '10 min', delta: [1, 'months'], format: 'D', num: 4380, secs: 600},
{id: 3, len: 'Month', win: '10m', full: '10 min', delta: [1, 'months'], format: 'D'}, {id: 3, len: 'Year', win: '2h', full: '2 hour', delta: [1, 'years'], format: 'M/D', num: 4380, secs: 7200},
{id: 4, len: 'Month', win: '1h', full: '1 hour', delta: [1, 'months'], format: 'D'},
{id: 5, len: 'Year', win: '2h', full: '2 hours', delta: [1, 'years'], format: 'M/D'},
{id: 6, len: 'Year', win: '1d', full: '1 day', delta: [1, 'years'], format: 'M/D'},
]; ];
const units = { const parseSlider = (end, duration, slider) => {
'PM10': ' ug/m³', //console.log(slider);
'PM2.5': ' ug/m³', // good luck remembering how this works
'VOC': ' / 500', const lowOffset = slider[0] * duration.secs - duration.num * duration.secs;
'CO2': ' ppm', const highOffset = slider[1] * duration.secs - duration.num * duration.secs;
'Energy': ' kWh',
'Power': ' W', const low = moment.unix(end.unix() + lowOffset);
'Temperature': ' °C', const high = moment.unix(end.unix() + highOffset);
'Humidity': '%',
'Setpoint': ' °C', const lowStr = low.utc().format('YYYY-MM-DDTHH:mm:ss[Z]');
'State': '', const highStr = high.utc().format('YYYY-MM-DDTHH:mm:ss[Z]');
'Lux': ' lx',
//console.log(lowStr, highStr);
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('');
//}
function useSensor(measurement, name, end, duration) { function useSensor(measurement, name, end, duration) {
const [data, setData] = useState(false); const [data, setData] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -61,12 +71,161 @@ function useSensor(measurement, name, end, duration) {
return [data, loading]; return [data, loading];
}; };
function MapViewManager({ coords, mapState, setMapState, loading, setSubmenu }) {
const map = useMap();
// Effect 1: Handle map events (pan/zoom) from the user
useMapEvents({
mousedown: () => setSubmenu(false),
zoomstart: () => setSubmenu(false),
moveend: () => {
const center = map.getCenter();
const newZoom = map.getZoom();
const newCenter = [center.lat, center.lng];
function Owntracks({end, duration}) { setMapState(prevState => {
// A small tolerance for floating point comparisons
const tolerance = 1e-5;
if (!prevState.center) {
return { zoom: newZoom, center: newCenter };
}
const zoomChanged = prevState.zoom !== newZoom;
const centerChanged = Math.abs(prevState.center[0] - newCenter[0]) > tolerance ||
Math.abs(prevState.center[1] - newCenter[1]) > tolerance;
if (zoomChanged || centerChanged) {
return { zoom: newZoom, center: newCenter };
}
// If nothing changed, return the previous state to prevent a re-render
return prevState;
});
},
});
// Effect 2: Handle programmatic view changes (refitting or setting from state)
useEffect(() => {
// Don't do anything while loading new data
if (loading) return;
// Case A: A refit is needed (signaled by null center)
if (mapState.center === null && coords.length > 0) {
const bounds = leaflet.latLngBounds(coords);
if (bounds.isValid()) {
map.fitBounds(bounds);
// After fitting, the 'moveend' event will fire and update the state naturally.
}
}
// Case B: A center is set in the state, ensure map is synced
else if (mapState.center) {
const currentCenter = map.getCenter();
const currentZoom = map.getZoom();
if (currentCenter.lat !== mapState.center[0] || currentCenter.lng !== mapState.center[1] || currentZoom !== mapState.zoom) {
map.setView(mapState.center, mapState.zoom);
}
}
}, [coords, mapState, loading, map, setMapState]);
return null;
}
function PolylineWithArrows({ coords, showDirection }) {
const map = useMap();
const polylineRef = useRef(null);
const decoratorRef = useRef(null);
useEffect(() => {
if (polylineRef.current) {
map.removeLayer(polylineRef.current);
}
if (decoratorRef.current) {
map.removeLayer(decoratorRef.current);
}
if (coords && coords.length > 1) {
const polyline = leaflet.polyline(coords, { color: 'blue' });
polylineRef.current = polyline;
map.addLayer(polyline);
if (showDirection) {
const decorator = leaflet.polylineDecorator(polyline, {
patterns: [
{
offset: 25,
repeat: 100,
symbol: leaflet.Symbol.arrowHead({
pixelSize: 10,
polygon: false,
pathOptions: {
stroke: true,
color: 'blue',
weight: 3
}
})
}
]
});
decoratorRef.current = decorator;
map.addLayer(decorator);
} else {
decoratorRef.current = null;
}
}
return () => {
if (polylineRef.current) {
map.removeLayer(polylineRef.current);
}
if (decoratorRef.current) {
map.removeLayer(decoratorRef.current);
}
};
}, [coords, map, showDirection]);
return null;
}
function Map({end, duration, slider, mapState, setMapState, setSubmenu, showDirection}) {
const [data, loading] = useSensor('owntracks', 'OwnTracks', end, duration); const [data, loading] = useSensor('owntracks', 'OwnTracks', end, duration);
const coords = data.length ? data.map(({ lat, lon }) => [lat, lon]).filter(([lat, lon]) => lat !== null || lon !== null) : []; 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();
@@ -75,18 +234,21 @@ function Owntracks({end, duration}) {
} }
return ( return (
<> <div className='container'>
{loading ? {loading ?
<p>Loading...</p> <p>Loading...</p>
: :
coords.length ? coords.length ?
<MapContainer center={coords[coords.length-1]} zoom={13} scrollWheelZoom={true} style={{ width: '100%', height: 'calc(100vh - 2.5rem)' }}> (
<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 <TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' 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' url='https://maptiles.p.rapidapi.com/en/map/v1/{z}/{x}/{y}.png?rapidapi-key=4375b0b1d8msh0c9e7fa3efb9adfp1769dfjsnd603a0387fea'
/> />
<Polyline pathOptions={{color: 'blue'}} positions={coords} /> <PolylineWithArrows coords={coords} showDirection={showDirection} />
</MapContainer> </MapContainer>
)
: :
<> <>
<p>No data</p> <p>No data</p>
@@ -97,51 +259,129 @@ function Owntracks({end, duration}) {
</form> </form>
</> </>
} }
</>
);
}
function Graphs({end, duration}) {
return (
<div className='container'>
<Owntracks end={end} duration={duration} />
</div> </div>
); );
} }
function Menu({duration, setDuration, end, setEnd}) { function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, setSubmenu, showDirection, setShowDirection}) {
const [submenu, setSubmenu] = useState(false); const [showRange, setShowRange] = useState(false);
const chooseDuration = (x) => { const chooseDuration = (x) => {
setSubmenu(false); setSubmenu(false);
setSlider([0, x.num]);
setDuration(x); setDuration(x);
}; };
const chooseEnd = (x) => { const chooseEnd = (x) => {
setSubmenu(false); setSubmenu(false);
const newEnd = x.add(...duration.delta); const newEnd = x.add(...duration.delta);
setSlider([0, duration.num]);
setEnd(newEnd); setEnd(newEnd);
}; };
const chooseNow = (x) => { const chooseNow = (x) => {
setSubmenu(false); setSubmenu(false);
setSlider([0, duration.num]);
setEnd(moment()); setEnd(moment());
}; };
const chooseMidnight = () => {
setSubmenu(false);
setSlider([0, duration.num]);
setEnd(moment().startOf('day'));
};
const rangeStart = (x) => {
setSubmenu(false);
setEnd(moment(range[0]).add(...duration.delta));
setSlider([0, duration.num]);
};
const rangeEnd = (x) => {
setSubmenu(false);
setEnd(moment(range[1]));
setSlider([0, duration.num]);
};
const next = () => { const next = () => {
setSubmenu(false); setSubmenu(false);
setSlider([0, duration.num]);
setEnd(prevEnd => moment(prevEnd).add(...duration.delta)); setEnd(prevEnd => moment(prevEnd).add(...duration.delta));
} }
const prev = () => { const prev = () => {
setSubmenu(false); setSubmenu(false);
setSlider([0, duration.num]);
setEnd(prevEnd => moment(prevEnd).subtract(...duration.delta)); setEnd(prevEnd => moment(prevEnd).subtract(...duration.delta));
} }
const resetToDefaults = () => {
window.location.href = window.location.pathname;
};
const range = parseSlider(end, duration, slider);
const rangeTime = (x) => {
if (new Date().getTimezoneOffset()) { // non-librewolf browser
return moment(x).format('lll'); // default to browser's TZ
} else {
return moment(x).tz('America/Edmonton').format('lll');
}
};
const rangeDelta = (range) => {
const start = moment(range[0]);
const end = moment(range[1]);
const diff = moment.duration(end.diff(start));
const parts = [];
const years = diff.years();
if (years > 0) parts.push(`${years} year${years > 1 ? 's' : ''}`);
const months = diff.months();
if (months > 0) parts.push(`${months} month${months > 1 ? 's' : ''}`);
const days = diff.days();
if (days > 0) parts.push(`${days} day${days > 1 ? 's' : ''}`);
const hours = diff.hours();
if (hours > 0) parts.push(`${hours} hour${hours > 1 ? 's' : ''}`);
const minutes = diff.minutes();
if (minutes > 0) parts.push(`${minutes} minute${minutes > 1 ? 's' : ''}`);
const seconds = diff.seconds();
if (seconds > 0) parts.push(`${seconds} second${seconds > 1 ? 's' : ''}`);
if (parts.length === 0) {
return '0 seconds';
}
return parts.join(', ');
};
return ( return (
<div className='menu'> <div className='menu'>
{!!submenu &&<div className='submenu'> {(showRange || !!submenu) && <div className='range'>
{rangeTime(range[0])} - {rangeTime(range[1])} <span style={{ whiteSpace: 'nowrap' }}>({rangeDelta(range)})</span>
</div>}
<div className='time-slider'>
<RangeSlider
min={0}
max={duration.num}
value={slider}
onInput={setSlider}
onThumbDragStart={() => setShowRange(true)}
onThumbDragEnd={() => setShowRange(false)}
onRangeDragStart={() => setShowRange(true)}
onRangeDragEnd={() => setShowRange(false)}
/>
</div>
{!!submenu &&
<div className='submenu'>
{submenu === 'end' && {submenu === 'end' &&
<> <>
<div className='submenu-header'> <div className='submenu-header'>
@@ -157,7 +397,18 @@ function Menu({duration, setDuration, end, setEnd}) {
/> />
</div> </div>
<button onClick={chooseNow}>Jump to Now</button> <div className='submenu-actions'>
<div className='submenu-group'>
<span>Jump to:</span>
<button onClick={chooseNow}>Now</button>
<button onClick={chooseMidnight}>Midnight</button>
</div>
<div className='submenu-group'>
<span>Shift to:</span>
<button onClick={rangeStart}>Range Start</button>
<button onClick={rangeEnd}>Range End</button>
</div>
</div>
</> </>
} }
@@ -169,11 +420,30 @@ function Menu({duration, setDuration, end, setEnd}) {
</div> </div>
{durations.map(x => {durations.map(x =>
<button key={x.id} onClick={() => chooseDuration(x)}>Last {x.len} / {x.full} data</button> <button key={x.id} onClick={() => chooseDuration(x)}>Last {x.len} ({x.full} data)</button>
)} )}
</> </>
} }
</div>}
{submenu === 'misc' &&
<>
<div className='submenu-header'>
<h2>Misc</h2>
<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>
</>
}
</div>
}
<div className='menu-container'> <div className='menu-container'>
<button onClick={() => prev()}>&lt;</button> <button onClick={() => prev()}>&lt;</button>
@@ -182,14 +452,21 @@ function Menu({duration, setDuration, end, setEnd}) {
onClick={() => setSubmenu('end')} onClick={() => setSubmenu('end')}
className={submenu === 'end' ? 'active' : ''} className={submenu === 'end' ? 'active' : ''}
> >
{moment(end).subtract(duration.delta[0], duration.delta[1]).format('ddd MMM DD')} {moment(end).subtract(duration.delta[0], duration.delta[1]).format('dd MMM DD')}
</button>
<button
onClick={() => setSubmenu('misc')}
className={submenu === 'misc' ? 'active' : ''}
>
</button> </button>
<button <button
onClick={() => setSubmenu('duration')} onClick={() => setSubmenu('duration')}
className={submenu === 'duration' ? 'active' : ''} className={submenu === 'duration' ? 'active' : ''}
> >
{duration.len} / {duration.win} {(duration.shortLen || duration.len)} / {duration.win}
</button> </button>
<button onClick={() => next()}>&gt;</button> <button onClick={() => next()}>&gt;</button>
@@ -199,8 +476,60 @@ function Menu({duration, setDuration, end, setEnd}) {
} }
function App() { function App() {
const [duration, setDuration] = useState(durations[0]); const params = new URLSearchParams(window.location.search);
const [end, setEnd] = useState(moment()); const initialDurationId = params.get('duration');
const initialEndTimestamp = params.get('end');
const initialSliderValue = params.get('slider');
const initialLat = params.get('lat');
const initialLng = params.get('lng');
const initialZoom = params.get('zoom');
const initialShowDirection = params.get('showDirection') === 'true';
const initialDuration = (initialDurationId && durations[parseInt(initialDurationId, 10)]) ? durations[parseInt(initialDurationId, 10)] : durations[0];
const initialEnd = initialEndTimestamp ? moment.unix(initialEndTimestamp) : moment();
const initialSlider = initialSliderValue ? initialSliderValue.split(',').map(Number) : [0, initialDuration.num];
const [duration, setDuration] = useState(initialDuration);
const [end, setEnd] = useState(initialEnd);
const [slider, setSlider] = useState(initialSlider);
const [mapState, setMapState] = useState({
center: (initialLat && initialLng) ? [parseFloat(initialLat), parseFloat(initialLng)] : null,
zoom: initialZoom ? parseInt(initialZoom, 10) : 13,
});
const [submenu, setSubmenu] = useState(false);
const [showDirection, setShowDirection] = useState(initialShowDirection);
const isInitialMount = useRef(true);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
} else {
// Reset map center to trigger a refit when new data arrives
setMapState(prev => ({ ...prev, center: null }));
}
}, [end, duration]);
useEffect(() => {
const handler = setTimeout(() => {
const params = new URLSearchParams();
params.set('duration', duration.id);
params.set('end', end.unix());
params.set('slider', slider.join(','));
if (showDirection) {
params.set('showDirection', 'true');
}
if (mapState.center) {
params.set('lat', mapState.center[0].toFixed(5));
params.set('lng', mapState.center[1].toFixed(5));
params.set('zoom', mapState.zoom);
}
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
}, 500);
return () => {
clearTimeout(handler);
};
}, [duration, end, slider, mapState, showDirection]);
return ( return (
<div> <div>
@@ -209,11 +538,22 @@ function App() {
setDuration={setDuration} setDuration={setDuration}
end={end} end={end}
setEnd={setEnd} setEnd={setEnd}
slider={slider}
setSlider={setSlider}
submenu={submenu}
setSubmenu={setSubmenu}
showDirection={showDirection}
setShowDirection={setShowDirection}
/> />
<Graphs <Map
end={end} end={end}
duration={duration} duration={duration}
slider={slider}
mapState={mapState}
setMapState={setMapState}
setSubmenu={setSubmenu}
showDirection={showDirection}
/> />
</div> </div>
); );

View File

@@ -3471,6 +3471,11 @@ cliui@^6.0.0:
strip-ansi "^6.0.0" strip-ansi "^6.0.0"
wrap-ansi "^6.2.0" wrap-ansi "^6.2.0"
clsx@^1.1.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
co@^4.6.0: co@^4.6.0:
version "4.6.0" version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -3710,6 +3715,11 @@ core-js@^2.4.0:
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==
core-js@^3.22.4:
version "3.37.1"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.37.1.tgz#d21751ddb756518ac5a00e4d66499df981a62db9"
integrity sha512-Xn6qmxrQZyB0FFY8E3bgRXei3lWDJHhvI+u0q9TKIYM49G8pAr0FgnnrFRAmsbptZL1yxRADVXn+x5AGsbBfyw==
core-js@^3.6.5: core-js@^3.6.5:
version "3.33.0" version "3.33.0"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.33.0.tgz#70366dbf737134761edb017990cf5ce6c6369c40" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.33.0.tgz#70366dbf737134761edb017990cf5ce6c6369c40"
@@ -7298,6 +7308,18 @@ 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-polylinedecorator@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/leaflet-polylinedecorator/-/leaflet-polylinedecorator-1.6.0.tgz#9ef79fd1b5302d67b72efe959a8ecd2553f27266"
integrity sha512-kn3krmZRetgvN0wjhgYL8kvyLS0tUogAl0vtHuXQnwlYNjbl7aLQpkoFUo8UB8gVZoB0dhI4Tb55VdTJAcYzzQ==
dependencies:
leaflet-rotatedmarker "^0.2.0"
leaflet-rotatedmarker@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/leaflet-rotatedmarker/-/leaflet-rotatedmarker-0.2.0.tgz#4467f49f98d1bfd56959bd9c6705203dd2601277"
integrity sha512-yc97gxLXwbZa+Gk9VCcqI0CkvIBC9oNTTjFsHqq4EQvANrvaboib4UdeQLyTnEqDpaXHCqzwwVIDHtvz2mUiDg==
leaflet@^1.9.4: leaflet@^1.9.4:
version "1.9.4" version "1.9.4"
resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d"
@@ -9512,6 +9534,14 @@ react-leaflet@^4.2.1:
dependencies: dependencies:
"@react-leaflet/core" "^2.1.0" "@react-leaflet/core" "^2.1.0"
react-range-slider-input@^3.0.7:
version "3.0.7"
resolved "https://registry.yarnpkg.com/react-range-slider-input/-/react-range-slider-input-3.0.7.tgz#88ceb118b33d7eb0550cec1f77fc3e60e0f880f9"
integrity sha512-yAJDDMUNkILOcZSCLCVbwgnoAM3v0AfdDysTCMXDwY/+ZRNRlv98TyHbVCwPFEd7qiI8Ca/stKb0GAy//NybYw==
dependencies:
clsx "^1.1.1"
core-js "^3.22.4"
react-refresh@^0.8.3: react-refresh@^0.8.3:
version "0.8.3" version "0.8.3"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"