feat(editor): pump banner, circular state diagram, shared picker

Editor UI overhaul:
* Pump banner — SVG of a generic centrifugal pump (volute, impeller,
  motor stub, suction + discharge pipes) at the top for visual orientation.
* Sequence-timing: side-panel inputs hover-coupled to a circular FSM donut.
  Arc angle proportional to phase seconds; idle a small loop slice at the
  top, operational the dominant arc at the bottom. Protected phases mark
  warm-up / cool-down with text-style shield (VS-15) inheriting arc colour.
  Donut height measured at runtime against the side-panel column so the
  bounding box lines up with the row stack.
* Movement mode: dropdown replaced with two compact 94x86 icon cards
  (Static linear ramp, Dynamic sigmoid).
* Output formats: switched to the shared evolv-icon-picker pattern (now
  also auto-applied platform-wide by generalFunctions/menu/iconHelpers).
* CLAUDE.md: Folder & File Layout section per EVOLV convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-18 21:31:01 +02:00
parent 5ea0b0bda6
commit 426c1a606b
2 changed files with 459 additions and 60 deletions

View File

@@ -21,3 +21,20 @@ Key points for this node:
- Stack same-level siblings vertically.
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
- Wrap in a Node-RED group box coloured `#86bbdd` (Equipment Module).
## Folder & File Layout
Every per-node file MUST use the folder name (`rotatingMachine`) **exactly**, case-sensitive. Full rule: [`.claude/rules/node-architecture.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/node-architecture.md) in the EVOLV superproject.
| Path | Required name |
|---|---|
| Entry file | `rotatingMachine.js` |
| Editor HTML | `rotatingMachine.html` |
| Node adapter | `src/nodeClass.js` |
| Domain logic | `src/specificClass.js` |
| Editor JS modules | `src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
| Tests | `test/{basic,integration,edge}/*.test.js` |
| Example flows | `examples/*.flow.json` |
When adding new files, read the rule above first to avoid drift.

View File

@@ -69,12 +69,14 @@
},
oneditprepare: function() {
// wait for the menu scripts to load
const node = this;
// wait for the menu scripts to load (asset/logger/position injected via menu.js)
let menuRetries = 0;
const maxMenuRetries = 100; // 5 seconds at 50ms intervals
const waitForMenuData = () => {
if (window.EVOLV?.nodes?.rotatingMachine?.initEditor) {
window.EVOLV.nodes.rotatingMachine.initEditor(this);
window.EVOLV.nodes.rotatingMachine.initEditor(node);
} else if (++menuRetries < maxMenuRetries) {
setTimeout(waitForMenuData, 50);
} else {
@@ -83,17 +85,189 @@
};
waitForMenuData();
// your existing projectsettings & asset dropdown logic can remain here
document.getElementById("node-input-speed");
document.getElementById("node-input-startup");
document.getElementById("node-input-warmup");
document.getElementById("node-input-shutdown");
document.getElementById("node-input-cooldown");
const movementMode = document.getElementById("node-input-movementMode");
if (movementMode) {
movementMode.value = this.movementMode || "staticspeed";
// -----------------------------------------------------------
// Movement-mode visual cards (replaces the old <select>).
// Same compact 94×86 card sizing as machineGroupControl.
// -----------------------------------------------------------
const modeInput = document.getElementById("node-input-movementMode");
const cards = document.querySelectorAll(".rm-mode-card");
const setMode = (val) => {
if (modeInput) modeInput.value = val;
cards.forEach((c) => {
const on = c.dataset.value === val;
c.classList.toggle("rm-mode-card-on", on);
c.setAttribute("aria-checked", String(on));
});
};
const initialMode = (node.movementMode === "dynspeed") ? "dynspeed" : "staticspeed";
setMode(initialMode);
cards.forEach((card) => {
card.addEventListener("click", () => setMode(card.dataset.value));
card.addEventListener("keydown", (e) => {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); setMode(card.dataset.value); }
});
});
// -----------------------------------------------------------
// Output-format pickers (shared widget from iconHelpers).
// Hidden <select>s carry the value; the icon-picker divs are
// upgraded in place. Same visuals as machineGroupControl.
// -----------------------------------------------------------
const helpers = window.EVOLV?.iconHelpers;
if (helpers && typeof helpers.renderOutputFormatPicker === "function") {
helpers.renderOutputFormatPicker(
document.getElementById("node-input-processOutputFormat"),
document.getElementById("rm-process-output-picker")
);
helpers.renderOutputFormatPicker(
document.getElementById("node-input-dbaseOutputFormat"),
document.getElementById("rm-dbase-output-picker")
);
}
// -----------------------------------------------------------
// Circular state-machine diagram (replaces the linear bars).
// Idle is a small fixed slice at the top; operational is a
// fixed dominant arc at the bottom; starting+warmingup and
// stopping+coolingdown each share one of the two side bands
// proportional to their seconds. Reaction speed shown as a
// small slope inside the donut hole.
// -----------------------------------------------------------
const TL = {
cx: 170, cy: 130,
innerR: 46, outerR: 80,
idleDeg: 30, // fixed slice at top, the loop-around
operationalDeg: 100, // fixed dominant arc at the bottom
sideMinDeg: 28 // each timed phase keeps at least this so labels fit
};
TL.sideDeg = (360 - TL.idleDeg - TL.operationalDeg) / 2; // 115° per side
function p2c(r, deg) {
const rad = deg * Math.PI / 180;
return [TL.cx + r * Math.sin(rad), TL.cy - r * Math.cos(rad)];
}
function arcPath(rIn, rOut, startDeg, endDeg) {
const [x1, y1] = p2c(rOut, startDeg);
const [x2, y2] = p2c(rOut, endDeg);
const [x3, y3] = p2c(rIn, endDeg);
const [x4, y4] = p2c(rIn, startDeg);
const largeArc = (endDeg - startDeg) > 180 ? 1 : 0;
return "M " + x1.toFixed(2) + " " + y1.toFixed(2) +
" A " + rOut + " " + rOut + " 0 " + largeArc + " 1 " + x2.toFixed(2) + " " + y2.toFixed(2) +
" L " + x3.toFixed(2) + " " + y3.toFixed(2) +
" A " + rIn + " " + rIn + " 0 " + largeArc + " 0 " + x4.toFixed(2) + " " + y4.toFixed(2) +
" Z";
}
function splitPair(a, b, total, minDeg) {
let aDeg, bDeg;
if (a + b === 0) { aDeg = bDeg = total / 2; }
else { aDeg = total * a / (a + b); bDeg = total - aDeg; }
if (aDeg < minDeg) { aDeg = minDeg; bDeg = total - minDeg; }
else if (bDeg < minDeg) { bDeg = minDeg; aDeg = total - minDeg; }
return [aDeg, bDeg];
}
function redrawTimeline() {
const speed = Math.max(0.01, parseFloat(document.getElementById("node-input-speed").value) || 1);
const startup = Math.max(0, parseFloat(document.getElementById("node-input-startup").value) || 0);
const warmup = Math.max(0, parseFloat(document.getElementById("node-input-warmup").value) || 0);
const shutdown = Math.max(0, parseFloat(document.getElementById("node-input-shutdown").value) || 0);
const cooldown = Math.max(0, parseFloat(document.getElementById("node-input-cooldown").value) || 0);
const [startingDeg, warmingupDeg] = splitPair(startup, warmup, TL.sideDeg, TL.sideMinDeg);
const [stoppingDeg, coolingdownDeg] = splitPair(shutdown, cooldown, TL.sideDeg, TL.sideMinDeg);
// Clockwise from top (0° = idle centre). Wrap idle across ±idleDeg/2.
const idleHalf = TL.idleDeg / 2;
const states = [
{ id: "idle", startDeg: -idleHalf, endDeg: idleHalf, label: "idle", time: null, above: true },
{ id: "starting", startDeg: idleHalf, endDeg: idleHalf + startingDeg, label: "starting", time: startup, above: false },
{ id: "warmingup", startDeg: idleHalf + startingDeg, endDeg: idleHalf + startingDeg + warmingupDeg, label: "\u{1F6E1} warm-up", time: warmup, above: false },
{ id: "operational", startDeg: idleHalf + TL.sideDeg, endDeg: idleHalf + TL.sideDeg + TL.operationalDeg, label: "operational", time: null, above: false },
{ id: "stopping", startDeg: idleHalf + TL.sideDeg + TL.operationalDeg, endDeg: idleHalf + TL.sideDeg + TL.operationalDeg + stoppingDeg, label: "stopping", time: shutdown, above: false },
{ id: "coolingdown", startDeg: idleHalf + TL.sideDeg + TL.operationalDeg + stoppingDeg, endDeg: idleHalf + TL.sideDeg + TL.operationalDeg + stoppingDeg + coolingdownDeg, label: "\u{1F6E1} cool-down", time: cooldown, above: false }
];
const labelR = (TL.innerR + TL.outerR) / 2;
const titleR = TL.outerR + 22;
states.forEach((s) => {
const arc = document.getElementById("rm-tl-" + s.id);
if (arc) arc.setAttribute("d", arcPath(TL.innerR, TL.outerR, s.startDeg, s.endDeg));
const midDeg = (s.startDeg + s.endDeg) / 2;
const normMid = ((midDeg % 360) + 360) % 360;
// State name OUTSIDE the ring.
const lbl = document.getElementById("rm-tl-lbl-" + s.id);
if (lbl) {
const [lx, ly] = p2c(titleR, midDeg);
lbl.setAttribute("x", lx.toFixed(2));
lbl.setAttribute("y", ly.toFixed(2));
let ta;
if (Math.abs(normMid) < 12 || Math.abs(normMid - 180) < 12 || normMid > 348) ta = "middle";
else if (normMid > 0 && normMid < 180) ta = "start";
else ta = "end";
lbl.setAttribute("text-anchor", ta);
const dy = (normMid < 12 || normMid > 348) ? "-4"
: (Math.abs(normMid - 180) < 12) ? "14"
: "4";
lbl.setAttribute("dy", dy);
lbl.textContent = s.label;
}
// Time value INSIDE arc.
const t = document.getElementById("rm-tl-time-" + s.id);
if (t) {
const [tx, ty] = p2c(labelR, midDeg);
t.setAttribute("x", tx.toFixed(2));
t.setAttribute("y", ty.toFixed(2));
t.setAttribute("text-anchor", "middle");
t.setAttribute("dy", "4");
t.textContent = (s.time == null) ? "" : (s.time + "s");
}
});
// Reaction-speed value in the donut hole.
const rampVal = document.getElementById("rm-tl-ramp-value");
if (rampVal) rampVal.textContent = speed + " %/s";
}
// Hover-couple: hover an input row → glow its arc.
document.querySelectorAll(".rm-row[data-couples]").forEach((row) => {
const targetId = row.dataset.couples;
row.addEventListener("mouseenter", () => {
document.getElementById(targetId)?.classList.add("rm-arc-highlight");
});
row.addEventListener("mouseleave", () => {
document.getElementById(targetId)?.classList.remove("rm-arc-highlight");
});
});
["speed", "startup", "warmup", "shutdown", "cooldown"].forEach((field) => {
const el = document.getElementById("node-input-" + field);
if (el) el.addEventListener("input", redrawTimeline);
});
// Size the donut SVG so its top/bottom line up with the side panel:
// measure the side-panel's computed height and apply it to the SVG.
// Re-runs on every dialog open (oneditprepare is per-edit).
function syncSvgHeight() {
const sidePanel = document.querySelector(".rm-diag-side");
const svg = document.getElementById("rm-timeline");
if (!sidePanel || !svg) return;
const h = sidePanel.getBoundingClientRect().height;
if (h > 0) svg.style.height = h + "px";
}
// First paint (next tick so the dialog is in the DOM).
// Use requestAnimationFrame chain so the side-panel height is measured
// AFTER the dialog has actually laid out — getBoundingClientRect on a
// freshly-created element returns 0 inside the same synchronous tick.
setTimeout(() => {
redrawTimeline();
requestAnimationFrame(() => requestAnimationFrame(syncSvgHeight));
}, 0);
},
oneditsave: function() {
const node = this;
@@ -114,13 +288,11 @@
["speed", "startup", "warmup", "shutdown", "cooldown"].forEach((field) => {
const element = document.getElementById(`node-input-${field}`);
const value = parseFloat(element?.value) || 0;
console.log(`----------------> Saving ${field}: ${value}`);
node[field] = value;
});
node.movementMode = document.getElementById("node-input-movementMode").value;
console.log(`----------------> Saving movementMode: ${node.movementMode}`);
const modeEl = document.getElementById("node-input-movementMode");
node.movementMode = (modeEl && modeEl.value) ? modeEl.value : "staticspeed";
}
});
</script>
@@ -128,65 +300,275 @@
<!-- Main UI Template -->
<script type="text/html" data-template-name="rotatingMachine">
<!-- Machine-specific controls -->
<div class="form-row">
<label for="node-input-speed"><i class="fa fa-clock-o"></i> Reaction Speed</label>
<input type="number" id="node-input-speed" style="width:60%;" placeholder="position units / second" />
<div style="font-size:11px;color:#666;margin-left:160px;">Ramp rate of the controller position in units per second (0100% controller range; e.g. 1 = 1%/s).</div>
</div>
<div class="form-row">
<label for="node-input-startup"><i class="fa fa-clock-o"></i> Startup Time</label>
<input type="number" id="node-input-startup" style="width:60%;" placeholder="seconds" />
<div style="font-size:11px;color:#666;margin-left:160px;">Seconds spent in the <code>starting</code> state before moving to <code>warmingup</code>.</div>
</div>
<div class="form-row">
<label for="node-input-warmup"><i class="fa fa-clock-o"></i> Warmup Time</label>
<input type="number" id="node-input-warmup" style="width:60%;" placeholder="seconds" />
<div style="font-size:11px;color:#666;margin-left:160px;">Seconds spent in the protected <code>warmingup</code> state before reaching <code>operational</code>.</div>
</div>
<div class="form-row">
<label for="node-input-shutdown"><i class="fa fa-clock-o"></i> Shutdown Time</label>
<input type="number" id="node-input-shutdown" style="width:60%;" placeholder="seconds" />
<div style="font-size:11px;color:#666;margin-left:160px;">Seconds spent in the <code>stopping</code> state before moving to <code>coolingdown</code>.</div>
</div>
<div class="form-row">
<label for="node-input-cooldown"><i class="fa fa-clock-o"></i> Cooldown Time</label>
<input type="number" id="node-input-cooldown" style="width:60%;" placeholder="seconds" />
<div style="font-size:11px;color:#666;margin-left:160px;">Seconds spent in the protected <code>coolingdown</code> state before returning to <code>idle</code>.</div>
</div>
<div class="form-row">
<label for="node-input-movementMode"><i class="fa fa-exchange"></i> Movement Mode</label>
<select id="node-input-movementMode" style="width:60%;">
<option value="staticspeed">Static</option>
<option value="dynspeed">Dynamic</option>
</select>
<!-- ============================================================ -->
<!-- PUMP / ROTATING MACHINE BANNER -->
<!-- Visual orientation only no inputs. Shows what the node -->
<!-- represents (centrifugal pump with suction + discharge). -->
<!-- ============================================================ -->
<div style="margin: 4px 0 14px 0; background: #fafcff; border: 1px solid #d9e6f2; border-radius: 4px; padding: 8px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 200"
style="display:block;width:100%;max-width:600px;margin:0 auto;"
font-family="Arial,sans-serif" font-size="11">
<defs>
<marker id="rm-arrow-flow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#1F4E79"/>
</marker>
<marker id="rm-arrow-rot" viewBox="0 0 10 10" refX="6" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#0c99d9"/>
</marker>
</defs>
<!-- Title -->
<text x="300" y="18" text-anchor="middle" fill="#1F4E79" font-size="13" font-weight="bold">Rotating machine pump / compressor / blower</text>
<!-- Suction pipe (left in) -->
<rect x="20" y="100" width="160" height="38" fill="#dde7f0" stroke="#1F4E79" stroke-width="2"/>
<line x1="40" y1="119" x2="170" y2="119" stroke="#1F4E79" stroke-width="2" marker-end="url(#rm-arrow-flow)"/>
<text x="100" y="92" text-anchor="middle" fill="#1F4E79" font-weight="bold">Suction</text>
<text x="100" y="156" text-anchor="middle" fill="#777" font-size="10" font-style="italic">upstream / inlet pressure</text>
<!-- Motor housing (top) + shaft -->
<rect x="220" y="30" width="44" height="40" rx="3" fill="#7f8c8d" stroke="#333" stroke-width="1.5"/>
<text x="242" y="55" text-anchor="middle" fill="#fff" font-size="13" font-weight="bold">M</text>
<line x1="242" y1="70" x2="242" y2="90" stroke="#333" stroke-width="2"/>
<text x="295" y="50" fill="#555" font-size="10" font-style="italic">motor / drive</text>
<!-- Volute (pump body) -->
<circle cx="242" cy="119" r="40" fill="#fff" stroke="#333" stroke-width="2"/>
<!-- Impeller curves (decorative) -->
<path d="M 242 95 Q 268 105 268 119 Q 268 133 242 143 Q 216 133 216 119 Q 216 105 242 95" fill="none" stroke="#86bbdd" stroke-width="1.5"/>
<path d="M 234 100 Q 258 110 258 119 Q 258 128 234 138" fill="none" stroke="#a9daee" stroke-width="1"/>
<!-- Rotation arrow inside volute -->
<path d="M 222 109 A 22 22 0 0 1 262 109" fill="none" stroke="#0c99d9" stroke-width="2" marker-end="url(#rm-arrow-rot)"/>
<text x="242" y="175" text-anchor="middle" fill="#333" font-size="10">impeller</text>
<!-- Discharge pipe (right out) -->
<rect x="304" y="100" width="160" height="38" fill="#dde7f0" stroke="#1F4E79" stroke-width="2"/>
<line x1="314" y1="119" x2="454" y2="119" stroke="#1F4E79" stroke-width="2" marker-end="url(#rm-arrow-flow)"/>
<text x="384" y="92" text-anchor="middle" fill="#1F4E79" font-weight="bold">Discharge</text>
<text x="384" y="156" text-anchor="middle" fill="#777" font-size="10" font-style="italic">downstream / outlet pressure</text>
<!-- Hint band right -->
<text x="484" y="92" fill="#1E8449" font-size="11" font-weight="bold"> flow Q</text>
<text x="484" y="108" fill="#1E8449" font-size="10" font-style="italic">/h (configurable)</text>
<text x="484" y="130" fill="#C0392B" font-size="11" font-weight="bold"> Δp head</text>
<text x="484" y="146" fill="#C0392B" font-size="10" font-style="italic">predicted from curve</text>
<!-- Hint footer -->
<text x="300" y="194" text-anchor="middle" fill="#777" font-size="10" font-style="italic">
Flow direction Pressure rises across the impeller Performance follows the Q-H / Q-P curves of the selected asset
</text>
</svg>
</div>
<!-- ============================================================ -->
<!-- SEQUENCE & REACTION TIMING -->
<!-- Side-panel inputs hover-coupled to a timeline of FSM phases. -->
<!-- Bar widths grow with the entered seconds. Protected phases -->
<!-- (warmingup / coolingdown) carry a 🛡 marker. The reaction- -->
<!-- speed value tilts the slope inside the operational bar. -->
<!-- ============================================================ -->
<h4>Sequence &amp; reaction timing</h4>
<p style="font-size:12px;color:#777;margin:0 0 6px 0;">Each timing input on the left sizes its phase on the timeline. <b>🛡 protected</b> phases (warm-up &amp; cool-down) cannot be aborted by a new command. Hover an input row to highlight the phase it controls.</p>
<style>
.rm-diag { display:flex; gap:20px; align-items:flex-start; margin: 0 0 14px 0; }
.rm-diag-side { width: 230px; flex: 0 0 230px; display:flex; flex-direction:column; gap:6px; }
/* SVG height is set at runtime by syncSvgHeight() in oneditprepare to
match the side-panel's computed height exactly. Width follows the
viewBox aspect ratio. The hard-coded fallback height covers the brief
window before the first sync runs. */
.rm-diag-svg { height:195px; width:auto; max-width:100%; display:block; }
.rm-diag-side .rm-row {
display:grid; grid-template-columns: minmax(0,1fr) 70px 18px; align-items:center;
gap:6px; padding:4px 6px 4px 10px; border-left:4px solid #ccc;
background:#fafafa; border-radius:3px; font-size:11px; cursor:pointer; min-width:0;
}
.rm-diag-side .rm-row:hover { background:#f0f0f0; }
.rm-diag-side .rm-row label { font-weight:600; margin:0; line-height:1.2; }
.rm-diag-side .rm-row .rm-sub { grid-column:1; font-size:10px; color:#888; font-weight:400; }
.rm-diag-side .rm-row input[type=number] {
width:100%; height:22px; box-sizing:border-box; font-size:11px;
padding:1px 4px; margin:0; border:1px solid #ccc; border-radius:3px; background:#fff;
}
.rm-diag-side .rm-row input[type=number]:focus { outline:1px solid #0c99d9; border-color:#0c99d9; }
.rm-diag-side .rm-row .rm-unit { color:#888; font-size:10px; text-align:right; }
/* Border colours matched to arc fills. */
.rm-row[data-stroke="#0c99d9"] { border-left-color:#0c99d9; }
.rm-row[data-stroke="#f39c12"] { border-left-color:#f39c12; }
.rm-row[data-stroke="#e67e22"] { border-left-color:#e67e22; }
.rm-row[data-stroke="#0c99d9"] label { color:#0c99d9; }
.rm-row[data-stroke="#f39c12"] label { color:#b9770e; }
.rm-row[data-stroke="#e67e22"] label { color:#af601a; }
/* Highlight class applied to a state's arc path on input-row hover. */
.rm-arc-highlight { stroke:#1F4E79 !important; stroke-width:3 !important; filter:brightness(1.08); }
/* Movement-mode cards — same compact 94×86 sizing as machineGroupControl. */
.rm-mode-cards { display:flex; gap:6px; flex-wrap:wrap; margin:6px 0 4px 0; }
.rm-mode-card {
width:94px; height:86px; box-sizing:border-box;
border:2px solid #d0d0d0; border-radius:4px; background:#fafafa;
padding:4px; cursor:pointer; user-select:none;
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px;
transition:border-color 80ms ease-out, background 80ms ease-out;
}
.rm-mode-card:hover { border-color:#86bbdd; background:#f5fafd; }
.rm-mode-card:focus { outline:2px solid #1F4E79; outline-offset:2px; }
.rm-mode-card-on { border-color:#50a8d9; background:#eaf4fb; }
.rm-mode-card-svg { width:100%; height:54px; display:flex; align-items:center; justify-content:center; }
.rm-mode-card-svg svg { width:100%; height:100%; display:block; }
.rm-mode-card-label { font-size:10px; line-height:1; font-weight:600; color:#333; white-space:nowrap; letter-spacing:0; }
.rm-mode-card:not(.rm-mode-card-on) .rm-mode-card-label { color:#888; }
/* Output-format rows mirror the mgc layout: nowrap label, native select
hidden, icon picker rendered alongside by iconHelpers. */
.rm-output-row > label { white-space:nowrap; width:130px; }
</style>
<div class="rm-diag">
<!-- LEFT: stacked colour-coded inputs. Hover a row matching SVG bar highlights. -->
<div class="rm-diag-side">
<div class="rm-row" data-stroke="#0c99d9" data-couples="rm-tl-operational">
<div><label>Reaction speed</label><div class="rm-sub">controller ramp rate (slope inside operational)</div></div>
<input type="number" id="node-input-speed" min="0.1" step="0.1" />
<span class="rm-unit">%/s</span>
</div>
<div class="rm-row" data-stroke="#f39c12" data-couples="rm-tl-starting">
<div><label>Startup time</label><div class="rm-sub">idle starting warmingup</div></div>
<input type="number" id="node-input-startup" min="0" step="1" />
<span class="rm-unit">s</span>
</div>
<div class="rm-row" data-stroke="#e67e22" data-couples="rm-tl-warmingup">
<div><label>Warm-up time 🛡</label><div class="rm-sub">protected cannot be aborted</div></div>
<input type="number" id="node-input-warmup" min="0" step="1" />
<span class="rm-unit">s</span>
</div>
<div class="rm-row" data-stroke="#f39c12" data-couples="rm-tl-stopping">
<div><label>Shutdown time</label><div class="rm-sub">operational stopping coolingdown</div></div>
<input type="number" id="node-input-shutdown" min="0" step="1" />
<span class="rm-unit">s</span>
</div>
<div class="rm-row" data-stroke="#e67e22" data-couples="rm-tl-coolingdown">
<div><label>Cool-down time 🛡</label><div class="rm-sub">protected cannot be aborted</div></div>
<input type="number" id="node-input-cooldown" min="0" step="1" />
<span class="rm-unit">s</span>
</div>
</div>
<!-- RIGHT: circular state-machine donut. All arc `d` and label x/y
values are written by redrawTimeline(). Each state is a wedge of
the ring; arc angle is proportional to its seconds.
Idle sits at the top (small fixed slice, the loop-around);
operational sits at the bottom (fixed dominant arc). -->
<svg id="rm-timeline" class="rm-diag-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 340 260"
style="background:#fff;border:1px solid #e5e5e5;border-radius:4px;"
preserveAspectRatio="xMidYMid meet"
font-family="Arial,sans-serif" font-size="11">
<!-- Title -->
<text x="170" y="14" text-anchor="middle" fill="#1F4E79" font-size="11" font-weight="bold">State machine sequence loop</text>
<!-- State arc wedges. Order in DOM = clockwise from top.
`d` attribute populated by redrawTimeline(). -->
<path id="rm-tl-idle" fill="#bdc3c7" stroke="#7f8c8d" stroke-width="1" />
<path id="rm-tl-starting" fill="#f39c12" stroke="#b9770e" stroke-width="1" />
<path id="rm-tl-warmingup" fill="#e67e22" stroke="#af601a" stroke-width="1" />
<path id="rm-tl-operational" fill="#2ecc71" stroke="#239b56" stroke-width="1" />
<path id="rm-tl-stopping" fill="#f39c12" stroke="#b9770e" stroke-width="1" />
<path id="rm-tl-coolingdown" fill="#e67e22" stroke="#af601a" stroke-width="1" />
<!-- State-name labels OUTSIDE the ring. x/y/text-anchor/dy set in JS. -->
<text id="rm-tl-lbl-idle" fill="#555" font-size="11" font-weight="bold"></text>
<text id="rm-tl-lbl-starting" fill="#b9770e" font-size="11" font-weight="bold"></text>
<text id="rm-tl-lbl-warmingup" fill="#af601a" font-size="11" font-weight="bold"></text>
<text id="rm-tl-lbl-operational" fill="#239b56" font-size="11" font-weight="bold"></text>
<text id="rm-tl-lbl-stopping" fill="#b9770e" font-size="11" font-weight="bold"></text>
<text id="rm-tl-lbl-coolingdown" fill="#af601a" font-size="11" font-weight="bold"></text>
<!-- Duration values INSIDE each arc. x/y set in JS. -->
<text id="rm-tl-time-idle" fill="#fff" font-size="10" font-weight="bold"></text>
<text id="rm-tl-time-starting" fill="#fff" font-size="10" font-weight="bold"></text>
<text id="rm-tl-time-warmingup" fill="#fff" font-size="10" font-weight="bold"></text>
<text id="rm-tl-time-operational" fill="#fff" font-size="10" font-weight="bold"></text>
<text id="rm-tl-time-stopping" fill="#fff" font-size="10" font-weight="bold"></text>
<text id="rm-tl-time-coolingdown" fill="#fff" font-size="10" font-weight="bold"></text>
<!-- Centre: reaction-speed value (no slope line donut hole stays clean). -->
<text x="170" y="125" text-anchor="middle" fill="#1F4E79" font-size="10" font-weight="bold">Reaction speed</text>
<text id="rm-tl-ramp-value" x="170" y="146" text-anchor="middle" fill="#0c99d9" font-size="16" font-weight="bold">1 %/s</text>
</svg>
</div>
<!-- ============================================================ -->
<!-- MOVEMENT MODE visual cards (was a <select>) -->
<!-- Hidden #node-input-movementMode keeps the save path working. -->
<!-- ============================================================ -->
<h4>Movement mode</h4>
<p style="font-size:12px;color:#777;margin:0 0 6px 0;">How the controller travels between setpoints during <code>accelerating</code> / <code>decelerating</code>.</p>
<div class="rm-mode-cards" role="radiogroup" aria-label="Movement mode">
<div class="rm-mode-card" data-value="staticspeed" tabindex="0" role="radio" aria-checked="false" aria-label="Static — constant ramp rate" title="Static — constant ramp rate">
<div class="rm-mode-card-svg">
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<line x1="12" y1="48" x2="70" y2="48" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<line x1="12" y1="48" x2="12" y2="8" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<line x1="14" y1="46" x2="68" y2="12" stroke="#1F4E79" stroke-width="3" stroke-linecap="round"/>
<circle cx="14" cy="46" r="2.6" fill="#1F4E79"/>
<circle cx="68" cy="12" r="2.6" fill="#1F4E79"/>
</svg>
</div>
<div class="rm-mode-card-label">Static</div>
</div>
<div class="rm-mode-card" data-value="dynspeed" tabindex="0" role="radio" aria-checked="false" aria-label="Dynamic — ease in/out" title="Dynamic — ease in/out">
<div class="rm-mode-card-svg">
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<line x1="12" y1="48" x2="70" y2="48" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<line x1="12" y1="48" x2="12" y2="8" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<!-- More pronounced sigmoid: control points pull the mid-section nearly flat
(y29 mid) so the S-shape reads clearly at thumbnail size. -->
<path d="M 14 46 C 22 46, 26 30, 41 29 C 56 28, 60 12, 68 12" fill="none" stroke="#1F4E79" stroke-width="3" stroke-linecap="round"/>
<circle cx="14" cy="46" r="2.6" fill="#1F4E79"/>
<circle cx="68" cy="12" r="2.6" fill="#1F4E79"/>
</svg>
</div>
<div class="rm-mode-card-label">Dynamic</div>
</div>
</div>
<!-- Hidden field kept for the save path, written by the cards above. -->
<input type="hidden" id="node-input-movementMode" />
<!-- ============================================================ -->
<!-- OUTPUT FORMATS same shared widget as machineGroupControl. -->
<!-- Native selects stay in the DOM (hidden) as save targets; the -->
<!-- icon-picker divs are upgraded by iconHelpers. -->
<!-- ============================================================ -->
<h3>Output Formats</h3>
<div class="form-row">
<div class="form-row rm-output-row">
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
<select id="node-input-processOutputFormat" style="width:60%;">
<select id="node-input-processOutputFormat" class="evolv-native-hidden" style="width:60%;">
<option value="process">process</option>
<option value="json">json</option>
<option value="csv">csv</option>
</select>
<div id="rm-process-output-picker" class="evolv-icon-picker"
role="radiogroup" aria-label="Process output format"></div>
</div>
<div class="form-row">
<div class="form-row rm-output-row">
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
<select id="node-input-dbaseOutputFormat" style="width:60%;">
<select id="node-input-dbaseOutputFormat" class="evolv-native-hidden" style="width:60%;">
<option value="influxdb">influxdb</option>
<option value="json">json</option>
<option value="csv">csv</option>
</select>
<div id="rm-dbase-output-picker" class="evolv-icon-picker"
role="radiogroup" aria-label="Database output format"></div>
</div>
<!-- Asset fields injected here -->
<!-- Asset / Logger / Position menus injected by menu.js -->
<div id="asset-fields-placeholder"></div>
<!-- Logger fields injected here -->
<div id="logger-fields-placeholder"></div>
<!-- Position fields injected here -->
<div id="position-fields-placeholder"></div>
</script>
@@ -196,11 +578,11 @@
<h3>Configuration</h3>
<ul>
<li><b>Reaction Speed</b>: controller ramp rate (position units / second). E.g. <code>1</code> = 1%/s, so Set 60% from idle reaches 60% in ~60&nbsp;s.</li>
<li><b>Startup / Warmup / Shutdown / Cooldown</b>: seconds per FSM phase. Warmup and Cooldown are <i>protected</i> they cannot be aborted by a new command.</li>
<li><b>Movement Mode</b>: <code>staticspeed</code> = linear ramp; <code>dynspeed</code> = ease-in/out.</li>
<li><b>Reaction speed</b>: controller ramp rate (position units / second). E.g. <code>1</code> = 1%/s, so a setpoint of 60% from idle reaches 60% in ~60&nbsp;s. Visualised as the slope inside the <i>operational</i> bar.</li>
<li><b>Startup / Warm-up / Shutdown / Cool-down</b>: seconds per FSM phase. Warm-up &amp; cool-down are <b>protected</b> they cannot be aborted by a new command (shown with 🛡 in the timeline).</li>
<li><b>Movement mode</b>: <code>staticspeed</code> = linear ramp; <code>dynspeed</code> = ease-in/out. Pick a card.</li>
<li><b>Asset</b> (menu): supplier, category, model (must match a curve in <code>generalFunctions</code>), flow unit (e.g. m³/h), curve units.</li>
<li><b>Output Formats</b>: <code>process</code>/<code>json</code>/<code>csv</code> on port 0; <code>influxdb</code>/<code>json</code>/<code>csv</code> on port 1.</li>
<li><b>Output formats</b>: <code>process</code>/<code>json</code>/<code>csv</code> on port 0; <code>influxdb</code>/<code>json</code>/<code>csv</code> on port 1.</li>
<li><b>Position</b> (menu): <code>upstream</code> / <code>atEquipment</code> / <code>downstream</code> relative to a parent group/station.</li>
</ul>