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>
This commit is contained in:
@@ -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
239
src/menu/iconHelpers.js
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -103,12 +103,68 @@ getHtmlInjectionCode(nodeName) {
|
||||
`;
|
||||
}
|
||||
|
||||
// 5) Compose everything into one client‐side 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 client‐side 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);
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user