5 Commits

Author SHA1 Message Date
znetsixe
533f74fe7e chore(dashboardAPI): bigger rim/floor labels with guaranteed clearance
Rim ('rim (X m)') and floor ('floor (0.00 m)') captions now use the same
size 14 font as the threshold labels (was size 10 — visibly smaller and
hard to read on a high-resolution dashboard). To make room for the larger
text while ensuring rim/floor can NEVER overlap the topmost (overflow) or
bottommost (outflow) threshold lines at any basin geometry, the tank's
vertical margins are bumped from 40 px to 48 px each:

  TANK_TOP: 40 -> 48,  TANK_BOT: 720 -> 712,  TANK_H: 680 -> 664

Rim placement: top:1, bottom:95 (4 % tall band at the top, spanning 7.6 -> 38 px).
Floor placement: top:95, bottom:1 (4 % tall band at the bottom).
The topmost threshold line (overflow at max basinHeight) sits at TANK_TOP=48 px,
leaving 10 px clearance above the line. Same for the bottommost (outflow) line.

Color: #8a8a8a -> #6a6a6a (slightly darker so it reads at the bigger size).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:59:58 +02:00
znetsixe
a16f526964 chore(dashboardAPI): enforce min visual gap between threshold lines
User-visible problem: with the basin config dryRunThresholdPercent=2 (so
dryRunLevel ≈ outflowLevel) and highVolumeSafetyThresholdPercent=98 (so
highSafetyLevel ≈ overflowLevel), two pairs of threshold lines sat right
on top of each other in the tank visual, leaving no room between them for
their labels. The 'BELOW' fallback in the label algorithm couldn't fit
either, so labels ended up crossing lines.

Fix: enforce a minimum 28 px visual gap between adjacent threshold lines
inside the tank (≈3.7 % of the 760-tall reference frame, > LABEL_H + 2).
Lines closer than that get spread apart while preserving order. If the
stack would push the lowest line past the tank floor, the whole stack
shifts up to fit. Slight geometric distortion is accepted — the tank
visual conveys ordering and zone structure, not exact-scale level
measurement; numeric values are still rendered next to each line.

Result: at any basin geometry, labels sit cleanly above their line with
no overlap, no label-on-line collision, and no fallback to a 'stacked'
position that crosses its own line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:52:19 +02:00
znetsixe
8afc6b9779 chore(dashboardAPI): center tank via canvas scale constraint + bigger labels
Per Grafana Canvas docs, the correct way to make elements stay centered
and stretch with panel size is to set 'constraint: { horizontal: scale,
vertical: scale }' on every element AND use margin-style placement
(top + bottom + left + right, all as percentages of the panel) instead
of pixel-based 'top + left + width + height'.

This commit:
- Adds 'constraint: scale/scale' to every canvas element.
- Converts all placements to percentage margins. Hardcoded canvas
  geometry (tank, zones, threshold lines, header, footer) uses literal
  percentages; per-basin geometry (yp_*, ty_*, etc.) is precomputed in
  _templateVarsForNode and emitted as percent values from the substitution.
- Adds derived 'zb_*', 'yb_*', 'tyb_*' substitution vars for bottom
  margins of zones, lines, and labels respectively.
- Splits name/value labels left/right of tank centre with a visible gap
  between them (was touching) and bumps font size 11 -> 14 for readability.

Result: at any panel/viewport size the tank fills the card with equal
left/right margins (~2.5%) and equal top/bottom margins (~5.26%) for
rim/floor captions, no letterboxing or right-side padding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:22:11 +02:00
znetsixe
193f913eb1 chore(dashboardAPI): center tank vertically + handle floor-edge labels
Tank rectangle moved from top-aligned (top=20 in 760 frame) to vertically
centered (top=40, with 40 px top + 40 px bottom margins for the rim and
floor caption text). Header rim caption shifted to y=20, footer floor to
y=724, so both sit just outside the tank rect.

Label algorithm extended: when a label would normally go BELOW its line
but doing so would push it past the tank floor (which happens for very
small dryRunThresholdPercent — dryRunLevel sits right on outflowLevel,
both nearly at the basin floor), it falls back to stacking ABOVE the
previous label instead of extending into invisible space. This keeps
all 5 threshold labels inside the visible canvas area at the cost of a
slight visual overlap of the lowest label with its own line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:38:58 +02:00
znetsixe
41a20d4679 chore(dashboardAPI): center basin labels, position above/below lines
Threshold labels were sitting right on top of their lines (label center
at line_y - 8) and were right-aligned at the tank's right edge. They now:

- Sit clearly above the line (label bottom 6 px above) by default, or
  below the line (label top 6 px below) when an adjacent threshold is
  closer than 24 px (would crowd both labels above their lines). For
  the current basin config this puts overflowLevel + inflowLevel +
  dryRunLevel ABOVE their lines, and highSafety + outflowLevel BELOW.
- Are centered horizontally in the tank (name at left:115 width:95
  right-aligned, value at left:215 width:80 left-aligned) so the
  combined phrase "overflowLevel  3.22 m" reads as one centered string.

Value width 60 → 80 so 'mm'-formatted small-meter values don't wrap to
two lines. Footer floor moved to y:728 to keep clear of the BELOW labels
near the tank floor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:32:45 +02:00
2 changed files with 179 additions and 74 deletions

View File

@@ -227,14 +227,15 @@
"root": {
"name": "Basin",
"type": "frame",
"placement": { "left": 0, "top": 0, "width": 400, "height": 760 },
"placement": { "left": 0, "top": 0, "right": 0, "bottom": 0 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "dark-green" } },
"elements": [
{
"name": "Zone Spill",
"type": "rectangle",
"placement": { "top": 20, "left": 10, "width": 380, "height": {{h_spill}} },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": 6.32, "left": 2.5, "right": 2.5, "bottom": {{zb_spill}} },
"background": { "color": { "fixed": "rgba(229, 67, 67, 0.18)" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "" } }
@@ -242,7 +243,8 @@
{
"name": "Zone HighSafety",
"type": "rectangle",
"placement": { "top": {{y_overflow}}, "left": 10, "width": 380, "height": {{h_highSafety}} },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_overflow}}, "left": 2.5, "right": 2.5, "bottom": {{zb_highSafety}} },
"background": { "color": { "fixed": "rgba(242, 165, 67, 0.16)" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "" } }
@@ -250,7 +252,8 @@
{
"name": "Zone Operating",
"type": "rectangle",
"placement": { "top": {{y_highSafety}}, "left": 10, "width": 380, "height": {{h_operating}} },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_highSafety}}, "left": 2.5, "right": 2.5, "bottom": {{zb_operating}} },
"background": { "color": { "fixed": "rgba(95, 179, 122, 0.14)" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "" } }
@@ -258,7 +261,8 @@
{
"name": "Zone Dead",
"type": "rectangle",
"placement": { "top": {{y_outflow}}, "left": 10, "width": 380, "height": {{h_dead}} },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_outflow}}, "left": 2.5, "right": 2.5, "bottom": {{zb_dead}} },
"background": { "color": { "fixed": "rgba(128, 128, 128, 0.20)" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "" } }
@@ -266,7 +270,8 @@
{
"name": "Tank Outline",
"type": "rectangle",
"placement": { "top": 20, "left": 10, "width": 380, "height": 680 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": 6.32, "left": 2.5, "right": 2.5, "bottom": 6.32 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "#8a8a8a" }, "width": 2 },
"config": { "text": { "mode": "fixed", "fixed": "" } }
@@ -274,133 +279,150 @@
{
"name": "Line Overflow",
"type": "rectangle",
"placement": { "top": {{y_overflow}}, "left": 10, "width": 380, "height": 1 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_overflow}}, "left": 2.5, "right": 2.5, "bottom": {{yb_overflow}} },
"background": { "color": { "fixed": "#e54343" } },
"border": { "color": { "fixed": "#e54343" }, "width": 0 }
},
{
"name": "Line HighSafety",
"type": "rectangle",
"placement": { "top": {{y_highSafety}}, "left": 10, "width": 380, "height": 1 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_highSafety}}, "left": 2.5, "right": 2.5, "bottom": {{yb_highSafety}} },
"background": { "color": { "fixed": "#f2a543" } },
"border": { "color": { "fixed": "#f2a543" }, "width": 0 }
},
{
"name": "Line Inflow",
"type": "rectangle",
"placement": { "top": {{y_inflow}}, "left": 10, "width": 380, "height": 1 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_inflow}}, "left": 2.5, "right": 2.5, "bottom": {{yb_inflow}} },
"background": { "color": { "fixed": "#5fb37a" } },
"border": { "color": { "fixed": "#5fb37a" }, "width": 0 }
},
{
"name": "Line DryRun",
"type": "rectangle",
"placement": { "top": {{y_dryRun}}, "left": 10, "width": 380, "height": 1 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_dryRun}}, "left": 2.5, "right": 2.5, "bottom": {{yb_dryRun}} },
"background": { "color": { "fixed": "#5b9bd5" } },
"border": { "color": { "fixed": "#5b9bd5" }, "width": 0 }
},
{
"name": "Line Outflow",
"type": "rectangle",
"placement": { "top": {{y_outflow}}, "left": 10, "width": 380, "height": 1 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{y_outflow}}, "left": 2.5, "right": 2.5, "bottom": {{yb_outflow}} },
"background": { "color": { "fixed": "#bfbfbf" } },
"border": { "color": { "fixed": "#bfbfbf" }, "width": 0 }
},
{
"name": "Label Overflow Name",
"type": "text",
"placement": { "top": {{ty_overflow}}, "left": 180, "width": 140, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_overflow}}, "left": 15, "right": 53, "bottom": {{tyb_overflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "overflowLevel" }, "color": { "fixed": "#c92020" }, "size": 11, "align": "right", "valign": "middle" }
"config": { "text": { "mode": "fixed", "fixed": "overflowLevel" }, "color": { "fixed": "#c92020" }, "size": 14, "align": "right", "valign": "middle" }
},
{
"name": "Label HighSafety Name",
"type": "text",
"placement": { "top": {{ty_highSafety}}, "left": 180, "width": 140, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_highSafety}}, "left": 15, "right": 53, "bottom": {{tyb_highSafety}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "highSafety" }, "color": { "fixed": "#cf7e20" }, "size": 11, "align": "right", "valign": "middle" }
"config": { "text": { "mode": "fixed", "fixed": "highSafety" }, "color": { "fixed": "#cf7e20" }, "size": 14, "align": "right", "valign": "middle" }
},
{
"name": "Label Inflow Name",
"type": "text",
"placement": { "top": {{ty_inflow}}, "left": 180, "width": 140, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_inflow}}, "left": 15, "right": 53, "bottom": {{tyb_inflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "inflowLevel" }, "color": { "fixed": "#3d8a5a" }, "size": 11, "align": "right", "valign": "middle" }
"config": { "text": { "mode": "fixed", "fixed": "inflowLevel" }, "color": { "fixed": "#3d8a5a" }, "size": 14, "align": "right", "valign": "middle" }
},
{
"name": "Label DryRun Name",
"type": "text",
"placement": { "top": {{ty_dryRun}}, "left": 180, "width": 140, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_dryRun}}, "left": 15, "right": 53, "bottom": {{tyb_dryRun}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "dryRunLevel" }, "color": { "fixed": "#3a76a8" }, "size": 11, "align": "right", "valign": "middle" }
"config": { "text": { "mode": "fixed", "fixed": "dryRunLevel" }, "color": { "fixed": "#3a76a8" }, "size": 14, "align": "right", "valign": "middle" }
},
{
"name": "Label Outflow Name",
"type": "text",
"placement": { "top": {{ty_outflow}}, "left": 180, "width": 140, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_outflow}}, "left": 15, "right": 53, "bottom": {{tyb_outflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "outflowLevel" }, "color": { "fixed": "#6a6a6a" }, "size": 11, "align": "right", "valign": "middle" }
"config": { "text": { "mode": "fixed", "fixed": "outflowLevel" }, "color": { "fixed": "#6a6a6a" }, "size": 14, "align": "right", "valign": "middle" }
},
{
"name": "Value Overflow",
"type": "metric-value",
"placement": { "top": {{ty_overflow}}, "left": 323, "width": 65, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_overflow}}, "left": 53, "right": 12, "bottom": {{tyb_overflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "overflowLevel" }, "color": { "fixed": "#c92020" }, "size": 11, "align": "left", "valign": "middle" }
"config": { "text": { "mode": "field", "fixed": "", "field": "overflowLevel" }, "color": { "fixed": "#c92020" }, "size": 14, "align": "left", "valign": "middle" }
},
{
"name": "Value HighSafety",
"type": "metric-value",
"placement": { "top": {{ty_highSafety}}, "left": 323, "width": 65, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_highSafety}}, "left": 53, "right": 12, "bottom": {{tyb_highSafety}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "highVolumeSafetyLevel" }, "color": { "fixed": "#cf7e20" }, "size": 11, "align": "left", "valign": "middle" }
"config": { "text": { "mode": "field", "fixed": "", "field": "highVolumeSafetyLevel" }, "color": { "fixed": "#cf7e20" }, "size": 14, "align": "left", "valign": "middle" }
},
{
"name": "Value Inflow",
"type": "metric-value",
"placement": { "top": {{ty_inflow}}, "left": 323, "width": 65, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_inflow}}, "left": 53, "right": 12, "bottom": {{tyb_inflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "inflowLevel" }, "color": { "fixed": "#3d8a5a" }, "size": 11, "align": "left", "valign": "middle" }
"config": { "text": { "mode": "field", "fixed": "", "field": "inflowLevel" }, "color": { "fixed": "#3d8a5a" }, "size": 14, "align": "left", "valign": "middle" }
},
{
"name": "Value DryRun",
"type": "metric-value",
"placement": { "top": {{ty_dryRun}}, "left": 323, "width": 65, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_dryRun}}, "left": 53, "right": 12, "bottom": {{tyb_dryRun}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "dryRunLevel" }, "color": { "fixed": "#3a76a8" }, "size": 11, "align": "left", "valign": "middle" }
"config": { "text": { "mode": "field", "fixed": "", "field": "dryRunLevel" }, "color": { "fixed": "#3a76a8" }, "size": 14, "align": "left", "valign": "middle" }
},
{
"name": "Value Outflow",
"type": "metric-value",
"placement": { "top": {{ty_outflow}}, "left": 323, "width": 65, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": {{ty_outflow}}, "left": 53, "right": 12, "bottom": {{tyb_outflow}} },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "field", "fixed": "", "field": "outflowLevel" }, "color": { "fixed": "#6a6a6a" }, "size": 11, "align": "left", "valign": "middle" }
"config": { "text": { "mode": "field", "fixed": "", "field": "outflowLevel" }, "color": { "fixed": "#6a6a6a" }, "size": 14, "align": "left", "valign": "middle" }
},
{
"name": "Header Rim",
"type": "text",
"placement": { "top": 2, "left": 10, "width": 380, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": 1, "left": 2.5, "right": 2.5, "bottom": 95 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "rim ({{heightBasin}} m)" }, "color": { "fixed": "#8a8a8a" }, "size": 10, "align": "center", "valign": "middle" }
"config": { "text": { "mode": "fixed", "fixed": "rim ({{heightBasin}} m)" }, "color": { "fixed": "#6a6a6a" }, "size": 14, "align": "center", "valign": "middle" }
},
{
"name": "Footer Floor",
"type": "text",
"placement": { "top": 702, "left": 10, "width": 380, "height": 16 },
"constraint": { "horizontal": "scale", "vertical": "scale" },
"placement": { "top": 95, "left": 2.5, "right": 2.5, "bottom": 1 },
"background": { "color": { "fixed": "transparent" } },
"border": { "color": { "fixed": "transparent" }, "width": 0 },
"config": { "text": { "mode": "fixed", "fixed": "floor (0.00 m)" }, "color": { "fixed": "#8a8a8a" }, "size": 10, "align": "center", "valign": "middle" }
"config": { "text": { "mode": "fixed", "fixed": "floor (0.00 m)" }, "color": { "fixed": "#6a6a6a" }, "size": 14, "align": "center", "valign": "middle" }
}
]
}

View File

@@ -189,42 +189,107 @@ class DashboardApi {
const dryRunLevel = outflowLevel * (1 + dryRunPct / 100);
const highSafetyLevel = overflowLevel * (highPct / 100);
// Canvas tank: rim at y=20px, floor at y=700px (680px tall). Must match
// hard-coded tank rectangle placement in config/pumpingStation.json
// (basin row is h:20 grid rows; canvas root frame is 400x760 px — taller
// than wide to match the card's aspect ratio so the tank fills the card
// vertically with no letterboxing).
const TANK_TOP = 20, TANK_BOT = 700, TANK_H = TANK_BOT - TANK_TOP;
// Reference frame: 400 (logical w) x 760 (logical h) px. With every
// canvas element using `constraint: { horizontal: scale, vertical: scale }`,
// Grafana interprets placement values as PERCENTAGES of the panel size,
// not pixels — so the basin stretches to fill the card at any viewport
// and stays centered without letterboxing.
// Tank reference: rim at y=48px (6.32%), floor at y=712px (93.68%),
// centred vertically with 48px top/bottom margins. Margins are sized
// so the size-14 'rim (X m)' and 'floor (0.00 m)' captions fit with
// ~10 px clearance from the topmost/bottommost threshold line — labels
// can never collide with a line at any basin geometry.
const FRAME_W = 400, FRAME_H = 760;
const TANK_TOP = 48, TANK_BOT = 712, TANK_H = TANK_BOT - TANK_TOP;
const yp = (v) => +(v / FRAME_H * 100).toFixed(2);
const xp = (v) => +(v / FRAME_W * 100).toFixed(2);
const hp = (v) => +(v / FRAME_H * 100).toFixed(2);
const wp = (v) => +(v / FRAME_W * 100).toFixed(2);
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);
let y_overflow = yFor(overflowLevel);
let y_highSafety = yFor(highSafetyLevel);
let y_inflow = yFor(inflowLevel);
let y_dryRun = yFor(dryRunLevel);
let 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;
// Enforce a minimum visual gap between adjacent threshold lines so labels
// can always sit cleanly between them — independent of how close the
// underlying physical thresholds are. Slight geometric distortion is
// acceptable: the tank visual conveys ORDERING and ZONE STRUCTURE, not
// exact-scale level measurement. Dashed/value labels carry the true
// numeric values.
const MIN_LINE_GAP = 28; // px (≈3.7% of 760-tall frame, > LABEL_H + 2)
const sorted = [
{ id: 'overflow', get: () => y_overflow, set: (v) => (y_overflow = v) },
{ id: 'highSafety', get: () => y_highSafety, set: (v) => (y_highSafety = v) },
{ id: 'inflow', get: () => y_inflow, set: (v) => (y_inflow = v) },
{ id: 'dryRun', get: () => y_dryRun, set: (v) => (y_dryRun = v) },
{ id: 'outflow', get: () => y_outflow, set: (v) => (y_outflow = v) },
].sort((a, b) => a.get() - b.get());
// Push down to enforce min gap (anchor: topmost line)
for (let i = 1; i < sorted.length; i++) {
const minY = sorted[i - 1].get() + MIN_LINE_GAP;
if (sorted[i].get() < minY) sorted[i].set(minY);
}
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;
// If the last (lowest) line went past the floor, shift the whole stack up.
const overshoot = sorted[sorted.length - 1].get() - TANK_BOT;
if (overshoot > 0) {
for (const item of sorted) item.set(item.get() - overshoot);
}
const ty = Object.fromEntries(labels.map((l) => [l.id, +l.y.toFixed(2)]));
// Label y-positions: labels sit either ABOVE or BELOW their threshold
// line, never on it. Each label is offset by ABOVE_OFFSET=22 px above
// its line by default (16 px tall label + 6 px clear above the line).
// If two thresholds are too close together for both labels to fit ABOVE
// their lines (label of the lower one would cross the upper line), the
// lower one's label flips BELOW its line instead. With the current
// basin (dryRun=2% means dryRunLevel sits right on outflowLevel; high-
// Safety=98% puts it just under overflowLevel) this naturally puts
// highSafety BELOW and outflow BELOW.
const LABEL_H = 16;
const ABOVE_OFFSET = 22; // label_top = line_y - 22 (6 px clear above line)
const BELOW_OFFSET = 6; // label_top = line_y + 6 (6 px clear below line)
const MIN_DIST_FOR_ABOVE = 24; // if distance to upper line < this, try below
const lines = [
{ id: 'overflow', line: y_overflow },
{ id: 'highSafety', line: y_highSafety },
{ id: 'inflow', line: y_inflow },
{ id: 'dryRun', line: y_dryRun },
{ id: 'outflow', line: y_outflow },
].sort((a, b) => a.line - b.line);
for (let i = 0; i < lines.length; i++) {
const prev = i > 0 ? lines[i - 1] : null;
const tooClose = prev && (lines[i].line - prev.line) < MIN_DIST_FOR_ABOVE;
if (tooClose) {
// Default to BELOW unless the label would be clipped by the tank
// floor (thresholds at the very bottom — dryRun=tiny% means
// dryRunLevel sits right on the floor). Then stack ABOVE the
// previous label instead, even if it slightly crowds its own line.
const belowY = lines[i].line + BELOW_OFFSET;
if (belowY + LABEL_H <= TANK_BOT) {
lines[i].y = belowY;
} else {
lines[i].y = prev.y + LABEL_H + 2; // stack above with 2 px gap
}
} else {
lines[i].y = lines[i].line - ABOVE_OFFSET;
}
}
const ty = Object.fromEntries(lines.map((l) => [l.id, +l.y.toFixed(2)]));
// Canvas elements use `constraint: { horizontal: scale, vertical: scale }`
// with margin-style placement (top + bottom + left + right, all %s of the
// panel). Bottom = % from panel bottom, top = % from panel top. Width and
// height are derived as 100 - top - bottom, etc.
// We emit *all* placement margins precomputed so the JSON template stays
// declarative.
const LABEL_H_PCT = hp(16); // 16 px label height as % of frame
const LINE_H_PCT = hp(1); // 1 px line height as % of frame
const bMargin = (top, h) => +(100 - top - h).toFixed(2);
const lineBottom = (lineY) => +(100 - yp(lineY) - LINE_H_PCT).toFixed(2);
const labelBottom = (lblY) => +(100 - yp(lblY) - LABEL_H_PCT).toFixed(2);
return {
heightBasin: +heightBasin.toFixed(2),
outflowLevel: +outflowLevel.toFixed(3),
@@ -232,16 +297,34 @@ class DashboardApi {
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,
// Threshold line top margins (% from panel top)
y_overflow: yp(y_overflow),
y_highSafety: yp(y_highSafety),
y_inflow: yp(y_inflow),
y_dryRun: yp(y_dryRun),
y_outflow: yp(y_outflow),
// Threshold line bottom margins (% from panel bottom)
yb_overflow: lineBottom(y_overflow),
yb_highSafety: lineBottom(y_highSafety),
yb_inflow: lineBottom(y_inflow),
yb_dryRun: lineBottom(y_dryRun),
yb_outflow: lineBottom(y_outflow),
// Zone bottom margins (zones end at the next line below)
zb_spill: +(100 - yp(y_overflow)).toFixed(2), // ends at overflow line
zb_highSafety: +(100 - yp(y_highSafety)).toFixed(2), // ends at highSafety line
zb_operating: +(100 - yp(y_outflow)).toFixed(2), // ends at outflow line
zb_dead: +(100 - yp(TANK_BOT)).toFixed(2), // ends at floor
// Label top margins (% from panel top) and bottom margins (% from panel bottom)
ty_overflow: yp(ty.overflow),
ty_highSafety: yp(ty.highSafety),
ty_inflow: yp(ty.inflow),
ty_dryRun: yp(ty.dryRun),
ty_outflow: yp(ty.outflow),
tyb_overflow: labelBottom(ty.overflow),
tyb_highSafety: labelBottom(ty.highSafety),
tyb_inflow: labelBottom(ty.inflow),
tyb_dryRun: labelBottom(ty.dryRun),
tyb_outflow: labelBottom(ty.outflow),
};
}