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>
This commit is contained in:
lzm
2026-05-28 08:59:28 +02:00
parent 36eaa2f859
commit d7f6613892
10 changed files with 1570 additions and 270 deletions

View File

@@ -0,0 +1,431 @@
// 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(); },
};
})();