Compare commits

...

1 Commits

Author SHA1 Message Date
znetsixe
34a4ef0610 feat(menu): global icon-picker visual layer + asset wizard
* iconHelpers.js (new): shared SVG library + renderSelectPicker /
  renderToggle helpers, injected once per editor session by MenuManager.
  Pulls the visual layer out of machineGroupControl so every node that
  loads /<node>/menu.js inherits the cards without per-node code.
* logger.js, physicalPosition.js: new initVisuals() step that upgrades
  the native checkbox + select to icon cards using the shared helpers.
  Native controls stay in the DOM (hidden) as the save targets.
* asset.js: rewrite the asset selector into a left->right wizard —
  chip strip (Supplier > Type > Model > Unit), per-stage type-to-filter
  combobox, node-aware spec strip + curve mini-chart sparkline. Models
  are server-side enriched with a slim previewCurve per softwareType
  (rotatingMachine Q-H, valve Cv, diffuser SOTE; measurement has no
  curve data yet). Hidden native selects remain canonical save targets.
* MenuManager: each menu's initEditor now owns its own initVisuals
  call so async-data menus (asset) can sequence visuals after loadData.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:10:28 +02:00
5 changed files with 889 additions and 41 deletions

View File

@@ -19,6 +19,7 @@ class AssetMenu {
return null;
}
const softwareType = category.softwareType || key;
return {
...category,
label: category.label || category.softwareType || key,
@@ -28,11 +29,18 @@ class AssetMenu {
types: (supplier.types || []).map((type) => ({
...type,
id: type.id || type.name,
models: (type.models || []).map((model) => ({
...model,
id: model.id || model.name,
units: model.units || []
}))
models: (type.models || []).map((model) => {
const id = model.id || model.name;
// Enrich each model with a slim preview curve (or null) so the
// editor wizard can draw a sparkline without a round-trip.
const previewCurve = this.buildPreviewCurve(softwareType, id, model.name);
return {
...model,
id,
units: model.units || [],
previewCurve: previewCurve || null
};
})
}))
}))
};
@@ -86,12 +94,353 @@ class AssetMenu {
};
}
// Client-side wizard layer: chips, combobox, spec strip, curve mini-chart.
// Listens to change events on the hidden <select>s that wireEvents already
// populates — so cascade/reset logic stays in one place.
getVisualInjectionCode(nodeName) {
return `
// Asset wizard visuals for ${nodeName}
(function injectAssetWizardCss() {
const id = 'evolv-asset-wizard-css';
if (document.getElementById(id)) return;
const css = [
'.evolv-asset-hidden-natives { position:absolute !important; left:-9999px !important; height:0 !important; overflow:hidden; }',
'.evolv-asset-wizard { display:flex; flex-direction:column; gap:10px; margin:6px 0 4px 0; }',
'.evolv-asset-chips { display:flex; flex-wrap:wrap; gap:6px; align-items:center; }',
'.evolv-asset-chip {',
' display:flex; align-items:center; gap:8px;',
' border:2px solid #d0d0d0; border-radius:18px; background:#fafafa;',
' padding:6px 12px; cursor:pointer; user-select:none;',
' font:inherit; color:#333;',
' transition:border-color 80ms ease-out, background 80ms ease-out;',
'}',
'.evolv-asset-chip:hover { border-color:#86bbdd; background:#f5fafd; }',
'.evolv-asset-chip[aria-selected="true"] { border-color:#1F4E79; background:#eaf4fb; }',
'.evolv-asset-chip[disabled] { opacity:0.5; cursor:not-allowed; }',
'.evolv-asset-chip-icon { color:#1F4E79; font-size:14px; }',
'.evolv-asset-chip-text { display:flex; flex-direction:column; line-height:1.15; text-align:left; }',
'.evolv-asset-chip-label { font-size:10px; font-weight:600; color:#888; letter-spacing:0.5px; text-transform:uppercase; }',
'.evolv-asset-chip-value { font-size:13px; font-weight:600; color:#1F4E79; max-width:160px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
'.evolv-asset-chip-value[data-empty="true"] { color:#aaa; font-weight:500; font-style:italic; }',
'.evolv-asset-chip-sep { color:#aaa; font-size:18px; line-height:1; user-select:none; }',
'.evolv-asset-combobox { display:flex; flex-direction:column; gap:4px; border:1px solid #d0d0d0; border-radius:4px; background:#fff; padding:8px; }',
'.evolv-asset-combobox-search { width:100%; box-sizing:border-box; padding:6px 8px; border:1px solid #ccc; border-radius:3px; font:inherit; }',
'.evolv-asset-combobox-search:focus { outline:none; border-color:#1F4E79; box-shadow:0 0 0 2px rgba(31,78,121,0.15); }',
'.evolv-asset-combobox-list { max-height:220px; overflow-y:auto; }',
'.evolv-asset-combobox-option {',
' padding:6px 10px; cursor:pointer; border-radius:3px;',
' font-size:13px; color:#333;',
'}',
'.evolv-asset-combobox-option:hover,',
'.evolv-asset-combobox-option.evolv-asset-combobox-option-active { background:#eaf4fb; color:#1F4E79; }',
'.evolv-asset-combobox-empty { padding:6px 10px; color:#888; font-size:12px; font-style:italic; }',
'.evolv-asset-summary { display:grid; grid-template-columns:1fr 200px; gap:12px; border:1px solid #e2e2e2; border-radius:4px; padding:10px 12px; background:#fafafa; align-items:center; }',
'.evolv-asset-specs { font-size:12px; color:#333; display:flex; flex-direction:column; gap:3px; }',
'.evolv-asset-spec-row { display:flex; gap:6px; }',
'.evolv-asset-spec-key { color:#888; min-width:80px; }',
'.evolv-asset-spec-val { color:#1F4E79; font-weight:600; }',
'.evolv-asset-curve { width:200px; height:90px; }',
'.evolv-asset-curve svg { width:100%; height:100%; display:block; }',
'.evolv-asset-curve-empty { display:flex; align-items:center; justify-content:center; color:#aaa; font-size:11px; font-style:italic; text-align:center; }',
'.evolv-asset-tag-row { margin-top:4px; }',
'@media (max-width:560px) {',
' .evolv-asset-chips { flex-direction:column; align-items:stretch; }',
' .evolv-asset-chip-sep { display:none; }',
' .evolv-asset-chip { width:100%; }',
' .evolv-asset-summary { grid-template-columns:1fr; }',
' .evolv-asset-curve { width:100%; }',
'}'
].join('\\n');
const style = document.createElement('style');
style.id = id;
style.textContent = css;
document.head.appendChild(style);
})();
window.EVOLV.nodes.${nodeName}.assetMenu.initVisuals = function(node) {
const wizard = document.getElementById('evolv-asset-wizard');
if (!wizard) return;
const stageMap = { supplier: 'node-input-supplier', type: 'node-input-assetType', model: 'node-input-model', unit: 'node-input-unit' };
const downstreamOf = { supplier: ['type','model','unit'], type: ['model','unit'], model: ['unit'], unit: [] };
const getSelect = (stage) => document.getElementById(stageMap[stage]);
const chips = Array.from(wizard.querySelectorAll('.evolv-asset-chip'));
const combobox = document.getElementById('evolv-asset-combobox');
const search = combobox ? combobox.querySelector('.evolv-asset-combobox-search') : null;
const list = combobox ? combobox.querySelector('.evolv-asset-combobox-list') : null;
const summary = document.getElementById('evolv-asset-summary');
const specsEl = document.getElementById('evolv-asset-specs');
const curveEl = document.getElementById('evolv-asset-curve');
let activeStage = null;
let activeIndex = -1;
// Update the chip value text from the live <select>. Empty selects
// show the placeholder; populated selects show the option label.
function syncChip(stage) {
const chip = chips.find((c) => c.getAttribute('data-stage') === stage);
if (!chip) return;
const select = getSelect(stage);
const valueEl = chip.querySelector('.evolv-asset-chip-value');
const labelDefault = stage === 'supplier' ? 'Select…' : '—';
if (!select || !select.value) {
valueEl.textContent = labelDefault;
valueEl.setAttribute('data-empty', 'true');
chip.disabled = false; // stage is reachable but empty
} else {
const opt = select.options[select.selectedIndex];
valueEl.textContent = (opt && opt.textContent) ? opt.textContent : select.value;
valueEl.removeAttribute('data-empty');
}
}
function syncAllChips() {
['supplier','type','model','unit'].forEach(syncChip);
}
function refreshAriaSelected() {
chips.forEach((c) => c.setAttribute('aria-selected', c.getAttribute('data-stage') === activeStage ? 'true' : 'false'));
}
function closeCombobox() {
activeStage = null;
combobox.hidden = true;
refreshAriaSelected();
}
function openStage(stage) {
const select = getSelect(stage);
if (!select) return;
// Skip if the parent stage hasn't been resolved (e.g. type before supplier).
// The parent select would have an empty value in that case.
const parentOrder = ['supplier','type','model','unit'];
const idx = parentOrder.indexOf(stage);
for (let i = 0; i < idx; i += 1) {
const parentSel = getSelect(parentOrder[i]);
if (!parentSel || !parentSel.value) {
if (window.RED && window.RED.notify) {
window.RED.notify('Pick ' + parentOrder[i] + ' first.', 'info');
}
return;
}
}
activeStage = stage;
combobox.hidden = false;
search.value = '';
search.placeholder = 'Filter ' + stage + '…';
renderList('');
refreshAriaSelected();
// Move focus to the search box so keyboard users get an immediate
// typing context after clicking a chip.
setTimeout(() => search.focus(), 0);
}
function getStageOptions(stage) {
const select = getSelect(stage);
if (!select) return [];
return Array.from(select.options)
.filter((o) => o.value !== '' && !o.disabled)
.map((o) => ({ value: o.value, label: o.textContent || o.value }));
}
function renderList(filter) {
if (!activeStage || !list) return;
const items = getStageOptions(activeStage);
const lc = String(filter || '').toLowerCase();
const matches = items.filter((it) => it.label.toLowerCase().includes(lc) || it.value.toLowerCase().includes(lc));
list.innerHTML = '';
activeIndex = matches.length ? 0 : -1;
if (!matches.length) {
const empty = document.createElement('div');
empty.className = 'evolv-asset-combobox-empty';
empty.textContent = items.length ? 'No matches.' : 'Nothing available — pick the previous stage first.';
list.appendChild(empty);
return;
}
matches.forEach((it, i) => {
const opt = document.createElement('div');
opt.className = 'evolv-asset-combobox-option';
if (i === 0) opt.classList.add('evolv-asset-combobox-option-active');
opt.setAttribute('role', 'option');
opt.setAttribute('data-value', it.value);
opt.textContent = it.label;
opt.addEventListener('mousedown', (e) => { e.preventDefault(); pickValue(it.value); });
opt.addEventListener('mouseenter', () => {
activeIndex = i;
list.querySelectorAll('.evolv-asset-combobox-option').forEach((el, j) => el.classList.toggle('evolv-asset-combobox-option-active', j === i));
});
list.appendChild(opt);
});
}
function pickValue(value) {
const select = getSelect(activeStage);
if (!select) return;
// Reset downstream selects so the cascade refreshes cleanly.
(downstreamOf[activeStage] || []).forEach((s) => {
const ds = getSelect(s);
if (ds) { ds.value = ''; ds.dispatchEvent(new Event('change', { bubbles: true })); }
});
select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true }));
syncAllChips();
updateSummary();
closeCombobox();
// Auto-advance to the next empty stage so the flow feels guided.
const order = ['supplier','type','model','unit'];
const i = order.indexOf(activeStage);
for (let n = i + 1; n < order.length; n += 1) {
const next = getSelect(order[n]);
if (next && (!next.value || next.options.length > 1)) {
openStage(order[n]);
return;
}
}
}
function updateSummary() {
const modelSel = getSelect('model');
if (!modelSel || !modelSel.value) {
if (summary) summary.hidden = true;
return;
}
if (summary) summary.hidden = false;
// Lookup the chosen model in the menuData tree to pull metadata + previewCurve.
const data = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
const categories = data.categories || {};
let chosenModel = null;
Object.keys(categories).forEach((catKey) => {
const cat = categories[catKey];
(cat.suppliers || []).forEach((sup) => (sup.types || []).forEach((t) => (t.models || []).forEach((m) => {
if (String(m.id || m.name) === String(modelSel.value)) chosenModel = m;
})));
});
renderSpecs(chosenModel);
renderCurve(chosenModel && chosenModel.previewCurve);
}
function renderSpecs(model) {
if (!specsEl) return;
specsEl.innerHTML = '';
if (!model) return;
const rows = [];
if (model.name) rows.push({ key: 'Name', val: model.name });
if (model.id && model.id !== model.name) rows.push({ key: 'ID', val: model.id });
if (Array.isArray(model.units) && model.units.length) rows.push({ key: 'Units', val: model.units.join(', ') });
// Pull any leftover scalar keys (rated_kW, voltage, etc.) — heuristic.
Object.keys(model).forEach((k) => {
if (['name','id','units','previewCurve','product_model_id','product_model_uuid'].indexOf(k) >= 0) return;
const v = model[k];
if (v == null) return;
if (typeof v === 'object') return;
rows.push({ key: k, val: String(v) });
});
rows.slice(0, 5).forEach((r) => {
const row = document.createElement('div');
row.className = 'evolv-asset-spec-row';
row.innerHTML = '<span class="evolv-asset-spec-key">' + r.key + '</span><span class="evolv-asset-spec-val">' + r.val + '</span>';
specsEl.appendChild(row);
});
}
function renderCurve(curve) {
if (!curveEl) return;
curveEl.innerHTML = '';
if (!curve || !Array.isArray(curve.x) || !Array.isArray(curve.y) || curve.x.length < 2) {
const empty = document.createElement('div');
empty.className = 'evolv-asset-curve-empty';
empty.textContent = 'no curve available';
curveEl.appendChild(empty);
return;
}
const W = 200, H = 90, P = 6;
const xs = curve.x, ys = curve.y;
const xMin = Math.min.apply(null, xs), xMax = Math.max.apply(null, xs);
const yMin = Math.min.apply(null, ys), yMax = Math.max.apply(null, ys);
const xRange = xMax - xMin || 1, yRange = yMax - yMin || 1;
const px = (x) => P + (W - 2*P) * (x - xMin) / xRange;
const py = (y) => (H - P) - (H - 2*P) * (y - yMin) / yRange;
const pts = xs.map((x, i) => px(x).toFixed(1) + ',' + py(ys[i]).toFixed(1)).join(' ');
const svg = [
'<svg viewBox="0 0 ' + W + ' ' + H + '" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">',
' <rect x="0" y="0" width="' + W + '" height="' + H + '" fill="#fff" stroke="#e5e5e5"/>',
' <polyline fill="none" stroke="#1F4E79" stroke-width="1.6" points="' + pts + '"/>',
' <g font-size="8" fill="#888" font-family="Arial, sans-serif">',
' <text x="' + P + '" y="9">' + (curve.yLabel || '') + '</text>',
' <text x="' + (W - P) + '" y="' + (H - 2) + '" text-anchor="end">' + (curve.xLabel || '') + '</text>',
(curve.legend ? '<text x="' + (W - P) + '" y="9" text-anchor="end" fill="#1F4E79">' + curve.legend + '</text>' : ''),
' </g>',
'</svg>'
].join('');
curveEl.innerHTML = svg;
}
// --- Wire chip clicks + select-change → chip refresh -------------
chips.forEach((chip) => {
chip.addEventListener('click', () => {
const stage = chip.getAttribute('data-stage');
if (activeStage === stage) {
closeCombobox();
} else {
openStage(stage);
}
});
});
['supplier','type','model','unit'].forEach((stage) => {
const sel = getSelect(stage);
if (sel) sel.addEventListener('change', () => { syncChip(stage); if (stage === 'model' || stage === 'unit') updateSummary(); });
});
// --- Combobox interactions -------------------------------------
if (search) {
search.addEventListener('input', () => renderList(search.value));
search.addEventListener('keydown', (e) => {
const optEls = Array.from(list.querySelectorAll('.evolv-asset-combobox-option'));
if (!optEls.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = (activeIndex + 1) % optEls.length;
optEls.forEach((el, i) => el.classList.toggle('evolv-asset-combobox-option-active', i === activeIndex));
optEls[activeIndex].scrollIntoView({ block: 'nearest' });
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = (activeIndex - 1 + optEls.length) % optEls.length;
optEls.forEach((el, i) => el.classList.toggle('evolv-asset-combobox-option-active', i === activeIndex));
optEls[activeIndex].scrollIntoView({ block: 'nearest' });
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && optEls[activeIndex]) {
pickValue(optEls[activeIndex].getAttribute('data-value'));
}
} else if (e.key === 'Escape') {
e.preventDefault();
closeCombobox();
}
});
}
// Initial render — fires after loadData has populated the natives.
syncAllChips();
updateSummary();
};
`;
}
getClientInitCode(nodeName) {
const htmlCode = this.getHtmlInjectionCode(nodeName);
const dataCode = this.getDataInjectionCode(nodeName);
const eventsCode = this.getEventInjectionCode(nodeName);
const syncCode = this.getSyncInjectionCode(nodeName);
const saveCode = this.getSaveInjectionCode(nodeName);
const visualCode = this.getVisualInjectionCode(nodeName);
return `
// --- AssetMenu for ${nodeName} ---
@@ -103,14 +452,19 @@ class AssetMenu {
${eventsCode}
${syncCode}
${saveCode}
${visualCode}
window.EVOLV.nodes.${nodeName}.assetMenu.initEditor = function(node) {
console.log('Initializing asset properties for ${nodeName}');
this.injectHtml();
this.wireEvents(node);
this.loadData(node).catch((error) =>
console.error('Asset menu load failed:', error)
);
const self = this;
this.loadData(node)
.then(() => { if (self.initVisuals) self.initVisuals(node); })
.catch((error) => {
console.error('Asset menu load failed:', error);
if (self.initVisuals) self.initVisuals(node);
});
};
`;
}
@@ -607,35 +961,165 @@ class AssetMenu {
}
getHtmlTemplate() {
// Wizard layout:
// 1. Section heading + chip strip (Supplier Type Model Unit).
// Chips are clickable buttons; clicking re-opens that stage's combobox
// and resets everything to its right.
// 2. Active-stage combobox: search input + filtered option list.
// 3. Spec strip + curve mini-chart (visible once a Model is picked).
// 4. Asset Tag row (still read-only, auto-resolved by syncAsset).
// 5. Hidden native <select>s (canonical save targets — Node-RED reads
// these on save; chip clicks mirror values into them).
return `
<!-- Asset Properties -->
<hr />
<h3>Asset selection</h3>
<div class="form-row">
<label for="node-input-supplier"><i class="fa fa-industry"></i> Supplier</label>
<select id="node-input-supplier" style="width:70%;"></select>
</div>
<div class="form-row">
<label for="node-input-assetType"><i class="fa fa-puzzle-piece"></i> Type</label>
<select id="node-input-assetType" style="width:70%;"></select>
</div>
<div class="form-row">
<label for="node-input-model"><i class="fa fa-wrench"></i> Model</label>
<select id="node-input-model" style="width:70%;"></select>
</div>
<div class="form-row">
<label for="node-input-unit"><i class="fa fa-balance-scale"></i> Unit</label>
<select id="node-input-unit" style="width:70%;"></select>
</div>
<div class="form-row">
<label for="node-input-assetTagNumber"><i class="fa fa-hashtag"></i> Asset Tag</label>
<input type="text" id="node-input-assetTagNumber" readonly style="width:70%;" />
<div class="form-tips" id="node-input-assetTagNumber-hint">Not registered yet</div>
<div class="evolv-asset-wizard" id="evolv-asset-wizard">
<div class="evolv-asset-chips" role="tablist" aria-label="Asset selection stages">
<button type="button" class="evolv-asset-chip" data-stage="supplier" aria-selected="false">
<span class="evolv-asset-chip-icon"><i class="fa fa-industry"></i></span>
<span class="evolv-asset-chip-text">
<span class="evolv-asset-chip-label">Supplier</span>
<span class="evolv-asset-chip-value" data-empty="true">Select…</span>
</span>
</button>
<span class="evolv-asset-chip-sep" aria-hidden="true"></span>
<button type="button" class="evolv-asset-chip" data-stage="type" aria-selected="false">
<span class="evolv-asset-chip-icon"><i class="fa fa-puzzle-piece"></i></span>
<span class="evolv-asset-chip-text">
<span class="evolv-asset-chip-label">Type</span>
<span class="evolv-asset-chip-value" data-empty="true"></span>
</span>
</button>
<span class="evolv-asset-chip-sep" aria-hidden="true"></span>
<button type="button" class="evolv-asset-chip" data-stage="model" aria-selected="false">
<span class="evolv-asset-chip-icon"><i class="fa fa-wrench"></i></span>
<span class="evolv-asset-chip-text">
<span class="evolv-asset-chip-label">Model</span>
<span class="evolv-asset-chip-value" data-empty="true">—</span>
</span>
</button>
<span class="evolv-asset-chip-sep" aria-hidden="true"></span>
<button type="button" class="evolv-asset-chip" data-stage="unit" aria-selected="false">
<span class="evolv-asset-chip-icon"><i class="fa fa-balance-scale"></i></span>
<span class="evolv-asset-chip-text">
<span class="evolv-asset-chip-label">Unit</span>
<span class="evolv-asset-chip-value" data-empty="true">—</span>
</span>
</button>
</div>
<div class="evolv-asset-combobox" id="evolv-asset-combobox" hidden>
<input type="text" class="evolv-asset-combobox-search" placeholder="Type to filter…" autocomplete="off" />
<div class="evolv-asset-combobox-list" role="listbox"></div>
</div>
<div class="evolv-asset-summary" id="evolv-asset-summary" hidden>
<div class="evolv-asset-specs" id="evolv-asset-specs"></div>
<div class="evolv-asset-curve" id="evolv-asset-curve"></div>
</div>
<div class="form-row evolv-asset-tag-row">
<label for="node-input-assetTagNumber"><i class="fa fa-hashtag"></i> Asset Tag</label>
<input type="text" id="node-input-assetTagNumber" readonly style="width:70%;" />
<div class="form-tips" id="node-input-assetTagNumber-hint">Not registered yet</div>
</div>
<div class="evolv-asset-hidden-natives" aria-hidden="true">
<select id="node-input-supplier"></select>
<select id="node-input-assetType"></select>
<select id="node-input-model"></select>
<select id="node-input-unit"></select>
</div>
</div>
<hr />
`;
}
// Build a slim preview curve `{x[], y[], xLabel, yLabel}` per model so the
// editor wizard can render a sparkline without round-tripping. Picks a
// representative slice for each software type's curve format.
buildPreviewCurve(softwareType, modelId, modelName) {
if (!modelId && !modelName) return null;
let loadCurve;
try {
// Lazy require — keep AssetMenu importable in environments that don't
// ship the curves dataset (e.g. unit tests with mocked managers).
loadCurve = require('../../index.js').loadCurve;
} catch (e) {
return null;
}
if (typeof loadCurve !== 'function') return null;
// Try id first, then name (legacy curve files are named after the
// model name rather than id — e.g. ECDV.json).
let curve = null;
try { curve = loadCurve(modelId) || (modelName ? loadCurve(modelName) : null); } catch (e) { curve = null; }
if (!curve) return null;
const type = String(softwareType || '').toLowerCase();
// Helpers — pick a "middle" key from an object whose keys are numeric strings.
const middleKey = (obj) => {
const keys = Object.keys(obj || {});
if (!keys.length) return null;
const sorted = keys.slice().sort((a, b) => Number(a) - Number(b));
return sorted[Math.floor(sorted.length / 2)];
};
const maxKey = (obj) => {
const keys = Object.keys(obj || {});
if (!keys.length) return null;
return keys.slice().sort((a, b) => Number(b) - Number(a))[0];
};
try {
if (type === 'rotatingmachine') {
// { np: { rpm: { x:[%speed], y:[..] } } } — pick top RPM slice.
const np = curve.np || curve;
const rpm = maxKey(np);
if (!rpm || !np[rpm] || !Array.isArray(np[rpm].x)) return null;
return {
x: np[rpm].x.slice(),
y: np[rpm].y.slice(),
xLabel: 'Speed (%)',
yLabel: 'Power',
legend: rpm + ' rpm'
};
}
if (type === 'valve') {
// { density: { dp: { x:[%opening], y:[m3/h] } } } — pick mid density/dp.
const densityKey = middleKey(curve);
if (!densityKey) return null;
const dpMap = curve[densityKey] || {};
const dpKey = middleKey(dpMap);
if (!dpKey || !dpMap[dpKey] || !Array.isArray(dpMap[dpKey].x)) return null;
return {
x: dpMap[dpKey].x.slice(),
y: dpMap[dpKey].y.slice(),
xLabel: 'Opening (%)',
yLabel: 'Flow (m³/h)',
legend: 'ρ=' + densityKey + ' · Δp=' + dpKey
};
}
if (type === 'diffuser') {
// { sote_curve: { coverage: { x:[flux], y:[%] } }, ... } — pick mid coverage on sote_curve.
const sote = curve.sote_curve || curve.SOTE_curve || curve;
const covKey = middleKey(sote);
if (!covKey || !sote[covKey] || !Array.isArray(sote[covKey].x)) return null;
return {
x: sote[covKey].x.slice(),
y: sote[covKey].y.slice(),
xLabel: 'Flux (Nm³/h·m²)',
yLabel: 'SOTE (%)',
legend: covKey + '% coverage'
};
}
// measurement + unknowns: no representative curve yet.
return null;
} catch (e) {
return null;
}
}
getHtmlInjectionCode(nodeName) {
const htmlTemplate = this.getHtmlTemplate()
.replace(/`/g, '\\`')

239
src/menu/iconHelpers.js Normal file
View File

@@ -0,0 +1,239 @@
'use strict';
// iconHelpers.js — shared visual layer for EVOLV editor menus.
//
// The other menu modules (logger, physicalPosition, …) render their HTML
// as plain Node-RED form rows with native <select>/<input> controls. This
// module emits a single client-side helper bundle (`window.EVOLV.iconHelpers`)
// that those menus call from their `initVisuals(node)` step to upgrade the
// native controls in-place to icon cards.
//
// The native controls stay in the DOM (hidden) so Node-RED's load/save
// path is untouched — clicks on the cards mirror back into the original
// <select>/<input>.
class IconHelpers {
static getClientInitCode() {
// Single IIFE so multiple menus on the same editor session share one
// copy of the helpers + one <style> tag.
return `
window.EVOLV = window.EVOLV || {};
if (!window.EVOLV.iconHelpers) {
window.EVOLV.iconHelpers = (function () {
const BLUE = '#1F4E79';
const STEEL = '#607484';
const UNIT = '#50a8d9';
const RED = '#B03A2E';
const AMBER = '#B7791F';
// ---- CSS (injected once) -----------------------------------
const CSS_ID = 'evolv-icon-pickers-css';
if (!document.getElementById(CSS_ID)) {
const style = document.createElement('style');
style.id = CSS_ID;
style.textContent = [
'.evolv-icon-picker { display:flex; gap:6px; flex-wrap:wrap; margin:6px 0 8px 0; }',
'.evolv-icon-option {',
' width:72px; height:72px; 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, opacity 80ms ease-out;',
'}',
'.evolv-icon-option:hover { border-color:#86bbdd; background:#f5fafd; }',
'.evolv-icon-option:focus { outline:2px solid #1F4E79; outline-offset:2px; }',
'.evolv-icon-option-on { border-color:#50a8d9; background:#eaf4fb; }',
'.evolv-icon-glyph { width:100%; height:46px; display:flex; align-items:center; justify-content:center; }',
'.evolv-icon-option svg { width:100%; height:100%; display:block; }',
'.evolv-icon-label { font-size:10px; line-height:1; font-weight:600; color:#333; white-space:nowrap; letter-spacing:0; }',
'.evolv-icon-option:not(.evolv-icon-option-on) .evolv-icon-label { color:#888; }',
'.evolv-icon-option-on .evolv-off-cross { display:none; }',
'.evolv-native-hidden { position:absolute !important; opacity:0 !important; width:1px !important; height:1px !important; pointer-events:none !important; }',
'.evolv-native-row-compact label { display:none; }',
'.evolv-compact-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:6px 0 8px 0; }',
'.evolv-log-toggle:not(.evolv-icon-option-on) svg .evolv-log-symbol,',
'.evolv-distance-toggle:not(.evolv-icon-option-on) svg .evolv-ruler-body { opacity:0.45; filter:grayscale(1); }',
].join('\\n');
document.head.appendChild(style);
}
// ---- SVG library (inline, no external assets) --------------
const SVG = {
error: \`
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="18" y="11" width="44" height="36" rx="3" fill="#fff" stroke="\${STEEL}" stroke-width="2.4"/>
<circle cx="40" cy="29" r="13" fill="#fff" stroke="\${RED}" stroke-width="3"/>
<line x1="34" y1="23" x2="46" y2="35" stroke="\${RED}" stroke-width="3.4" stroke-linecap="round"/>
<line x1="46" y1="23" x2="34" y2="35" stroke="\${RED}" stroke-width="3.4" stroke-linecap="round"/>
</svg>\`,
warn: \`
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="18" y="11" width="44" height="36" rx="3" fill="#fff" stroke="\${STEEL}" stroke-width="2.4"/>
<path d="M40 17 L54 41 H26 Z" fill="#fff" stroke="\${AMBER}" stroke-width="3" stroke-linejoin="round"/>
<line x1="40" y1="25" x2="40" y2="33" stroke="\${AMBER}" stroke-width="3" stroke-linecap="round"/>
<circle cx="40" cy="38" r="2.2" fill="\${AMBER}"/>
</svg>\`,
info: \`
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="18" y="11" width="44" height="36" rx="3" fill="#fff" stroke="\${STEEL}" stroke-width="2.4"/>
<circle cx="40" cy="29" r="13" fill="#fff" stroke="\${BLUE}" stroke-width="3"/>
<line x1="40" y1="28" x2="40" y2="37" stroke="\${BLUE}" stroke-width="3.2" stroke-linecap="round"/>
<circle cx="40" cy="22" r="2.4" fill="\${BLUE}"/>
</svg>\`,
debug: \`
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="13" y="12" width="54" height="34" rx="3" fill="#fff" stroke="\${STEEL}" stroke-width="2.4"/>
<path d="M20 34 H29 L33 24 L40 39 L46 29 H59" fill="none" stroke="\${BLUE}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="59" cy="29" r="3" fill="\${UNIT}" stroke="#fff" stroke-width="1"/>
</svg>\`,
logToggle: \`
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<g class="evolv-log-symbol">
<rect x="10" y="10" width="60" height="38" rx="3" fill="#1F2933" stroke="\${STEEL}" stroke-width="2.4"/>
<path d="M18 22 L26 29 L18 36" fill="none" stroke="#7ED957" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="30" y1="38" x2="50" y2="38" stroke="#7ED957" stroke-width="2.6" stroke-linecap="round"/>
</g>
<g class="evolv-off-cross" stroke="\${RED}" stroke-width="4.5" stroke-linecap="round">
<line x1="14" y1="12" x2="66" y2="46"/>
</g>
</svg>\`,
upstream: \`
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="4" y="22" width="42" height="14" fill="#f7fafc" stroke="\${STEEL}" stroke-width="2.4"/>
<line x1="9" y1="29" x2="38" y2="29" stroke="\${BLUE}" stroke-width="2.2" stroke-linecap="round"/>
<path d="M33 25 L39 29 L33 33" fill="none" stroke="\${BLUE}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="58" cy="29" r="12" fill="#f7fafc" stroke="\${STEEL}" stroke-width="2.4"/>
<polygon points="52,22 52,36 66,29" fill="\${STEEL}"/>
</svg>\`,
atEquipment: \`
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="40" cy="34" r="13" fill="#f7fafc" stroke="\${STEEL}" stroke-width="2.4"/>
<polygon points="33,25 33,43 48,34" fill="\${STEEL}"/>
<line x1="40" y1="14" x2="40" y2="21" stroke="\${STEEL}" stroke-width="2"/>
<circle cx="40" cy="9" r="6.5" fill="#fff" stroke="\${BLUE}" stroke-width="2.2"/>
<line x1="34" y1="9" x2="46" y2="9" stroke="\${BLUE}" stroke-width="1.6"/>
</svg>\`,
downstream: \`
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="22" cy="29" r="12" fill="#f7fafc" stroke="\${STEEL}" stroke-width="2.4"/>
<polygon points="16,22 16,36 30,29" fill="\${STEEL}"/>
<rect x="34" y="22" width="42" height="14" fill="#f7fafc" stroke="\${STEEL}" stroke-width="2.4"/>
<line x1="42" y1="29" x2="71" y2="29" stroke="\${BLUE}" stroke-width="2.2" stroke-linecap="round"/>
<path d="M65 25 L71 29 L65 33" fill="none" stroke="\${BLUE}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>\`,
distance: \`
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<g class="evolv-ruler-body">
<rect x="12" y="22" width="56" height="14" rx="1.5" fill="#fff" stroke="\${STEEL}" stroke-width="2.4"/>
<g stroke="\${STEEL}" stroke-width="1.8" stroke-linecap="round">
<line x1="20" y1="22" x2="20" y2="30"/>
<line x1="28" y1="22" x2="28" y2="27"/>
<line x1="36" y1="22" x2="36" y2="30"/>
<line x1="44" y1="22" x2="44" y2="27"/>
<line x1="52" y1="22" x2="52" y2="30"/>
<line x1="60" y1="22" x2="60" y2="27"/>
</g>
</g>
<g class="evolv-off-cross" stroke="\${RED}" stroke-width="4.5" stroke-linecap="round">
<line x1="16" y1="14" x2="64" y2="46"/>
</g>
</svg>\`,
};
// ---- Helpers -----------------------------------------------
function dispatchChange(el) {
el.dispatchEvent(new Event('change', { bubbles: true }));
}
// renderSelectPicker: replace a native <select> with a row of
// icon cards. labels object maps option.value → display string.
function renderSelectPicker(select, holder, icons, labels) {
if (!select || !holder || holder.dataset.evolvReady === '1') return;
holder.dataset.evolvReady = '1';
select.classList.add('evolv-native-hidden');
const options = Array.from(select.options).map((option) => ({
value: option.value,
title: option.textContent || option.value,
label: (labels && labels[option.value]) || option.textContent || option.value,
svg: icons[option.value],
})).filter((option) => option.svg);
holder.innerHTML = options.map((option) => (
'<div class="evolv-icon-option" data-value="' + option.value + '" role="radio" tabindex="0"' +
' aria-label="' + option.title + '" aria-checked="false" title="' + option.title + '">' +
' <div class="evolv-icon-glyph">' + option.svg + '</div>' +
' <div class="evolv-icon-label">' + option.label + '</div>' +
'</div>'
)).join('');
const buttons = Array.from(holder.querySelectorAll('.evolv-icon-option'));
function sync() {
const current = select.value || (options[0] && options[0].value) || '';
for (const button of buttons) {
const on = button.getAttribute('data-value') === current;
button.classList.toggle('evolv-icon-option-on', on);
button.setAttribute('aria-checked', String(on));
}
}
function pick(value) {
select.value = value;
dispatchChange(select);
sync();
}
for (const button of buttons) {
button.addEventListener('click', () => pick(button.getAttribute('data-value')));
button.addEventListener('keydown', (event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
pick(button.getAttribute('data-value'));
}
});
}
select.addEventListener('change', sync);
sync();
}
// renderToggle: replace a checkbox with a single icon card whose
// label flips between {on, off}. Passing a string for label
// uses the same string for both states.
function renderToggle(checkbox, holder, svg, label) {
if (!checkbox || !holder || holder.dataset.evolvReady === '1') return;
holder.dataset.evolvReady = '1';
checkbox.classList.add('evolv-native-hidden');
const labels = typeof label === 'string' ? { on: label, off: label } : label;
holder.innerHTML =
'<div class="evolv-icon-glyph">' + svg + '</div>' +
'<div class="evolv-icon-label">' + labels.off + '</div>';
const labelEl = holder.querySelector('.evolv-icon-label');
function sync() {
const on = checkbox.checked;
holder.classList.toggle('evolv-icon-option-on', on);
holder.setAttribute('aria-checked', String(on));
if (labelEl) labelEl.textContent = on ? labels.on : labels.off;
}
function toggle() {
checkbox.checked = !checkbox.checked;
dispatchChange(checkbox);
sync();
}
holder.addEventListener('click', toggle);
holder.addEventListener('keydown', (event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
toggle();
}
});
checkbox.addEventListener('change', sync);
sync();
}
return { SVG, renderSelectPicker, renderToggle };
})();
}
`;
}
}
module.exports = IconHelpers;

View File

@@ -3,6 +3,7 @@ const AssetMenu = require('./asset.js');
const LoggerMenu = require('./logger.js');
const PhysicalPositionMenu = require('./physicalPosition.js');
const AquonSamplesMenu = require('./aquonSamples.js');
const IconHelpers = require('./iconHelpers.js');
const ConfigManager = require('../configs');
class MenuManager {
@@ -138,6 +139,9 @@ class MenuManager {
window.EVOLV.nodes = window.EVOLV.nodes || {};
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
// Shared icon-picker helpers (no-op if already loaded by another node)
${IconHelpers.getClientInitCode()}
// Initialize menu namespaces
${menuTypes.map(type => `window.EVOLV.nodes.${nodeName}.${type}Menu = window.EVOLV.nodes.${nodeName}.${type}Menu || {};`).join('\n ')}
@@ -163,6 +167,8 @@ class MenuManager {
try {
${menuTypes.map(type => `
try {
// initEditor is responsible for calling initVisuals
// at the right time (after any async data load).
if (window.EVOLV.nodes.${nodeName}.${type}Menu && window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor) {
window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor(node);
}

View File

@@ -103,12 +103,68 @@ getHtmlInjectionCode(nodeName) {
`;
}
// 5) Compose everything into one clientside payload
// 5) Client-side: upgrade native controls to icon cards.
//
// Runs after wireEvents (which has already hooked the checkbox + select).
// Adds a small toggle card next to the native checkbox and a 4-icon
// picker row next to the native select; the natives are then hidden.
getVisualInjectionCode(nodeName) {
return `
// Logger visual upgrade for ${nodeName}
window.EVOLV.nodes.${nodeName}.loggerMenu.initVisuals = function(node) {
const helpers = window.EVOLV && window.EVOLV.iconHelpers;
if (!helpers) return;
// --- Log toggle (replaces native checkbox + label) ----------
const checkbox = document.getElementById('node-input-enableLog');
if (checkbox) {
const row = checkbox.closest('.form-row');
if (row && !document.getElementById('evolv-log-toggle-' + node.id)) {
row.classList.add('evolv-native-row-compact');
const holder = document.createElement('div');
holder.id = 'evolv-log-toggle-' + node.id;
holder.className = 'evolv-icon-option evolv-log-toggle';
holder.setAttribute('role', 'switch');
holder.setAttribute('tabindex', '0');
holder.setAttribute('aria-label', 'Logging');
holder.setAttribute('aria-checked', 'false');
holder.setAttribute('title', 'Logging');
row.appendChild(holder);
helpers.renderToggle(checkbox, holder, helpers.SVG.logToggle, { on: 'Log', off: 'Off' });
}
}
// --- Log-level picker (replaces native select) --------------
const select = document.getElementById('node-input-logLevel');
if (select) {
const row = document.getElementById('row-logLevel');
if (row && !document.getElementById('evolv-log-level-picker-' + node.id)) {
row.classList.add('evolv-native-row-compact');
const holder = document.createElement('div');
holder.id = 'evolv-log-level-picker-' + node.id;
holder.className = 'evolv-icon-picker';
holder.setAttribute('role', 'radiogroup');
holder.setAttribute('aria-label', 'Log level');
row.appendChild(holder);
helpers.renderSelectPicker(
select,
holder,
{ error: helpers.SVG.error, warn: helpers.SVG.warn, info: helpers.SVG.info, debug: helpers.SVG.debug },
{ error: 'Error', warn: 'Warn', info: 'Info', debug: 'Debug' }
);
}
}
};
`;
}
// 6) Compose everything into one clientside payload
getClientInitCode(nodeName) {
const dataCode = this.getDataInjectionCode(nodeName);
const eventCode = this.getEventInjectionCode(nodeName);
const saveCode = this.getSaveInjectionCode(nodeName);
const htmlCode = this.getHtmlInjectionCode(nodeName);
const dataCode = this.getDataInjectionCode(nodeName);
const eventCode = this.getEventInjectionCode(nodeName);
const saveCode = this.getSaveInjectionCode(nodeName);
const htmlCode = this.getHtmlInjectionCode(nodeName);
const visualCode = this.getVisualInjectionCode(nodeName);
return `
// --- LoggerMenu for ${nodeName} ---
@@ -119,13 +175,16 @@ getHtmlInjectionCode(nodeName) {
${dataCode}
${eventCode}
${saveCode}
${visualCode}
// oneditprepare calls this
// oneditprepare calls this. Visual upgrade runs last so the natives
// are already populated + wired.
window.EVOLV.nodes.${nodeName}.loggerMenu.initEditor = function(node) {
// ------------------ BELOW sequence is important! -------------------------------
this.injectHtml();
this.loadData(node);
this.wireEvents(node);
if (this.initVisuals) this.initVisuals(node);
};
`;
}

View File

@@ -245,12 +245,69 @@ getSaveInjectionCode(nodeName) {
`;
}
// 7) Compose everything into one client bundle
// 7) Client-side: upgrade native controls to icon cards.
//
// Runs after wireEvents. Wraps the position <select> with a 3-card row
// (upstream / atEquipment / downstream) and the hasDistance checkbox
// with a single toggle card. The native controls are hidden but stay
// in the DOM as save targets.
getVisualInjectionCode(nodeName) {
return `
// PhysicalPosition visual upgrade for ${nodeName}
window.EVOLV.nodes.${nodeName}.positionMenu.initVisuals = function(node) {
const helpers = window.EVOLV && window.EVOLV.iconHelpers;
if (!helpers) return;
// --- Position picker (replaces native <select>) -------------
const select = document.getElementById('node-input-positionVsParent');
if (select) {
const row = select.closest('.form-row');
if (row && !document.getElementById('evolv-position-picker-' + node.id)) {
row.classList.add('evolv-native-row-compact');
const holder = document.createElement('div');
holder.id = 'evolv-position-picker-' + node.id;
holder.className = 'evolv-icon-picker';
holder.setAttribute('role', 'radiogroup');
holder.setAttribute('aria-label', 'Physical position vs parent');
row.appendChild(holder);
helpers.renderSelectPicker(
select,
holder,
{ upstream: helpers.SVG.upstream, atEquipment: helpers.SVG.atEquipment, downstream: helpers.SVG.downstream },
{ upstream: 'Upstream', atEquipment: 'At', downstream: 'Downstream' }
);
}
}
// --- Distance toggle (replaces native checkbox) -------------
const checkbox = document.getElementById('node-input-hasDistance');
if (checkbox) {
const row = checkbox.closest('.form-row');
if (row && !document.getElementById('evolv-distance-toggle-' + node.id)) {
row.classList.add('evolv-native-row-compact');
const holder = document.createElement('div');
holder.id = 'evolv-distance-toggle-' + node.id;
holder.className = 'evolv-icon-option evolv-distance-toggle';
holder.setAttribute('role', 'switch');
holder.setAttribute('tabindex', '0');
holder.setAttribute('aria-label', 'Distance');
holder.setAttribute('aria-checked', 'false');
holder.setAttribute('title', 'Distance');
row.appendChild(holder);
helpers.renderToggle(checkbox, holder, helpers.SVG.distance, { on: 'Distance', off: 'Off' });
}
}
};
`;
}
// 8) Compose everything into one client bundle
getClientInitCode(nodeName) {
const htmlCode = this.getHtmlInjectionCode(nodeName);
const dataCode = this.getDataInjectionCode(nodeName);
const eventCode = this.getEventInjectionCode(nodeName);
const saveCode = this.getSaveInjectionCode(nodeName);
const htmlCode = this.getHtmlInjectionCode(nodeName);
const dataCode = this.getDataInjectionCode(nodeName);
const eventCode = this.getEventInjectionCode(nodeName);
const saveCode = this.getSaveInjectionCode(nodeName);
const visualCode = this.getVisualInjectionCode(nodeName);
return `
// --- PhysicalPositionMenu for ${nodeName} ---
@@ -261,12 +318,15 @@ getSaveInjectionCode(nodeName) {
${dataCode}
${eventCode}
${saveCode}
${visualCode}
// hook into oneditprepare
// hook into oneditprepare. Visual upgrade runs last so the natives
// are already populated + wired.
window.EVOLV.nodes.${nodeName}.positionMenu.initEditor = function(node) {
this.injectHtml();
this.loadData(node);
this.wireEvents(node);
if (this.initVisuals) this.initVisuals(node);
};
`;
}