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>
432 lines
17 KiB
JavaScript
432 lines
17 KiB
JavaScript
// 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(); },
|
||
};
|
||
})();
|