Files
EVOLV/.claude/rules/node-red-flow-layout.md
znetsixe 0d7af6bfff
Some checks failed
CI / lint-and-test (push) Has been cancelled
refactor(examples): split pumpingstation demo across 4 concern-based tabs + add layout rule set
The demo was a single 96-node tab with everything wired directly. Now
4 tabs wired only through named link-out / link-in pairs, and a
permanent rule set for future Claude sessions to follow.

Tabs (by concern, not by data flow):

  🏭 Process Plant   only EVOLV nodes (3 pumps + MGC + PS + 6 measurements)
                     + per-node output formatters
  📊 Dashboard UI   only ui-* widgets, button/setpoint wrappers, trend
                     splitters
  🎛️ Demo Drivers   random demand generator + state holder. Removable
                     in production
  ⚙️ Setup & Init   one-shot deploy-time injects (mode, scaling,
                     auto-startup, random-on)

Cross-tab wiring uses a fixed named-channel contract (cmd:demand,
cmd:mode, cmd:setpoint-A, evt:pump-A, etc.) — multiple emitters can
target a single link-in for fan-in, e.g. both the slider and the random
generator feed cmd:demand.

Bug fixes folded in:

1. Trend chart was empty / scrambled. Root cause: the trend-feeder
   function had ONE output that wired to BOTH flow and power charts,
   so each chart received both flow and power msgs and the legend
   garbled. Now: 2 outputs (flow → flow chart, power → power chart),
   one msg per output.

2. Every ui-text and ui-chart fell on the (0, 0) corner of the editor
   canvas. Root cause: the helper functions accepted x/y parameters
   but never assigned them on the returned node dict — Node-RED
   defaulted every widget to (0, 0) and they piled on top of each
   other. The dashboard render was unaffected (it lays out by group/
   order), but the editor was unreadable. Fixed both helpers and added
   a verification step ("no node should be at (0, 0)") to the rule set.

Spacing convention (now codified):
- 6 lanes per tab at x = [120, 380, 640, 900, 1160, 1420]
- 80 px standard row pitch, 30-40 px for tight ui-text stacks
- 200 px gap between sections, with a comment header per section

New rule set: .claude/rules/node-red-flow-layout.md
- Tab boundaries by concern
- Link-channel naming convention (cmd:/evt:/setup: prefixes)
- Spacing constants
- Trend-split chart pattern
- Inject node payload typing pitfall (per-prop v/vt)
- Dashboard widget rules (every ui-* needs x/y!)
- Do/don't checklist
- Link-out/link-in JSON cheat sheet
- 5-step layout verification before declaring a flow done

CLAUDE.md updated to point at the new rule set.

Verified end-to-end on Dockerized Node-RED 2026-04-13: 168 nodes across
4 tabs, all wired via 22 link-out / 19 link-in pairs, no nodes at
(0, 0), pumps reach operational ~5 s after deploy, MGC distributes
random demand, trends populate per pump.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:13:27 +02:00

10 KiB

Node-RED Flow Layout Rules

How to lay out a multi-tab Node-RED demo or production flow so it is readable, debuggable, and trivially extendable. These rules apply to anything you build with examples/ flows, dashboards, or production deployments.

1. Tab boundaries — by CONCERN, not by data

Every node lives on the tab matching its concern, never where it happens to be wired:

Tab Lives here Never here
🏭 Process Plant EVOLV nodes (rotatingMachine, MGC, pumpingStation, measurement, reactor, settler, …) + small per-node output formatters UI widgets, demo drivers, one-shot setup injects
📊 Dashboard UI All ui-* widgets, the wrapper functions that turn a button click into a typed msg, the trend-feeder split functions Anything that produces data autonomously, anything that talks to EVOLV nodes directly
🎛️ Demo Drivers Random generators, scripted scenarios, schedule injectors, anything that exists only to drive the demo Real production data sources (those go on Process Plant or are wired in externally)
⚙️ Setup & Init One-shot once: true injects (setMode, setScaling, auto-startup) Anything that fires more than once

Why these four: each tab can be disabled or deleted independently. Disable Demo Drivers → demo becomes inert until a real data source is wired. Disable Setup → fresh deploys don't auto-configure (good for debugging). Disable Dashboard UI → headless mode for tests. Process Plant always stays.

If you find yourself wanting a node "between" two tabs, you've named your concerns wrong — re-split.

Never wire a node on tab A directly to a node on tab B. Use named link-out / link-in pairs:

[ui-slider] ──► [link out  cmd:demand] ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
                                                                    │
                                                                    ▼
[random gen] ─► [link out  cmd:demand] ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─►  [link in  cmd:demand] ──► [router] ──► [MGC]
                                                                    ▲
                                                                    │
                                  many link-outs may target one link-in

Naming convention

Channels follow <direction>:<topic> lowercase, kebab-case after the colon:

  • cmd: — UI / drivers → process. Carries commands.
  • evt: — process → UI / external. Carries state events.
  • setup: — setup tab → wherever. Carries one-shot init.

Examples used in the pumping-station demo:

  • cmd:demand, cmd:randomToggle, cmd:mode
  • cmd:station-startup, cmd:station-shutdown, cmd:station-estop
  • cmd:setpoint-A, cmd:setpoint-B, cmd:setpoint-C
  • cmd:pump-A-seq (start/stop for pump A specifically)
  • evt:pump-A, evt:pump-B, evt:pump-C, evt:mgc, evt:ps
  • setup:to-mgc

Channels are the contract

The list of channel names IS the inter-tab API. Document it in the demo's README. Renaming a channel is a breaking change.

When to use one channel vs many

  • One channel, many emitters: same kind of message from multiple sources (e.g. cmd:demand is fired by both the slider and the random generator).
  • Different channels: messages with different meaning even if they go to the same node (e.g. don't fold cmd:setpoint-A into a generic cmd:pump-A — keep setpoint and start/stop separate).
  • Avoid one mega-channel: a "process commands" channel that the receiver routes-by-topic is harder to read than separate channels per concern.

link call is for synchronous request/response (waits for a paired link out in return mode). For fan-out, use plain link out (mode=link) with multiple targets, or a single link out → single link in → function-node fan-out (whichever is clearer for your case).

3. Spacing and visual layout

Nodes need air to be readable. Apply these constants in any flow generator:

LANE_X = [120, 380, 640, 900, 1160, 1420]   # 6 vertical lanes per tab
ROW = 80                                     # standard row pitch
SECTION_GAP = 200                            # extra y-shift between sections

Lane assignment (process plant tab as example)

Lane Contents
0 (x=120) Inputs from outside the tab — link-in nodes, injects
1 (x=380) First-level transformers — wrappers, fan-outs, routers
2 (x=640) Mid-level — section comments live here too
3 (x=900) Target nodes — the EVOLV node itself (pump, MGC, PS)
4 (x=1160) Output formatters — function nodes that build dashboard-friendly payloads
5 (x=1420) Outputs to outside the tab — link-out nodes, debug taps

Inputs flow left → right. Don't loop wires backwards across the tab.

Section comments

Every logical group within a tab gets a comment header at lane 2 with a ── Section name ── style label. Use them liberally — every 3-5 nodes deserves a header. The info field on the comment carries the multi-line description.

Section spacing

SECTION_GAP = 200 between sections, on top of the standard row pitch. Don't pack sections together — when you have 6 measurements on a tab, give each pump 4 rows + a 200 px gap to the next pump. Yes, it makes tabs scroll. Scroll is cheap; visual confusion is expensive.

4. Charts — the trend-split rule

ui-chart with category: "topic" + categoryType: "msg" plots one series per unique msg.topic. So:

  • One chart per metric type (one chart for flow, one for power).
  • Each chart receives msgs whose topic is the series label (e.g. Pump A, Pump B, Pump C).

The trend-split function pattern

A common bug: feeding both flow and power msgs to a single function output that wires to both charts. Both charts then plot all metrics, garbling the legend.

Fix: the trend-feeder function MUST have one output per chart, and split:

// outputs: 2
// wires: [["chart_flow"], ["chart_power"]]
const flowMsg  = p.flowNum  != null ? { topic: 'Pump A', payload: p.flowNum  } : null;
const powerMsg = p.powerNum != null ? { topic: 'Pump A', payload: p.powerNum } : null;
return [flowMsg, powerMsg];

A null msg on a given output sends nothing on that output — exactly what we want.

Chart axis settings to actually configure

  • removeOlder + removeOlderUnit: how much history to keep (e.g. 10 minutes).
  • removeOlderPoints: cap on points per series (200 is sensible for a demo).
  • ymin / ymax: leave blank for autoscale, or set numeric strings if you want a fixed range.

5. Inject node — payload typing

Multi-prop inject must populate v and vt per prop, not just the legacy top-level payload + payloadType:

{
  "props": [
    {"p": "topic", "vt": "str"},
    {"p": "payload", "v": "{\"action\":\"startup\"}", "vt": "json"}
  ],
  "topic": "execSequence",
  "payload": "{\"action\":\"startup\"}",
  "payloadType": "json"
}

If you only fill the top-level fields, payload_type=json is silently treated as str.

6. Dashboard widget rules

  • Widget = display only. No business logic in ui-text formats or ui-template HTML.
  • Buttons emit a typed string payload ("fired" or similar). Convert to the real msg shape with a tiny wrapper function on the same tab, before the link-out.
  • Sliders use passthru: true so they re-emit on input messages (useful for syncing initial state from the process side later).
  • One ui-page per demo. Multiple groups under one page is the natural split.
  • Group widths should sum to a multiple of 12. The page grid is 12 columns. A row of 4 + 4 + 4 or 6 + 6 works; mixing arbitrary widths leaves gaps.
  • EVERY ui- node needs x and y keys.* Without them Node-RED dumps the node at (0,0) — every text widget and chart piles up in the top-left of the editor canvas. The dashboard itself still renders correctly (it lays out by group/order, not editor x/y), but the editor view is unreadable. If you write a flow generator helper, set x and y on the dict EVERY time. Test with jq '[.[] | select(.x==0 and .y==0 and (.type|tostring|startswith("ui-")))]' after generating.

7. Do / don't checklist

Do:

  • Generate flows from a Python builder (build_flow.py) — it's the source of truth.
  • Use deterministic IDs (pump_a, meas_pump_a_u, lin_demand_to_mgc) — reproducible diffs across regenerations.
  • Tag every channel name with cmd: / evt: / setup:.
  • Comment every section, even short ones.
  • Verify trends with a ui-chart of synthetic data first, before plumbing real data through.

Don't:

  • Don't use replace_all on a Python identifier that appears in a node's own wires definition — you'll create self-loops (>250k msg/s discovered the hard way).
  • Don't wire across tabs directly. The wire IS allowed but it makes the editor unreadable.
  • Don't put dashboard widgets next to EVOLV nodes — different concerns.
  • Don't pack nodes within 40 px of each other — labels overlap, wires snap to wrong handles.
  • Don't ship enableLog: "debug" in a demo — fills the container log within seconds and obscures real errors.
{
  "id": "lout_demand_dash",
  "type": "link out",
  "z": "tab_ui",
  "name": "cmd:demand",
  "mode": "link",
  "links": ["lin_demand_to_mgc"],
  "x": 380, "y": 140,
  "wires": []
}
{
  "id": "lin_demand_to_mgc",
  "type": "link in",
  "z": "tab_process",
  "name": "cmd:demand",
  "links": ["lout_demand_dash", "lout_demand_drivers"],
  "x": 120, "y": 1500,
  "wires": [["demand_fanout_mgc_ps"]]
}

Both ends store the paired ids in links. The name is cosmetic (label only) — Node-RED routes by id. Multiple emitters can target one receiver; one emitter can target multiple receivers.

9. Verifying the layout

Before declaring a flow done:

  1. Open the tab in the editor — every wire should run left → right. No backward loops.
  2. Open each section by section comment — visible in 1 screen height. If not, raise SECTION_GAP.
  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.