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:
| **📊 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**:
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:
| 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`).
### Required chart properties (FlowFuse ui-chart renders blank without ALL of these)
Derived from working charts in rotatingMachine/examples/03-Dashboard. Every property listed below is mandatory — omit any one and the chart renders blank with no error message.
-`interpolation` MUST be set (`"linear"`, `"step"`, `"bezier"`, `"cubic"`, `"cubic-mono"`). Without it: no line drawn.
-`yAxisProperty: "payload"` + `yAxisPropertyType: "msg"` tells the chart WHERE in the msg to find the y-value. Without these: chart has no data to plot.
-`xAxisPropertyType: "timestamp"` tells the chart to use `msg.timestamp` (or auto-generated) for the x-axis.
-`width` and `height` are **numbers, not strings**. `width: 12` (correct) vs `width: "12"` (may break).
-`removeOlderPoints: ""` (empty string) → retention is controlled by removeOlder + removeOlderUnit only. Set to a number string to additionally cap points per series.
-`colors` array defines the palette for auto-assigned series colours. Provide at least 3.
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:
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)
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. Node configuration completeness — ALWAYS set every field
When placing an EVOLV node in a flow (demo or production), configure **every config field** the node's schema defines — don't rely on schema defaults for operational parameters. Schema defaults exist to make the validator happy, not to represent a realistic plant.
**Why this matters:** A pumpingStation with `basinVolume: 10` but default `heightOverflow: 2.5` and default `heightOutlet: 0.2` creates an internally inconsistent basin where the fill % exceeds 100%, safety guards fire at wrong thresholds, and the demo looks broken. Every field interacts with every other field.
**The rule:**
1. Read the node's config schema (`generalFunctions/src/configs/<nodeName>.json`) before writing the flow.
2. For each section (basin, hydraulics, control, safety, scaling, smoothing, …), set EVERY field explicitly in the flow JSON — even if you'd pick the same value as the default.
3. Add a comment in the flow generator per section explaining WHY you chose each value (e.g. "basin sized so sinus peak takes 6 min to fill from startLevel to overflow").
5. If a gauge or chart references a config value (basin height, maxVol), derive it from the same source — never hardcode a number that was computed elsewhere.
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.
## 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.0 Two color systems — palette swatch vs. editor group
EVOLV uses two distinct color schemes for two distinct purposes. Mixing them up is the most common visual-design bug we see in flows.
| System | Where it's set | What it signals | Scheme |
|---|---|---|---|
| **Palette swatch** | `RED.nodes.registerType(..., { color })` in `<node>.html` | "Which node am I picking from the sidebar?" | **Domain-hue per node** (table below) |
| **Editor group rectangle** | `style.fill` on a `group` node in `flow.json` | "Which S88 cluster does this box represent?" | **S88 level** (§10.1 table) |
**Palette swatches (set 2026-05-21).** Family hue = function. Within a family, darker = higher S88 / "more controller-ish."
**Important:** the §10.1 "Colour" column below refers to **editor groups + lane backgrounds** (S88), not to the palette swatch. Don't use the S88 hex inside `registerType`; don't use the palette hex inside a `flow.json` group `style.fill`.
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`).
- 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.
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.