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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const softwareType = category.softwareType || key;
|
||||||
return {
|
return {
|
||||||
...category,
|
...category,
|
||||||
label: category.label || category.softwareType || key,
|
label: category.label || category.softwareType || key,
|
||||||
@@ -28,11 +29,18 @@ class AssetMenu {
|
|||||||
types: (supplier.types || []).map((type) => ({
|
types: (supplier.types || []).map((type) => ({
|
||||||
...type,
|
...type,
|
||||||
id: type.id || type.name,
|
id: type.id || type.name,
|
||||||
models: (type.models || []).map((model) => ({
|
models: (type.models || []).map((model) => {
|
||||||
...model,
|
const id = model.id || model.name;
|
||||||
id: model.id || model.name,
|
// Enrich each model with a slim preview curve (or null) so the
|
||||||
units: model.units || []
|
// 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) {
|
getClientInitCode(nodeName) {
|
||||||
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
||||||
const dataCode = this.getDataInjectionCode(nodeName);
|
const dataCode = this.getDataInjectionCode(nodeName);
|
||||||
const eventsCode = this.getEventInjectionCode(nodeName);
|
const eventsCode = this.getEventInjectionCode(nodeName);
|
||||||
const syncCode = this.getSyncInjectionCode(nodeName);
|
const syncCode = this.getSyncInjectionCode(nodeName);
|
||||||
const saveCode = this.getSaveInjectionCode(nodeName);
|
const saveCode = this.getSaveInjectionCode(nodeName);
|
||||||
|
const visualCode = this.getVisualInjectionCode(nodeName);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
// --- AssetMenu for ${nodeName} ---
|
// --- AssetMenu for ${nodeName} ---
|
||||||
@@ -103,14 +452,19 @@ class AssetMenu {
|
|||||||
${eventsCode}
|
${eventsCode}
|
||||||
${syncCode}
|
${syncCode}
|
||||||
${saveCode}
|
${saveCode}
|
||||||
|
${visualCode}
|
||||||
|
|
||||||
window.EVOLV.nodes.${nodeName}.assetMenu.initEditor = function(node) {
|
window.EVOLV.nodes.${nodeName}.assetMenu.initEditor = function(node) {
|
||||||
console.log('Initializing asset properties for ${nodeName}');
|
console.log('Initializing asset properties for ${nodeName}');
|
||||||
this.injectHtml();
|
this.injectHtml();
|
||||||
this.wireEvents(node);
|
this.wireEvents(node);
|
||||||
this.loadData(node).catch((error) =>
|
const self = this;
|
||||||
console.error('Asset menu load failed:', error)
|
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() {
|
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 `
|
return `
|
||||||
<!-- Asset Properties -->
|
|
||||||
<hr />
|
<hr />
|
||||||
<h3>Asset selection</h3>
|
<h3>Asset selection</h3>
|
||||||
<div class="form-row">
|
<div class="evolv-asset-wizard" id="evolv-asset-wizard">
|
||||||
<label for="node-input-supplier"><i class="fa fa-industry"></i> Supplier</label>
|
<div class="evolv-asset-chips" role="tablist" aria-label="Asset selection stages">
|
||||||
<select id="node-input-supplier" style="width:70%;"></select>
|
<button type="button" class="evolv-asset-chip" data-stage="supplier" aria-selected="false">
|
||||||
</div>
|
<span class="evolv-asset-chip-icon"><i class="fa fa-industry"></i></span>
|
||||||
<div class="form-row">
|
<span class="evolv-asset-chip-text">
|
||||||
<label for="node-input-assetType"><i class="fa fa-puzzle-piece"></i> Type</label>
|
<span class="evolv-asset-chip-label">Supplier</span>
|
||||||
<select id="node-input-assetType" style="width:70%;"></select>
|
<span class="evolv-asset-chip-value" data-empty="true">Select…</span>
|
||||||
</div>
|
</span>
|
||||||
<div class="form-row">
|
</button>
|
||||||
<label for="node-input-model"><i class="fa fa-wrench"></i> Model</label>
|
<span class="evolv-asset-chip-sep" aria-hidden="true">›</span>
|
||||||
<select id="node-input-model" style="width:70%;"></select>
|
<button type="button" class="evolv-asset-chip" data-stage="type" aria-selected="false">
|
||||||
</div>
|
<span class="evolv-asset-chip-icon"><i class="fa fa-puzzle-piece"></i></span>
|
||||||
<div class="form-row">
|
<span class="evolv-asset-chip-text">
|
||||||
<label for="node-input-unit"><i class="fa fa-balance-scale"></i> Unit</label>
|
<span class="evolv-asset-chip-label">Type</span>
|
||||||
<select id="node-input-unit" style="width:70%;"></select>
|
<span class="evolv-asset-chip-value" data-empty="true">—</span>
|
||||||
</div>
|
</span>
|
||||||
<div class="form-row">
|
</button>
|
||||||
<label for="node-input-assetTagNumber"><i class="fa fa-hashtag"></i> Asset Tag</label>
|
<span class="evolv-asset-chip-sep" aria-hidden="true">›</span>
|
||||||
<input type="text" id="node-input-assetTagNumber" readonly style="width:70%;" />
|
<button type="button" class="evolv-asset-chip" data-stage="model" aria-selected="false">
|
||||||
<div class="form-tips" id="node-input-assetTagNumber-hint">Not registered yet</div>
|
<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>
|
</div>
|
||||||
<hr />
|
<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) {
|
getHtmlInjectionCode(nodeName) {
|
||||||
const htmlTemplate = this.getHtmlTemplate()
|
const htmlTemplate = this.getHtmlTemplate()
|
||||||
.replace(/`/g, '\\`')
|
.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 LoggerMenu = require('./logger.js');
|
||||||
const PhysicalPositionMenu = require('./physicalPosition.js');
|
const PhysicalPositionMenu = require('./physicalPosition.js');
|
||||||
const AquonSamplesMenu = require('./aquonSamples.js');
|
const AquonSamplesMenu = require('./aquonSamples.js');
|
||||||
|
const IconHelpers = require('./iconHelpers.js');
|
||||||
const ConfigManager = require('../configs');
|
const ConfigManager = require('../configs');
|
||||||
|
|
||||||
class MenuManager {
|
class MenuManager {
|
||||||
@@ -138,6 +139,9 @@ class MenuManager {
|
|||||||
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
||||||
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
|
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
|
// Initialize menu namespaces
|
||||||
${menuTypes.map(type => `window.EVOLV.nodes.${nodeName}.${type}Menu = window.EVOLV.nodes.${nodeName}.${type}Menu || {};`).join('\n ')}
|
${menuTypes.map(type => `window.EVOLV.nodes.${nodeName}.${type}Menu = window.EVOLV.nodes.${nodeName}.${type}Menu || {};`).join('\n ')}
|
||||||
|
|
||||||
@@ -163,6 +167,8 @@ class MenuManager {
|
|||||||
try {
|
try {
|
||||||
${menuTypes.map(type => `
|
${menuTypes.map(type => `
|
||||||
try {
|
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) {
|
if (window.EVOLV.nodes.${nodeName}.${type}Menu && window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor) {
|
||||||
window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor(node);
|
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) {
|
getClientInitCode(nodeName) {
|
||||||
const dataCode = this.getDataInjectionCode(nodeName);
|
const dataCode = this.getDataInjectionCode(nodeName);
|
||||||
const eventCode = this.getEventInjectionCode(nodeName);
|
const eventCode = this.getEventInjectionCode(nodeName);
|
||||||
const saveCode = this.getSaveInjectionCode(nodeName);
|
const saveCode = this.getSaveInjectionCode(nodeName);
|
||||||
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
||||||
|
const visualCode = this.getVisualInjectionCode(nodeName);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
// --- LoggerMenu for ${nodeName} ---
|
// --- LoggerMenu for ${nodeName} ---
|
||||||
@@ -119,13 +175,16 @@ getHtmlInjectionCode(nodeName) {
|
|||||||
${dataCode}
|
${dataCode}
|
||||||
${eventCode}
|
${eventCode}
|
||||||
${saveCode}
|
${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) {
|
window.EVOLV.nodes.${nodeName}.loggerMenu.initEditor = function(node) {
|
||||||
// ------------------ BELOW sequence is important! -------------------------------
|
// ------------------ BELOW sequence is important! -------------------------------
|
||||||
this.injectHtml();
|
this.injectHtml();
|
||||||
this.loadData(node);
|
this.loadData(node);
|
||||||
this.wireEvents(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) {
|
getClientInitCode(nodeName) {
|
||||||
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
||||||
const dataCode = this.getDataInjectionCode(nodeName);
|
const dataCode = this.getDataInjectionCode(nodeName);
|
||||||
const eventCode = this.getEventInjectionCode(nodeName);
|
const eventCode = this.getEventInjectionCode(nodeName);
|
||||||
const saveCode = this.getSaveInjectionCode(nodeName);
|
const saveCode = this.getSaveInjectionCode(nodeName);
|
||||||
|
const visualCode = this.getVisualInjectionCode(nodeName);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
// --- PhysicalPositionMenu for ${nodeName} ---
|
// --- PhysicalPositionMenu for ${nodeName} ---
|
||||||
@@ -261,12 +318,15 @@ getSaveInjectionCode(nodeName) {
|
|||||||
${dataCode}
|
${dataCode}
|
||||||
${eventCode}
|
${eventCode}
|
||||||
${saveCode}
|
${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) {
|
window.EVOLV.nodes.${nodeName}.positionMenu.initEditor = function(node) {
|
||||||
this.injectHtml();
|
this.injectHtml();
|
||||||
this.loadData(node);
|
this.loadData(node);
|
||||||
this.wireEvents(node);
|
this.wireEvents(node);
|
||||||
|
if (this.initVisuals) this.initVisuals(node);
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user