From 64944aa9d8610e9099426bb8269875945acb0a38 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Mon, 13 Apr 2026 17:31:57 +0200 Subject: [PATCH] docs(rules): add S88-hierarchical placement rules for Node-RED flows Sections 10-16 extend the existing flow-layout rule with a deterministic lane-and-group convention anchored in the S88 hierarchy: - 8 logical lanes: L0 inputs -> L1 adapters -> L2 CM -> L3 EM -> L4 UN -> L5 PC -> L6 formatters -> L7 outputs. 240 px between lanes. - Lane assignment is by S88 level, not by node name. New nodes inherit a lane via a NODE_LEVEL registry, no rule change needed. - Every parent + its direct children is wrapped in a Node-RED group box coloured by the parent's S88 level (Pump A = EM blue, MGC = Unit blue, PS = Process Cell blue, ...). Search the parent's name -> group highlights. - Utility clusters (mode broadcast, station-wide commands, demand fan-out) use neutral-grey group boxes. - Dashboard / setup / demo-driver tabs each get a variant of the rule. - Spacing constants, place() and wrap_in_group() helpers, an 8-step verification checklist. Off-spec colours (settler orange, monster teal, diffuser and dashboardAPI missing) are flagged in Section 16 as a follow-up cleanup. The NODE_LEVEL registry already maps those nodes to their semantic S88 level regardless of what the node's own colour currently says. Rule lives in the superproject only; per-node repos will reference it from their own CLAUDE.md files (separate commits per submodule). Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/rules/node-red-flow-layout.md | 237 ++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) diff --git a/.claude/rules/node-red-flow-layout.md b/.claude/rules/node-red-flow-layout.md index 69e1e61..114c8b1 100644 --- a/.claude/rules/node-red-flow-layout.md +++ b/.claude/rules/node-red-flow-layout.md @@ -204,3 +204,240 @@ Before declaring a flow done: 3. **Hit the dashboard URL — every widget has data.** `n/a` everywhere is a contract failure. 4. **For charts, watch a series populate over 30 s.** A blank chart after 30 s = bug. 5. **Disable each tab one at a time and re-deploy.** Process Plant alone should still load (just inert). Dashboard UI alone should serve a page (just empty). If disabling a tab errors out, the tab boundaries are wrong. + +## 10. Hierarchical placement — by S88 level, not by node name + +The lane assignment maps to the **S88 hierarchy**, not to specific node names. Any node that lives at a given S88 level goes in the same lane regardless of what kind of equipment it is. New node types added to the platform inherit a lane by their S88 category — no rule change needed. + +### 10.1 Lane convention (x-axis = S88 level) + +| Lane | x | Purpose | S88 level | Colour | Current EVOLV nodes | +|---:|---:|---|---|---|---| +| **L0** | 120 | Tab inputs | — | (none) | `link in`, `inject` | +| **L1** | 360 | Adapters | — | (none) | `function` (msg-shape wrappers) | +| **L2** | 600 | Control Module | CM | `#a9daee` | `measurement` | +| **L3** | 840 | Equipment Module | EM | `#86bbdd` | `rotatingMachine`, `valve`, `diffuser` | +| **L4** | 1080 | Unit | UN | `#50a8d9` | `machineGroupControl`, `valveGroupControl`, `reactor`, `settler`, `monster` | +| **L5** | 1320 | Process Cell | PC | `#0c99d9` | `pumpingStation` | +| **L6** | 1560 | Output formatters | — | (none) | `function` (build dashboard payload from port 0) | +| **L7** | 1800 | Tab outputs | — | (none) | `link out`, `debug` | + +Spacing: **240 px** between lanes. Tab width ≤ 1920 px (fits standard monitors without horizontal scroll in the editor). + +**Area level** (`#0f52a5`) is reserved for plant-wide coordination and currently unused — when added, allocate a new lane and shift formatter/output one lane right (i.e. expand to 9 lanes if and when needed). + +### 10.2 The group rule (Node-RED `group` boxes anchor each parent + its children) + +Use Node-RED's native `group` node (the visual box around a set of nodes — not to be confused with `ui-group`) to anchor every "parent + direct children" cluster. The box makes ownership unambiguous and lets you collapse the cluster in the editor. + +**Group rules:** + +- **One Node-RED group per parent + its direct children.** + Example: `Pump A + meas-A-up + meas-A-dn` is one group, named `Pump A`. +- **Group colour = parent's S88 colour.** + So a Pump-A group is `#86bbdd` (Equipment Module). A reactor group is `#50a8d9` (Unit). +- **Group `style.label = true`** so the box shows the parent's name. +- **Group must contain all the children's adapters / wrappers / formatters** too if those exclusively belong to the parent. The box is the visual anchor for "this is everything that owns / serves Pump A". +- **Utility groups for cross-cutting logic** (mode broadcast, station-wide commands, demand fan-out) use a neutral colour (`#dddddd`). + +JSON shape: + +```json +{ + "id": "grp_pump_a", + "type": "group", + "z": "tab_process", + "name": "Pump A", + "style": { "label": true, "stroke": "#000000", "fill": "#86bbdd", "fill-opacity": "0.10" }, + "nodes": ["meas_pump_a_u", "meas_pump_a_d", "pump_a", "format_pump_a", "lin_setpoint_pump_a", "build_setpoint_pump_a", "lin_seq_pump_a", "lout_evt_pump_a"], + "x": 80, "y": 100, "w": 1800, "h": 200 +} +``` + +`x/y/w/h` is the bounding box of contained nodes + padding — compute it from the children's positions. + +### 10.3 The hierarchy rule, restated + +> Nodes at the **same S88 level** (siblings sharing one parent) **stack vertically in the same lane**. +> +> Nodes at **different S88 levels** (parent ↔ child) sit **next to each other on different lanes**. + +### 10.4 Worked example — pumping station demo + +``` + L0 L1 L2 L3 L4 L5 L6 L7 + (input) (adapter) (CM) (EM) (Unit) (PC) (formatter) (output) + +┌── group: Pump A (#86bbdd) ─────────────────────────────────────────────────────────────────────────────────────────┐ +│ [lin-set-A] [build-A] │ +│ [lin-seq-A] │ +│ [meas-A-up] │ +│ [meas-A-dn] → [Pump A] → │ +│ [format-A] →[lout-evt-A] +└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + +┌── group: Pump B (#86bbdd) ─────────────────────────────────────────────────────────────────────────────────────────┐ +│ ... same shape ... │ +└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + +┌── group: Pump C (#86bbdd) ─────────────────────────────────────────────────────────────────────────────────────────┐ +│ ... same shape ... │ +└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + +┌── group: MGC — Pump Group (#50a8d9) ──────────────────────────────────────────────────────────────────────────────┐ +│ [lin-demand] [demand→MGC+PS] [MGC] [format-MGC]→[lout-evt-MGC] +└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + +┌── group: Pumping Station (#0c99d9) ───────────────────────────────────────────────────────────────────────────────┐ +│ [PS] [format-PS]→[lout-evt-PS] +└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + +┌── group: Mode broadcast (#dddddd, neutral) ───────────────────────────────────────────────────────────────────────┐ +│ [lin-mode] [fan-mode] ─────────────► to all 3 pumps in the Pump A/B/C groups │ +└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + +┌── group: Station-wide commands (#dddddd) ─────────────────────────────────────────────────────────────────────────┐ +│ [lin-start] [fan-start] ─► to pumps │ +│ [lin-stop] [fan-stop] │ +│ [lin-estop] [fan-estop] │ +└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +What that buys: + +- Search "Pump A" highlights the whole group box (parent + sensors + adapters + formatter). +- S88 colour of the group box tells you the level at a glance. +- Wires are horizontal within a group; cross-group wires (Pump A port 2 → MGC) cross only one band. +- Collapse a group in the editor and it becomes a single tile — clutter disappears during reviews. + +### 10.5 Multi-input fan-in rule + +Stack link-ins tightly at L0, centred on the destination's y. Merge node one lane right at the same y. + +### 10.6 Multi-output fan-out rule + +Source at the y-centre of its destinations; destinations stack vertically in the next lane. Wires fork cleanly without jogging. + +### 10.7 Link-in placement (within a tab) + +- All link-ins on **L0**. +- Order them top-to-bottom by the y of their **first downstream target**. +- Link-ins that feed the same destination share the same y-band as that destination. + +### 10.8 Link-out placement (within a tab) + +- All link-outs on **L7** (the rightmost lane). +- Each link-out's y matches its **upstream source's** y, so the wire is horizontal. + +### 10.9 Cross-tab wire rule + +Cross-tab wires use `link out` / `link in` pairs (see Section 2). Direct cross-tab wires are forbidden. + +### 10.10 The "no jog" verification + +- A wire whose source y == destination y is fine (perfectly horizontal). +- A wire that jogs vertically by ≤ 80 px is fine (one row of slop). +- A wire that jogs by > 80 px means **the destination is in the wrong group y-band**. Move the destination, not the source — the source's position was determined by its own group. + +## 11. Dashboard tab variant + +Dashboard widgets are stamped to the real grid by the FlowFuse renderer; editor x/y is for the editor's readability. + +- Use only **L0, L2, L4, L7**: + - L0 = `link in` (events from process) + - L2 = `ui-*` inputs (sliders, switches, buttons) + - L4 = wrapper / format / trend-split functions + - L7 = `link out` (commands going back) +- **One Node-RED group per `ui-group`.** Editor group's name matches the `ui-group` name. Colour follows the S88 level of the represented equipment (MGC group = `#50a8d9`, Pump A group = `#86bbdd`, …) so the editor view mirrors the dashboard structure. +- Within the group, widgets stack vertically by their visual order in the dashboard. + +## 12. Setup tab variant + +Single-column ladder L0 → L7, ordered top-to-bottom by `onceDelay`. Wrap in a single neutral-grey Node-RED group named `Deploy-time setup`. + +## 13. Demo Drivers tab variant + +Same as Process Plant but typically only L0, L2, L4, L7 are used. Wrap each driver (random gen, scripted scenario, …) in its own neutral Node-RED group. + +## 14. Spacing constants (final) + +```python +LANE_X = [120, 360, 600, 840, 1080, 1320, 1560, 1800] +SIBLING_PITCH = 40 +GROUP_GAP = 200 +TAB_TOP_MARGIN = 80 +GROUP_PADDING = 20 # extra px around child bounding box for the Node-RED group box + +S88_COLORS = { + "AR": "#0f52a5", # Area (currently unused) + "PC": "#0c99d9", # Process Cell + "UN": "#50a8d9", # Unit + "EM": "#86bbdd", # Equipment Module + "CM": "#a9daee", # Control Module + "neutral": "#dddddd", +} + +# Registry: drop a new node type here to place it automatically. +NODE_LEVEL = { + "measurement": "CM", + "rotatingMachine": "EM", + "valve": "EM", + "diffuser": "EM", + "machineGroupControl": "UN", + "valveGroupControl": "UN", + "reactor": "UN", + "settler": "UN", + "monster": "UN", + "pumpingStation": "PC", + "dashboardAPI": "neutral", +} +``` + +Helpers for the build script: + +```python +def place(lane, group_index, position_in_group, group_size): + """Compute (x, y) for a node in a process group.""" + x = LANE_X[lane] + band_centre = TAB_TOP_MARGIN + group_index * (group_size * SIBLING_PITCH + GROUP_GAP) \ + + (group_size - 1) * SIBLING_PITCH / 2 + y = band_centre + (position_in_group - (group_size - 1) / 2) * SIBLING_PITCH + return int(x), int(y) + +def wrap_in_group(child_ids, name, s88_color, nodes_by_id, padding=GROUP_PADDING): + """Compute the Node-RED group box around a set of children.""" + xs = [nodes_by_id[c]["x"] for c in child_ids] + ys = [nodes_by_id[c]["y"] for c in child_ids] + return { + "type": "group", "name": name, + "style": {"label": True, "stroke": "#000000", "fill": s88_color, "fill-opacity": "0.10"}, + "nodes": list(child_ids), + "x": min(xs) - padding, "y": min(ys) - padding, + "w": max(xs) - min(xs) + 160 + 2 * padding, + "h": max(ys) - min(ys) + 40 + 2 * padding, + } +``` + +## 15. Verification checklist (extends Section 9) + +After building a tab: + +1. **No wire jogs > 80 px vertically within a group.** +2. **Each lane contains nodes of one purpose only** (never an `ui-text` on L3; never a `rotatingMachine` on L2). +3. **Peers share a lane; parents and children sit on adjacent lanes.** +4. **Every parent + direct children sit inside one Node-RED group box, coloured by the parent's S88 level.** +5. **Utility groups** (mode broadcast, station commands, demand fan-out) wrapped in neutral-grey Node-RED groups. +6. **Section comments at the top of each group band.** +7. **Editor scrollable in y but NOT in x** on a normal monitor. +8. **Search test:** typing the parent's name in the editor highlights the whole group box. + +## 16. S88 colour cleanup (separate follow-up task) + +These nodes don't currently follow the S88 palette. They should be brought in line in a separate session before the placement rule is fully consistent across the editor: + +- `settler` (`#e4a363` orange) → should be `#50a8d9` (Unit) +- `monster` (`#4f8582` teal) → should be `#50a8d9` (Unit) +- `diffuser` (no colour set) → should be `#86bbdd` (Equipment Module) +- `dashboardAPI` (no colour set) → utility, no S88 colour needed + +Until cleaned up, the placement rule still works — `NODE_LEVEL` (Section 14) already maps these to their semantic S88 level regardless of the node's own colour.