data-centre-nameplates / nameplates-app.js
constructelligence's picture
Data Centre Construction: rebrand + frontier-construction-AI positioning
5b8c6ad verified
Raw History Blame Contribute Delete
37.4 kB
// Nameplates page controller: load an equipment schedule, photograph a plate,
// OCR it (Tesseract.js, in the browser), parse and check the fields against the
// schedule, and keep an asset register that exports to CSV.
import {
FIELDS, FIELD_GROUPS, EQUIPMENT_TYPES, STATUS_LABELS, SAMPLE_SCHEDULE, SAMPLE_PLATES,
parseNameplate, parseEquipmentSchedule, candidatesFor, checkAgainst, statusOf,
registerSummary, registerCsv, duplicateSerials, voltagesIn,
} from './nameplate-model.js';
const TESSERACT = 'https://cdn.jsdelivr.net/npm/tesseract.js@5.1.1/dist/tesseract.min.js';
const STORE = 'buildvision.nameplates.v1';
const LOW_CONF = 70;
const NUMERIC = new Set(['phase', 'hz', 'kva', 'kw', 'amps', 'pf', 'mca', 'mocp', 'sccr', 'charge', 'tons', 'btu', 'cfm', 'gpm', 'batteryAh', 'cells', 'rpm', 'weight', 'uHeight', 'powerW', 'rackLoad', 'outlets', 'portSpeed', 'ports']);
// Dark enough for white label text on the photo overlay.
const COLORS = {
manufacturer: '#0f2547', model: '#1b5fa8', serial: '#7340a8', voltage: '#c0262d', phase: '#b04f00', hz: '#7a4a35',
kva: '#1f7a32', kw: '#0b7285', amps: '#a8327a', pf: '#8a1c5a', mca: '#5c6b00', mocp: '#8a5a00', sccr: '#6b2d8a',
enclosure: '#3a4a5a', standards: '#4a3f6b', batteryType: '#5a2d6b', batteryAh: '#7a3f00', cells: '#6b4a00',
refrigerant: '#4d6b1f', charge: '#2f6b3a', tons: '#0b6b6b', btu: '#0b5c8a', cfm: '#5a5c00', gpm: '#00636b',
uHeight: '#3a3f8a', formFactor: '#4a3f8a', powerW: '#8a5a1f', rackLoad: '#6b5a1f', outletTypes: '#6b3a6b', outlets: '#6b3a6b', portSpeed: '#3a6b5a', ports: '#3a6b5a',
rpm: '#7a2c2c', weight: '#444a52', mfgDate: '#555b63',
};
const $ = id => document.getElementById(id);
const els = Object.fromEntries([
'npSchedDrop', 'npSchedFile', 'npSchedSample', 'npSchedClear', 'npSchedStatus', 'npDrop', 'npFile', 'npSample', 'npRotate',
'npRun', 'npProg', 'npStatus', 'npModel', 'npCanvas', 'npEmpty', 'npQuality', 'npReading', 'npVerdict', 'npFields', 'npTag', 'npTagNote',
'npChecks', 'npAdd', 'npText', 'npSummary', 'npCsv', 'npClearReg', 'npSearch', 'npFilter', 'npRows', 'npEmptyReg', 'npMissing',
].map(id => [id.slice(2, 3).toLowerCase() + id.slice(3), $(id)]));
const state = {
schedule: null, // { name, items }
register: [], // [{ id, tag, type, fields, checks, status, thumb, scannedAt }]
image: null, // canvas holding the photo as loaded (after rotation)
reading: null, // parseNameplate() result, with edits applied
candidates: [],
tag: '',
sample: 0,
editing: null, // register id being reviewed (Add then updates it)
photoFilter: 'all', // register status filter
photoQuery: '', // register search text
photos: new Map(), // register id -> { image, reading } for this session (photos are not stored)
};
window.BuildVisionNameplates = state;
// ------------------------------------------------------------------ storage
function load() {
try {
const s = JSON.parse(localStorage.getItem(STORE) || 'null');
if (s) { state.schedule = s.schedule || null; state.register = s.register || []; }
} catch { /* private window or cleared storage: start empty */ }
}
function save() {
try { localStorage.setItem(STORE, JSON.stringify({ schedule: state.schedule, register: state.register })); }
catch { setStatus('Could not save the register in this browser. Download the CSV to keep it.', true); }
}
function setStatus(msg, err = false) { els.status.textContent = msg; els.status.classList.toggle('err', err); }
function setProgress(p) { els.prog.style.width = `${Math.round(p * 100)}%`; }
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
// "Model" -> "model" in a sentence, but MOCP and kVA keep their case.
const inSentence = label => (label.slice(1) === label.slice(1).toLowerCase() ? label.toLowerCase() : label);
const typeLabel = k => (EQUIPMENT_TYPES.find(t => t[0] === k) || [])[1] || '';
// ------------------------------------------------------------------ schedule
function setSchedule(text, name) {
const { items, problems } = parseEquipmentSchedule(text);
if (!items.length) {
els.schedStatus.textContent = problems[0] || 'No equipment rows found.';
els.schedStatus.classList.add('err');
return;
}
state.schedule = { name, items };
els.schedStatus.classList.remove('err');
save();
renderSchedule(problems);
if (state.reading) rematch();
renderRegister();
}
function renderSchedule(problems = []) {
els.schedClear.hidden = !state.schedule;
if (!state.schedule) { els.schedStatus.textContent = 'No schedule loaded: plates are read but not checked.'; return; }
const n = state.schedule.items.length;
els.schedStatus.textContent = `${state.schedule.name}: ${n} item${n === 1 ? '' : 's'}.${problems.length ? ` ${problems.join(' ')}` : ''}`;
}
// ------------------------------------------------------------------ photo
async function decodeFile(file) {
const url = URL.createObjectURL(file);
try {
const img = new Image();
img.src = url;
await img.decode();
return img;
} finally { URL.revokeObjectURL(url); }
}
async function loadImageFile(file) {
try {
const img = await decodeFile(file);
setImage(img, img.naturalWidth, img.naturalHeight);
setStatus(`Loaded ${file.name}. Read it when ready.`);
} catch {
setStatus('That file could not be opened as an image.', true);
}
}
/**
* A walk's worth of photos: read each, match it to its likeliest tag and put it on the register.
* Flagged and low-confidence plates are then reviewed by clicking their row.
*/
async function readBatch(files) {
const busy = [els.run, els.sample, els.rotate, els.add];
busy.forEach(b => { b.disabled = true; });
let done = 0, failed = 0;
try {
for (const [i, file] of files.entries()) {
setProgress(i / files.length);
setStatus(`Reading ${i + 1} of ${files.length}: ${file.name}…`);
let img;
try { img = await decodeFile(file); } catch { failed++; continue; }
setImage(img, img.naturalWidth, img.naturalHeight);
state.reading = await ocrImage(state.image);
state.reading.source = file.name;
rematch(true);
addToRegister({ quiet: true });
done++;
}
setProgress(1);
const added = state.register.slice(-done);
const flagged = added.filter(r => r.status === 'mismatch').length;
const review = added.filter(r => r.status !== 'verified').length;
setStatus(`Read ${done} plate${done === 1 ? '' : 's'}${failed ? ` (${failed} file${failed === 1 ? '' : 's'} could not be opened)` : ''}: ` +
`${done - review} verified, ${flagged} flagged${review - flagged ? `, ${review - flagged} to review` : ''}. Click a row to review it.`, failed > 0 && !done);
} catch (e) {
setStatus(`OCR failed: ${e.message}. It needs a network connection the first time.`, true);
} finally {
busy.forEach(b => { b.disabled = false; });
els.add.disabled = false;
}
}
/** Keep phone photos to a size OCR handles quickly; upscale small crops so text has enough pixels. */
function fitForOcr(src, w, h) {
const scale = Math.min(2400 / Math.max(w, h), Math.max(1, 1400 / Math.max(w, h)));
const c = document.createElement('canvas');
c.width = Math.round(w * scale); c.height = Math.round(h * scale);
c.getContext('2d').drawImage(src, 0, 0, c.width, c.height);
return c;
}
function setImage(src, w, h) {
state.image = fitForOcr(src, w, h);
state.reading = null;
state.editing = null;
getWorker().catch(() => {}); // start the 11 MB download and engine start-up now, not on Read
els.reading.hidden = true;
els.run.disabled = false;
els.rotate.disabled = false;
showQuality(photoWarning(state.image, w, h));
draw();
}
/** Show (or clear) the advisory "retake this photo" line. */
function showQuality(text) {
if (!els.quality) return;
els.quality.textContent = text || '';
els.quality.classList.toggle('warn', !!text);
}
/**
* Advisory read of the photo before OCR: too blurry, dark, glared or small to
* read. Returns a sentence to show, or '' when the shot looks usable. Never
* blocks a read; it just tells the person to retake if the result looks poor.
*/
function photoWarning(src, origW, origH) {
try {
const w = Math.min(240, src.width), h = Math.max(1, Math.round((src.height * w) / src.width));
const c = document.createElement('canvas');
c.width = w; c.height = h;
const g = c.getContext('2d', { willReadFrequently: true });
g.drawImage(src, 0, 0, w, h);
const d = g.getImageData(0, 0, w, h).data;
const n = w * h, gray = new Float32Array(n);
let sum = 0, bright = 0;
for (let i = 0, j = 0; j < n; i += 4, j++) { gray[j] = d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114; sum += gray[j]; if (gray[j] > 250) bright++; }
let lapSum = 0, lapSq = 0, count = 0;
for (let y = 1; y < h - 1; y++) for (let x = 1; x < w - 1; x++) {
const i = y * w + x;
const lap = 4 * gray[i] - gray[i - 1] - gray[i + 1] - gray[i - w] - gray[i + w];
lapSum += lap; lapSq += lap * lap; count++;
}
const lapVar = count ? lapSq / count - (lapSum / count) ** 2 : 0;
const notes = [];
if (Math.min(origW || src.width, origH || src.height) < 500) notes.push('is low resolution');
else if (lapVar < 20) notes.push('looks blurry');
if (sum / n < 45) notes.push('is dark');
if (bright / n > 0.12) notes.push('has glare over the plate');
return notes.length ? `Photo ${notes.join(' and ')}. Retake closer and straight on, without glare, for a better read.` : '';
} catch { return ''; }
}
function rotate() {
const s = state.image;
const c = document.createElement('canvas');
c.width = s.height; c.height = s.width;
const g = c.getContext('2d');
g.translate(c.width, 0); g.rotate(Math.PI / 2); g.drawImage(s, 0, 0);
state.image = c;
state.reading = null;
els.reading.hidden = true;
draw();
}
/** A synthetic stamped-metal plate (fictional maker), drawn here so the sample needs no image file. */
function samplePlate(lines) {
const c = document.createElement('canvas');
c.width = 1400; c.height = 960;
const g = c.getContext('2d');
const bg = g.createLinearGradient(0, 0, c.width, c.height);
bg.addColorStop(0, '#d9dcdf'); bg.addColorStop(0.5, '#c4c8cc'); bg.addColorStop(1, '#d2d5d8');
g.fillStyle = '#5b6067'; g.fillRect(0, 0, c.width, c.height);
g.fillStyle = bg; roundRect(g, 40, 40, c.width - 80, c.height - 80, 26); g.fill();
for (let y = 60; y < c.height - 60; y += 3) { g.fillStyle = `rgba(255,255,255,${0.05 + 0.05 * Math.sin(y * 0.7)})`; g.fillRect(60, y, c.width - 120, 1); }
for (const [x, y] of [[80, 80], [c.width - 80, 80], [80, c.height - 80], [c.width - 80, c.height - 80]]) {
g.fillStyle = '#8b9096'; g.beginPath(); g.arc(x, y, 14, 0, Math.PI * 2); g.fill();
}
g.fillStyle = '#1b1e22';
g.textBaseline = 'alphabetic';
g.font = 'bold 54px Arial, Helvetica, sans-serif';
g.fillText(lines[0], 120, 175);
g.fillRect(120, 200, c.width - 240, 4);
g.font = 'bold 38px Arial, Helvetica, sans-serif';
g.fillText(lines[1], 120, 270);
g.font = 'bold 44px "Courier New", Courier, monospace';
lines.slice(2).forEach((t, i) => g.fillText(t, 120, 360 + i * 84));
return c;
}
function roundRect(g, x, y, w, h, r) {
g.beginPath(); g.moveTo(x + r, y); g.arcTo(x + w, y, x + w, y + h, r); g.arcTo(x + w, y + h, x, y + h, r);
g.arcTo(x, y + h, x, y, r); g.arcTo(x, y, x + w, y, r); g.closePath();
}
// ------------------------------------------------------------------ OCR
let workerPromise = null;
function loadScript(src) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src; s.async = true; s.onload = resolve; s.onerror = () => reject(new Error(`Could not load ${src}`));
document.head.appendChild(s);
});
}
let onOcrProgress = () => {};
function getWorker() {
if (!workerPromise) {
workerPromise = (async () => {
els.model.textContent = 'Loading OCR…';
if (!window.Tesseract) await loadScript(TESSERACT);
const w = await window.Tesseract.createWorker('eng', 1, { logger: m => onOcrProgress(m) });
// Plates are blocks of short lines; keep spacing so "480Y/277 VAC 3 PH" stays on one line.
await w.setParameters({ preserve_interword_spaces: '1' });
els.model.textContent = 'OCR ready';
return w;
})().catch(e => { workerPromise = null; els.model.textContent = 'OCR unavailable'; throw e; });
}
return workerPromise;
}
/**
* Plate photo -> clean grayscale for OCR:
* 1. grayscale;
* 2. denoise, only when the photo is noisy (median neighbour difference), since blur costs sharp text;
* 3. flatten lighting: divide by a heavily blurred copy, which removes glare, shadows and the gradient
* across brushed metal and keeps text polarity (dark-on-light and light-on-dark plates both work);
* 4. stretch contrast (2nd–98th percentile).
* Each step was kept because it raised field accuracy in tests/nameplates-bench.mjs.
*/
export function preprocess(src) {
const w = src.width, h = src.height, n = w * h;
const c = document.createElement('canvas');
c.width = w; c.height = h;
const g = c.getContext('2d', { willReadFrequently: true });
g.drawImage(src, 0, 0);
const im = g.getImageData(0, 0, w, h);
const d = im.data;
let gray = new Float32Array(n);
for (let i = 0, j = 0; j < n; i += 4, j++) gray[j] = d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114;
const noise = noiseLevel(gray, w, h);
if (noise > 4) gray = boxBlur(boxBlur(gray, w, h, noise > 12 ? 2 : 1), w, h, 1);
// Lighting estimate: a wide blur. Computing it at half or quarter resolution was tried and cost
// accuracy on the hardest photos (tests/nameplates-bench.mjs "phone": 98% -> 93%).
const R = Math.max(8, Math.round(Math.max(w, h) / 30));
const bg = boxBlur(boxBlur(gray, w, h, R), w, h, R);
const flat = new Float32Array(n);
for (let j = 0; j < n; j++) flat[j] = Math.min(255, (gray[j] / (bg[j] + 4)) * 128);
const hist = new Uint32Array(256);
for (let j = 0; j < n; j++) hist[flat[j] | 0]++;
let lo = 0, hi = 255, acc = 0;
for (; lo < 255 && (acc += hist[lo]) < n * 0.02; lo++);
acc = 0;
for (; hi > 0 && (acc += hist[hi]) < n * 0.02; hi--);
const span = Math.max(1, hi - lo);
for (let i = 0, j = 0; j < n; i += 4, j++) {
const v = Math.max(0, Math.min(255, ((flat[j] - lo) * 255) / span));
d[i] = d[i + 1] = d[i + 2] = v;
}
g.putImageData(im, 0, 0);
return deskew(c, d, w, h);
}
/**
* Straighten a plate shot at an angle: the text-line angle is the one whose row projection of
* text pixels is most peaked (lines of text in sharp rows, gaps between). Searched ±20° on a
* downsampled copy; Tesseract's own rotateAuto handles 90°/180° turns but not this.
*/
function deskew(c, d, w, h) {
const hist = new Uint32Array(256);
for (let i = 0; i < d.length; i += 4) hist[d[i]]++;
let mode = 0;
for (let v = 1; v < 256; v++) if (hist[v] > hist[mode]) mode = v;
const step = Math.max(1, Math.round(Math.max(w, h) / 500));
const pts = [];
for (let y = 0; y < h; y += step) for (let x = 0; x < w; x += step) if (Math.abs(d[(y * w + x) * 4] - mode) > 90) pts.push(x, y);
if (pts.length < 200) return c;
const bins = Math.ceil((w + h) / step) + 2;
const peakiness = deg => {
const t = (deg * Math.PI) / 180, sin = Math.sin(t), cos = Math.cos(t), rows = new Float64Array(bins);
for (let i = 0; i < pts.length; i += 2) rows[((pts[i + 1] * cos - pts[i] * sin) / step + w / step) | 0]++;
let sum = 0;
for (const r of rows) sum += r * r;
return sum;
};
let best = 0, bestScore = peakiness(0);
for (let deg = -20; deg <= 20; deg += 1) { const sc = peakiness(deg); if (sc > bestScore) { bestScore = sc; best = deg; } }
for (let deg = best - 0.75; deg <= best + 0.75; deg += 0.25) { const sc = peakiness(deg); if (sc > bestScore) { bestScore = sc; best = deg; } }
if (Math.abs(best) < 1) return c;
const out = document.createElement('canvas');
out.width = w; out.height = h;
const g = out.getContext('2d');
g.fillStyle = `rgb(${mode},${mode},${mode})`;
g.fillRect(0, 0, w, h);
g.translate(w / 2, h / 2);
g.rotate((-best * Math.PI) / 180);
g.drawImage(c, -w / 2, -h / 2);
out.deskewAngle = best;
return out;
}
/** Median absolute difference between horizontal neighbours on every 3rd row: ~0–2 on a clean photo, 20+ on a grainy one. */
function noiseLevel(gray, w, h) {
const hist = new Uint32Array(256);
let count = 0;
for (let y = 1; y < h; y += 3) for (let x = 0, i = y * w; x < w - 1; x++, i++) { hist[Math.min(255, Math.abs(gray[i] - gray[i + 1]) | 0)]++; count++; }
let acc = 0;
for (let v = 0; v < 256; v++) if ((acc += hist[v]) >= count / 2) return v;
return 0;
}
/** Separable box blur with running sums, O(n) in the radius. */
function boxBlur(src, w, h, r) {
const tmp = new Float32Array(src.length), out = new Float32Array(src.length);
for (let y = 0; y < h; y++) {
const row = y * w;
let sum = 0;
for (let x = -r; x <= r; x++) sum += src[row + Math.min(w - 1, Math.max(0, x))];
for (let x = 0; x < w; x++) {
tmp[row + x] = sum / (2 * r + 1);
sum += src[row + Math.min(w - 1, x + r + 1)] - src[row + Math.max(0, x - r)];
}
}
for (let x = 0; x < w; x++) {
let sum = 0;
for (let y = -r; y <= r; y++) sum += tmp[Math.min(h - 1, Math.max(0, y)) * w + x];
for (let y = 0; y < h; y++) {
out[y * w + x] = sum / (2 * r + 1);
sum += tmp[Math.min(h - 1, y + r + 1) * w + x] - tmp[Math.max(0, y - r) * w + x];
}
}
return out;
}
/**
* OCR one image into a parsed reading. Pass 1 uses Tesseract's automatic layout (PSM 3). That
* occasionally classes a whole tilted plate as a picture and returns no text, and it drops lines
* on busy photos, so when pass 1 reads few fields a sparse-text pass (PSM 11) runs and the two
* merge field by field, keeping the more confident line. Only weak reads pay for the second pass.
*/
async function ocrImage(src) {
const worker = await getWorker();
const img = preprocess(src);
const pass = async psm => {
await worker.setParameters({ tessedit_pageseg_mode: psm });
const { data } = await worker.recognize(img, { rotateAuto: true });
return parseNameplate((data.lines || []).map(l => ({ text: l.text, confidence: l.confidence, bbox: l.bbox })));
};
const first = await pass('3');
const reading = Object.keys(first.fields).length >= 6 && first.conf >= 70 ? first : mergeReadings(first, await pass('11'));
reading.deskew = img.deskewAngle || 0; // OCR boxes are in the straightened frame; draw() turns them back
return reading;
}
/** Two readings of one plate -> one: per field, the value from the more confident line. */
function mergeReadings(a, b) {
const base = Object.keys(b.fields).length > Object.keys(a.fields).length ? b : a;
const other = base === a ? b : a;
const offset = base.lines.length;
const fields = { ...base.fields };
for (const [k, f] of Object.entries(other.fields)) {
if (!fields[k] || f.conf > fields[k].conf + 5) fields[k] = { ...f, line: f.line + offset };
}
return { ...base, fields, type: base.type || other.type, lines: [...base.lines, ...other.lines] };
}
// Hooks for tests/nameplates-bench.mjs: OCR an arbitrary canvas, draw a sample plate.
window.__nameplates = { ocrImage, samplePlate, getWorker, fitForOcr, preprocess };
async function read() {
if (!state.image) return;
els.run.disabled = true;
setProgress(0.02);
try {
setStatus('Loading the OCR engine (first read only)…');
onOcrProgress = m => {
if (m.status === 'recognizing text') { setProgress(0.3 + 0.7 * m.progress); setStatus('Reading the plate…'); }
else if (/load|initializ/.test(m.status)) { setProgress(0.05 + 0.25 * (m.progress || 0)); setStatus(`${m.status[0].toUpperCase()}${m.status.slice(1)}…`); }
};
state.reading = await ocrImage(state.image);
setProgress(1);
const found = Object.keys(state.reading.fields).length;
setStatus(found ? `Read ${found} field${found === 1 ? '' : 's'} at ${state.reading.conf}% average OCR confidence. Check them against the plate.` :
'No nameplate fields were recognised. Try a closer, straighter photo, or rotate it.', !found);
rematch(true);
} catch (e) {
setStatus(`OCR failed: ${e.message}. It needs a network connection the first time.`, true);
} finally {
els.run.disabled = false;
}
}
// ------------------------------------------------------------------ reading
function rematch(pickBest = false) {
const r = state.reading;
if (!r) return;
const items = state.schedule?.items || [];
state.candidates = candidatesFor(r, items, state.register);
if (pickBest || !items.some(i => i.tag === state.tag)) state.tag = state.candidates[0]?.item.tag || '';
renderReading();
draw();
}
function currentChecks() {
const item = state.schedule?.items.find(i => i.tag === state.tag);
return item ? checkAgainst(state.reading, item) : [];
}
function renderReading() {
const r = state.reading;
els.reading.hidden = !r;
if (!r) return;
els.text.textContent = r.text || '(no text)';
// Fields: grouped, editable, low-confidence ones highlighted.
const typeOpts = ['<option value="">Unknown</option>', ...EQUIPMENT_TYPES.map(([k, label]) => `<option value="${k}"${r.type === k ? ' selected' : ''}>${esc(label)}</option>`)].join('');
const fieldInput = ([k, label]) => {
const f = r.fields[k];
const low = f && !f.edited && f.conf < LOW_CONF;
return `<label for="npf-${k}"><span class="np-key" style="background:${f ? COLORS[k] : 'transparent'}"></span>${esc(label)}</label>` +
`<input id="npf-${k}" data-key="${k}" value="${esc(f?.value ?? '')}" ${low ? `class="low" title="OCR confidence ${f.conf}%: check against the plate"` : ''} autocomplete="off" spellcheck="false" />`;
};
els.fields.innerHTML = `<label for="npf-type">Type</label><select id="npf-type">${typeOpts}</select>` +
FIELD_GROUPS.map(([g, label]) => {
const group = FIELDS.filter(f => (f[2] || 'id') === g);
return group.length ? `<div class="np-group">${esc(label)}</div>${group.map(fieldInput).join('')}` : '';
}).join('');
// Tag picker: likely tags first, then the rest of the schedule.
const items = state.schedule?.items || [];
const cand = new Set(state.candidates.map(c => c.item.tag));
const opt = (i, extra = '') => `<option value="${esc(i.tag)}"${i.tag === state.tag ? ' selected' : ''}>${esc(i.tag)}${i.model ? ` · ${esc(i.model)}` : ''}${extra}</option>`;
els.tag.innerHTML = `<option value=""${state.tag ? '' : ' selected'}>Not on the schedule</option>` +
(state.candidates.length ? `<optgroup label="Likely matches">${state.candidates.map(c => opt(c.item, c.registered ? ' (already scanned)' : '')).join('')}</optgroup>` : '') +
(items.length > cand.size ? `<optgroup label="All scheduled items">${items.filter(i => !cand.has(i.tag)).map(i => opt(i)).join('')}</optgroup>` : '');
els.tag.disabled = !items.length;
const same = state.candidates.filter(c => c.score === state.candidates[0]?.score).length;
els.tagNote.textContent = !items.length ? 'Load an equipment schedule to check this plate.'
: !state.candidates.length ? 'No scheduled item looks like this plate.'
: same > 1 ? `${same} scheduled items share this model. Picked the first not yet scanned; change it to the tag on site.`
: '';
if (state.register.some(x => x.tag && x.tag === state.tag)) els.tagNote.textContent += ` ${state.tag} is already on the register; adding replaces it.`;
renderChecks();
}
function renderChecks() {
const checks = currentChecks();
const status = statusOf(checks, state.tag);
els.checks.innerHTML = checks.map(c => `<li class="${c.status}"><span>${c.status === 'ok' ? '✓' : c.status === 'mismatch' ? '✗' : '–'}</span>` +
`<span><b>${esc(c.label)}</b> ${c.status === 'ok' ? esc(c.found) : c.status === 'mismatch' ? `schedule ${esc(c.expected)}, plate ${esc(c.found)}` : `not read (schedule ${esc(c.expected)})`}</span></li>`).join('');
const bad = checks.filter(c => c.status === 'mismatch').map(c => inSentence(c.label));
els.verdict.className = `np-verdict ${status}`;
els.verdict.textContent = status === 'verified' ? `Matches ${state.tag} on every scheduled field.`
: status === 'mismatch' ? `Does not match ${state.tag}: ${bad.join(', ')}. Hold before install.`
: status === 'partial' ? `Matches ${state.tag} so far; some scheduled fields were not read.`
: state.schedule ? 'Not matched to the schedule.' : 'Read only: no schedule loaded to check against.';
}
function editField(key, raw) {
const r = state.reading;
const v = raw.trim();
if (!v) delete r.fields[key];
else if (key === 'voltage') r.fields.voltage = { value: v, conf: 100, edited: true, values: voltagesIn(v.toUpperCase()).values.length ? voltagesIn(v.toUpperCase()).values : (v.match(/\d{3,5}/g) || []).map(Number) };
else r.fields[key] = { value: NUMERIC.has(key) ? (parseFloat(v.replace(',', '')) || v) : v, conf: 100, edited: true, line: r.fields[key]?.line };
$(`npf-${key}`)?.classList.remove('low');
if (key === 'model' || key === 'serial' || key === 'manufacturer') {
state.candidates = candidatesFor(r, state.schedule?.items || [], state.register);
}
renderChecks();
}
// ------------------------------------------------------------------ canvas
function draw() {
const c = els.canvas, s = state.image;
els.empty.hidden = !!s;
c.hidden = !s;
if (!s) return;
c.width = s.width; c.height = s.height;
const g = c.getContext('2d');
g.drawImage(s, 0, 0);
const r = state.reading;
if (!r) return;
// Outline each line a field came from, labelled with every field read off it (voltage, phase
// and Hz often share one line). Boxes are in the straightened frame OCR saw: rotate them back.
const byLine = new Map();
for (const [k, f] of Object.entries(r.fields)) {
if (f.edited || !r.lines[f.line]?.bbox) continue;
byLine.set(f.line, [...(byLine.get(f.line) || []), k]);
}
const lw = Math.max(2, s.width / 450), fs = Math.round(Math.max(14, s.width / 60)), pad = fs * 0.3;
g.save();
g.translate(s.width / 2, s.height / 2);
g.rotate(((r.deskew || 0) * Math.PI) / 180);
g.translate(-s.width / 2, -s.height / 2);
g.font = `600 ${fs}px system-ui, sans-serif`;
g.textBaseline = 'middle';
for (const [line, keys] of byLine) {
const b = r.lines[line].bbox;
g.strokeStyle = COLORS[keys[0]]; g.lineWidth = lw;
g.strokeRect(b.x0 - 4, b.y0 - 4, b.x1 - b.x0 + 8, b.y1 - b.y0 + 8);
let x = b.x0 - 4;
for (const k of keys) {
const label = FIELDS.find(f => f[0] === k)?.[1] || k;
const tw = g.measureText(label).width + pad * 2, th = fs + pad * 1.4, y = Math.max(0, b.y0 - 4 - th);
g.fillStyle = COLORS[k]; g.fillRect(x, y, tw, th);
g.fillStyle = '#fff'; g.fillText(label, x + pad, y + th / 2);
x += tw + 3;
}
}
g.restore();
}
// ------------------------------------------------------------------ register
function addToRegister({ quiet = false } = {}) {
const r = state.reading;
if (!r) return;
const checks = currentChecks();
const prev = state.editing && state.register.find(x => x.id === state.editing);
const entry = {
id: prev?.id || `np-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
tag: state.tag, type: $('npf-type')?.value ?? r.type ?? '', fields: structuredClone(r.fields),
checks, status: statusOf(checks, state.tag), thumb: prev?.thumb || thumbnail(state.image),
scannedAt: prev?.scannedAt || Date.now(), source: r.source || prev?.source || '',
// Fields OCR was unsure of and nobody has corrected: listed on the register for a second look.
lowConf: Object.entries(r.fields).filter(([, f]) => !f.edited && f.conf < LOW_CONF).map(([k, f]) => `${k}:${f.conf}`),
};
for (const f of Object.values(entry.fields)) { delete f.line; }
state.photos.set(entry.id, { image: state.image, reading: structuredClone(r) });
const at = prev ? state.register.indexOf(prev) : -1;
state.register = state.register.filter(x => x.id !== entry.id && !(entry.tag && x.tag === entry.tag));
if (at >= 0) state.register.splice(Math.min(at, state.register.length), 0, entry);
else state.register.push(entry);
save();
renderRegister();
if (!quiet) setStatus(`${entry.tag || 'Unmatched plate'} ${prev ? 'updated on' : 'added to'} the register (${STATUS_LABELS[entry.status].toLowerCase()}).`);
state.editing = null;
els.add.textContent = 'Add to register';
state.reading = null;
els.reading.hidden = true;
draw();
}
/** Open a register entry for review: its photo and reading if read this session, else its stored fields over the thumbnail. */
async function review(id) {
const entry = state.register.find(x => x.id === id);
if (!entry) return;
const photo = state.photos.get(id);
if (photo) {
state.image = photo.image;
state.reading = structuredClone(photo.reading);
state.reading.fields = structuredClone(entry.fields);
for (const [k, f] of Object.entries(state.reading.fields)) f.line = photo.reading.fields[k]?.line;
} else {
const img = new Image();
img.src = entry.thumb;
try { await img.decode(); state.image = fitForOcr(img, img.naturalWidth, img.naturalHeight); } catch { state.image = null; }
state.reading = { fields: structuredClone(entry.fields), type: entry.type, conf: 100, text: '', lines: [] };
}
state.reading.type = entry.type || state.reading.type;
state.editing = id;
state.candidates = candidatesFor(state.reading, state.schedule?.items || [], state.register.filter(x => x.id !== id));
state.tag = entry.tag;
els.add.textContent = 'Update register entry';
els.run.disabled = !state.image;
els.rotate.disabled = !state.image;
renderReading();
draw();
renderRegister();
els.reading.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
setStatus(`Reviewing ${entry.tag || 'an unmatched plate'}. Correct any field, then update the entry.`);
}
function thumbnail(src) {
const c = document.createElement('canvas');
const s = 240 / Math.max(src.width, src.height);
c.width = Math.round(src.width * s); c.height = Math.round(src.height * s);
c.getContext('2d').drawImage(src, 0, 0, c.width, c.height);
return c.toDataURL('image/jpeg', 0.7);
}
function renderRegister() {
const items = state.schedule?.items || [];
const sum = registerSummary(state.register, items);
const dups = new Set(duplicateSerials(state.register).flat());
els.summary.innerHTML = state.register.length || items.length ? [
items.length ? `<span><b>${sum.scannedOfSchedule}</b> of ${items.length} scheduled scanned</span>` : `<span><b>${sum.scanned}</b> scanned</span>`,
`<span><b>${sum.verified}</b> verified</span>`,
`<span style="color:${sum.flagged ? 'var(--danger)' : 'inherit'}"><b>${sum.flagged}</b> flagged</span>`,
sum.unmatched ? `<span><b>${sum.unmatched}</b> not on schedule</span>` : '',
sum.duplicates ? `<span style="color:var(--danger)"><b>${sum.duplicates}</b> duplicate serial${sum.duplicates === 1 ? '' : 's'}</span>` : '',
].join('') : '';
// Filter and search the register: a walk can be hundreds of tags.
const q = state.photoQuery.trim().toUpperCase();
const shown = state.register.filter(r => {
if (state.photoFilter !== 'all' && r.status !== state.photoFilter) return false;
if (!q) return true;
return [r.tag, typeLabel(r.type), r.fields?.model?.value, r.fields?.serial?.value, r.fields?.manufacturer?.value, r.source]
.join(' ').toUpperCase().includes(q);
});
els.rows.innerHTML = shown.map(r => {
const issues = r.checks.filter(c => c.status === 'mismatch').map(c => `${c.label}: schedule ${c.expected}, plate ${c.found}`);
if (dups.has(r.id)) issues.push('Serial also on another scanned plate');
if (r.lowConf?.length) issues.push(`Check ${r.lowConf.map(x => { const [k, c] = x.split(':'); return `${inSentence((FIELDS.find(f => f[0] === k) || [k, k])[1])} (OCR ${c}%)`; }).join(', ')}`);
return `<tr data-id="${r.id}" class="${r.id === state.editing ? 'sel' : ''}" tabindex="0" title="Review this plate"><td class="np-hide-sm">${r.thumb ? `<img src="${r.thumb}" alt="">` : ''}</td>` +
`<td><b>${esc(r.tag || '—')}</b><br><span class="np-note">${esc(typeLabel(r.type))}</span></td>` +
`<td><span class="np-chip ${r.status}">${esc(STATUS_LABELS[r.status])}</span></td>` +
`<td>${esc(r.fields.model?.value || '')}</td><td class="np-hide-sm">${esc(r.fields.serial?.value || '')}</td>` +
`<td class="np-hide-sm">${esc(r.fields.voltage?.value || '')}</td><td>${esc(issues.join('; '))}</td>` +
`<td><button class="np-btn link" type="button" data-remove="${r.id}" aria-label="Remove ${esc(r.tag || 'entry')}">✕</button></td></tr>`;
}).join('');
els.emptyReg.hidden = state.register.length > 0;
if (els.emptyReg) els.emptyReg.textContent = state.register.length && !shown.length
? 'No register entries match the filter.'
: 'Nothing scanned yet. Each plate you add appears here with its check result.';
if (els.filter) els.filter.value = state.photoFilter;
els.csv.disabled = !state.register.length && !items.length;
els.clearReg.disabled = !state.register.length;
els.missing.textContent = items.length && sum.missing.length && state.register.length
? `Not scanned yet (${sum.missing.length}): ${sum.missing.slice(0, 24).join(', ')}${sum.missing.length > 24 ? '…' : ''}` +
(shown.length !== state.register.length ? ` Showing ${shown.length} of ${state.register.length} scanned.` : '') : '';
}
function download(name, text, type) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], { type }));
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
// ------------------------------------------------------------------ wiring
function dropZone(zone, input, onFile, onFiles) {
const take = list => { const files = [...list]; if (files.length > 1 && onFiles) onFiles(files); else if (files[0]) onFile(files[0]); };
input.addEventListener('change', () => { take(input.files); input.value = ''; });
zone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } });
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('over'); });
zone.addEventListener('dragleave', () => zone.classList.remove('over'));
zone.addEventListener('drop', e => { e.preventDefault(); zone.classList.remove('over'); take(e.dataTransfer.files); });
}
dropZone(els.schedDrop, els.schedFile, async f => setSchedule(await f.text(), f.name));
dropZone(els.drop, els.file, loadImageFile, files => readBatch(files.filter(f => /^image\//.test(f.type) || /\.(jpe?g|png|webp|heic|gif|bmp)$/i.test(f.name))));
els.schedSample.addEventListener('click', () => setSchedule(SAMPLE_SCHEDULE, 'Sample schedule'));
els.schedClear.addEventListener('click', () => { state.schedule = null; save(); renderSchedule(); rematch(); renderRegister(); });
els.sample.addEventListener('click', () => {
const c = samplePlate(SAMPLE_PLATES[state.sample % SAMPLE_PLATES.length]);
state.sample++;
setImage(c, c.width, c.height);
setStatus(`Sample plate ${((state.sample - 1) % SAMPLE_PLATES.length) + 1} of ${SAMPLE_PLATES.length} loaded (a drawn plate, fictional maker). Read it when ready.`);
});
els.rotate.addEventListener('click', rotate);
els.run.addEventListener('click', read);
els.add.addEventListener('click', () => addToRegister());
els.tag.addEventListener('change', () => { state.tag = els.tag.value; renderReading(); });
els.fields.addEventListener('change', e => {
if (e.target.id === 'npf-type') { state.reading.type = e.target.value || null; state.candidates = candidatesFor(state.reading, state.schedule?.items || [], state.register); renderReading(); }
else if (e.target.dataset.key) editField(e.target.dataset.key, e.target.value);
});
els.rows.addEventListener('click', e => {
const id = e.target.closest('[data-remove]')?.dataset.remove;
if (id) {
state.register = state.register.filter(r => r.id !== id);
state.photos.delete(id);
if (state.editing === id) { state.editing = null; state.reading = null; els.reading.hidden = true; els.add.textContent = 'Add to register'; }
save();
renderRegister();
return;
}
const row = e.target.closest('tr[data-id]');
if (row) review(row.dataset.id);
});
els.rows.addEventListener('keydown', e => {
const row = e.target.closest('tr[data-id]');
if (row && (e.key === 'Enter' || e.key === ' ') && !e.target.closest('button')) { e.preventDefault(); review(row.dataset.id); }
});
els.clearReg.addEventListener('click', () => {
if (!confirm('Remove every scanned plate from the register?')) return;
state.register = [];
save();
renderRegister();
});
els.search?.addEventListener('input', () => { state.photoQuery = els.search.value; renderRegister(); });
els.filter?.addEventListener('change', () => { state.photoFilter = els.filter.value; renderRegister(); });
els.csv.addEventListener('click', () => download('asset-register.csv', registerCsv(state.register, state.schedule?.items || []), 'text/csv'));
load();
renderSchedule();
renderRegister();