Compare commits

...

54 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
45272a6242 Switch export to basement temperature 2024-07-15 19:41:17 +00:00
61fd657952 Take API key, add basement sensor 2024-07-15 19:41:17 +00:00
88dbba168c Take API key, adjust ranges 2024-07-15 19:41:17 +00:00
ba630b6fb9 Extract correct chrome version from exception 2024-07-15 19:41:17 +00:00
9 changed files with 4941 additions and 3737 deletions

1
.gitignore vendored
View File

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

View File

@@ -31,6 +31,7 @@ const units = {
'Setpoint': ' °C',
'State': '',
'Lux': ' lx',
'Soil': '',
};
function useSensor(measurement, name, end, duration) {
@@ -41,7 +42,8 @@ function useSensor(measurement, name, end, duration) {
const get = async() => {
setLoading(true);
try {
const params = { end: end.unix(), duration: duration.len.toLowerCase(), window: duration.win };
const api_key = localStorage.getItem('api_key', 'null');
const params = { end: end.unix(), duration: duration.len.toLowerCase(), window: duration.win, api_key: api_key };
const res = await axios.get(
'https://sensors-api.dns.t0.vc/history/'+measurement+'/'+name,
{ params: params },
@@ -107,7 +109,7 @@ function ChartContainer({name, data, lastFormatter, loading, children, topMargin
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;
if (data.length) {
const data_end = data.slice(-2);
@@ -194,12 +196,12 @@ function SolarPower({end, duration}) {
);
}
function OutsideTemperature({end, duration}) {
const [data, loading, tickFormatter] = useSensor('temperature', 'Outside', end, duration);
function Temperature({name, sensorName, end, duration, yDomain, showHumidity, showFreezingLine}) {
const [data, loading, tickFormatter] = useSensor('temperature', sensorName, end, duration);
return (
<ChartContainer
name='Outside Temperature'
name={name}
data={data}
lastFormatter={(x) => x.temperature_C?.toFixed(1) + ' °C'}
loading={loading}
@@ -209,62 +211,18 @@ function OutsideTemperature({end, duration}) {
minTickGap={10}
tickFormatter={tickFormatter}
/>
{showHumidity &&
<YAxis
yAxisId='right'
domain={[0, 100]}
orientation='right'
hide={true}
/>
}
<YAxis
domain={[-40, 40]}
yAxisId={showHumidity ? 'left' : undefined}
domain={yDomain}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={v => v.toFixed(1) + ' °C'}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine y={0} stroke='purple'>
<Label value='Freezing' offset={7} position='bottom' />
</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}) {
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]}
@@ -272,14 +230,16 @@ function NookTemperature({end, duration}) {
separator=': '
/>
<ReferenceLine yAxisId='left' y={0} stroke='blue'>
<Label value='Freezing' offset={7} position='bottom' />
</ReferenceLine>
{showFreezingLine &&
<ReferenceLine yAxisId={showHumidity ? 'left' : undefined} y={0} stroke='purple'>
<Label value='Freezing' offset={7} position='bottom' />
</ReferenceLine>
}
<ReferenceLine yAxisId='left' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<ReferenceLine yAxisId={showHumidity ? 'left' : undefined} x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
yAxisId='left'
yAxisId={showHumidity ? 'left' : undefined}
type='monotone'
dataKey='temperature_C'
name='Temperature'
@@ -288,207 +248,22 @@ function NookTemperature({end, duration}) {
dot={false}
isAnimationActive={false}
/>
<Line
yAxisId='right'
type='monotone'
dataKey='humidity'
name='Humidity'
stroke='blue'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
{showHumidity &&
<Line
yAxisId='right'
type='monotone'
dataKey='humidity'
name='Humidity'
stroke='blue'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
}
</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 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}) {
const [data, loading, tickFormatter] = useSensor('thermostat', 'Venstar', end, duration);
@@ -718,12 +493,12 @@ function LivingRoomDust({end, duration}) {
);
}
function LivingRoomAir({end, duration}) {
const [data, loading, tickFormatter] = useSensor('air', 'Living Room', end, duration);
function Air({name, sensorName, end, duration}) {
const [data, loading, tickFormatter] = useSensor('air', sensorName, end, duration);
return (
<ChartContainer
name='Living Room Air'
name={name}
data={data}
lastFormatter={(x) => x.max_p10?.toFixed(1) + ' ug/m³'}
loading={loading}
@@ -807,6 +582,8 @@ function LivingRoomAir({end, duration}) {
);
}
function BedroomSleep({end, duration}) {
const [data, loading, tickFormatter] = useSensor('sleep', 'Bedroom', end, duration);
@@ -847,12 +624,12 @@ function BedroomSleep({end, duration}) {
);
}
function LivingRoomLux({end, duration}) {
const [data, loading, tickFormatter] = useSensor('lux', 'Living Room', end, duration);
function Lux({name, sensorName, end, duration}) {
const [data, loading, tickFormatter] = useSensor('lux', sensorName, end, duration);
return (
<ChartContainer
name='Living Room Lux'
name={name}
data={data}
lastFormatter={(x) => x.lux?.toFixed(1) + ' lx'}
loading={loading}
@@ -892,22 +669,93 @@ 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}) {
const api_key = localStorage.getItem('api_key', false);
const handleSubmit = (e) => {
e.preventDefault();
const api_key = e.target[0].value;
localStorage.setItem('api_key', api_key);
}
return (
<div className='container'>
<SolarPower end={end} duration={duration} />
<LivingRoomDust end={end} duration={duration} />
<LivingRoomAir end={end} duration={duration} />
<OutsideTemperature end={end} duration={duration} />
<BedroomTemperature end={end} duration={duration} />
<NookTemperature end={end} duration={duration} />
<SeedsTemperature end={end} duration={duration} />
<MiscTemperature 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='Bedroom Air' sensorName='Bedroom' end={end} duration={duration} />
<Temperature name='Outside Temperature' sensorName='Outside' end={end} duration={duration} yDomain={[-40, 40]} showHumidity={false} showFreezingLine={true} />
<Temperature name='Bedroom Temperature' sensorName='Bedroom' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<Temperature name='Nook Temperature' sensorName='Nook' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<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} />
<Gas end={end} duration={duration} />
<Water 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 ||
<div>
<form onSubmit={handleSubmit}>
<p>
<input placeholder='API key' />
</p>
</form>
</div>
}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
import time
import traceback
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service
from selenium.common.exceptions import NoSuchElementException
from selenium.common.exceptions import NoSuchElementException, WebDriverException
from webdriver_manager.chrome import ChromeDriverManager
import undetected_chromedriver as uc
@@ -17,7 +18,22 @@ chrome_options.add_argument('--headless')
chrome_options.add_argument('start-maximized')
chrome_options.add_argument('--no-sandbox')
driver = uc.Chrome(service=ser, options=chrome_options, version_main=116)
try:
driver = uc.Chrome(service=ser, options=chrome_options)
except WebDriverException as e:
print('Wrong chrome driver version, extracting correct version...')
tb = traceback.format_exc()
version_string = tb.split('Current browser version is ')[1]
major_version = version_string.split('.')[0]
print('Trying version:', major_version)
chrome_options = uc.ChromeOptions()
chrome_options.add_argument('--headless')
chrome_options.add_argument('start-maximized')
chrome_options.add_argument('--no-sandbox')
driver = uc.Chrome(service=ser, options=chrome_options, version_main=int(major_version))
try:
driver.get('https://sensors.dns.t0.vc')
@@ -31,7 +47,8 @@ try:
'Outside_Temperature',
'Bedroom_Temperature',
'Nook_Temperature',
'Misc_Temperature',
#'Misc_Temperature',
'Basement_Temperature',
'Nook_Thermostat',
'Gas_Usage',
'Water_Usage',

92
main.py
View File

@@ -33,6 +33,7 @@ solar_client = InfluxDBClient('localhost', 8086, database='solar2')
PORT = 6903 if PROD else 6904
def controller_message(message):
logging.info('Sending controller message: %s', message)
payload = dict(home=message)
r = requests.post('https://tbot.tannercollin.com/message', data=payload, timeout=10)
if r.status_code == 200:
@@ -78,8 +79,10 @@ class Sensor():
value = {}
prev_value = {}
bad_keys = []
last_update = time.time()
last_update = None
update_period = None
skip_if_hasnt_changed = False
skip_cooldown = 1.0
def __init__(self, id_, name):
self.id_ = id_
@@ -102,11 +105,25 @@ class Sensor():
return str(before) != str(after)
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
data = self.value.copy()
self.transform(data)
try:
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:
data.pop(key, None)
@@ -133,16 +150,16 @@ class Sensor():
logging.info('Wrote %s data to InfluxDB: %s', self, data)
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:
logging.warning('Missed expected update from %s.', self)
self.last_update = time.time()
def update(self, data):
self.last_update = time.time()
self.prev_value = self.value
self.value = data
self.log()
self.last_update = time.time()
async def poll(self):
return
@@ -161,6 +178,7 @@ class ThermostatSensor(Sensor):
'dehum_setpoint'
]
update_period = 300
skip_if_hasnt_changed = True
def __init__(self, id_, ip, name):
self.id_ = id_
@@ -193,6 +211,7 @@ class OwnTracksSensor(Sensor):
'created_at',
]
update_period = 90
skip_cooldown = False
class DustSensor(Sensor):
type_ = 'dust'
@@ -230,6 +249,18 @@ class SleepSensor(Sensor):
except TypeError:
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):
type_ = 'solar'
@@ -249,7 +280,7 @@ class Acurite606TX(Sensor):
self.offset = offset
def transform(self, data):
if data['battery_ok'] != 1:
if data.get('battery_ok', None) != 1:
logging.error('%s battery not ok!', self)
data['temperature_C'] = float(data['temperature_C']) + self.offset
@@ -264,16 +295,29 @@ class AcuRite6002RM(Sensor):
update_period = 40
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.name = name
self.offset = offset
self.temp_offset = temp_offset
self.hum_offset = hum_offset
def transform(self, data):
if data['battery_ok'] != 1:
if data.get('battery_ok', None) != 1:
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():
@@ -414,6 +458,9 @@ async def history(request):
elif measurement == 'air':
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)
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':
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)
@@ -495,13 +542,28 @@ if __name__ == '__main__':
sensors.add(ERTSCMSensor('78628180', 'Gas'))
sensors.add(OwnTracksSensor('owntracks1', 'OwnTracks'))
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('5613', 'Misc', 0.0)) # A
sensors.add(AcuRite6002RM('5109', 'Nook', 0.4)) # B
sensors.add(AcuRite6002RM('11087', 'Bedroom', -0.3)) # C
sensors.add(AcuRite6002RM('999998', 'Misc', 0.7, -1.0)) # A
sensors.add(AcuRite6002RM('12516', 'Basement', 0.7, -1.0)) # A
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(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()
a = loop.create_task(poll_sensors()).add_done_callback(task_died)

View File

@@ -8,6 +8,7 @@
"@testing-library/user-event": "^12.1.10",
"axios": "^0.21.1",
"leaflet": "^1.9.4",
"leaflet-polylinedecorator": "^1.6.0",
"moment": "^2.29.1",
"moment-timezone": "^0.5.34",
"react": "^18.0.0",
@@ -15,6 +16,7 @@
"react-dom": "^17.0.2",
"react-is": "^17.0.2",
"react-leaflet": "^4.2.1",
"react-range-slider-input": "^3.0.7",
"react-scripts": "4.0.3",
"web-vitals": "^1.0.1"
},
@@ -31,8 +33,8 @@
]
},
"browserslist": [
">0.2%",
"not dead",
"not op_mini all"
">0.2%",
"not dead",
"not op_mini all"
]
}

View File

@@ -8,9 +8,8 @@ body {
.container {
display: flex;
flex-wrap: wrap;
justify-content: center;
margin-bottom: 3rem;
flex-direction: column;
align-items: center;
}
.chart {
@@ -40,6 +39,16 @@ h2 {
z-index: 9999;
}
.time-slider {
padding: 1em 0.5em;
}
.range {
color: white;
text-align: center;
padding: 0.5rem;
}
.submenu {
background-color: #666;
max-width: 40em;
@@ -51,6 +60,7 @@ h2 {
.submenu h2 {
color: white;
font-size: 1.1em;
}
.submenu-header {
@@ -73,6 +83,7 @@ h2 {
color: white;
border-radius: 0;
border: 0;
font-family: sans-serif;
}
.menu button:hover {
@@ -87,6 +98,67 @@ h2 {
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 {
background: none;
border: none;
@@ -100,3 +172,24 @@ h2 {
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,40 +1,49 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
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 'react-datetime/css/react-datetime.css';
import axios from 'axios';
import moment from 'moment-timezone';
import RangeSlider from 'react-range-slider-input';
import './App.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 = [
{id: 0, len: 'Day', win: '10m', full: '10 min', delta: [1, 'days'], format: 'HH'},
{id: 1, len: 'Day', win: '1h', full: '1 hour', delta: [1, 'days'], format: 'HH'},
{id: 2, len: 'Week', win: '1h', full: '1 hour', delta: [7, 'days'], format: 'HH'},
{id: 3, len: 'Week', win: '1d', full: '1 day', delta: [7, 'days'], format: 'D'},
{id: 4, len: 'Month', win: '1d', full: '1 day', delta: [1, 'months'], format: 'D'},
{id: 5, len: 'Month', win: '7d', full: '7 day', delta: [1, 'months'], format: 'D'},
{id: 6, len: 'Year', win: '1d', full: '1 day', delta: [1, 'years'], format: 'M/D'},
{id: 7, len: 'Year', win: '30d', full: '30 day', delta: [1, 'years'], format: 'M'},
{id: 0, len: 'Day', win: '1m', full: '1 min', delta: [1, 'days'], format: 'HH', num: 1440, secs: 60},
{id: 1, len: 'Week', win: '3m', full: '3 min', delta: [7, 'days'], format: 'HH', num: 3360, secs: 180},
{id: 2, len: 'Month', shortLen: 'Mth', win: '10m', full: '10 min', delta: [1, 'months'], format: 'D', num: 4380, secs: 600},
{id: 3, len: 'Year', win: '2h', full: '2 hour', delta: [1, 'years'], format: 'M/D', num: 4380, secs: 7200},
];
const units = {
'PM10': ' ug/m³',
'PM2.5': ' ug/m³',
'VOC': ' / 500',
'CO2': ' ppm',
'Energy': ' kWh',
'Power': ' W',
'Temperature': ' °C',
'Humidity': '%',
'Setpoint': ' °C',
'State': '',
'Lux': ' lx',
const parseSlider = (end, duration, slider) => {
//console.log(slider);
// good luck remembering how this works
const lowOffset = slider[0] * duration.secs - duration.num * duration.secs;
const highOffset = slider[1] * duration.secs - duration.num * duration.secs;
const low = moment.unix(end.unix() + lowOffset);
const high = moment.unix(end.unix() + highOffset);
const lowStr = low.utc().format('YYYY-MM-DDTHH:mm:ss[Z]');
const highStr = high.utc().format('YYYY-MM-DDTHH:mm:ss[Z]');
//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) {
const [data, setData] = useState(false);
const [loading, setLoading] = useState(false);
@@ -62,107 +71,380 @@ function useSensor(measurement, name, end, duration) {
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}) {
const [data, loading] = useSensor('owntracks', '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 coords = data.length ? data.map(({ lat, lon }) => [lat, lon]).filter(([lat, lon]) => lat !== null || lon !== null) : [];
const zoomChanged = prevState.zoom !== newZoom;
const centerChanged = Math.abs(prevState.center[0] - newCenter[0]) > tolerance ||
Math.abs(prevState.center[1] - newCenter[1]) > tolerance;
return (
<>
{loading ?
<p>Loading...</p>
:
coords.length ?
<MapContainer center={coords[coords.length-1]} zoom={13} scrollWheelZoom={true} style={{ width: '100%', height: 'calc(100vh - 2.5rem)' }}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Polyline pathOptions={{color: 'blue'}} positions={coords} />
</MapContainer>
:
<p>No coords</p>
}
</>
);
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 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) => {
e.preventDefault();
const api_key = e.target[0].value;
localStorage.setItem('api_key', api_key);
}
function Graphs({end, duration}) {
return (
<div className='container'>
<Owntracks end={end} duration={duration} />
{loading ?
<p>Loading...</p>
:
coords.length ?
(
<MapContainer center={mapState.center || [0, 0]} zoom={mapState.zoom} scrollWheelZoom={true} style={{ width: '100%', height: 'calc(100vh - 2.5rem)' }}>
<MapViewManager coords={coords} mapState={mapState} setMapState={setMapState} loading={loading} setSubmenu={setSubmenu} />
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url='https://maptiles.p.rapidapi.com/en/map/v1/{z}/{x}/{y}.png?rapidapi-key=4375b0b1d8msh0c9e7fa3efb9adfp1769dfjsnd603a0387fea'
/>
<PolylineWithArrows coords={coords} showDirection={showDirection} />
</MapContainer>
)
:
<>
<p>No data</p>
<form onSubmit={handleSubmit}>
<p>
<input placeholder='API key' />
</p>
</form>
</>
}
</div>
);
}
function Menu({duration, setDuration, end, setEnd}) {
const [submenu, setSubmenu] = useState(false);
function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, setSubmenu, showDirection, setShowDirection}) {
const [showRange, setShowRange] = useState(false);
const chooseDuration = (x) => {
setSubmenu(false);
setSlider([0, x.num]);
setDuration(x);
};
const chooseEnd = (x) => {
setSubmenu(false);
const newEnd = x.add(...duration.delta);
setSlider([0, duration.num]);
setEnd(newEnd);
};
const chooseNow = (x) => {
setSubmenu(false);
setSlider([0, duration.num]);
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 = () => {
setSubmenu(false);
setSlider([0, duration.num]);
setEnd(prevEnd => moment(prevEnd).add(...duration.delta));
}
const prev = () => {
setSubmenu(false);
setSlider([0, duration.num]);
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 (
<div className='menu'>
{!!submenu &&<div className='submenu'>
{submenu === 'end' &&
<>
<div className='submenu-header'>
<h2>Choose start date:</h2>
<button onClick={() => setSubmenu(false)}>×</button>
</div>
<div className='datepicker'>
<Datetime
input={false}
timeFormat={false}
onChange={(x) => chooseEnd(x)}
/>
</div>
<button onClick={chooseNow}>Jump to Now</button>
</>
}
{submenu === 'duration' &&
<>
<div className='submenu-header'>
<h2>Choose duration:</h2>
<button onClick={() => setSubmenu(false)}>×</button>
</div>
{durations.map(x =>
<button key={x.id} onClick={() => chooseDuration(x)}>Last {x.len} / {x.full} data</button>
)}
</>
}
{(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' &&
<>
<div className='submenu-header'>
<h2>Choose start date:</h2>
<button onClick={() => setSubmenu(false)}>×</button>
</div>
<div className='datepicker'>
<Datetime
input={false}
timeFormat={false}
onChange={(x) => chooseEnd(x)}
/>
</div>
<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>
</>
}
{submenu === 'duration' &&
<>
<div className='submenu-header'>
<h2>Choose duration:</h2>
<button onClick={() => setSubmenu(false)}>×</button>
</div>
{durations.map(x =>
<button key={x.id} onClick={() => chooseDuration(x)}>Last {x.len} ({x.full} data)</button>
)}
</>
}
{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'>
<button onClick={() => prev()}>&lt;</button>
@@ -170,14 +452,21 @@ function Menu({duration, setDuration, end, setEnd}) {
onClick={() => setSubmenu('end')}
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
onClick={() => setSubmenu('duration')}
className={submenu === 'duration' ? 'active' : ''}
>
{duration.len} / {duration.win}
{(duration.shortLen || duration.len)} / {duration.win}
</button>
<button onClick={() => next()}>&gt;</button>
@@ -187,8 +476,60 @@ function Menu({duration, setDuration, end, setEnd}) {
}
function App() {
const [duration, setDuration] = useState(durations[0]);
const [end, setEnd] = useState(moment());
const params = new URLSearchParams(window.location.search);
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 (
<div>
@@ -197,11 +538,22 @@ function App() {
setDuration={setDuration}
end={end}
setEnd={setEnd}
slider={slider}
setSlider={setSlider}
submenu={submenu}
setSubmenu={setSubmenu}
showDirection={showDirection}
setShowDirection={setShowDirection}
/>
<Graphs
<Map
end={end}
duration={duration}
slider={slider}
mapState={mapState}
setMapState={setMapState}
setSubmenu={setSubmenu}
showDirection={showDirection}
/>
</div>
);

View File

@@ -3471,6 +3471,11 @@ cliui@^6.0.0:
strip-ansi "^6.0.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:
version "4.6.0"
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"
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:
version "3.33.0"
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"
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:
version "1.9.4"
resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d"
@@ -9512,6 +9534,14 @@ react-leaflet@^4.2.1:
dependencies:
"@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:
version "0.8.3"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"