feat(dashboardAPI): basin canvas + bar gauge for pumpingStation
Replaces the configuration row's Heights + Volume Limits stat panels and
the radial Fill % gauge with an integrated basin visual that conveys tank
geometry and live water level at a glance.
Configuration row → Basin row:
- Vertical bar gauge bound to level (m) with min=0/max=basinHeight and
thresholds at outflow/dryRun/inflow/highSafety/overflow safety levels.
- Canvas panel with tank outline, zone tints (dead/operating/highSafety/
spill), threshold lines + named labels, and live numeric readouts for
each threshold value plus current level/volume/fill at the bottom.
- Level + Volume timeseries moved next to the basin visual so the row
reads as basin → trends left-to-right.
Other layout polish:
- Status row Fill % gauge removed; remaining 4 stats widen to w:6 each.
- Old "Basin" row header dropped (its panels migrated into the new row).
- Configuration row renamed to "Basin".
Mechanics:
- dashboardAPI substitutes mustache {{var}} placeholders in templates at
JSON.parse time. Per-softwareType var sets live in _templateVarsForNode;
pumpingStation gets basin geometry + derived safety levels + canvas
pixel y-positions + min-gap-enforced label positions.
- Mustache braces stay distinct from Grafana's ${var} dashboard variables.
- Canvas Flux query pivots heights + predicted level/volume/percent into
one row with normalized field names so metric-value elements can bind.
No node-side telemetry change: dryRunLevel + highVolumeSafetyLevel already
reach Influx via getOutput() (specificClass.js:248,250) and outputUtils
iterates every key with no filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dashboardAPI",
|
"name": "dashboardAPI",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "EVOLV Grafana dashboard generator (Node-RED node).",
|
"description": "EVOLV Grafana dashboard generator (Node-RED node).",
|
||||||
"main": "dashboardAPI.js",
|
"main": "dashboardAPI.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ function defaultBucketForPosition(positionVsParent) {
|
|||||||
return 'lvl2';
|
return 'lvl2';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replace `{{name}}` placeholders in a raw JSON template string with values
|
||||||
|
// from `vars`. Unknown placeholders are left intact. Used to inject node-config
|
||||||
|
// derived constants (basin geometry, threshold y-positions) into a template
|
||||||
|
// before JSON.parse — so the resulting dashboard has concrete numbers in
|
||||||
|
// fieldConfig.thresholds and canvas element placements. Mustache-style braces
|
||||||
|
// keep these placeholders distinct from Grafana's own `${var}` dashboard
|
||||||
|
// variables (which are interpreted by Grafana at render time).
|
||||||
|
function substituteTemplateVars(rawJson, vars) {
|
||||||
|
if (!vars || !Object.keys(vars).length) return rawJson;
|
||||||
|
return rawJson.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (m, key) => (
|
||||||
|
Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : m
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
function updateTemplatingVar(dashboard, varName, value) {
|
function updateTemplatingVar(dashboard, varName, value) {
|
||||||
const list = dashboard?.templating?.list;
|
const list = dashboard?.templating?.list;
|
||||||
if (!Array.isArray(list)) return;
|
if (!Array.isArray(list)) return;
|
||||||
@@ -140,13 +154,94 @@ class DashboardApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadTemplate(softwareType) {
|
loadTemplate(softwareType, templateVars = null) {
|
||||||
const templatePath = this._templateFileForSoftwareType(softwareType);
|
const templatePath = this._templateFileForSoftwareType(softwareType);
|
||||||
if (!templatePath) return null;
|
if (!templatePath) return null;
|
||||||
const raw = fs.readFileSync(templatePath, 'utf8');
|
let raw = fs.readFileSync(templatePath, 'utf8');
|
||||||
|
// Always substitute — falls back to per-softwareType defaults so the
|
||||||
|
// template is JSON-parseable even when no nodeConfig is provided (tests,
|
||||||
|
// smoke-loading, etc.). _templateVarsForNode returns {} for types that
|
||||||
|
// don't use placeholders, which is a no-op pass.
|
||||||
|
const vars = templateVars || this._templateVarsForNode(softwareType, null);
|
||||||
|
raw = substituteTemplateVars(raw, vars);
|
||||||
return JSON.parse(raw);
|
return JSON.parse(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-softwareType numeric vars baked into the template before JSON.parse.
|
||||||
|
// Today only pumpingStation needs this (basin geometry → bar-gauge thresholds
|
||||||
|
// and canvas y-positions). Other types return {} and skip substitution.
|
||||||
|
_templateVarsForNode(softwareType, nodeConfig) {
|
||||||
|
const st = String(softwareType || '').toLowerCase();
|
||||||
|
if (st !== 'pumpingstation') return {};
|
||||||
|
|
||||||
|
// configManager.buildConfig nests basin geometry under `basin.*` and
|
||||||
|
// safety percentages under `safety.*` (see generalFunctions/configManager).
|
||||||
|
const basin = nodeConfig?.basin || {};
|
||||||
|
const safety = nodeConfig?.safety || {};
|
||||||
|
const heightBasin = Number(basin.height) || 4;
|
||||||
|
const inflowLevel = Number(basin.inflowLevel) || 0;
|
||||||
|
const outflowLevel = Number(basin.outflowLevel) || 0;
|
||||||
|
const overflowLevel = Number(basin.overflowLevel) || heightBasin;
|
||||||
|
const dryRunPct = Number(safety.dryRunThresholdPercent) || 30;
|
||||||
|
const highPct = Number(safety.highVolumeSafetyThresholdPercent) || 90;
|
||||||
|
|
||||||
|
// Mirror specificClass._computeSafetyPoints derivation (pumpingStation).
|
||||||
|
const dryRunLevel = outflowLevel * (1 + dryRunPct / 100);
|
||||||
|
const highSafetyLevel = overflowLevel * (highPct / 100);
|
||||||
|
|
||||||
|
// Canvas tank: rim at y=20px, floor at y=260px (240px tall). Must match
|
||||||
|
// hard-coded tank rectangle placement in config/pumpingStation.json.
|
||||||
|
const TANK_TOP = 20, TANK_BOT = 260, TANK_H = TANK_BOT - TANK_TOP;
|
||||||
|
const yFor = (v) => +(TANK_BOT - (v / heightBasin) * TANK_H).toFixed(2);
|
||||||
|
const tyFor = (yLine) => +(yLine - 8).toFixed(2); // centre 16px text on the line
|
||||||
|
|
||||||
|
const y_overflow = yFor(overflowLevel);
|
||||||
|
const y_highSafety = yFor(highSafetyLevel);
|
||||||
|
const y_inflow = yFor(inflowLevel);
|
||||||
|
const y_dryRun = yFor(dryRunLevel);
|
||||||
|
const y_outflow = yFor(outflowLevel);
|
||||||
|
|
||||||
|
// Label y-positions get min-gap enforcement so labels never overlap even
|
||||||
|
// when thresholds sit nearly on top of each other (e.g. dryRun=2 % means
|
||||||
|
// dryRunLevel sits right on outflowLevel; highSafety=98 % puts it under
|
||||||
|
// overflow). Lines stay at proportional y; only the label text moves.
|
||||||
|
// Two-pass (down + up) mirrors editor's basin-diagram.js placement logic.
|
||||||
|
const GAP = 20;
|
||||||
|
const labels = [
|
||||||
|
{ id: 'overflow', y: tyFor(y_overflow) },
|
||||||
|
{ id: 'highSafety', y: tyFor(y_highSafety) },
|
||||||
|
{ id: 'inflow', y: tyFor(y_inflow) },
|
||||||
|
{ id: 'dryRun', y: tyFor(y_dryRun) },
|
||||||
|
{ id: 'outflow', y: tyFor(y_outflow) },
|
||||||
|
].sort((a, b) => a.y - b.y);
|
||||||
|
for (let i = 1; i < labels.length; i++) {
|
||||||
|
if (labels[i].y < labels[i - 1].y + GAP) labels[i].y = labels[i - 1].y + GAP;
|
||||||
|
}
|
||||||
|
for (let i = labels.length - 2; i >= 0; i--) {
|
||||||
|
if (labels[i].y > labels[i + 1].y - GAP) labels[i].y = labels[i + 1].y - GAP;
|
||||||
|
}
|
||||||
|
const ty = Object.fromEntries(labels.map((l) => [l.id, +l.y.toFixed(2)]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
heightBasin: +heightBasin.toFixed(2),
|
||||||
|
outflowLevel: +outflowLevel.toFixed(3),
|
||||||
|
inflowLevel: +inflowLevel.toFixed(3),
|
||||||
|
overflowLevel: +overflowLevel.toFixed(3),
|
||||||
|
dryRunLevel: +dryRunLevel.toFixed(3),
|
||||||
|
highSafetyLevel: +highSafetyLevel.toFixed(3),
|
||||||
|
y_overflow, y_highSafety, y_inflow, y_dryRun, y_outflow,
|
||||||
|
h_spill: +(y_overflow - TANK_TOP).toFixed(2),
|
||||||
|
h_highSafety: +(y_highSafety - y_overflow).toFixed(2),
|
||||||
|
h_operating: +(y_outflow - y_highSafety).toFixed(2),
|
||||||
|
h_dead: +(TANK_BOT - y_outflow).toFixed(2),
|
||||||
|
ty_overflow: ty.overflow,
|
||||||
|
ty_highSafety: ty.highSafety,
|
||||||
|
ty_inflow: ty.inflow,
|
||||||
|
ty_dryRun: ty.dryRun,
|
||||||
|
ty_outflow: ty.outflow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Collect every `meta.emittedFields` declared by panels in a template.
|
// Collect every `meta.emittedFields` declared by panels in a template.
|
||||||
// Used by #39's parent panel filter — a parent panel whose emittedFields
|
// Used by #39's parent panel filter — a parent panel whose emittedFields
|
||||||
// are fully covered by its children's panels is removed.
|
// are fully covered by its children's panels is removed.
|
||||||
@@ -248,7 +343,8 @@ class DashboardApi {
|
|||||||
const title = nodeConfig?.general?.name || String(nodeId);
|
const title = nodeConfig?.general?.name || String(nodeId);
|
||||||
|
|
||||||
// Missing templates are treated as non-fatal: we skip only that dashboard.
|
// Missing templates are treated as non-fatal: we skip only that dashboard.
|
||||||
const dashboard = this.loadTemplate(softwareType);
|
const templateVars = this._templateVarsForNode(softwareType, nodeConfig);
|
||||||
|
const dashboard = this.loadTemplate(softwareType, templateVars);
|
||||||
if (!dashboard) {
|
if (!dashboard) {
|
||||||
this.logger.warn(`Skipping dashboard generation: no template for softwareType=${softwareType}`);
|
this.logger.warn(`Skipping dashboard generation: no template for softwareType=${softwareType}`);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user