# 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**: ```text [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 `:` 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. ### 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: ```python 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: ```js // 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`: ```json { "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. ## 8. The link-out / link-in JSON shape (cheat sheet) ```json { "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": [] } ``` ```json { "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.