diff --git a/CHANGELOG.md b/CHANGELOG.md
index c46129a..376f2a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,8 @@ All notable changes to Climate Monitor. Older versions: see the [GitHub releases
### Added
- **Wood moisture** (equilibrium moisture content, USDA Wood Handbook) per sensor, with its own diagram, a correction for the wood species and a response time (averaged value, so short peaks do not trigger alarms). On/off in Settings → Display.
- **Warning and alarm level** per limit, e.g. a warning at 18 % and an alarm at 20 % wood moisture.
-- **Zones** in the Mollier and wood diagram: coloured areas of temperature × humidity from min/max or drawn freely in the Mollier diagram, with a notification when a sensor leaves a desired zone or enters a danger zone.
+- **Zones** in the wood diagram: coloured areas of temperature × humidity with a notification when a sensor leaves a desired zone or enters a danger zone. A zone starts as a square; drag the corners, drag the dot in the middle of a side to add a corner, double-click a corner to remove it. Click a zone to change or delete it.
+- **Limits in the time chart** as dashed lines (yellow = warning, red = alarm).
- **Alarm window** from the top bar with muting per alarm (15 minutes to 24 hours).
- **Dark/light toggle** in the top bar, remembered per browser.
- **Logo** in the top bar and as favicon.
diff --git a/README.md b/README.md
index 0684756..4ca91c3 100644
--- a/README.md
+++ b/README.md
@@ -66,7 +66,8 @@ A real-time climate monitoring dashboard for Modbus RTU temperature and humidity
- Comfort Score (0-6, based on Humidex)
- **Psychrometric chart**: Mollier diagram with live indicator and comfort zone visualization
- **Wood moisture** (optional): equilibrium moisture content per sensor, a diagram with zones from your limits, warning and alarm levels
-- **Zones**: your own coloured areas of temperature × humidity in the Mollier and wood diagram (from min/max or drawn freely), with a notification when a sensor leaves a desired zone or enters a danger zone
+- **Zones**: your own coloured areas of temperature × humidity in the wood diagram, shaped by dragging corners, with a notification when a sensor leaves a desired zone or enters a danger zone
+- **Limits in the chart**: the limits of the shown quantity appear as dashed lines in the time chart
- **Historical data replay**: Time-travel through data with preset buttons or custom date/time selection
- **Interactive slider**: Live updates while dragging for smooth historical navigation
- **Scientific analysis**: Humidex formula according to Environment Canada standard
@@ -102,11 +103,14 @@ A real-time climate monitoring dashboard for Modbus RTU temperature and humidity
#### Mollier diagram

-*Current state of every sensor in its own colour, with saturation line, RH curves, comfort zone and your own zones (here a desired comfort zone and a mould-risk danger zone).*
+*Current state of every sensor in its own colour, with saturation line, RH curves and comfort zone.*
#### Wood moisture

-*Equilibrium wood moisture: lines of equal wood moisture, the 18 % warning and 20 % alarm limits and the zones.*
+*Equilibrium wood moisture: lines of equal wood moisture, the 18 % warning and 20 % alarm limits and your zones (here a desired comfort zone and a mould-risk danger zone).*
+
+
+*Zone editor: drag the corners, drag a dot in the middle of a side to add a corner.*
#### Historical replay

@@ -291,7 +295,7 @@ ClimateMonitor/
├── translations.py # Texts in NL, EN, DE, FR, ES
├── test_app.py # Automated tests
├── deploy/ # Service install scripts (Linux, Windows)
-├── assets/ # Logo (header and favicon), served by Dash
+├── assets/ # Logo (header and favicon) and the zone editor script, served by Dash
├── img/ # README screenshots
├── .env # Configuration (not in git)
├── .env.example # Example configuration
@@ -331,13 +335,14 @@ Every sensor keeps the same colour everywhere (its marker, trend line, chart lin
**Chart**: choose the quantity with the tabs (temperature, humidity, dew point, absolute humidity,
comfort), and choose *All sensors (combined)* or a single sensor and the time range on the right.
-**Mollier diagram and replay**: shows the current state of all sensors and your zones. Select one
-sensor in the chart to enable *Choose range for historical analysis*.
+**Mollier diagram and replay**: shows the current state of all sensors. Select one sensor in the
+chart to enable *Choose range for historical analysis*.
-**Drawing a zone**: click *Draw zone* above the Mollier diagram, draw a closed shape with the mouse
-or a finger, give it a name, choose desired zone / danger zone / display only and a colour, and save
-(requires being logged in). The zone applies to the sensor selected above the chart, or to all
-sensors. Change it later in Settings → Notifications: limits → Zones.
+**Zones** (wood diagram): click **+ Zone** to start with a square. Drag the corners into place; drag
+the dot in the middle of a side to add a corner there (square → pentagon → …); double-click a corner
+to remove it. Works with mouse and touch. Give the zone a name, choose the sensor, *desired zone* /
+*danger zone* / *display only*, the level and a colour, and save (requires being logged in). To
+change or delete a zone, click it in the diagram or pick it from *Edit zone…*.
### Configuration
@@ -410,9 +415,9 @@ its detailed view and historical replay.
- Add a limit per quantity (temperature, humidity, dew point, absolute humidity, wood moisture)
with a minimum and/or maximum and a level (*Warning* or *Alarm*), for *All sensors* or for one
sensor. A rule for one sensor replaces the rule for all sensors for that quantity and level.
- - *Zones*: an area of temperature × humidity (min/max, or drawn in the Mollier diagram) with a
- colour. *Desired zone* notifies when a sensor leaves it, *danger zone* when a sensor enters it,
- *display only* just draws it.
+ - *Zones*: an area of temperature × humidity with a colour, shaped in the wood diagram.
+ *Desired zone* notifies when a sensor leaves it, *danger zone* when a sensor enters it,
+ *display only* just draws it. Settings lists the zones to change their other properties.
- *Alarm delay*: how long a limit must be exceeded before an alert is sent (no alerts for spikes).
- *Hysteresis*: how far back inside the limit (or away from a zone edge) a value must be before it
counts as normal again, so a value hovering around the limit does not cause a stream of messages.
@@ -658,7 +663,7 @@ python test_app.py
- Buses and sensors: validation, upgrade of existing data, data types and byte order, calibration, scanning, failure isolation
- Notifications: limits and levels, zones, alarm delay, hysteresis, recovery, muting, offline alerts, retries
- Wood moisture: formula against the Wood Handbook, response time, restart after settings change
-- Dashboard: form fields, zones in the diagrams, drawing zones, theme toggle, alarm window, phone layout
+- Dashboard: form fields, zone editor, limits in the chart, theme toggle, alarm window, phone layout
- MQTT: topics, payloads, reconnect behaviour
- Translations: same placeholders in every language, no untranslated text in the layout
@@ -673,7 +678,7 @@ pip freeze > requirements.txt
- **app.py**: entry point, starts the Modbus reader, notifications, MQTT and the web server
- **layout.py**: page structure, theme tokens (dark/light) and styling
-- **callbacks.py**: dashboard (board/meters, chart, status, Mollier and wood diagram, drawing zones, alarm window, theme)
+- **callbacks.py**: dashboard (board/meters, chart, status, Mollier and wood diagram, zone editor, alarm window, theme)
- **settings_ui.py**: Settings tab
- **settings.py** / **devices.py**: settings, buses and sensors stored in the database
- **modbus_reader.py**: one thread per RS-485 bus, batched database writes
diff --git a/assets/zone_editor.js b/assets/zone_editor.js
new file mode 100644
index 0000000..0fa9e01
--- /dev/null
+++ b/assets/zone_editor.js
@@ -0,0 +1,168 @@
+/* Zone-editor voor het houtdiagram (temperatuur × RV).
+ *
+ * Tekent over het Plotly-diagram een vorm met sleepbare hoeken (grote bollen) en op elke zijde een
+ * middenbol. Een middenbol slepen voegt daar een hoek toe; dubbelklik op een hoek verwijdert hem
+ * (minimaal 3). Werkt met muis en touch (pointer events). Na elke wijziging gaan de punten naar de
+ * Dash-store 'zone-edit-points'; de server slaat ze op.
+ *
+ * Aangestuurd door de clientside callback zones.edit met de inhoud van de store 'zone-edit'
+ * ({id, points} of null = editor uit).
+ */
+(function () {
+ 'use strict';
+ var SVG_NS = 'http://www.w3.org/2000/svg';
+ var GRAPH_ID = 'wood-chart';
+ var editor = { points: null, svg: null, plot: null, drag: null };
+
+ function plotElement() {
+ var graph = document.getElementById(GRAPH_ID);
+ return graph ? graph.querySelector('.js-plotly-plot') : null;
+ }
+
+ function axes(plot) {
+ var layout = plot && plot._fullLayout;
+ return layout && layout.xaxis && layout.yaxis ? layout : null;
+ }
+
+ function toPixels(layout, point) {
+ return [layout._size.l + layout.xaxis.l2p(point[0]), layout._size.t + layout.yaxis.l2p(point[1])];
+ }
+
+ function toData(layout, x, y) {
+ var temp = layout.xaxis.p2l(x - layout._size.l);
+ var rh = layout.yaxis.p2l(y - layout._size.t);
+ return [Math.round(temp * 10) / 10, Math.round(Math.min(100, Math.max(0, rh)) * 10) / 10];
+ }
+
+ function commit() {
+ if (window.dash_clientside && window.dash_clientside.set_props) {
+ window.dash_clientside.set_props('zone-edit-points', { data: editor.points.map(function (p) { return p.slice(); }) });
+ }
+ }
+
+ function circle(cx, cy, radius, className) {
+ var c = document.createElementNS(SVG_NS, 'circle');
+ c.setAttribute('cx', cx);
+ c.setAttribute('cy', cy);
+ c.setAttribute('r', radius);
+ c.setAttribute('class', className);
+ return c;
+ }
+
+ function startDrag(event, index) {
+ event.preventDefault();
+ event.stopPropagation();
+ editor.drag = index;
+ }
+
+ function draw() {
+ var plot = plotElement();
+ var layout = axes(plot);
+ if (!editor.points || !layout) {
+ return;
+ }
+ var host = plot.querySelector('.svg-container');
+ if (!editor.svg || editor.svg.parentNode !== host) {
+ editor.svg = document.createElementNS(SVG_NS, 'svg');
+ editor.svg.setAttribute('class', 'zone-editor');
+ host.appendChild(editor.svg);
+ }
+ var svg = editor.svg;
+ svg.setAttribute('width', layout.width);
+ svg.setAttribute('height', layout.height);
+ while (svg.firstChild) {
+ svg.removeChild(svg.firstChild);
+ }
+ var pixels = editor.points.map(function (p) { return toPixels(layout, p); });
+ var shape = document.createElementNS(SVG_NS, 'polygon');
+ shape.setAttribute('points', pixels.map(function (p) { return p.join(','); }).join(' '));
+ shape.setAttribute('class', 'zone-editor-shape');
+ svg.appendChild(shape);
+
+ // Middenbollen: slepen voegt een hoek toe tussen hoek i en i + 1
+ pixels.forEach(function (p, i) {
+ var next = pixels[(i + 1) % pixels.length];
+ var mid = circle((p[0] + next[0]) / 2, (p[1] + next[1]) / 2, 6, 'zone-editor-mid');
+ mid.addEventListener('pointerdown', function (event) {
+ editor.points.splice(i + 1, 0, toData(layout, (p[0] + next[0]) / 2, (p[1] + next[1]) / 2));
+ startDrag(event, i + 1);
+ draw();
+ });
+ svg.appendChild(mid);
+ });
+ // Hoeken: slepen; dubbelklik verwijdert (minimaal 3 hoeken)
+ pixels.forEach(function (p, i) {
+ var corner = circle(p[0], p[1], 8, 'zone-editor-corner');
+ corner.addEventListener('pointerdown', function (event) { startDrag(event, i); });
+ corner.addEventListener('dblclick', function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ if (editor.points.length > 3) {
+ editor.points.splice(i, 1);
+ draw();
+ commit();
+ }
+ });
+ svg.appendChild(corner);
+ });
+ }
+
+ function onMove(event) {
+ if (editor.drag === null || !editor.points) {
+ return;
+ }
+ var plot = plotElement();
+ var layout = axes(plot);
+ if (!layout) {
+ return;
+ }
+ var box = plot.querySelector('.svg-container').getBoundingClientRect();
+ editor.points[editor.drag] = toData(layout, event.clientX - box.left, event.clientY - box.top);
+ draw();
+ }
+
+ function onUp() {
+ if (editor.drag !== null) {
+ editor.drag = null;
+ commit();
+ }
+ }
+
+ function stop() {
+ editor.points = null;
+ editor.drag = null;
+ if (editor.svg && editor.svg.parentNode) {
+ editor.svg.parentNode.removeChild(editor.svg);
+ }
+ editor.svg = null;
+ }
+
+ function watchPlot() {
+ // Na elke (her)tekening of zoom van Plotly de bollen opnieuw plaatsen
+ var plot = plotElement();
+ if (plot && plot !== editor.plot && plot.on) {
+ editor.plot = plot;
+ plot.on('plotly_afterplot', draw);
+ }
+ }
+
+ window.addEventListener('pointermove', onMove);
+ window.addEventListener('pointerup', onUp);
+ window.addEventListener('pointercancel', onUp);
+
+ window.dash_clientside = Object.assign({}, window.dash_clientside, {
+ zones: {
+ edit: function (editing) {
+ stop();
+ if (editing && editing.points) {
+ editor.points = editing.points.map(function (p) { return [Number(p[0]), Number(p[1])]; });
+ watchPlot();
+ // Het diagram wordt mogelijk net opnieuw opgebouwd: tekenen na de volgende frame
+ window.requestAnimationFrame(function () { watchPlot(); draw(); });
+ commit();
+ }
+ return window.dash_clientside.no_update;
+ }
+ }
+ });
+}());
diff --git a/callbacks.py b/callbacks.py
index 21fa106..78a9517 100644
--- a/callbacks.py
+++ b/callbacks.py
@@ -3,7 +3,7 @@
import math
import pandas as pd
from datetime import datetime, timedelta
-from dash import ALL, Patch, callback_context, html, no_update, Input, Output, State
+from dash import ALL, ClientsideFunction, callback_context, html, no_update, Input, Output, State
import plotly.graph_objects as go
import alerts
@@ -17,7 +17,7 @@
from translations import TRANSLATIONS
from database import get_all_measurement_tables, build_union_query, DB_FILE
from modbus_reader import derived_values
-from psychrometric import add_device_points, add_zones, path_to_points, create_psychrometric_chart, create_psychrometric_chart_historical
+from psychrometric import add_device_points, create_psychrometric_chart, create_psychrometric_chart_historical
# Categorisch palet per sensor (vaste volgorde, gevalideerd op kleurenblindheid en contrast).
# Sensor 9 en verder krijgen een neutrale kleur; status-kleuren worden nooit voor sensoren gebruikt.
@@ -33,6 +33,7 @@
hover='#eceee8', ok='#0b8a0b', warn='#c98500', alarm='#d03b3b', offline='#7d838a'),
}
FONT = 'system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, sans-serif'
+NEW_ZONE_SQUARE = ((15, 40), (25, 40), (25, 60), (15, 60)) # nieuwe zone begint als vierkant (°C, % RV)
METRIC_UNITS = {'temperature': '°C', 'humidity': '%', 'dewpoint': '°C', 'absolute_humidity': 'g/m³', 'comfort': '',
'wood_emc': '%'}
METRIC_KEYS = {'temperature': 'temperature', 'humidity': 'humidity', 'dewpoint': 'dewpoint',
@@ -120,8 +121,11 @@ def humidex_score(temp, hum):
return 0
-def metric_figure(df, names, colors, metric, t, mode):
- """Eén grootheid, één lijn per sensor (of één sensor), in de kleuren van het thema."""
+def metric_figure(df, names, colors, metric, t, mode, limits=()):
+ """Eén grootheid, één lijn per sensor (of één sensor), in de kleuren van het thema.
+
+ limits: [(min, max, niveau)] uit de alarmregels, als gestippelde lijnen (geel = waarschuwing, rood = alarm).
+ """
fig = go.Figure()
unit = METRIC_UNITS[metric]
for device_id, name in names.items():
@@ -140,6 +144,14 @@ def metric_figure(df, names, colors, metric, t, mode):
else f'{name}: %{{y}}'))
style_figure(fig, mode)
fig.update_layout(showlegend=len(fig.data) > 1)
+ k = THEMES[mode]
+ for low, high, level in limits:
+ color = k['warn'] if level == 'warning' else k['alarm']
+ for value in (low, high):
+ if value is not None:
+ fig.add_hline(y=value, line=dict(color=color, width=1.5, dash='dash'),
+ annotation_text=f"{t['opt_level_' + level]} {value:g}", annotation_position='top left',
+ annotation_font=dict(color=color, size=11))
title = t[METRIC_KEYS[metric]] + (f' ({unit})' if unit else '')
fig.update_yaxes(title_text=title)
if metric == 'humidity':
@@ -149,13 +161,18 @@ def metric_figure(df, names, colors, metric, t, mode):
return fig
-def wood_zones(device_id):
- """Houtvochtgrenzen uit de alarmregels: [(min, max, niveau)] voor één sensor of de globale regels."""
+def limit_rules(metric, device_id):
+ """Grenzen van één grootheid uit de alarmregels: [(min, max, niveau)] voor één sensor of de globale regels."""
if device_id:
rules = alerts.effective_rules(device_id).values()
else:
rules = [r for r in alerts.load_rules() if r['enabled'] and r['device_id'] is None]
- return [(r['min_value'], r['max_value'], r['level']) for r in rules if r['metric'] == 'wood_emc']
+ return [(r['min_value'], r['max_value'], r['level']) for r in rules if r['metric'] == metric]
+
+
+def wood_zones(device_id):
+ """Houtvochtgrenzen uit de alarmregels (voor het houtdiagram)."""
+ return limit_rules('wood_emc', device_id)
def theme_mollier(fig, mode, point_color=None):
@@ -332,83 +349,19 @@ def update_graph(n, time_range_minutes, relayout_data, lang, device_sel, metric,
fig.update_yaxes(visible=False)
return fig, count_text, stored_relayout
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='s').dt.tz_localize('UTC').dt.tz_convert(settings.get('timezone')).dt.tz_localize(None)
- fig = metric_figure(df, names, device_colors(mode), metric, t, mode)
+ fig = metric_figure(df, names, device_colors(mode), metric, t, mode, limit_rules(metric, device_filter))
return fig, count_text, stored_relayout
- @app.callback(
- [Output('draw-zone-msg', 'children'),
- Output('zone-drawing', 'data', allow_duplicate=True),
- Output('draw-bar', 'style', allow_duplicate=True)],
- Input('save-zone-btn', 'n_clicks'),
- [State('zone-draw-path', 'data'),
- State('draw-zone-name', 'value'),
- State('draw-zone-mode', 'value'),
- State('draw-zone-color', 'value'),
- State('device-selector', 'value'),
- State('selected-language', 'data')],
- prevent_initial_call=True
- )
- def save_drawn_zone(n_clicks, path, name, mode, color, device_sel, lang):
- """Getekende vorm opslaan als zone (alleen ingelogd: het dashboard kan publiek zijn)."""
- t = TRANSLATIONS.get(lang) or TRANSLATIONS['en']
- if not auth.is_logged_in():
- return t['draw_login'], no_update, no_update
- points = path_to_points(path)
- if len(points) < 3:
- return t['draw_empty'], no_update, no_update
- rows = settings_ui.table_rows('zones') + [{
- 'scope': device_sel or 0, 'name': (name or '').strip() or f"{t['zone']} {len(zones.load()) + 1}",
- 'color': color or 'blue', 'mode': mode or 'none', 'level': 'warning', 'shape': 'polygon',
- 'points': json.dumps(points),
- 'enabled': 'true'}]
- errors = zones.save(rows)
- if errors:
- return t['msg_save_failed'], no_update, no_update
- return t['draw_saved'], False, {'display': 'none'}
-
- @app.callback(
- [Output('zone-drawing', 'data'),
- Output('draw-bar', 'style'),
- Output('psychrometric-chart', 'figure', allow_duplicate=True)],
- [Input('draw-zone-btn', 'n_clicks'),
- Input('cancel-zone-btn', 'n_clicks')],
- prevent_initial_call=True
- )
- def toggle_zone_drawing(draw_clicks, cancel_clicks):
- """Tekenmodus: Plotly tekent een gesloten pad; annuleren wist de tekening."""
- drawing = callback_context.triggered_id == 'draw-zone-btn'
- figure = Patch()
- figure['layout']['dragmode'] = 'drawclosedpath' if drawing else 'zoom'
- figure['layout']['newshape'] = {'line': {'color': zones.COLORS['blue'], 'width': 2},
- 'fillcolor': zones.fill_color('blue', 0.2)}
- if not drawing:
- figure['layout']['shapes'] = []
- return drawing, {} if drawing else {'display': 'none'}, figure
-
- @app.callback(
- Output('zone-draw-path', 'data'),
- Input('psychrometric-chart', 'relayoutData'),
- State('zone-drawing', 'data'),
- prevent_initial_call=True
- )
- def keep_drawn_path(relayout, drawing):
- """Laatst getekende vorm onthouden (Plotly geeft alle vormen in 'shapes')."""
- shapes = (relayout or {}).get('shapes')
- if not drawing or not shapes:
- return no_update
- return shapes[-1].get('path')
-
# Callback voor psychrometric chart
@app.callback(
Output('psychrometric-chart', 'figure'),
[Input('graph-update', 'n_intervals'),
Input('selected-language', 'data'),
- Input('device-selector', 'value')],
- State('zone-drawing', 'data')
+ Input('device-selector', 'value')]
)
- def update_psychrometric_chart(n, lang, device_sel, drawing):
+ def update_psychrometric_chart(n, lang, device_sel):
"""Update psychrometrisch diagram met actuele meetwaarden (alle devices of één device)"""
- if not settings.get('show_mollier') or drawing: # tijdens tekenen niet verversen: wist de tekening
+ if not settings.get('show_mollier'):
return no_update
if lang is None:
lang = 'nl'
@@ -422,30 +375,150 @@ def update_psychrometric_chart(n, lang, device_sel, drawing):
coldest = min((p[1] for p in points if p[1] is not None), default=0)
if coldest < 0:
fig.update_xaxes(range=[coldest - 3, 40]) # punten onder 0 °C zichtbaar houden
- return add_zones(fig, zones.visible(device_sel)) # na theme_mollier: dat kleurt vlakken als comfortzone
+ return fig
- @app.callback(
- Output('wood-chart', 'figure'),
- [Input('graph-update', 'n_intervals'),
- Input('selected-language', 'data'),
- Input('device-selector', 'value')],
- State('main-tabs', 'value')
- )
- def update_wood_chart(n, lang, device_sel, tab):
- """Houtvochtdiagram: actuele punten per sensor en de zones uit de houtvochtregels"""
- if not settings.get('show_wood') or (callback_context.triggered_id == 'graph-update' and (n % 5 or tab == 'settings')):
- return no_update
+ def wood_chart(lang, device_sel, editing):
+ """Houtvochtdiagram: actuele punten per sensor, houtvochtgrenzen en zones (zone in bewerking: door de editor)."""
t = TRANSLATIONS.get(lang) or TRANSLATIONS['en']
mode = theme_mode()
colors = device_colors(mode)
latest = latest_per_device()
points = [(d['name'], latest[d['id']][1], latest[d['id']][2], colors.get(d['id'])) for d in devices.list_devices()
if d['id'] in latest and (not device_sel or d['id'] == device_sel)]
- fig = wood.figure(points, wood_zones(device_sel), t, THEMES[mode], zones.visible(device_sel))
+ areas = [z for z in zones.visible(device_sel) if not editing or z['id'] != editing.get('id')]
+ fig = wood.figure(points, wood_zones(device_sel), t, THEMES[mode], areas)
fig = style_figure(fig, mode, height=520)
fig.update_layout(hovermode='closest', margin=dict(l=60, r=50, t=30, b=50))
return fig
+ @app.callback(
+ Output('wood-chart', 'figure'),
+ [Input('graph-update', 'n_intervals'),
+ Input('selected-language', 'data'),
+ Input('device-selector', 'value')],
+ [State('zone-edit', 'data'),
+ State('main-tabs', 'value')]
+ )
+ def update_wood_chart(n, lang, device_sel, editing, tab):
+ """Live houtvochtdiagram (elke 5 s); tijdens het bewerken van een zone stil."""
+ if not settings.get('show_wood') or editing:
+ return no_update
+ if callback_context.triggered_id == 'graph-update' and (n % 5 or tab == 'settings'):
+ return no_update
+ return wood_chart(lang, device_sel, editing)
+
+ @app.callback(
+ Output('wood-chart', 'figure', allow_duplicate=True),
+ Input('zone-edit', 'data'),
+ [State('selected-language', 'data'),
+ State('device-selector', 'value')],
+ prevent_initial_call=True
+ )
+ def wood_chart_for_editing(editing, lang, device_sel):
+ """Editor open of dicht: diagram zonder (of weer met) de zone in bewerking.
+
+ Een eigen callback: Dash vervangt een lopend verzoek door een nieuwer verzoek van dezelfde callback, dus
+ via update_wood_chart zou de ververs-tik van elke seconde dit diagram telkens overschrijven.
+ """
+ if not settings.get('show_wood'):
+ return no_update
+ return wood_chart(lang, device_sel, editing)
+
+ @app.callback(
+ [Output('zone-edit', 'data'),
+ Output('zone-edit-bar', 'style'),
+ Output('zone-edit-name', 'value'),
+ Output('zone-edit-scope', 'value'),
+ Output('zone-edit-mode', 'value'),
+ Output('zone-edit-level', 'value'),
+ Output('zone-edit-color', 'value'),
+ Output('zone-edit-scope', 'options'),
+ Output('zone-edit-points', 'data')],
+ [Input('zone-add-btn', 'n_clicks'),
+ Input('zone-edit-select', 'value')],
+ [State('device-selector', 'value'),
+ State('selected-language', 'data')],
+ prevent_initial_call=True
+ )
+ def start_zone_edit(add_clicks, zone_id, device_sel, lang):
+ """Nieuwe zone (vierkant) of een bestaande zone openen in de editor."""
+ t = TRANSLATIONS.get(lang) or TRANSLATIONS['en']
+ if callback_context.triggered_id == 'zone-add-btn':
+ zone = {'id': None, 'points': [list(p) for p in NEW_ZONE_SQUARE],
+ 'name': f"{t['zone']} {len(zones.load()) + 1}", 'device_id': device_sel or None,
+ 'mode': 'keep_outside', 'level': 'warning', 'color': 'red'}
+ else:
+ zone = next((z for z in zones.load() if z['id'] == zone_id), None)
+ if zone is None:
+ return (no_update,) * 9
+ return ({'id': zone['id'], 'points': zone['points']}, {}, zone['name'], zone['device_id'] or 0, zone['mode'],
+ zone['level'], zone['color'], layout.scope_options(t), None)
+
+ @app.callback(
+ [Output('zone-edit-msg', 'children'),
+ Output('zone-edit', 'data', allow_duplicate=True),
+ Output('zone-edit-bar', 'style', allow_duplicate=True),
+ Output('zone-edit-select', 'value'),
+ Output('zone-edit-select', 'options')],
+ [Input('zone-save-btn', 'n_clicks'),
+ Input('zone-delete-btn', 'n_clicks'),
+ Input('zone-cancel-btn', 'n_clicks')],
+ [State('zone-edit', 'data'),
+ State('zone-edit-points', 'data'),
+ State('zone-edit-name', 'value'),
+ State('zone-edit-scope', 'value'),
+ State('zone-edit-mode', 'value'),
+ State('zone-edit-level', 'value'),
+ State('zone-edit-color', 'value'),
+ State('selected-language', 'data')],
+ prevent_initial_call=True
+ )
+ def finish_zone_edit(save_clicks, delete_clicks, cancel_clicks, editing, points, name, scope, mode, level, color,
+ lang):
+ """Opslaan, verwijderen of annuleren (opslaan en verwijderen alleen ingelogd: het dashboard kan publiek zijn)."""
+ t = TRANSLATIONS.get(lang) or TRANSLATIONS['en']
+ closed = ({'display': 'none'}, None)
+ trigger = callback_context.triggered_id
+ if trigger == 'zone-cancel-btn' or not editing:
+ return '', None, *closed, no_update
+ if not auth.is_logged_in():
+ return t['draw_login'], no_update, no_update, no_update, no_update
+ rows = settings_ui.table_rows('zones')
+ if trigger == 'zone-delete-btn':
+ rows = [r for r in rows if r['id'] != editing['id']]
+ message = t['zone_deleted']
+ else:
+ existing = next((r for r in rows if editing['id'] and r['id'] == editing['id']), None)
+ row = {'id': editing['id'], 'scope': scope or 0, 'name': (name or '').strip() or t['zone'], 'color': color,
+ 'mode': mode, 'level': level, 'shape': 'polygon', 'points': json.dumps(points or editing['points']),
+ 'enabled': existing['enabled'] if existing else 'true'}
+ rows = [row if r is existing else r for r in rows] if existing else rows + [row]
+ message = t['draw_saved']
+ if zones.save(rows):
+ return t['msg_save_failed'], no_update, no_update, no_update, no_update
+ return message, None, *closed, layout.zone_options()
+
+ @app.callback(
+ Output('zone-edit-select', 'value', allow_duplicate=True),
+ Input('wood-chart', 'clickData'),
+ State('wood-chart', 'figure'),
+ prevent_initial_call=True
+ )
+ def open_clicked_zone(click, figure):
+ """Klik op een zone in het houtdiagram opent die zone in de editor (zone-id staat in trace.meta)."""
+ try:
+ trace = figure['data'][click['points'][0]['curveNumber']]
+ except (TypeError, KeyError, IndexError):
+ return no_update
+ return trace.get('meta') if trace.get('hoveron') == 'fills' and trace.get('meta') else no_update
+
+ # De editor zelf draait in de browser (assets/zone_editor.js): sleepbare hoeken en middenbollen
+ app.clientside_callback(
+ ClientsideFunction(namespace='zones', function_name='edit'),
+ Output('zone-editor-ready', 'data'),
+ Input('zone-edit', 'data')
+ )
+
# Callback voor modal open/close
@app.callback(
Output('range-modal', 'style'),
@@ -964,11 +1037,16 @@ def update_device_overview(n, lang, view, tab):
Output('custom-end-date', 'placeholder'),
Output('custom-start-hour', 'placeholder'),
Output('custom-end-hour', 'placeholder'),
- Output('draw-zone-btn', 'children'),
- Output('draw-zone-hint', 'children'),
- Output('draw-zone-name', 'placeholder'),
- Output('save-zone-btn', 'children'),
- Output('cancel-zone-btn', 'children')] +
+ Output('zone-add-btn', 'children'),
+ Output('zone-edit-select', 'placeholder'),
+ Output('zone-edit-hint', 'children'),
+ Output('zone-edit-name', 'placeholder'),
+ Output('zone-edit-mode', 'options'),
+ Output('zone-edit-level', 'options'),
+ Output('zone-edit-color', 'options'),
+ Output('zone-save-btn', 'children'),
+ Output('zone-delete-btn', 'children'),
+ Output('zone-cancel-btn', 'children')] +
[Output(f'preset-{key}', 'children') for key in PRESET_BUTTONS],
Input('selected-language', 'data')
)
@@ -977,5 +1055,6 @@ def update_history_language(lang):
t = TRANSLATIONS.get(lang) or TRANSLATIONS['en']
return [t['btn_select_range'], t['modal_title'], t['quick_select'], t['specific_period'], t['start_datetime'],
t['end_datetime'], t['btn_apply'], t['select_date'], t['select_date'], t['hour_placeholder'],
- t['hour_placeholder'], t['btn_draw_zone'], t['hint_draw_zone'], t['lbl_zone_name'], t['btn_save_zone'],
- t['btn_cancel']] + [t[f'btn_{key}'] for key in PRESET_BUTTONS]
+ t['hour_placeholder'], t['btn_new_zone'], t['lbl_edit_zone'], t['hint_edit_zone'], t['lbl_zone_name'],
+ layout.zone_mode_options(t), layout.level_options(t), layout.zone_color_options(t), t['btn_save_zone'],
+ t['btn_delete_zone'], t['btn_cancel']] + [t[f'btn_{key}'] for key in PRESET_BUTTONS]
diff --git a/img/mollier.png b/img/mollier.png
index 0bdf5b3..64cc1b7 100644
Binary files a/img/mollier.png and b/img/mollier.png differ
diff --git a/img/settings-limits.png b/img/settings-limits.png
index a0c3a90..88df6a4 100644
Binary files a/img/settings-limits.png and b/img/settings-limits.png differ
diff --git a/img/wood-diagram.png b/img/wood-diagram.png
index 8860b91..3c121f9 100644
Binary files a/img/wood-diagram.png and b/img/wood-diagram.png differ
diff --git a/img/zone-editor.png b/img/zone-editor.png
new file mode 100644
index 0000000..4529832
Binary files /dev/null and b/img/zone-editor.png differ
diff --git a/layout.py b/layout.py
index 34fef7f..e0a2724 100644
--- a/layout.py
+++ b/layout.py
@@ -152,6 +152,13 @@
.draw-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.draw-actions .input { width: 220px; max-width: 100%; }
.draw-select { width: 230px; max-width: 100%; }
+ .zone-editor { position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; z-index: 5; }
+ .zone-editor-shape { fill: var(--accent); fill-opacity: .14; stroke: var(--accent); stroke-width: 2; stroke-dasharray: 6 4; }
+ .zone-editor-corner { fill: var(--panel); stroke: var(--accent); stroke-width: 2.5; pointer-events: all; cursor: grab; touch-action: none; }
+ .zone-editor-corner:hover { fill: var(--accent); }
+ .zone-editor-mid { fill: var(--accent); fill-opacity: .55; pointer-events: all; cursor: copy; touch-action: none; }
+ .zone-editor-mid:hover { fill-opacity: 1; }
+ @media (pointer: coarse) { .zone-editor-corner { r: 13px; } .zone-editor-mid { r: 10px; } }
.slider-box { padding: 4px 16px 18px; }
.slider-time { font-family: var(--mono); font-size: 15px; margin-bottom: 8px; }
@@ -341,6 +348,20 @@ def current_theme():
return mode if mode in THEMES else 'dark'
+def zone_options():
+ """Keuzelijst 'Zone bewerken…' in het houtpaneel."""
+ return [{'label': z['name'], 'value': z['id']} for z in zones.load()]
+
+
+def level_options(t):
+ return [{'label': t[f'opt_level_{level}'], 'value': level} for level in ('warning', 'alarm')]
+
+
+def scope_options(t):
+ return [{'label': t['opt_all_devices'], 'value': 0}] + [
+ {'label': f"#{d['id']} {d['name']}", 'value': d['id']} for d in devices.list_devices()]
+
+
def zone_mode_options(t):
return [{'label': t[f'opt_mode_{mode}'], 'value': mode} for mode in zones.MODES]
@@ -409,24 +430,6 @@ def create_layout():
html.Section(id='mollier-panel', className='panel', style=mollier_style(), children=[
html.Div(className='panel-head', children=[
html.Span(id='label-psychrometric-chart', className='panel-title', children=t['psychrometric_chart']),
- html.Button(t['btn_draw_zone'], id='draw-zone-btn', n_clicks=0, className='btn btn-small'),
- ]),
- # Zone tekenen: zolang dit open is pauzeert de live-verversing (die zou de tekening wissen)
- dcc.Store(id='zone-drawing', data=False),
- dcc.Store(id='zone-draw-path'),
- html.Div(id='draw-bar', className='draw-bar', style={'display': 'none'}, children=[
- html.Div(t['hint_draw_zone'], id='draw-zone-hint', className='hint'),
- html.Div(className='draw-actions', children=[
- dcc.Input(id='draw-zone-name', type='text', placeholder=t['lbl_zone_name'], className='input',
- maxLength=50),
- dcc.Dropdown(id='draw-zone-mode', options=zone_mode_options(t), value='keep_outside',
- clearable=False, searchable=False, className='draw-select'),
- dcc.Dropdown(id='draw-zone-color', options=zone_color_options(t), value='red',
- clearable=False, searchable=False, className='draw-select'),
- html.Button(t['btn_save_zone'], id='save-zone-btn', n_clicks=0, className='btn btn-primary btn-small'),
- html.Button(t['btn_cancel'], id='cancel-zone-btn', n_clicks=0, className='btn btn-small'),
- html.Span(id='draw-zone-msg', className='hint'),
- ]),
]),
html.Div(className='panel-body', children=dcc.Graph(
id='psychrometric-chart', config={'displaylogo': False, 'displayModeBar': False})),
@@ -446,6 +449,37 @@ def create_layout():
html.Section(id='wood-panel', className='panel', style=wood_style(), children=[
html.Div(className='panel-head', children=[
html.Span(id='label-wood-chart', className='panel-title', children=t['wood_chart']),
+ html.Div(className='draw-actions', children=[
+ dcc.Dropdown(id='zone-edit-select', options=zone_options(), value=None, placeholder=t['lbl_edit_zone'],
+ clearable=False, searchable=False, className='draw-select'),
+ html.Button(t['btn_new_zone'], id='zone-add-btn', n_clicks=0, className='btn btn-small'),
+ ]),
+ ]),
+ # Zone-editor: assets/zone_editor.js tekent sleepbare hoeken en middenbollen over het diagram en
+ # schrijft de punten in zone-edit-points; zone-edit = {'id', 'points'} van de zone in bewerking
+ dcc.Store(id='zone-edit'),
+ dcc.Store(id='zone-edit-points'),
+ dcc.Store(id='zone-editor-ready'),
+ html.Div(id='zone-edit-bar', className='draw-bar', style={'display': 'none'}, children=[
+ html.Div(t['hint_edit_zone'], id='zone-edit-hint', className='hint'),
+ html.Div(className='draw-actions', children=[
+ dcc.Input(id='zone-edit-name', type='text', placeholder=t['lbl_zone_name'], className='input',
+ maxLength=50),
+ dcc.Dropdown(id='zone-edit-scope', options=[], value=0, clearable=False, searchable=False,
+ className='draw-select'),
+ dcc.Dropdown(id='zone-edit-mode', options=zone_mode_options(t), value='keep_outside',
+ clearable=False, searchable=False, className='draw-select'),
+ dcc.Dropdown(id='zone-edit-level', options=level_options(t), value='warning',
+ clearable=False, searchable=False, className='draw-select'),
+ dcc.Dropdown(id='zone-edit-color', options=zone_color_options(t), value='red',
+ clearable=False, searchable=False, className='draw-select'),
+ ]),
+ html.Div(className='draw-actions', children=[
+ html.Button(t['btn_save_zone'], id='zone-save-btn', n_clicks=0, className='btn btn-primary btn-small'),
+ html.Button(t['btn_delete_zone'], id='zone-delete-btn', n_clicks=0, className='btn btn-small'),
+ html.Button(t['btn_cancel'], id='zone-cancel-btn', n_clicks=0, className='btn btn-small btn-link'),
+ html.Span(id='zone-edit-msg', className='hint'),
+ ]),
]),
html.Div(className='panel-body', children=dcc.Graph(
id='wood-chart', config={'displaylogo': False, 'displayModeBar': False})),
diff --git a/psychrometric.py b/psychrometric.py
index 82b9965..4b1462f 100644
--- a/psychrometric.py
+++ b/psychrometric.py
@@ -315,35 +315,3 @@ def add_device_points(fig, points):
))
return fig
-
-def add_zones(fig, zone_list):
- """Zones (veelhoek in temperatuur × RV) in het Mollier diagram; randen volgen de RV-lijnen."""
- import zones
- for zone in zone_list:
- temps, ratios = [], []
- points = zone['points']
- for (t1, h1), (t2, h2) in zip(points, points[1:] + points[:1]):
- for f in np.linspace(0, 1, 16, endpoint=False):
- temp, rh = t1 + (t2 - t1) * f, h1 + (h2 - h1) * f
- pw = 611.2 * np.exp(17.62 * temp / (243.12 + temp)) * rh / 100
- temps.append(temp)
- ratios.append(0.622 * pw / (101325 - pw) * 1000)
- color = zones.COLORS[zone['color']]
- fig.add_trace(go.Scatter(x=temps + temps[:1], y=ratios + ratios[:1], mode='lines', fill='toself',
- name=zone['name'], fillcolor=zones.fill_color(zone['color']),
- line=dict(color=color, width=1.5), hoverinfo='skip'))
- if zone_list: # zones onder de sensorpunten en lijnen leggen
- fig.data = fig.data[-len(zone_list):] + fig.data[:-len(zone_list)]
- return fig
-
-
-def path_to_points(path):
- """Plotly tekenpad ('M20,7.3L30,13.3Z', x = °C, y = g/kg) naar zonepunten [[°C, RV %], ...]."""
- import re
- points = []
- for temp, ratio in re.findall(r'(-?[\d.]+),(-?[\d.]+)', path or ''):
- temp, w = float(temp), max(float(ratio), 0) / 1000
- pw = w * 101325 / (0.622 + w)
- pws = 611.2 * np.exp(17.62 * temp / (243.12 + temp))
- points.append([round(temp, 2), round(float(min(max(pw / pws * 100, 0), 100)), 1)])
- return points
diff --git a/settings_ui.py b/settings_ui.py
index 1e11235..38b5be7 100644
--- a/settings_ui.py
+++ b/settings_ui.py
@@ -26,8 +26,7 @@
BYTE_ORDER_LABELS = {'ABCD': 'ABCD · big endian', 'DCBA': 'DCBA · little endian', 'BADC': 'BADC · byte swap',
'CDAB': 'CDAB · word swap'}
RULE_COLUMNS = ['scope', 'metric', 'level', 'min_value', 'max_value', 'enabled']
-ZONE_COLUMNS = ['name', 'scope', 'mode', 'level', 'color', 'temp_min', 'temp_max', 'rh_min', 'rh_max', 'enabled']
-RECT_FIELDS = ['temp_min', 'temp_max', 'rh_min', 'rh_max']
+ZONE_COLUMNS = ['name', 'scope', 'mode', 'level', 'color', 'enabled'] # vorm: in het houtdiagram (zone-editor)
TABLE_COLUMNS = {'buses': BUS_COLUMNS, 'devices': DEVICE_COLUMNS, 'rules': RULE_COLUMNS, 'zones': ZONE_COLUMNS}
HIDDEN_FIELDS = {'zones': ['shape', 'points']} # meegestuurd zonder zichtbaar veld (getekende vorm)
WIDE_FIELDS = {'name', 'port', 'bus', 'scope', 'metric', 'byte_order', 'mode'} # twee kolommen breed
@@ -83,8 +82,8 @@ def table_rows(table):
"""Opgeslagen bussen, sensoren, grenswaarden of zones als formulierregels (aan/uit als 'true'/'false')."""
if table == 'zones':
return [{'id': z['id'], 'scope': z['device_id'] or 0, 'name': z['name'], 'mode': z['mode'], 'level': z['level'],
- 'color': z['color'], 'shape': z['shape'], 'points': json.dumps(z['points']),
- **{f: z[f] for f in RECT_FIELDS}, 'enabled': 'true' if z['enabled'] else 'false'} for z in zones.load()]
+ 'color': z['color'], 'shape': 'polygon', 'points': json.dumps(z['points']),
+ 'enabled': 'true' if z['enabled'] else 'false'} for z in zones.load()]
if table == 'rules':
return [{'id': None, 'scope': r['device_id'] or 0, 'metric': r['metric'], 'level': r['level'],
'min_value': r['min_value'], 'max_value': r['max_value'], 'enabled': 'true' if r['enabled'] else 'false'}
@@ -162,12 +161,10 @@ def row_form(table, rows, t, bus_names=None):
value=row.get(field)) for field in ['id'] + HIDDEN_FIELDS.get(table, [])]
for field in TABLE_COLUMNS[table]:
cell_id = {'type': 'cfg-cell', 'table': table, 'row': index, 'field': field}
- if row.get('shape') == 'polygon' and field in RECT_FIELDS:
- if field == RECT_FIELDS[0]: # getekende vorm: één vak in plaats van vier min/max-velden
- cells.append(html.Div(id=cell_id, className='cfg-cell wide', children=[
- html.Span(t['col_shape'], className='cfg-label'),
- html.Span(t['zone_free_shape'], className='cfg-static')]))
- continue
+ if table == 'zones' and field == 'enabled': # vorm: alleen in het houtdiagram aan te passen
+ cells.append(html.Div(className='cfg-cell wide', children=[
+ html.Span(t['col_shape'], className='cfg-label'),
+ html.Span(t['zone_shape_in_diagram'], className='cfg-static')]))
control = _control(table, index, field, row.get(field), t, bus_names)
# Label om het veld heen (koppeling zonder id); het vinkje heeft al een eigen label
wrapper = html.Div if field == 'enabled' else html.Label
@@ -202,10 +199,6 @@ def _new_row(table, rows, bus_names):
'slave_id': next(i for i in range(1, 248) if i not in used), 'function_code': 4, 'register_temp': 1,
'register_humidity': 2, 'data_type': 'int16', 'byte_order': 'ABCD', 'scale_temp': 0.1,
'scale_humidity': 0.1, 'offset_temp': 0, 'offset_humidity': 0, 'enabled': 'true'}
- if table == 'zones':
- return {'id': None, 'scope': 0, 'name': f'Zone {len(rows) + 1}', 'mode': 'keep_inside', 'level': 'warning',
- 'color': 'green', 'shape': 'rect', 'points': None, 'temp_min': 18, 'temp_max': 24, 'rh_min': 40,
- 'rh_max': 60, 'enabled': 'true'}
return {'id': None, 'scope': 0, 'metric': 'temperature', 'level': 'alarm', 'min_value': None, 'max_value': None,
'enabled': 'true'}
@@ -279,7 +272,6 @@ def _alerts_section(t):
html.H4(t['table_zones']),
html.Div(t['hint_zones'], className='field-hint'),
html.Div(id='zones-form', className='cfg-list', children=row_form('zones', table_rows('zones'), t)),
- html.Button(t['btn_add_zone'], id='add-zone', n_clicks=0, className='btn btn-small'),
html.Div(id='zone-errors', className='field-error'),
html.H4(t['lbl_alert_log']),
html.Div(id='alert-log', children=alert_log_list(t)),
@@ -549,13 +541,13 @@ def edit_rules(n_clicks, remove_clicks, values, lang):
@app.callback(
Output('zones-form', 'children'),
- [Input('add-zone', 'n_clicks'),
- Input({'type': 'cfg-remove', 'table': 'zones', 'row': ALL}, 'n_clicks')],
+ Input({'type': 'cfg-remove', 'table': 'zones', 'row': ALL}, 'n_clicks'),
[State(_fields('zones'), 'value'),
State('selected-language', 'data')],
prevent_initial_call=True
)
- def edit_zones(n_clicks, remove_clicks, values, lang):
+ def edit_zones(remove_clicks, values, lang):
+ """Zone verwijderen uit de lijst (nieuwe zones en de vorm: in het houtdiagram)."""
if not auth.is_logged_in():
return no_update
return edit_rows('zones', _t(lang), bus_names=[])
diff --git a/test_app.py b/test_app.py
index 98daa47..b8bb1ee 100644
--- a/test_app.py
+++ b/test_app.py
@@ -572,7 +572,7 @@ def test_settings_callbacks_require_login():
assert callbacks['change_password'](1, 'x', 'y', 'y', 'nl')[0] == TRANSLATIONS_NL['settings_locked']
assert 'admin' not in str(callbacks['show_settings']('settings', 'nl')) # alleen het inlogscherm
assert callbacks['edit_rules'](1, [], [], 'nl') is dash.no_update
- assert callbacks['edit_zones'](1, [], [], 'nl') is dash.no_update
+ assert callbacks['edit_zones']([], [], 'nl') is dash.no_update
assert callbacks['send_test'](1, 0, [], 'nl') == TRANSLATIONS_NL['settings_locked']
assert callbacks['refresh_alert_log'](5, 'settings', 'nl') is dash.no_update
assert callbacks['test_mqtt'](1, [], 'nl') == TRANSLATIONS_NL['settings_locked']
@@ -610,7 +610,7 @@ def test_config_forms_round_trip():
def test_zone_form_round_trip():
- """Zones in Settings: rechthoek via min/max-velden, getekende vorm blijft behouden (geen min/max-velden)."""
+ """Zones in Settings: eigenschappen bewerken, de vorm (in het houtdiagram gemaakt) blijft behouden."""
import settings_ui
import zones
from dash import html
@@ -622,8 +622,13 @@ def test_zone_form_round_trip():
'points': '[[10, 80], [30, 85], [20, 95]]', 'enabled': 'true'}]) == {}
form = html.Div(settings_ui.row_form('zones', settings_ui.table_rows('zones'), TRANSLATIONS['nl']))
fields = [c for c in components(form) if isinstance(getattr(c, 'id', None), dict) and c.id.get('type') == 'cfg']
- assert not any(c.id['row'] == 1 and c.id['field'] == 'temp_min' for c in fields)
- assert TRANSLATIONS_NL['zone_free_shape'] in str(form)
+ assert not any(c.id['field'] in ('temp_min', 'temp_max', 'rh_min', 'rh_max') for c in fields)
+ assert str(form).count(TRANSLATIONS_NL['zone_shape_in_diagram']) == 2
+ original, auth.is_logged_in = auth.is_logged_in, lambda: True
+ try:
+ assert 'add-zone' not in str(settings_ui.render_settings('nl')) # nieuwe zone: in het houtdiagram
+ finally:
+ auth.is_logged_in = original
rows = settings_ui.collect([{'id': c.id, 'value': c.value} for c in fields])
assert zones.save(rows) == {}
assert [z['points'] for z in zones.load()] == [[[18.0, 40.0], [24.0, 40.0], [24.0, 60.0], [18.0, 60.0]],
@@ -682,7 +687,69 @@ def test_zone_alarms_on_leaving_and_entering():
assert len(outbox_texts()) == 2 and alerts.device_status(cel['id'])[0] == 'ok'
-def test_zones_drawn_in_mollier_and_wood_diagram():
+def test_zones_only_in_wood_diagram():
+ """Zones horen bij het houtdiagram (temperatuur × RV); het Mollier-diagram toont alleen de vaste comfortzone."""
+ import callbacks
+ import layout
+ import zones
+ from dash import Dash
+ setup_alerts([])
+ assert settings.save({'show_wood': ['on']}) == {}
+ assert zones.save([{'scope': 0, 'name': 'Opslag', 'color': 'blue', 'mode': 'none', 'level': 'alarm',
+ 'temp_min': 10, 'temp_max': 20, 'rh_min': 50, 'rh_max': 70, 'enabled': 'true'}]) == {}
+ app = Dash(__name__)
+ callbacks.register_callbacks(app)
+ mollier = run_callback(app, 'psychrometric-chart.figure', 0, 'nl', 0, trigger='selected-language.data')
+ assert 'Opslag' not in [trace.name for trace in mollier.data]
+ assert 'draw-zone-btn' not in str(layout.create_layout())
+ wood = run_callback(app, 'wood-chart.figure', 0, 'nl', 0, None, 'dashboard', trigger='selected-language.data')
+ zone = next(trace for trace in wood.data if trace.name == 'Opslag')
+ assert zone.fill == 'toself' and zone.line.color == zones.COLORS['blue']
+
+
+def test_zone_editor_add_save_edit_delete():
+ """Zone-editor in het houtdiagram: nieuw = vierkant, hoeken/middenbollen slepen (JS), opslaan alleen ingelogd."""
+ import callbacks
+ import layout
+ import zones
+ from dash import Dash
+ setup_alerts([])
+ assert settings.save({'show_wood': ['on']}) == {}
+ rendered = str(layout.create_layout())
+ for component_id in ('zone-add-btn', 'zone-edit-select', 'zone-edit-bar', 'zone-edit-points'):
+ assert component_id in rendered, component_id
+ app = Dash(__name__)
+ callbacks.register_callbacks(app)
+ start = '..zone-edit.data...zone-edit-bar.style'
+ edit, bar, *_ = run_callback(app, start, 1, None, 0, 'nl', trigger='zone-add-btn.n_clicks')
+ assert edit['id'] is None and len(edit['points']) == 4 and bar == {} # begint als vierkant
+ shape = edit['points'][:2] + [[22.0, 70.0]] + edit['points'][2:] # middenbol versleept: vijfhoek
+ finish = 'zone-edit-msg.children'
+ fields = ('Nat', 0, 'keep_outside', 'alarm', 'red', 'nl')
+ with app.server.test_request_context():
+ msg = run_callback(app, finish, 1, 0, 0, edit, shape, *fields, trigger='zone-save-btn.n_clicks')[0]
+ assert msg == TRANSLATIONS_NL['draw_login'] and zones.load() == [] # dashboard kan publiek zijn
+ original, auth.is_logged_in = auth.is_logged_in, lambda: True
+ try:
+ msg, edit_after, bar_after, *_ = run_callback(app, finish, 1, 0, 0, edit, shape, *fields,
+ trigger='zone-save-btn.n_clicks')
+ zone = zones.load()[0]
+ assert msg == TRANSLATIONS_NL['draw_saved'] and edit_after is None and bar_after == {'display': 'none'}
+ assert zone['points'] == shape and zone['name'] == 'Nat' and zone['mode'] == 'keep_outside'
+ edit, bar, name, *_ = run_callback(app, start, 0, zone['id'], 0, 'nl', trigger='zone-edit-select.value')
+ assert edit == {'id': zone['id'], 'points': shape} and name == 'Nat' # bestaande zone bewerken
+ run_callback(app, finish, 0, 1, 0, edit, None, *fields, trigger='zone-delete-btn.n_clicks', value=1)
+ assert zones.load() == []
+ finally:
+ auth.is_logged_in = original
+
+
+def test_zone_editing_redraw_is_not_overwritten_by_the_interval():
+ """Editor open: eigen callback tekent het diagram zonder de zone in bewerking; de seconde-tik doet niets.
+
+ Dash vervangt een lopend verzoek door een nieuwer verzoek van dezelfde callback: zat het hertekenen in de
+ ververs-callback, dan overschreef de tik het steeds (de oude zone bleef onder de editor staan).
+ """
import callbacks
import zones
from dash import Dash
@@ -690,13 +757,54 @@ def test_zones_drawn_in_mollier_and_wood_diagram():
assert settings.save({'show_wood': ['on']}) == {}
assert zones.save([{'scope': 0, 'name': 'Opslag', 'color': 'blue', 'mode': 'none', 'level': 'alarm',
'temp_min': 10, 'temp_max': 20, 'rh_min': 50, 'rh_max': 70, 'enabled': 'true'}]) == {}
+ editing = {'id': zones.load()[0]['id'], 'points': [[10, 50], [20, 50], [20, 70], [10, 70]]}
app = Dash(__name__)
callbacks.register_callbacks(app)
- for output in ('psychrometric-chart.figure', 'wood-chart.figure'):
- fig = run_callback(app, output, 0, 'nl', 0, *(['dashboard'] if output == 'wood-chart.figure' else [False]),
- trigger='selected-language.data')
- zone = next(trace for trace in fig.data if trace.name == 'Opslag')
- assert zone.fill == 'toself' and zone.line.color == zones.COLORS['blue'], output
+ key = next(k for k, v in app.callback_map.items()
+ if 'wood-chart.figure' in k and [i['id'] for i in v['inputs']] == ['zone-edit'])
+ fig = app.callback_map[key]['callback'].__wrapped__(editing, 'nl', 0)
+ assert 'Opslag' not in [trace.name for trace in fig.data]
+ assert run_callback(app, 'wood-chart.figure', 5, 'nl', 0, editing, 'dashboard',
+ trigger='graph-update.n_intervals') is callbacks.no_update
+
+
+def test_click_zone_in_wood_diagram_opens_editor():
+ """Klik op een zone in het houtdiagram: die zone gaat open in de editor (zonder keuzelijst)."""
+ import callbacks
+ import zones
+ from dash import Dash
+ setup_alerts([])
+ assert settings.save({'show_wood': ['on']}) == {}
+ assert zones.save([{'scope': 0, 'name': 'Opslag', 'color': 'blue', 'mode': 'none', 'level': 'alarm',
+ 'temp_min': 10, 'temp_max': 20, 'rh_min': 50, 'rh_max': 70, 'enabled': 'true'}]) == {}
+ zone_id = zones.load()[0]['id']
+ app = Dash(__name__)
+ callbacks.register_callbacks(app)
+ fig = run_callback(app, 'wood-chart.figure', 0, 'nl', 0, None, 'dashboard', trigger='selected-language.data')
+ curve = next(i for i, trace in enumerate(fig.data) if trace.name == 'Opslag')
+ assert fig.data[curve].hoveron == 'fills' # klikbaar op het vlak zelf
+ figure = fig.to_plotly_json()
+ assert run_callback(app, 'zone-edit-select.value@', {'points': [{'curveNumber': curve}]}, figure,
+ trigger='wood-chart.clickData') == zone_id
+ assert run_callback(app, 'zone-edit-select.value@', {'points': [{'curveNumber': len(fig.data) - 1}]}, figure,
+ trigger='wood-chart.clickData') is callbacks.no_update # sensorpunt: geen zone
+
+
+def test_zone_editor_script_is_wired():
+ """De sleep-editor (assets/zone_editor.js) is de clientside callback zones.edit en levert de punten aan Dash.
+
+ Het slepen zelf (hoeken, middenbollen, dubbelklik) wordt in de browser getest.
+ """
+ import callbacks
+ from dash import Dash
+ script = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'zone_editor.js'),
+ encoding='utf-8').read()
+ for needle in ('zones:', 'edit: function', "set_props('zone-edit-points'", 'zone-editor-mid', 'zone-editor-corner',
+ 'dblclick', 'pointerdown', 'plotly_afterplot'):
+ assert needle in script, needle
+ app = Dash(__name__)
+ callbacks.register_callbacks(app)
+ assert 'zone-editor-ready.data' in app.callback_map
def test_zone_alarm_has_margin_at_the_edge():
@@ -725,59 +833,6 @@ def test_zone_alarm_has_margin_at_the_edge():
assert any('Schimmel' in x for x in outbox_texts())
-def test_drawn_mollier_path_becomes_zone_points():
- """Plotly tekenpad (x = °C, y = g/kg) naar zonepunten in °C × RV."""
- import psychrometric
- points = psychrometric.path_to_points('M20,7.29L30,13.31L25,40Z')
- assert [round(t) for t, _ in points] == [20, 30, 25]
- assert abs(points[0][1] - 50) < 1 and abs(points[1][1] - 50) < 1 and points[2][1] == 100 # boven verzadiging: 100 %
-
-
-def test_save_drawn_zone_only_when_logged_in():
- import callbacks
- import zones
- from dash import Dash
- setup_alerts([])
- app = Dash(__name__)
- callbacks.register_callbacks(app)
- path = 'M20,7.29L30,13.31L25,4Z'
- with app.server.test_request_context():
- msg, drawing, _ = run_callback(app, 'draw-zone-msg.children', 1, path, 'Droog', 'keep_outside', 'red', 0, 'nl',
- trigger='save-zone-btn.n_clicks')
- assert msg == TRANSLATIONS_NL['draw_login'] and zones.load() == [] # dashboard kan publiek zijn
- original, auth.is_logged_in = auth.is_logged_in, lambda: True
- try:
- assert run_callback(app, 'draw-zone-msg.children', 1, None, 'Droog', 'keep_outside', 'red', 0, 'nl',
- trigger='save-zone-btn.n_clicks')[0] == TRANSLATIONS_NL['draw_empty']
- msg, drawing, _ = run_callback(app, 'draw-zone-msg.children', 1, path, 'Droog', 'keep_outside', 'red', 0, 'nl',
- trigger='save-zone-btn.n_clicks')
- finally:
- auth.is_logged_in = original
- assert msg == TRANSLATIONS_NL['draw_saved'] and drawing is False
- zone = zones.load()[0]
- assert zone['name'] == 'Droog' and zone['shape'] == 'polygon' and len(zone['points']) == 3 and zone['mode'] == 'keep_outside' and zone['color'] == 'red'
-
-
-def test_draw_mode_pauses_live_mollier_and_keeps_path():
- import callbacks
- import layout
- from dash import Dash
- setup_alerts([])
- rendered = str(layout.create_layout())
- assert 'draw-zone-btn' in rendered and 'draw-bar' in rendered
- assert 'draw-zone-mode' in rendered and 'draw-zone-color' in rendered
- app = Dash(__name__)
- callbacks.register_callbacks(app)
- drawing, bar, _ = run_callback(app, '..zone-drawing.data...draw-bar.style', 1, 0, trigger='draw-zone-btn.n_clicks')
- assert drawing is True and bar == {}
- assert run_callback(app, 'psychrometric-chart.figure', 1, 'nl', 0, True, trigger='graph-update.n_intervals') \
- is callbacks.no_update # live-verversing zou de tekening wissen
- assert run_callback(app, 'zone-draw-path.data', {'shapes': [{'path': 'M1,2L3,4L5,6Z'}]}, True,
- trigger='psychrometric-chart.relayoutData') == 'M1,2L3,4L5,6Z'
- drawing, bar, _ = run_callback(app, '..zone-drawing.data...draw-bar.style', 1, 1, trigger='cancel-zone-btn.n_clicks')
- assert drawing is False and bar == {'display': 'none'}
-
-
def test_zone_alarm_does_not_stick_after_zones_are_saved_again():
"""Opslaan geeft zones nieuwe id's (bv. na tekenen): een alarm op een oude zone mag niet blijven hangen."""
import zones
@@ -1318,10 +1373,10 @@ def test_wood_chart_and_board_follow_setting():
conn.close()
app = Dash(__name__)
callbacks.register_callbacks(app)
- assert run_callback(app, 'wood-chart.figure', 0, 'nl', 0, 'dashboard', trigger='selected-language.data') is \
+ assert run_callback(app, 'wood-chart.figure', 0, 'nl', 0, None, 'dashboard', trigger='selected-language.data') is \
callbacks.no_update # houtvocht staat uit
assert settings.save({'show_wood': ['on']}) == {}
- fig = run_callback(app, 'wood-chart.figure', 0, 'nl', 0, 'dashboard', trigger='selected-language.data')
+ fig = run_callback(app, 'wood-chart.figure', 0, 'nl', 0, None, 'dashboard', trigger='selected-language.data')
assert {'Koelcel', 'Waarschuwing ≥ 18 %'} <= {trace.name for trace in fig.data}
board, _ = run_callback(app, 'device-overview.children', 0, 'nl', 'board', 'dashboard', trigger='view-toggle.value')
assert 'Houtvocht' in json.dumps(board, cls=plotly.utils.PlotlyJSONEncoder, ensure_ascii=False)
@@ -1389,6 +1444,22 @@ def test_wood_value_restarts_when_wood_settings_change():
assert abs(wood.update(1, 20, 65, now=now + 60) - 10.0) < 0.2
+def test_limits_shown_in_time_chart():
+ """Grenswaarden van de getoonde grootheid als lijnen in de tijdgrafiek (alleen die grootheid)."""
+ import callbacks
+ import pandas as pd
+ setup_alerts([{'scope': 0, 'metric': 'temperature', 'max_value': 8, 'level': 'alarm', 'enabled': 'true'},
+ {'scope': 0, 'metric': 'temperature', 'min_value': 2, 'max_value': 6, 'level': 'warning',
+ 'enabled': 'true'},
+ {'scope': 0, 'metric': 'humidity', 'max_value': 80, 'enabled': 'true'}])
+ df = pd.DataFrame({'timestamp': [1, 2], 'temperature': [5.0, 7.0], 'humidity': [60.0, 70.0], 'device_id': [1, 1]})
+ limits = callbacks.limit_rules('temperature', None)
+ fig = callbacks.metric_figure(df, {1: 'Kas'}, {}, 'temperature', TRANSLATIONS['nl'], 'dark', limits=limits)
+ lines = sorted((shape.y0, shape.line.color) for shape in fig.layout.shapes)
+ k = callbacks.THEMES['dark']
+ assert lines == [(2, k['warn']), (6, k['warn']), (8, k['alarm'])]
+
+
def test_warning_and_alarm_levels():
names = setup_alerts([{'scope': 0, 'metric': 'wood_emc', 'max_value': 18, 'level': 'warning', 'enabled': 'true'},
{'scope': 0, 'metric': 'wood_emc', 'max_value': 20, 'level': 'alarm', 'enabled': 'true'}])
diff --git a/translations.py b/translations.py
index 6cfeed3..ecfe568 100644
--- a/translations.py
+++ b/translations.py
@@ -217,6 +217,12 @@
'alert_low': '⚠️ {device}: {metric} {value} {unit} is onder het minimum van {limit} {unit} ({time})',
'alert_warn_high': '🟡 {device}: {metric} {value} {unit} is boven de waarschuwingsgrens van {limit} {unit} ({time})',
'alert_warn_low': '🟡 {device}: {metric} {value} {unit} is onder de waarschuwingsgrens van {limit} {unit} ({time})',
+ 'btn_new_zone': '+ Zone',
+ 'lbl_edit_zone': 'Zone bewerken…',
+ 'hint_edit_zone': 'Sleep de hoeken naar de gewenste plek. Sleep een bolletje midden op een zijde om een hoek toe te voegen; dubbelklik een hoek om hem te verwijderen.',
+ 'btn_delete_zone': 'Verwijderen',
+ 'zone_deleted': 'Zone verwijderd.',
+ 'zone_shape_in_diagram': 'Vorm aanpassen in het houtdiagram',
'lbl_scan': 'Bus scannen',
'hint_scan': 'Zoekt welke sensoren (slave-adres 1-30) antwoorden op een opgeslagen bus. Het uitlezen van die bus pauzeert zolang de scan loopt (maximaal ongeveer 10 seconden).',
'btn_scan': 'Scannen',
@@ -241,8 +247,8 @@
'alert_zone_recovered': '✅ {device}: {temperature} °C / {humidity} % is weer goed voor zone {zone} ({time})',
'zone': 'Zone',
'err_zone_points': 'Een getekende zone heeft minstens 3 punten nodig',
- 'table_zones': 'Zones (Mollier- en houtdiagram)',
- 'hint_zones': 'Een zone is een gebied van temperatuur × luchtvochtigheid met een eigen kleur. Gewenste zone: melding als een sensor de zone verlaat. Gevarenzone: melding als een sensor de zone binnengaat. Vrije vormen teken je in het Mollier-diagram.',
+ 'table_zones': 'Zones (houtdiagram)',
+ 'hint_zones': 'Een zone is een gebied van temperatuur × luchtvochtigheid met een eigen kleur. Gewenste zone: melding als een sensor de zone verlaat. Gevarenzone: melding als een sensor de zone binnengaat. De vorm maak en wijzig je in het houtdiagram.',
'btn_add_zone': '+ Zone toevoegen',
'col_color': 'Kleur',
'col_mode': 'Melding',
@@ -576,6 +582,12 @@
'alert_low': '⚠️ {device}: {metric} {value} {unit} is below the minimum of {limit} {unit} ({time})',
'alert_warn_high': '🟡 {device}: {metric} {value} {unit} is above the warning limit of {limit} {unit} ({time})',
'alert_warn_low': '🟡 {device}: {metric} {value} {unit} is below the warning limit of {limit} {unit} ({time})',
+ 'btn_new_zone': '+ Zone',
+ 'lbl_edit_zone': 'Edit zone…',
+ 'hint_edit_zone': 'Drag the corners into place. Drag a dot in the middle of a side to add a corner; double-click a corner to remove it.',
+ 'btn_delete_zone': 'Delete',
+ 'zone_deleted': 'Zone deleted.',
+ 'zone_shape_in_diagram': 'Edit the shape in the wood diagram',
'lbl_scan': 'Scan bus',
'hint_scan': 'Finds which sensors (slave address 1-30) respond on a saved bus. Reading that bus pauses while the scan runs (about 10 seconds at most).',
'btn_scan': 'Scan',
@@ -600,8 +612,8 @@
'alert_zone_recovered': '✅ {device}: {temperature} °C / {humidity} % is fine again for zone {zone} ({time})',
'zone': 'Zone',
'err_zone_points': 'A drawn zone needs at least 3 points',
- 'table_zones': 'Zones (Mollier and wood diagram)',
- 'hint_zones': 'A zone is an area of temperature × humidity with its own colour. Desired zone: notification when a sensor leaves it. Danger zone: notification when a sensor enters it. Draw free shapes in the Mollier diagram.',
+ 'table_zones': 'Zones (wood diagram)',
+ 'hint_zones': 'A zone is an area of temperature × humidity with its own colour. Desired zone: notification when a sensor leaves it. Danger zone: notification when a sensor enters it. Create and shape zones in the wood diagram.',
'btn_add_zone': '+ Add zone',
'col_color': 'Colour',
'col_mode': 'Notification',
@@ -935,6 +947,12 @@
'alert_low': '⚠️ {device}: {metric} {value} {unit} liegt unter dem Minimum von {limit} {unit} ({time})',
'alert_warn_high': '🟡 {device}: {metric} {value} {unit} liegt über der Warngrenze von {limit} {unit} ({time})',
'alert_warn_low': '🟡 {device}: {metric} {value} {unit} liegt unter der Warngrenze von {limit} {unit} ({time})',
+ 'btn_new_zone': '+ Zone',
+ 'lbl_edit_zone': 'Zone bearbeiten…',
+ 'hint_edit_zone': 'Ziehen Sie die Ecken an die gewünschte Stelle. Ziehen Sie einen Punkt in der Mitte einer Seite, um eine Ecke hinzuzufügen; Doppelklick auf eine Ecke entfernt sie.',
+ 'btn_delete_zone': 'Löschen',
+ 'zone_deleted': 'Zone gelöscht.',
+ 'zone_shape_in_diagram': 'Form im Holzdiagramm bearbeiten',
'lbl_scan': 'Bus scannen',
'hint_scan': 'Sucht, welche Sensoren (Slave-Adresse 1-30) an einem gespeicherten Bus antworten. Das Auslesen dieses Busses pausiert während des Scans (höchstens etwa 10 Sekunden).',
'btn_scan': 'Scannen',
@@ -959,8 +977,8 @@
'alert_zone_recovered': '✅ {device}: {temperature} °C / {humidity} % ist wieder in Ordnung für Zone {zone} ({time})',
'zone': 'Zone',
'err_zone_points': 'Eine gezeichnete Zone braucht mindestens 3 Punkte',
- 'table_zones': 'Zonen (Mollier- und Holzdiagramm)',
- 'hint_zones': 'Eine Zone ist ein Bereich aus Temperatur × Luftfeuchte mit eigener Farbe. Wunschzone: Meldung, wenn ein Sensor sie verlässt. Gefahrenzone: Meldung, wenn ein Sensor sie betritt. Freie Formen zeichnen Sie im Mollier-Diagramm.',
+ 'table_zones': 'Zonen (Holzdiagramm)',
+ 'hint_zones': 'Eine Zone ist ein Bereich aus Temperatur × Luftfeuchte mit eigener Farbe. Wunschzone: Meldung, wenn ein Sensor sie verlässt. Gefahrenzone: Meldung, wenn ein Sensor sie betritt. Die Form erstellen und ändern Sie im Holzdiagramm.',
'btn_add_zone': '+ Zone hinzufügen',
'col_color': 'Farbe',
'col_mode': 'Meldung',
@@ -1294,6 +1312,12 @@
'alert_low': '⚠️ {device} : {metric} {value} {unit} est sous le minimum de {limit} {unit} ({time})',
'alert_warn_high': "🟡 {device} : {metric} {value} {unit} dépasse le seuil d'avertissement de {limit} {unit} ({time})",
'alert_warn_low': "🟡 {device} : {metric} {value} {unit} est sous le seuil d'avertissement de {limit} {unit} ({time})",
+ 'btn_new_zone': '+ Zone',
+ 'lbl_edit_zone': 'Modifier une zone…',
+ 'hint_edit_zone': 'Faites glisser les coins à la position voulue. Faites glisser un point au milieu d’un côté pour ajouter un coin ; double-cliquez sur un coin pour le supprimer.',
+ 'btn_delete_zone': 'Supprimer',
+ 'zone_deleted': 'Zone supprimée.',
+ 'zone_shape_in_diagram': 'Modifier la forme dans le diagramme du bois',
'lbl_scan': 'Scanner le bus',
'hint_scan': 'Cherche quels capteurs (adresse esclave 1-30) répondent sur un bus enregistré. La lecture de ce bus est suspendue pendant le scan (environ 10 secondes au maximum).',
'btn_scan': 'Scanner',
@@ -1318,8 +1342,8 @@
'alert_zone_recovered': '✅ {device} : {temperature} °C / {humidity} % est de nouveau correct pour la zone {zone} ({time})',
'zone': 'Zone',
'err_zone_points': 'Une zone dessinée doit avoir au moins 3 points',
- 'table_zones': 'Zones (diagramme de Mollier et du bois)',
- 'hint_zones': 'Une zone est une plage de température × humidité avec sa propre couleur. Zone souhaitée : notification quand un capteur la quitte. Zone de danger : notification quand un capteur y entre. Dessinez des formes libres dans le diagramme de Mollier.',
+ 'table_zones': 'Zones (diagramme du bois)',
+ 'hint_zones': 'Une zone est une plage de température × humidité avec sa propre couleur. Zone souhaitée : notification quand un capteur la quitte. Zone de danger : notification quand un capteur y entre. La forme se crée et se modifie dans le diagramme du bois.',
'btn_add_zone': '+ Ajouter une zone',
'col_color': 'Couleur',
'col_mode': 'Notification',
@@ -1653,6 +1677,12 @@
'alert_low': '⚠️ {device}: {metric} {value} {unit} está por debajo del mínimo de {limit} {unit} ({time})',
'alert_warn_high': '🟡 {device}: {metric} {value} {unit} supera el límite de aviso de {limit} {unit} ({time})',
'alert_warn_low': '🟡 {device}: {metric} {value} {unit} está por debajo del límite de aviso de {limit} {unit} ({time})',
+ 'btn_new_zone': '+ Zona',
+ 'lbl_edit_zone': 'Editar zona…',
+ 'hint_edit_zone': 'Arrastre las esquinas a su sitio. Arrastre un punto en el centro de un lado para añadir una esquina; haga doble clic en una esquina para quitarla.',
+ 'btn_delete_zone': 'Eliminar',
+ 'zone_deleted': 'Zona eliminada.',
+ 'zone_shape_in_diagram': 'Editar la forma en el diagrama de la madera',
'lbl_scan': 'Escanear bus',
'hint_scan': 'Busca qué sensores (dirección esclavo 1-30) responden en un bus guardado. La lectura de ese bus se detiene mientras dura el escaneo (unos 10 segundos como máximo).',
'btn_scan': 'Escanear',
@@ -1677,8 +1707,8 @@
'alert_zone_recovered': '✅ {device}: {temperature} °C / {humidity} % vuelve a estar bien para la zona {zone} ({time})',
'zone': 'Zona',
'err_zone_points': 'Una zona dibujada necesita al menos 3 puntos',
- 'table_zones': 'Zonas (diagrama de Mollier y de la madera)',
- 'hint_zones': 'Una zona es un área de temperatura × humedad con su propio color. Zona deseada: aviso cuando un sensor sale de ella. Zona de peligro: aviso cuando un sensor entra en ella. Las formas libres se dibujan en el diagrama de Mollier.',
+ 'table_zones': 'Zonas (diagrama de la madera)',
+ 'hint_zones': 'Una zona es un área de temperatura × humedad con su propio color. Zona deseada: aviso cuando un sensor sale de ella. Zona de peligro: aviso cuando un sensor entra en ella. La forma se crea y se modifica en el diagrama de la madera.',
'btn_add_zone': '+ Añadir zona',
'col_color': 'Color',
'col_mode': 'Aviso',
diff --git a/wood.py b/wood.py
index 9587c0b..89755ba 100644
--- a/wood.py
+++ b/wood.py
@@ -111,7 +111,9 @@ def figure(points, limits, t, k, areas=()):
corners = zone['points'] + zone['points'][:1]
fig.add_trace(go.Scatter(x=[p[0] for p in corners], y=[p[1] for p in corners], mode='lines', fill='toself',
name=zone['name'], fillcolor=zones.fill_color(zone['color']),
- line=dict(color=zones.COLORS[zone['color']], width=1.5), hoverinfo='skip'))
+ line=dict(color=zones.COLORS[zone['color']], width=1.5),
+ # klikbaar op het vlak: opent de zone in de editor (meta = zone-id)
+ hoveron='fills', hoverinfo='name', meta=zone.get('id')))
for level in ('warning', 'alarm'): # alarm als laatste: ligt over de waarschuwing
color = k['warn'] if level == 'warning' else k['alarm']
for low, high, zone_level in limits: