feat(mgc): consume shared icon-picker visuals + modernize editor menu

* compact-fields.js (new): trimmed to MGC-only output-format pickers
  (processOutputFormat / dbaseOutputFormat). The logger toggle/level
  and physical-position visuals now come from generalFunctions'
  shared iconHelpers, auto-injected via /machineGroupControl/menu.js.
* mode-cards.js: strategy cards re-styled — Most-efficient (BEP bell
  with dot on the curve peak), Priority (clean staircase), Maintenance
  (Font Awesome fa-wrench). Rendezvous toggle flips Active / Inactive
  label dynamically.
* mgc.html: dropped the duplicated .mgc-icon-* CSS rules (now live in
  the shared iconHelpers stylesheet). Strategy + rendezvous CSS stays
  local (MGC-specific). Output picker holders switched to the shared
  .evolv-icon-picker class.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-18 11:10:54 +02:00
parent 472402c62d
commit 6833e9f3a8
4 changed files with 218 additions and 115 deletions

View File

@@ -15,24 +15,41 @@
dependency order: index.js (namespace + helpers) → modules → oneditprepare. -->
<script src="/machineGroupControl/editor/index.js"></script>
<script src="/machineGroupControl/editor/mode-cards.js"></script>
<script src="/machineGroupControl/editor/compact-fields.js"></script>
<script src="/machineGroupControl/editor/oneditprepare.js"></script>
<style>
/* Mode-card picker. Three cards stack horizontally; on a narrow editor pane
they wrap. Selected card gets a thick #50a8d9 (Unit-colour) border. */
.mgc-mode-cards { display:flex; gap:8px; flex-wrap:wrap; margin:6px 0 4px 0; }
.mgc-mode-card {
flex:1 1 0; min-width:140px; box-sizing:border-box;
/* MGC-specific UI: strategy mode cards + rendezvous toggle.
Generic .evolv-icon-picker / .evolv-icon-option styles for the
output-format pickers come from generalFunctions' iconHelpers (auto-
injected by /menu.js). */
.mgc-mode-cards,
.mgc-toggle-row { display:flex; gap:6px; flex-wrap:wrap; margin:6px 0 4px 0; }
.mgc-mode-card,
.mgc-toggle-card {
width:94px; height:86px; box-sizing:border-box;
border:2px solid #d0d0d0; border-radius:4px; background:#fafafa;
padding:6px 8px 8px 8px; cursor:pointer; user-select:none;
display:flex; flex-direction:column; gap:4px;
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;
}
.mgc-mode-card:hover { border-color:#86bbdd; background:#f5fafd; }
.mgc-mode-card-on { border-color:#50a8d9; background:#eaf4fb; }
.mgc-mode-card-svg svg { width:100%; height:auto; max-height:90px; display:block; }
.mgc-mode-card-label { font-weight:600; font-size:12px; color:#333; }
.mgc-mode-card-caption { font-size:10px; color:#666; line-height:1.3; }
.mgc-mode-card:hover,
.mgc-toggle-card:hover { border-color:#86bbdd; background:#f5fafd; }
.mgc-mode-card:focus,
.mgc-toggle-card:focus { outline:2px solid #1F4E79; outline-offset:2px; }
.mgc-mode-card-on,
.mgc-toggle-card-on { border-color:#50a8d9; background:#eaf4fb; }
.mgc-mode-card-svg,
.mgc-toggle-card-svg { width:100%; height:54px; display:flex; align-items:center; justify-content:center; }
.mgc-mode-card-svg svg,
.mgc-toggle-card-svg svg { width:100%; height:100%; display:block; }
.mgc-mode-card-label,
.mgc-toggle-card-label { font-size:10px; line-height:1; font-weight:600; color:#333; white-space:nowrap; letter-spacing:0; }
.mgc-toggle-card:not(.mgc-toggle-card-on) .mgc-toggle-card-svg { opacity:0.45; filter:grayscale(1); }
.mgc-toggle-card:not(.mgc-toggle-card-on) .mgc-toggle-card-label { color:#888; }
.mgc-hidden-checkbox { position:absolute; opacity:0; width:1px; height:1px; pointer-events:none; }
.mgc-section-divider { border:0; border-top:1px solid #d6d6d6; margin:12px 0; }
.mgc-output-row > label { white-space:nowrap; width:130px; }
</style>
<script>
@@ -89,7 +106,7 @@
// Initialize the menu data for the node, then the visual modules.
// Both attach to window.EVOLV.nodes.machineGroupControl.* — the
// menu endpoint populates loggerMenu/positionMenu/initEditor; the
// editor scripts populate editor.modeCards/demandContract.
// editor scripts populate editor.modeCards/rendezvousToggle/compactFields.
const waitForMenuData = () => {
if (window.EVOLV?.nodes?.machineGroupControl?.initEditor) {
window.EVOLV.nodes.machineGroupControl.initEditor(self);
@@ -133,48 +150,39 @@
role="radiogroup" aria-label="Control strategy mode">
<!-- mode-cards.js renders three card divs here -->
</div>
<p style="margin-top:8px;color:#666;font-size:11px;">
Demand is self-describing per <code>set.demand</code> message: a bare number is
treated as % of group capacity; <code>{value, unit}</code> with a flow unit
(<code>m3/h</code>, <code>l/s</code>, <code>m3/s</code>, &hellip;) is dispatched
in absolute terms. Negative value stops all pumps.
</p>
<hr class="mgc-section-divider" />
<h3>Rendezvous planner</h3>
<div class="form-row" style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" id="node-input-useRendezvous"
style="width:auto;margin:0;vertical-align:middle;" />
<label for="node-input-useRendezvous" style="width:auto;margin:0;cursor:pointer;">
Same-time landing
</label>
<div class="form-row mgc-toggle-row">
<input type="checkbox" id="node-input-useRendezvous" class="mgc-hidden-checkbox" />
<div id="mgc-rendezvous-toggle" class="mgc-toggle-card"
role="switch" tabindex="0" aria-label="Same-time landing"
aria-checked="false" title="Same-time landing"></div>
</div>
<p style="margin-top:4px;color:#666;font-size:11px;">
When enabled (default), every dispatch is routed through the rendezvous
planner regardless of control strategy: per-pump moves are delayed so all
pumps reach their setpoint at the same wall-clock instant
<code>t* = max(eta<sub>i</sub>)</code>. When disabled, every
<code>flowmovement</code> fires immediately and each pump ramps at its
own configured reaction speed (legacy behaviour).
</p>
<hr class="mgc-section-divider" />
<h3>Output Formats</h3>
<div class="form-row">
<div class="form-row mgc-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="mgc-process-output-picker" class="evolv-icon-picker"
role="radiogroup" aria-label="Process output format"></div>
</div>
<div class="form-row">
<div class="form-row mgc-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="mgc-dbase-output-picker" class="evolv-icon-picker"
role="radiogroup" aria-label="Database output format"></div>
</div>
<hr class="mgc-section-divider" />
<!-- Logger fields injected here -->
<div id="logger-fields-placeholder"></div>

View File

@@ -0,0 +1,92 @@
// compact-fields.js — MGC-only output-format icon picker.
//
// Logger toggle/level and physical-position visuals now live in the shared
// generalFunctions/src/menu/iconHelpers.js (auto-injected by MenuManager), so
// the only MGC-local visuals left are the two output-format dropdowns
// (processOutputFormat, dbaseOutputFormat) — those fields aren't part of any
// shared menu.
(function () {
const editor = window.EVOLV?.nodes?.machineGroupControl?.editor;
if (!editor) return;
const BLUE = '#1F4E79';
const STEEL = '#607484';
// MGC-only SVGs (output formats only — logger/position SVGs come from
// window.EVOLV.iconHelpers.SVG).
const SVG = {
process: `
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="10" y="14" width="20" height="30" rx="2" fill="#f7fafc" stroke="${STEEL}" stroke-width="2.4"/>
<rect x="50" y="14" width="20" height="30" rx="2" fill="#f7fafc" stroke="${STEEL}" stroke-width="2.4"/>
<line x1="30" y1="29" x2="46" y2="29" stroke="${BLUE}" stroke-width="3" stroke-linecap="round"/>
<path d="M42 24 L48 29 L42 34" fill="none" stroke="${BLUE}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`,
json: `
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<g fill="none" stroke="${BLUE}" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M30 14 C22 16 22 26 27 29 C22 32 22 42 30 44"/>
<path d="M50 14 C58 16 58 26 53 29 C58 32 58 42 50 44"/>
</g>
<g fill="${STEEL}">
<circle cx="36" cy="29" r="2.2"/>
<circle cx="44" cy="29" r="2.2"/>
</g>
</svg>`,
csv: `
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="12" y="12" width="56" height="34" rx="2" fill="#fff" stroke="${STEEL}" stroke-width="2.4"/>
<line x1="12" y1="22" x2="68" y2="22" stroke="${STEEL}" stroke-width="2"/>
<g stroke="${STEEL}" stroke-width="1.6">
<line x1="12" y1="34" x2="68" y2="34"/>
<line x1="31" y1="12" x2="31" y2="46"/>
<line x1="49" y1="12" x2="49" y2="46"/>
</g>
</svg>`,
influxdb: `
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<ellipse cx="40" cy="15" rx="22" ry="6" fill="#f7fafc" stroke="${STEEL}" stroke-width="2.4"/>
<path d="M18 15 V42 C18 46 28 49 40 49 C52 49 62 46 62 42 V15" fill="#f7fafc" stroke="${STEEL}" stroke-width="2.4"/>
<path d="M18 28 C26 32 54 32 62 28" fill="none" stroke="${STEEL}" stroke-width="1.6" opacity="0.6"/>
<path d="M22 39 L30 32 L38 41 L46 34 L54 38" fill="none" stroke="${BLUE}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`,
};
const outputIcons = {
process: SVG.process,
json: SVG.json,
csv: SVG.csv,
influxdb: SVG.influxdb,
};
const outputLabels = {
process: 'Process',
json: 'JSON',
csv: 'CSV',
influxdb: 'Influx',
};
function initOutputFormats() {
const helpers = window.EVOLV?.iconHelpers;
if (!helpers) return;
const processSelect = document.getElementById('node-input-processOutputFormat');
const processHolder = document.getElementById('mgc-process-output-picker');
if (processSelect && processHolder) {
helpers.renderSelectPicker(processSelect, processHolder, outputIcons, outputLabels);
}
const dbaseSelect = document.getElementById('node-input-dbaseOutputFormat');
const dbaseHolder = document.getElementById('mgc-dbase-output-picker');
if (dbaseSelect && dbaseHolder) {
helpers.renderSelectPicker(dbaseSelect, dbaseHolder, outputIcons, outputLabels);
}
}
function init() {
initOutputFormats();
}
editor.compactFields = { init };
})();

View File

@@ -1,14 +1,10 @@
// mode-cards.js — visual radio picker for the three control-strategy modes.
// mode-cards.js — visual pickers for control-strategy modes and planner flags.
//
// Replaces the plain <select id="node-input-mode"> with three illustrated
// cards. The original <input> stays in the DOM but is hidden — Node-RED reads
// its value on save, exactly as before. Clicking a card sets that value and
// fires editor.emitModeChange so downstream UI (none today, future widgets
// such as a parameter panel) can re-render.
// Replaces the plain mode field with compact illustrated controls. The
// original inputs stay in the DOM but are hidden — Node-RED reads their values
// on save, exactly as before.
//
// Three cards: optimalControl (BEP-curve), priorityControl (flow ladder),
// maintenance (status-only badge). SVGs are inline so the editor doesn't
// need to fetch additional assets.
// SVGs are inline so the editor doesn't need to fetch additional assets.
(function () {
const editor = window.EVOLV?.nodes?.machineGroupControl?.editor;
@@ -17,95 +13,61 @@
const MODES = [
{
value: 'optimalControl',
label: 'optimalControl',
caption: 'Picks the pump combination whose BEP sits closest to current demand.',
ariaLabel: 'Optimal control',
label: 'Most-efficient',
svg: `
<svg viewBox="0 0 160 90" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<text x="6" y="14" font-size="9" fill="#444">η</text>
<line x1="14" y1="78" x2="154" y2="78" stroke="#444" stroke-width="1"/>
<line x1="14" y1="78" x2="14" y2="14" stroke="#444" stroke-width="1"/>
<text x="118" y="88" font-size="8" fill="#666">demand →</text>
<!-- Three pump-combination efficiency humps. Each combination has
its own BEP (peak). The optimizer "gravitates" toward whichever
peak sits closest to the current demand. Quadratic-Bezier peak
formula: peak_y = (y0 + 2*cy + y1)/4 — so for y0=y1=78 (foot on
x-axis), cy=22 → peak_y=50, cy=-26 → peak_y=26, cy=10 → peak_y=44. -->
<path d="M 16 78 Q 32 22 50 78" fill="none" stroke="#888" stroke-width="1.1"/>
<path d="M 44 78 Q 72 -26 100 78" fill="none" stroke="#1E8449" stroke-width="2"/>
<path d="M 92 78 Q 122 10 152 78" fill="none" stroke="#888" stroke-width="1.1"/>
<!-- BEP markers sit ON each hump's apex — small grey for unpicked
combos, large red for the selected (winner) combination. -->
<circle cx="33" cy="50" r="2" fill="#888"/>
<circle cx="72" cy="26" r="3.2" fill="#C0392B" stroke="#fff" stroke-width="1"/>
<circle cx="122" cy="44" r="2" fill="#888"/>
<!-- Current demand (dashed line) lines up with combo #2's BEP, so
combo #2 wins — drawn thicker/green above. -->
<line x1="72" y1="14" x2="72" y2="78" stroke="#1F4E79" stroke-dasharray="2 2" stroke-width="0.9"/>
<text x="46" y="20" font-size="7" fill="#1F4E79">demand</text>
<text x="80" y="22" font-size="7" fill="#C0392B" font-weight="bold">BEP</text>
<!-- Combination labels under each curve. -->
<text x="33" y="86" font-size="6" fill="#666" text-anchor="middle">P1</text>
<text x="72" y="86" font-size="6" fill="#1E8449" text-anchor="middle" font-weight="bold">P1+P2</text>
<text x="122" y="86" font-size="6" fill="#666" text-anchor="middle">P1+P2+P3</text>
<svg viewBox="0 0 120 72" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<line x1="12" y1="60" x2="112" y2="60" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<line x1="12" y1="60" x2="12" y2="10" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<path d="M 16 60 Q 62 -30 108 60" fill="none" stroke="#1E8449" stroke-width="3" stroke-linecap="round"/>
<line x1="62" y1="15" x2="62" y2="60" stroke="#1F4E79" stroke-dasharray="3 3" stroke-width="1.2"/>
<circle cx="62" cy="15" r="5.5" fill="#1E8449" stroke="#fff" stroke-width="1.6"/>
</svg>`,
},
{
value: 'priorityControl',
label: 'priorityControl',
caption: 'Sequential equal-flow ramp — fill pumps one-by-one in priority order.',
ariaLabel: 'Priority control',
label: 'Priority',
svg: `
<svg viewBox="0 0 160 90" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<text x="6" y="14" font-size="9" fill="#444">flow</text>
<line x1="14" y1="78" x2="154" y2="78" stroke="#444" stroke-width="1"/>
<line x1="14" y1="78" x2="14" y2="14" stroke="#444" stroke-width="1"/>
<text x="118" y="88" font-size="8" fill="#666">demand →</text>
<polyline points="14,72 50,72 50,52 86,52 86,32 122,32 122,16 154,16"
fill="none" stroke="#1F4E79" stroke-width="2"/>
<text x="28" y="86" font-size="7" fill="#666">P1</text>
<text x="64" y="86" font-size="7" fill="#666">P2</text>
<text x="100" y="86" font-size="7" fill="#666">P3</text>
<svg viewBox="0 0 120 72" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<line x1="12" y1="60" x2="112" y2="60" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<line x1="12" y1="60" x2="12" y2="10" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<polyline points="14,54 38,54 38,40 62,40 62,26 86,26 86,14 110,14"
fill="none" stroke="#1F4E79" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>
</svg>`,
},
{
value: 'maintenance',
label: 'maintenance',
caption: 'Monitor only. Dispatch and stop-all commands are rejected; status messages still flow.',
svg: `
<svg viewBox="0 0 160 90" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="80" cy="42" r="22" fill="none" stroke="#888" stroke-width="2"/>
<circle cx="80" cy="42" r="8" fill="#888"/>
<g stroke="#888" stroke-width="3" stroke-linecap="round">
<line x1="80" y1="14" x2="80" y2="24"/>
<line x1="80" y1="60" x2="80" y2="70"/>
<line x1="52" y1="42" x2="62" y2="42"/>
<line x1="98" y1="42" x2="108" y2="42"/>
<line x1="60" y1="22" x2="67" y2="29"/>
<line x1="93" y1="55" x2="100" y2="62"/>
<line x1="60" y1="62" x2="67" y2="55"/>
<line x1="93" y1="29" x2="100" y2="22"/>
</g>
<text x="80" y="84" text-anchor="middle" font-size="8" fill="#888">monitor only</text>
</svg>`,
ariaLabel: 'Maintenance',
label: 'Maintenance',
svg: `<i class="fa fa-wrench" style="font-size:40px;color:#607484;" aria-hidden="true"></i>`,
},
];
// Render the three cards into the placeholder div. The hidden <select> stays
// intact — the card click handler writes its value back to that <select> so
// Node-RED's save path is unchanged.
const RENDEZVOUS_SVG = `
<svg viewBox="0 0 120 72" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<line x1="12" y1="60" x2="112" y2="60" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<line x1="12" y1="60" x2="12" y2="10" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
<line x1="96" y1="12" x2="96" y2="60" stroke="#1F4E79" stroke-dasharray="3 3" stroke-width="1.4"/>
<path d="M 18 52 C 38 50, 64 38, 96 20" fill="none" stroke="#1E8449" stroke-width="2.6" stroke-linecap="round"/>
<path d="M 18 58 C 40 56, 64 42, 96 20" fill="none" stroke="#50a8d9" stroke-width="2.6" stroke-linecap="round"/>
<path d="M 18 44 C 42 44, 66 34, 96 20" fill="none" stroke="#C0392B" stroke-width="2.6" stroke-linecap="round"/>
<circle cx="96" cy="20" r="6" fill="#1F4E79" stroke="#fff" stroke-width="1.6"/>
</svg>`;
// Render the three cards into the placeholder div. The hidden input stays
// intact; the card click handler writes its value back so Node-RED's save
// path is unchanged.
function init(/* node */) {
const placeholder = document.getElementById('mgc-mode-cards');
const hidden = document.getElementById('node-input-mode');
if (!placeholder || !hidden) return;
placeholder.innerHTML = MODES.map((m) => `
<div class="mgc-mode-card" data-mode="${m.value}" role="radio" tabindex="0" aria-checked="false">
<div class="mgc-mode-card" data-mode="${m.value}" role="radio" tabindex="0"
aria-label="${m.ariaLabel}" aria-checked="false" title="${m.ariaLabel}">
<div class="mgc-mode-card-svg">${m.svg}</div>
<div class="mgc-mode-card-label">${m.label}</div>
<div class="mgc-mode-card-caption">${m.caption}</div>
</div>
`).join('');
@@ -138,5 +100,40 @@
syncHighlight();
}
function initRendezvousToggle(/* node */) {
const placeholder = document.getElementById('mgc-rendezvous-toggle');
const checkbox = document.getElementById('node-input-useRendezvous');
if (!placeholder || !checkbox) return;
placeholder.innerHTML = `
<div class="mgc-toggle-card-svg">${RENDEZVOUS_SVG}</div>
<div class="mgc-toggle-card-label">Inactive</div>
`;
const labelEl = placeholder.querySelector('.mgc-toggle-card-label');
function syncHighlight() {
const on = checkbox.checked;
placeholder.classList.toggle('mgc-toggle-card-on', on);
placeholder.setAttribute('aria-checked', String(on));
if (labelEl) labelEl.textContent = on ? 'Active' : 'Inactive';
}
function toggle() {
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
syncHighlight();
}
placeholder.addEventListener('click', toggle);
placeholder.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
toggle();
}
});
checkbox.addEventListener('change', syncHighlight);
syncHighlight();
}
editor.modeCards = { init };
editor.rendezvousToggle = { init: initRendezvousToggle };
})();

View File

@@ -12,5 +12,11 @@
if (ns.editor.modeCards && typeof ns.editor.modeCards.init === 'function') {
ns.editor.modeCards.init(node);
}
if (ns.editor.rendezvousToggle && typeof ns.editor.rendezvousToggle.init === 'function') {
ns.editor.rendezvousToggle.init(node);
}
if (ns.editor.compactFields && typeof ns.editor.compactFields.init === 'function') {
ns.editor.compactFields.init(node);
}
};
})();