From 34a4ef061062bad12aa630c1a25a1ce8fe35227b Mon Sep 17 00:00:00 2001 From: znetsixe Date: Mon, 18 May 2026 11:10:28 +0200 Subject: [PATCH] feat(menu): global icon-picker visual layer + asset wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 //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) --- src/menu/asset.js | 542 +++++++++++++++++++++++++++++++++-- src/menu/iconHelpers.js | 239 +++++++++++++++ src/menu/index.js | 6 + src/menu/logger.js | 71 ++++- src/menu/physicalPosition.js | 72 ++++- 5 files changed, 889 insertions(+), 41 deletions(-) create mode 100644 src/menu/iconHelpers.js diff --git a/src/menu/asset.js b/src/menu/asset.js index 47bdac3..1de399f 100644 --- a/src/menu/asset.js +++ b/src/menu/asset.js @@ -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 . 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 = '' + r.key + '' + r.val + ''; + 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 = [ + '' + ].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 - -
- - -
-
- - -
-
- - -
-
- - -
Not registered yet
+
+
+ + + + + + + +
+ + + + + +
+ + +
Not registered yet
+
+ +

`; } + // 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, '\\`') diff --git a/src/menu/iconHelpers.js b/src/menu/iconHelpers.js new file mode 100644 index 0000000..55058a3 --- /dev/null +++ b/src/menu/iconHelpers.js @@ -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 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 +// . + +class IconHelpers { + static getClientInitCode() { + // Single IIFE so multiple menus on the same editor session share one + // copy of the helpers + one