// Measurement editor — digital-mode channel row editor. // // Replaces the raw JSON textarea with a repeatable card UI. The textarea // remains the source of truth on the node (node.channels is still a JSON // string), so server-side parsing in nodeClass.js is untouched. // // IMPORTANT ARCHITECTURE NOTE: // Field edits (typing in key/unit, changing a dropdown, ticking a checkbox) // MUST NOT trigger a full rerender. A full rebuild destroys the input you // are typing into and the next keystroke is lost (one-letter-then-stop bug). // We split the two paths explicitly: // - commitFieldEdit() — state + textarea sync + targeted DOM updates // (duplicate-key red borders). Use for every per- // field edit. // - rerenderAll() — full rebuild. Use only for structural changes: // add channel, delete channel, expand/collapse, // raw-JSON toggle, init. (function () { const ns = window.MeasEditor = window.MeasEditor || {}; // --- Option sources --------------------------------------------------- // Canonical types map 1:1 to MeasurementContainer axes; for those, the // conversion machinery in generalFunctions expects unit ∈ a known set. // Free-text units would silently break conversion, so the unit field // becomes a select for canonical types. Custom types (humidity, co2, // voc, …) bypass conversion per the docs, so unit stays free text. const TYPE_OPTIONS = [ 'pressure', 'flow', 'power', 'temperature', 'volume', 'length', 'mass', 'energy', 'humidity', 'co2', 'voc', ]; const POSITION_OPTIONS = ['upstream', 'atEquipment', 'downstream']; const OUTLIER_METHODS = ['zScore', 'iqr', 'modifiedZScore']; // Per-type unit suggestions. The list is curated to the most common units // from generalFunctions/src/convert/definitions/.js; users who need // exotic units can fall back to raw-JSON view. const UNIT_OPTIONS = { pressure: ['Pa', 'kPa', 'MPa', 'hPa', 'bar', 'mbar', 'torr', 'psi'], flow: ['m³/s', 'm³/h', 'L/s', 'L/min', 'gpm'], power: ['W', 'kW', 'MW', 'hp'], temperature: ['C', 'K', 'F', 'R'], volume: ['mL', 'L', 'm³', 'gal'], length: ['mm', 'cm', 'm', 'km', 'in', 'ft'], mass: ['mg', 'g', 'kg', 't', 'oz', 'lb'], energy: ['Wh', 'kWh', 'J', 'kJ', 'MJ'], // custom types intentionally omitted → unit becomes free text }; const isCanonicalType = (t) => Object.prototype.hasOwnProperty.call(UNIT_OPTIONS, t); const getSmoothMethods = () => { const arr = window.EVOLV?.nodes?.measurement?.config?.smoothing?.smoothMethod?.rules?.values || []; const names = arr.map((o) => o.value); return names.length ? names : ['none', 'mean', 'min', 'max', 'sd', 'median', 'weightedMovingAverage', 'lowPass', 'highPass', 'bandPass', 'kalman', 'savitzkyGolay']; }; const newChannel = () => ({ key: '', type: 'pressure', position: 'atEquipment', unit: UNIT_OPTIONS.pressure[0], scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 }, smoothing: { smoothWindow: 10, smoothMethod: 'mean' }, outlierDetection: { enabled: false, method: 'zScore', threshold: 3 }, }); const mergeDefaults = (raw) => { const d = newChannel(); return { key: raw.key ?? '', type: raw.type ?? d.type, position: raw.position ?? d.position, unit: raw.unit ?? '', distance: raw.distance ?? null, scaling: { ...d.scaling, ...(raw.scaling || {}) }, smoothing: { ...d.smoothing, ...(raw.smoothing || {}) }, outlierDetection: { ...d.outlierDetection, ...(raw.outlierDetection || {}) }, }; }; // --- State ------------------------------------------------------------ let _channels = []; const _expanded = new Set(); let _jsonMode = false; // --- Small DOM helpers ------------------------------------------------ const el = (tag, attrs = {}, children = []) => { const e = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (v == null) continue; if (k === 'class') e.className = v; else if (k === 'style' && typeof v === 'object') Object.assign(e.style, v); else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v); else e.setAttribute(k, v); } for (const c of (Array.isArray(children) ? children : [children])) { if (c == null || c === false) continue; e.appendChild(typeof c === 'string' || typeof c === 'number' ? document.createTextNode(String(c)) : c); } return e; }; const selectFrom = (opts, value, onChange, extraClass) => { const sel = el('select', { class: 'meas-ch-input' + (extraClass ? ' ' + extraClass : '') }); const optsWithValue = value && !opts.includes(value) ? [...opts, value] : opts; for (const o of optsWithValue) sel.appendChild(el('option', { value: o }, o)); sel.value = value || ''; sel.addEventListener('change', () => onChange(sel.value)); return sel; }; const numInput = (value, onChange, opts = {}) => { const inp = el('input', { type: 'number', class: 'meas-ch-input meas-ch-num', step: opts.step ?? 'any', placeholder: opts.placeholder ?? '', value: (value === '' || value == null || Number.isNaN(value)) ? '' : value, }); inp.addEventListener('input', () => { const v = parseFloat(inp.value); onChange(Number.isFinite(v) ? v : (opts.allowNull ? null : 0)); }); return inp; }; const textInput = (value, onChange, placeholder) => { const inp = el('input', { type: 'text', class: 'meas-ch-input', value: value || '', placeholder: placeholder || '' }); inp.addEventListener('input', () => onChange(inp.value)); return inp; }; const checkbox = (checked, onChange, labelText) => { const cb = el('input', { type: 'checkbox' }); cb.checked = !!checked; cb.addEventListener('change', () => onChange(cb.checked)); return el('label', { class: 'meas-ch-cb' }, [cb, ' ', labelText]); }; // --- Sync + targeted updates ----------------------------------------- const serialize = () => JSON.stringify(_channels, null, 2); const syncTextarea = () => { const ta = document.getElementById('node-input-channels'); if (!ta) return; ta.value = serialize(); ta.dispatchEvent(new Event('input', { bubbles: true })); }; const refreshKeyValidationClasses = () => { const { dupes, blanks } = keyValidation(); document.querySelectorAll('#meas-channels-rows [data-role="ch-key"]').forEach((inp) => { const i = parseInt(inp.dataset.idx, 10); const bad = dupes.has(i) || blanks.has(i); inp.classList.toggle('meas-ch-err', bad); }); }; // Single entry point for every per-field edit. Does NOT rerender. const commitFieldEdit = () => { syncTextarea(); refreshKeyValidationClasses(); }; // --- Validation ------------------------------------------------------- const keyValidation = () => { const seen = new Map(); const dupes = new Set(); const blanks = new Set(); _channels.forEach((c, i) => { const k = (c.key || '').trim(); if (!k) { blanks.add(i); return; } if (seen.has(k)) { dupes.add(i); dupes.add(seen.get(k)); } else seen.set(k, i); }); return { dupes, blanks }; }; // --- Unit cell (type-driven, swapped in-place on type change) -------- const renderUnitCell = (channel, cardIndex) => { const cell = el('div', { class: 'meas-ch-unit-cell', 'data-role': 'ch-unit-cell', 'data-idx': String(cardIndex) }); if (isCanonicalType(channel.type)) { const opts = UNIT_OPTIONS[channel.type]; const sel = selectFrom(opts, channel.unit || opts[0], (v) => { channel.unit = v; commitFieldEdit(); }); cell.appendChild(sel); } else { const inp = textInput(channel.unit, (v) => { channel.unit = v; commitFieldEdit(); }, 'unit (free text)'); cell.appendChild(inp); } return cell; }; // Replace just the unit cell inside one card. No full rerender → focus // on the type select is preserved. const swapUnitCell = (cardIndex, channel) => { const old = document.querySelector(`#meas-channels-rows [data-role="ch-unit-cell"][data-idx="${cardIndex}"]`); if (!old) return; old.replaceWith(renderUnitCell(channel, cardIndex)); }; // --- Render: advanced sub-sections ------------------------------------ // These call commitFieldEdit() on edits (no rerender). The only // exceptions are the enabled-toggle checkboxes: ticking them dims the // sub-grid, which requires re-rendering JUST that card. We accept the // tiny focus blip on a checkbox click — focus on a checkbox after a // click isn't ergonomically important. const renderScalingSection = (channel, cardIndex) => { const sc = channel.scaling; return el('div', { class: 'meas-ch-sub' }, [ el('div', { class: 'meas-ch-sub-title' }, [ checkbox(sc.enabled, (v) => { sc.enabled = v; rerenderCard(cardIndex); }, 'Scaling'), ]), el('div', { class: 'meas-ch-sub-grid' + (sc.enabled ? '' : ' meas-ch-dim') }, [ el('label', {}, 'input min'), numInput(sc.inputMin, (v) => { sc.inputMin = v; commitFieldEdit(); }), el('label', {}, 'input max'), numInput(sc.inputMax, (v) => { sc.inputMax = v; commitFieldEdit(); }), el('label', {}, 'output min'), numInput(sc.absMin, (v) => { sc.absMin = v; commitFieldEdit(); }), el('label', {}, 'output max'), numInput(sc.absMax, (v) => { sc.absMax = v; commitFieldEdit(); }), el('label', {}, 'offset'), numInput(sc.offset, (v) => { sc.offset = v; commitFieldEdit(); }), ]), ]); }; const renderSmoothingSection = (channel) => { const sm = channel.smoothing; return el('div', { class: 'meas-ch-sub' }, [ el('div', { class: 'meas-ch-sub-title' }, 'Smoothing'), el('div', { class: 'meas-ch-sub-grid' }, [ el('label', {}, 'method'), selectFrom(getSmoothMethods(), sm.smoothMethod || 'mean', (v) => { sm.smoothMethod = v; commitFieldEdit(); }), el('label', {}, 'window'), numInput(sm.smoothWindow, (v) => { sm.smoothWindow = v; commitFieldEdit(); }, { step: 1, placeholder: '10' }), ]), ]); }; const renderOutlierSection = (channel, cardIndex) => { const od = channel.outlierDetection; return el('div', { class: 'meas-ch-sub' }, [ el('div', { class: 'meas-ch-sub-title' }, [ checkbox(od.enabled, (v) => { od.enabled = v; rerenderCard(cardIndex); }, 'Outlier detection'), ]), el('div', { class: 'meas-ch-sub-grid' + (od.enabled ? '' : ' meas-ch-dim') }, [ el('label', {}, 'method'), selectFrom(OUTLIER_METHODS, od.method || 'zScore', (v) => { od.method = v; commitFieldEdit(); }), el('label', {}, 'threshold'), numInput(od.threshold, (v) => { od.threshold = v; commitFieldEdit(); }, { placeholder: '3' }), ]), ]); }; // --- Render: one card ------------------------------------------------- const renderCard = (channel, index) => { const isExpanded = _expanded.has(index); // Key input — tagged with data-role + data-idx so the validation pass // can find and re-class it without rebuilding the card. const keyInput = textInput(channel.key, (v) => { channel.key = v; commitFieldEdit(); }, 'e.g. temperature'); keyInput.dataset.role = 'ch-key'; keyInput.dataset.idx = String(index); // Type select — on change: update unit (reset to first unit of the new // type if previous unit isn't valid there), swap the unit cell in // place, and sync. No card rebuild. const typeSelect = selectFrom(TYPE_OPTIONS, channel.type, (v) => { channel.type = v; if (isCanonicalType(v)) { const validUnits = UNIT_OPTIONS[v]; if (!validUnits.includes(channel.unit)) channel.unit = validUnits[0]; } swapUnitCell(index, channel); commitFieldEdit(); }, 'meas-ch-w-110'); const posSelect = selectFrom(POSITION_OPTIONS, channel.position, (v) => { channel.position = v; commitFieldEdit(); }, 'meas-ch-w-110'); const unitCell = renderUnitCell(channel, index); const head = el('div', { class: 'meas-ch-head', 'data-card-idx': String(index) }, [ el('span', { class: 'meas-ch-num-badge' }, '#' + (index + 1)), keyInput, typeSelect, posSelect, unitCell, el('button', { type: 'button', class: 'meas-ch-btn meas-ch-btn-toggle', title: isExpanded ? 'Hide advanced' : 'Show advanced (scaling / smoothing / outlier)', onclick: () => { if (isExpanded) _expanded.delete(index); else _expanded.add(index); rerenderAll(); }, }, isExpanded ? '▴ less' : '▾ more'), el('button', { type: 'button', class: 'meas-ch-btn meas-ch-btn-del', title: 'Remove this channel', onclick: () => { _channels.splice(index, 1); const next = new Set(); _expanded.forEach((i) => { if (i < index) next.add(i); else if (i > index) next.add(i - 1); }); _expanded.clear(); next.forEach((i) => _expanded.add(i)); syncTextarea(); rerenderAll(); }, }, '×'), ]); const card = el('div', { class: 'meas-ch-card', 'data-card-idx': String(index) }, [head]); if (isExpanded) { card.appendChild(el('div', { class: 'meas-ch-adv' }, [ renderScalingSection(channel, index), renderSmoothingSection(channel), renderOutlierSection(channel, index), ])); } return card; }; // Rebuild a single card in place. Used by the enabled-toggle handlers // that need to flip the dim class on a sub-grid. const rerenderCard = (index) => { const existing = document.querySelector(`#meas-channels-rows .meas-ch-card[data-card-idx="${index}"]`); if (!existing) { rerenderAll(); return; } const replacement = renderCard(_channels[index], index); existing.replaceWith(replacement); syncTextarea(); }; // --- Render: full list ------------------------------------------------ const rerenderAll = () => { const host = document.getElementById('meas-channels-rows'); if (!host) return; host.innerHTML = ''; if (_channels.length === 0) { host.appendChild(el('div', { class: 'meas-ch-empty' }, 'No channels yet. Click "+ Add channel" to define the first one.')); } else { _channels.forEach((c, i) => host.appendChild(renderCard(c, i))); } refreshKeyValidationClasses(); updateRawToggleButtonLabel(); }; const updateRawToggleButtonLabel = () => { const btn = document.getElementById('meas-channels-raw-toggle'); const raw = document.getElementById('meas-channels-raw'); if (!btn || !raw) return; raw.style.display = _jsonMode ? '' : 'none'; btn.textContent = _jsonMode ? '▴ Hide raw JSON' : '▾ Show raw JSON'; }; // --- Public API ------------------------------------------------------- ns.digitalChannels = { init(node) { const host = document.getElementById('meas-channels-rows'); if (!host) return; const ta = document.getElementById('node-input-channels'); const raw = (ta?.value || node?.channels || '[]').trim() || '[]'; try { const parsed = JSON.parse(raw); _channels = Array.isArray(parsed) ? parsed.map(mergeDefaults) : []; } catch { _channels = []; } const addBtn = document.getElementById('meas-channels-add'); if (addBtn && !addBtn.dataset.bound) { addBtn.dataset.bound = '1'; addBtn.addEventListener('click', () => { _channels.push(newChannel()); _expanded.add(_channels.length - 1); syncTextarea(); rerenderAll(); }); } const rawBtn = document.getElementById('meas-channels-raw-toggle'); if (rawBtn && !rawBtn.dataset.bound) { rawBtn.dataset.bound = '1'; rawBtn.addEventListener('click', () => { if (_jsonMode) { try { const parsed = JSON.parse((ta?.value || '[]').trim() || '[]'); if (!Array.isArray(parsed)) throw new Error('not an array'); _channels = parsed.map(mergeDefaults); } catch (e) { if (typeof RED !== 'undefined' && RED.notify) { RED.notify('Channels JSON is invalid: ' + e.message + ' — stay in JSON view to fix.', 'error'); } return; } } _jsonMode = !_jsonMode; rerenderAll(); }); } if (ta && !ta.dataset.boundBlur) { ta.dataset.boundBlur = '1'; ta.addEventListener('blur', () => { if (!_jsonMode) return; try { const parsed = JSON.parse(ta.value.trim() || '[]'); if (Array.isArray(parsed)) _channels = parsed.map(mergeDefaults); } catch { /* leave alone */ } }); } syncTextarea(); rerenderAll(); }, commit() { syncTextarea(); }, }; })();