Files
measurement/src/editor/digital-channels.js
lzm d7f6613892 refactor(measurement): modularize editor JS
Move inline <script> from measurement.html into 8 modules under
src/editor/. measurement.js adds the static-file routes that serve them
to Node-RED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:10:05 +02:00

432 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/<type>.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(); },
};
})();