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>
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.
2. Cross-tab wiring — link nodes only, named channels
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:modecmd:station-startup,cmd:station-shutdown,cmd:station-estopcmd:setpoint-A,cmd:setpoint-B,cmd:setpoint-Ccmd:pump-A-seq(start/stop for pump A specifically)evt:pump-A,evt:pump-B,evt:pump-C,evt:mgc,evt:pssetup: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:demandis 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-Ainto a genericcmd: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.
Don't use link-call for fan-out
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
topicis 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-textformats orui-templateHTML. - 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: trueso 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 + 4or6 + 6works; mixing arbitrary widths leaves gaps. - EVERY ui- node needs
xandykeys.* 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, setxandyon the dict EVERY time. Test withjq '[.[] | 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-chartof synthetic data first, before plumbing real data through.
❌ Don't:
- Don't use
replace_allon 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.
8. The link-out / link-in JSON shape (cheat sheet)
{
"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:
- Open the tab in the editor — every wire should run left → right. No backward loops.
- Open each section by section comment — visible in 1 screen height. If not, raise
SECTION_GAP. - Hit the dashboard URL — every widget has data.
n/aeverywhere is a contract failure. - For charts, watch a series populate over 30 s. A blank chart after 30 s = bug.
- 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.