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>
614 lines
35 KiB
HTML
614 lines
35 KiB
HTML
<!--
|
||
| S88-niveau | Primair (blokkleur) | Tekstkleur |
|
||
| ---------------------- | ------------------- | ---------- |
|
||
| **Area** | `#0f52a5` | wit |
|
||
| **Process Cell** | `#0c99d9` | wit |
|
||
| **Unit** | `#50a8d9` | zwart |
|
||
| **Equipment (Module)** | `#86bbdd` | zwart |
|
||
| **Control Module** | `#a9daee` | zwart |
|
||
|
||
-->
|
||
<!-- Load the dynamic menu & config endpoints -->
|
||
<script src="/rotatingMachine/menu.js"></script>
|
||
<script src="/rotatingMachine/configData.js"></script>
|
||
|
||
<script>
|
||
RED.nodes.registerType("rotatingMachine", {
|
||
category: "EVOLV",
|
||
color: "#86bbdd",
|
||
defaults: {
|
||
name: { value: "" },
|
||
|
||
// Define specific properties
|
||
speed: { value: 1, required: true },
|
||
startup: { value: 0 },
|
||
warmup: { value: 0 },
|
||
shutdown: { value: 0 },
|
||
cooldown: { value: 0 },
|
||
movementMode : { value: "staticspeed" }, // static or dynamic
|
||
machineCurve : { value: {}},
|
||
processOutputFormat: { value: "process" },
|
||
dbaseOutputFormat: { value: "influxdb" },
|
||
|
||
// Asset identifier surface. supplier/category/assetType are
|
||
// derived at runtime via assetResolver.resolveAssetMetadata(model);
|
||
// do NOT add them back here. See src/registry/README.md.
|
||
uuid: { value: "" },
|
||
assetTagNumber: { value: "" },
|
||
model: { value: "" },
|
||
unit: { value: "" },
|
||
curvePressureUnit: { value: "mbar" },
|
||
curveFlowUnit: { value: "" },
|
||
curvePowerUnit: { value: "kW" },
|
||
curveControlUnit: { value: "%" },
|
||
|
||
//logger properties
|
||
enableLog: { value: false },
|
||
logLevel: { value: "error" },
|
||
|
||
//physicalAspect
|
||
positionVsParent: { value: "" },
|
||
positionIcon: { value: "" },
|
||
hasDistance: { value: false },
|
||
distance: { value: 0 },
|
||
distanceUnit: { value: "m" },
|
||
distanceDescription: { value: "" }
|
||
|
||
},
|
||
inputs: 1,
|
||
outputs: 3,
|
||
inputLabels: ["Input"],
|
||
outputLabels: ["process", "dbase", "parent"],
|
||
icon: "font-awesome/fa-cog",
|
||
|
||
label: function () {
|
||
// No more `this.category` on the node — fall back to model id, then a
|
||
// generic name. supplier/category/type live in the registry now.
|
||
const stem = this.model ? this.model : "Machine";
|
||
return (this.positionIcon || "") + " " + stem;
|
||
},
|
||
|
||
oneditprepare: function() {
|
||
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(node);
|
||
} else if (++menuRetries < maxMenuRetries) {
|
||
setTimeout(waitForMenuData, 50);
|
||
} else {
|
||
console.warn("rotatingMachine: menu scripts failed to load within 5 seconds");
|
||
}
|
||
};
|
||
waitForMenuData();
|
||
|
||
// -----------------------------------------------------------
|
||
// 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;
|
||
|
||
// save asset fields
|
||
if (window.EVOLV?.nodes?.rotatingMachine?.assetMenu?.saveEditor) {
|
||
window.EVOLV.nodes.rotatingMachine.assetMenu.saveEditor(this);
|
||
}
|
||
// save logger fields
|
||
if (window.EVOLV?.nodes?.rotatingMachine?.loggerMenu?.saveEditor) {
|
||
window.EVOLV.nodes.rotatingMachine.loggerMenu.saveEditor(this);
|
||
}
|
||
// save position field
|
||
if (window.EVOLV?.nodes?.rotatingMachine?.positionMenu?.saveEditor) {
|
||
window.EVOLV.nodes.rotatingMachine.positionMenu.saveEditor(this);
|
||
}
|
||
|
||
["speed", "startup", "warmup", "shutdown", "cooldown"].forEach((field) => {
|
||
const element = document.getElementById(`node-input-${field}`);
|
||
const value = parseFloat(element?.value) || 0;
|
||
node[field] = value;
|
||
});
|
||
|
||
const modeEl = document.getElementById("node-input-movementMode");
|
||
node.movementMode = (modeEl && modeEl.value) ? modeEl.value : "staticspeed";
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<!-- Main UI Template -->
|
||
<script type="text/html" data-template-name="rotatingMachine">
|
||
|
||
<!-- ============================================================ -->
|
||
<!-- 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">m³/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 & 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 & 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
|
||
(y≈29 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 rm-output-row">
|
||
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
|
||
<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 rm-output-row">
|
||
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
||
<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 / Logger / Position menus injected by menu.js -->
|
||
<div id="asset-fields-placeholder"></div>
|
||
<div id="logger-fields-placeholder"></div>
|
||
<div id="position-fields-placeholder"></div>
|
||
|
||
</script>
|
||
|
||
<script type="text/html" data-help-name="rotatingMachine">
|
||
<p><b>Rotating Machine</b>: individual pump / compressor / blower control module. Runs a 10-state S88 sequence, predicts flow and power from a supplier curve, and publishes process + telemetry outputs each second.</p>
|
||
|
||
<h3>Configuration</h3>
|
||
<ul>
|
||
<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 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 & 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>Position</b> (menu): <code>upstream</code> / <code>atEquipment</code> / <code>downstream</code> relative to a parent group/station.</li>
|
||
</ul>
|
||
|
||
<h3>Input topics (<code>msg.topic</code>)</h3>
|
||
<ul>
|
||
<li><code>setMode</code> — <code>payload</code> = <code>auto</code> | <code>virtualControl</code> | <code>fysicalControl</code></li>
|
||
<li><code>execSequence</code> — <code>payload</code> = <code>{source, action:"execSequence", parameter: "startup"|"shutdown"|"entermaintenance"|"exitmaintenance"}</code></li>
|
||
<li><code>execMovement</code> — <code>payload</code> = <code>{source, action:"execMovement", setpoint: 0..100}</code> (controller %)</li>
|
||
<li><code>flowMovement</code> — <code>payload</code> = <code>{source, action:"flowMovement", setpoint: <flow in configured unit>}</code></li>
|
||
<li><code>emergencystop</code> — <code>payload</code> = <code>{source, action:"emergencystop"}</code>. Aborts any active movement.</li>
|
||
<li><code>simulateMeasurement</code> — <code>payload</code> = <code>{type:"pressure"|"flow"|"temperature"|"power", position, value, unit}</code>. Injects dashboard-side measurement.</li>
|
||
<li><code>showWorkingCurves</code>, <code>CoG</code> — diagnostics, reply arrives on port 0.</li>
|
||
</ul>
|
||
|
||
<h3>Output ports</h3>
|
||
<ol>
|
||
<li><b>process</b> — delta-compressed process payload. Consumers must cache and merge each tick. Keys use 4-segment format <code>type.variant.position.childId</code> (e.g. <code>flow.predicted.downstream.default</code>).</li>
|
||
<li><b>dbase</b> — InfluxDB telemetry.</li>
|
||
<li><b>parent</b> — <code>registerChild</code> handshake for a parent <code>machineGroupControl</code> / <code>pumpingStation</code>.</li>
|
||
</ol>
|
||
|
||
<h3>State machine</h3>
|
||
<p>States: <code>idle → starting → warmingup → operational → (accelerating ⇄ decelerating) → operational → stopping → coolingdown → idle</code>. <code>emergencystop → off</code> is reachable from every active state.</p>
|
||
<p>If a <code>shutdown</code> or <code>emergencystop</code> sequence is requested while a setpoint move is in flight (<code>accelerating</code> / <code>decelerating</code>), the move is aborted automatically and the sequence proceeds once the FSM returns to <code>operational</code>.</p>
|
||
|
||
<h3>Predictions</h3>
|
||
<p>Flow and power predictions only produce meaningful values once at least one pressure child is reporting (or a <code>simulateMeasurement</code> pressure is injected). Inject BOTH upstream and downstream for best accuracy.</p>
|
||
</script>
|