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:
431
src/editor/digital-channels.js
Normal file
431
src/editor/digital-channels.js
Normal 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(); },
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user