From f293885cd05a671547a85413741edd35dc540e1f Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 21 Aug 2026 13:42:27 +1000 Subject: [PATCH] Show the spreadsheet column mapping instead of guessing at it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Importing a sheet guessed which column was the controller name and which was the sign text, then silently acted on the guess. On a sheet whose headers it did not recognise, the guess fell through to "first column", which is usually the row number — so the destinations came out named 1, 2, 3. Now the import opens a preview: the two columns are dropdowns, seeded with the guess and showing a sample of each, above a table of exactly what will be created. Nothing is imported until it looks right. The guessing itself is better too: - a top row of words above rows containing numbers is treated as a header even when none of its labels are recognised - with no recognisable columns, the column carrying the longest words wins - one recognised column stands in for the other, since a destination's name is usually exactly what the sign shows, rather than picking at random - a truncated name no longer keeps a trailing space, which would also stop it matching an existing destination on a later import Also adds custom sign sizes, for boards that are not one of the three fitted models. Widths snap to a multiple of 8 since the record stores width in bytes. Co-Authored-By: Claude Opus 5 --- README.md | 20 ++- app/index.html | 39 ++++++ app/main.mjs | 118 +++++++++++++++--- dist/DestoGod.html | 255 +++++++++++++++++++++++++++++++++------ samples/odd-headers.xlsx | Bin 0 -> 4972 bytes src/codec/sheet.mjs | 100 +++++++++++---- test/sheet.mjs | 8 ++ 7 files changed, 455 insertions(+), 85 deletions(-) create mode 100644 samples/odd-headers.xlsx diff --git a/README.md b/README.md index 920c6e6..e9f1903 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,20 @@ That is what this does. of the real sign, and scrolls exactly as it will on the bus. TP5 shows you nothing until you export. - **Tells you when a message will not fit** and by how many pixels. -- **Imports a spreadsheet.** Drop in an `.xlsx` (or CSV) and it reads the - `Line | Line Name | Description | Content (Display)` layout of the template - these buses ship with — `Line Name` becomes what the driver sees on the - controller, `Content (Display)` becomes what the sign shows. Names that - already exist are updated rather than duplicated. No library involved: the - `.xlsx` is unzipped and parsed by the browser itself. +- **Any sign size**, not just the three fitted models — pick "Custom size…" and + type it. Widths snap to a multiple of 8, because the record stores the width + in whole bytes. +- **Imports a spreadsheet.** Drop in an `.xlsx` (or CSV) and it shows you which + column it thinks is the controller name and which is the sign text, with a + preview of the result, before importing anything — both are dropdowns you can + change. It understands the `Line | Line Name | Description | Content (Display)` + template these buses ship with, and copes with sheets that look nothing like + it. Names that already exist are updated rather than duplicated. No library + involved: the `.xlsx` is unzipped and parsed by the browser itself. + + Guessing silently is how you end up with a list of destinations called "1", + "2", "3" — the row-number column winning over the name column. Hence showing + the guess rather than trusting it. - **Paste a whole list at once** instead of typing destinations one at a time. - **Delete a destination** with the × on its row in the list, or clear the whole list at once. Both offer an Undo instead of asking you to confirm first — a diff --git a/app/index.html b/app/index.html index cf6e6c2..9122362 100644 --- a/app/index.html +++ b/app/index.html @@ -129,6 +129,16 @@ dialog .dlg-body{padding:20px} dialog h3{margin:0 0 6px; font-size:16px} dialog p{margin:0 0 14px; color:var(--dim); font-size:13px} dialog .dlg-foot{display:flex; justify-content:flex-end; gap:9px; padding:14px 20px; border-top:1px solid var(--line)} +#map-preview{margin-top:16px; border:1px solid var(--line); border-radius:8px; overflow:auto; max-height:230px} +#map-preview table{border-collapse:collapse; width:100%; font-size:12px} +#map-preview th{ + position:sticky; top:0; background:var(--panel2); text-align:left; padding:7px 10px; + font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--dim); white-space:nowrap; +} +#map-preview td{padding:6px 10px; border-top:1px solid var(--line); white-space:nowrap} +#map-preview .led{color:var(--amber); font-weight:600} +#map-preview .nm{font-weight:650} +#map-count{margin-top:9px; font-size:12px; color:var(--dim)} textarea{min-height:170px; resize:vertical; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px} #drop{ position:fixed; inset:0; background:#0b0e14e6; display:none; place-items:center; @@ -155,6 +165,12 @@ kbd{background:#12141a; border:1px solid var(--line); border-bottom-width:2px; b
DestoGodbus destination signs
+
@@ -181,6 +197,29 @@ kbd{background:#12141a; border:1px solid var(--line); border-bottom-width:2px; b
Drop a .td5, .tp5, spreadsheet or CSV to open
+ +
+

Import from spreadsheet

+

Check the two columns below are the right ones, then import. Everything can still be edited afterwards.

+
+
+ + +
+
+ + +
+
+
+
+
+
+ + +
+
+

Paste a list of destinations

diff --git a/app/main.mjs b/app/main.mjs index c72d6fb..2eecff3 100644 --- a/app/main.mjs +++ b/app/main.mjs @@ -3,7 +3,7 @@ import { FONTS } from '../src/fonts.js'; import { fontFromJSON, renderText, trimX } from '../src/codec/bitfont.mjs'; import { parseTd5, buildTd5, frameToPixels, pixelsToFrame, EFFECT } from '../src/codec/td5.mjs'; import { parseTp5, buildTp5, toSimple } from '../src/codec/tp5.mjs'; -import { readXlsx, readCsv, mapRows } from '../src/codec/sheet.mjs'; +import { readXlsx, readCsv, analyseSheet, applyMapping } from '../src/codec/sheet.mjs'; // Sign sizes from the Yutong programming manual (section 2.2). const MODELS = [ @@ -14,6 +14,7 @@ const MODELS = [ { label: 'Other — 160 × 16', width: 160, height: 16 }, { label: 'Other — 192 × 16', width: 192, height: 16 }, { label: 'Other — 256 × 16', width: 256, height: 16 }, + { label: 'Custom size…', custom: true }, ]; const SYSTEM_FONTS = ['Impact', 'Arial Narrow', 'Arial Black', 'Arial', 'Helvetica', 'Verdana', 'Tahoma']; @@ -477,17 +478,18 @@ function renderHeader() { $('#company').value = state.company; const sel = $('#model'); if (!sel.options.length) { - for (const m of MODELS) sel.append(el('option', { value: m.width, textContent: m.label })); + for (const m of MODELS) { + sel.append(el('option', { value: m.custom ? 'custom' : String(m.width), textContent: m.label })); + } } - const known = MODELS.some((m) => m.width === state.screen.width); - if (!known) { - const label = `Custom — ${state.screen.width} × ${state.screen.height}`; - let opt = [...sel.options].find((o) => o.dataset.custom); - if (!opt) { opt = el('option', { value: state.screen.width }); opt.dataset.custom = '1'; sel.append(opt); } - opt.value = String(state.screen.width); - opt.textContent = label; + const preset = MODELS.find((m) => !m.custom && m.width === state.screen.width && m.height === state.screen.height); + sel.value = state.customSize || !preset ? 'custom' : String(state.screen.width); + const custom = sel.value === 'custom'; + $('#custom-size').hidden = !custom; + if (custom) { + $('#cust-w').value = state.screen.width; + $('#cust-h').value = state.screen.height; } - sel.value = String(state.screen.width); const has = state.destinations.length > 0; $('#btn-export').disabled = !has; $('#btn-export-tp5').disabled = !has; @@ -823,16 +825,71 @@ function openBulk() { $('#bulk-text').value = ''; showDialog($('#bulk')); } // ------------------------------------------------------------------- files -/** Bring in destinations from a spreadsheet, appending to whatever is loaded. */ +/** + * Show what the sheet is about to become before committing to it. Guessing the + * columns silently is how you end up with a list of destinations called + * "1", "2", "3" — so the guess is shown, and can be corrected. + */ +let pendingSheet = null; + function importRows(rows, label) { - const { company, destinations } = mapRows(rows); - if (!destinations.length) { - return toast(`No destinations found in that ${label}. Expected a column of destination text.`, true); + const info = analyseSheet(rows); + if (!info.rowCount) { + return toast(`No rows found in that ${label}.`, true); } - if (company && !state.company) state.company = company; + pendingSheet = { rows, info, label }; + + const nameSel = $('#map-name'), textSel = $('#map-text'); + nameSel.textContent = ''; textSel.textContent = ''; + for (const c of info.columns) { + if (!c.filled) continue; + const describe = `${c.label}${c.sample.length ? ` — ${c.sample.slice(0, 2).join(', ')}` : ''}`; + nameSel.append(el('option', { value: c.index, textContent: describe })); + textSel.append(el('option', { value: c.index, textContent: describe })); + } + nameSel.value = String(info.nameCol); + textSel.value = String(info.textCol); + nameSel.onchange = textSel.onchange = drawMappingPreview; + + drawMappingPreview(); + showDialog($('#mapdlg')); +} + +function drawMappingPreview() { + if (!pendingSheet) return; + const { rows, info } = pendingSheet; + const nameCol = Number($('#map-name').value); + const textCol = Number($('#map-text').value); + const found = applyMapping(rows, { start: info.start, nameCol, textCol }); + + const host = $('#map-preview'); + host.textContent = ''; + const table = el('table'); + const head = el('tr'); + head.append(el('th', { textContent: 'On the controller' }), el('th', { textContent: 'On the sign' })); + table.append(head); + for (const d of found.slice(0, 12)) { + const tr = el('tr'); + tr.append(el('td', { className: 'nm', textContent: d.name }), el('td', { className: 'led', textContent: d.text })); + table.append(tr); + } + host.append(table); + + $('#map-count').textContent = found.length + ? `${found.length} destination${found.length === 1 ? '' : 's'}` + (found.length > 12 ? ' — showing the first 12' : '') + : 'Nothing usable in those columns — try different ones.'; + $('#map-ok').disabled = found.length === 0; + pendingSheet.mapped = found; +} + +/** Commit the previewed mapping into the destination list. */ +function commitSheet() { + const { info, mapped, label } = pendingSheet ?? {}; + if (!mapped?.length) return; + if (info.company && !state.company) state.company = info.company; let added = 0, updated = 0; - for (const r of destinations) { + for (const r of mapped) { const existing = state.destinations.find((x) => x.name.trim().toUpperCase() === r.name.toUpperCase()); if (existing) { setName(existing, r.name); @@ -849,6 +906,7 @@ function importRows(rows, label) { } } state.selected = state.destinations[state.destinations.length - 1]?.id ?? state.selected; + pendingSheet = null; renderAll(); toast(`${label}: added ${added}${updated ? `, updated ${updated}` : ''} destination${added === 1 && !updated ? '' : 's'}.`); } @@ -917,18 +975,40 @@ $('#btn-sheet').onclick = () => { $('#file').dataset.sheet = '1'; $('#file').cli $('#file').onchange = (e) => { if (e.target.files[0]) openFile(e.target.files[0]); e.target.value = ''; }; $('#btn-add').onclick = addOne; $('#btn-clear').onclick = removeAll; +$('#map-cancel').onclick = () => { pendingSheet = null; closeDialog($('#mapdlg')); }; +$('#map-ok').onclick = () => { closeDialog($('#mapdlg')); commitSheet(); }; $('#btn-bulk').onclick = openBulk; $('#btn-export').onclick = exportTd5; $('#btn-export-tp5').onclick = exportTp5; $('#company').oninput = (e) => { state.company = e.target.value; }; -$('#model').onchange = (e) => { - const m = MODELS.find((x) => String(x.width) === e.target.value); - if (m) state.screen = { ...m }; +/** Re-render anything we drew ourselves; imported artwork is left alone. */ +function screenChanged() { for (const d of state.destinations) for (const p of d.pages) if (!p.pristine) p.bitmap = null; renderAll(); +} + +$('#model').onchange = (e) => { + if (e.target.value === 'custom') { + state.customSize = true; + } else { + const m = MODELS.find((x) => !x.custom && String(x.width) === e.target.value); + if (m) { state.screen = { width: m.width, height: m.height }; state.customSize = false; } + } + screenChanged(); }; +const applyCustomSize = () => { + const w = Math.max(8, Math.min(1024, Number($('#cust-w').value) || state.screen.width)); + const h = Math.max(8, Math.min(64, Number($('#cust-h').value) || state.screen.height)); + // The record stores the width in whole bytes, so it has to be a multiple of 8. + state.screen = { width: Math.round(w / 8) * 8, height: h }; + state.customSize = true; + screenChanged(); +}; +$('#cust-w').onchange = applyCustomSize; +$('#cust-h').onchange = applyCustomSize; + $('#bulk-cancel').onclick = () => closeDialog($('#bulk')); $('#bulk-ok').onclick = () => { const lines = $('#bulk-text').value.split('\n').map((s) => s.trim()).filter(Boolean); diff --git a/dist/DestoGod.html b/dist/DestoGod.html index 04c4262..c0dbbc4 100644 --- a/dist/DestoGod.html +++ b/dist/DestoGod.html @@ -134,6 +134,16 @@ dialog .dlg-body{padding:20px} dialog h3{margin:0 0 6px; font-size:16px} dialog p{margin:0 0 14px; color:var(--dim); font-size:13px} dialog .dlg-foot{display:flex; justify-content:flex-end; gap:9px; padding:14px 20px; border-top:1px solid var(--line)} +#map-preview{margin-top:16px; border:1px solid var(--line); border-radius:8px; overflow:auto; max-height:230px} +#map-preview table{border-collapse:collapse; width:100%; font-size:12px} +#map-preview th{ + position:sticky; top:0; background:var(--panel2); text-align:left; padding:7px 10px; + font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--dim); white-space:nowrap; +} +#map-preview td{padding:6px 10px; border-top:1px solid var(--line); white-space:nowrap} +#map-preview .led{color:var(--amber); font-weight:600} +#map-preview .nm{font-weight:650} +#map-count{margin-top:9px; font-size:12px; color:var(--dim)} textarea{min-height:170px; resize:vertical; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px} #drop{ position:fixed; inset:0; background:#0b0e14e6; display:none; place-items:center; @@ -160,6 +170,12 @@ kbd{background:#12141a; border:1px solid var(--line); border-bottom-width:2px; b
DestoGodbus destination signs
+
@@ -186,6 +202,29 @@ kbd{background:#12141a; border:1px solid var(--line); border-bottom-width:2px; b
Drop a .td5, .tp5, spreadsheet or CSV to open
+ +
+

Import from spreadsheet

+

Check the two columns below are the right ones, then import. Everything can still be edited afterwards.

+
+
+ + +
+
+ + +
+
+
+
+
+
+ + +
+
+

Paste a list of destinations

@@ -1059,57 +1098,115 @@ function readCsv(text) { // -------------------------------------------------------------- mapping const norm = (s) => String(s ?? '').toLowerCase().replace(/[^a-z]/g, ''); +const colLabel = (i) => { + let n = i, out = ''; + do { out = String.fromCharCode(65 + (n % 26)) + out; n = Math.floor(n / 26) - 1; } while (n >= 0); + return out; +}; /** - * Work out which columns hold the controller name and the sign text. - * The template that ships with these buses uses - * `Line | Line Name | Description | Content (Display)`. + * Describe a sheet without committing to anything: where the header is, what + * columns exist with a sample of each, and a best guess at which column holds + * the controller name and which holds the sign text. + * + * The guess is only a starting point — the app shows it and lets you change it, + * because sheets in the wild do not all look like the shipped template + * (`Line | Line Name | Description | Content (Display)`), and silently guessing + * wrong is how you end up with destinations called "1", "2", "3". */ -function mapRows(rows) { +function analyseSheet(rows) { let company = ''; for (const r of rows.slice(0, 5)) { const i = r.findIndex((c) => norm(c).startsWith('companyname')); if (i >= 0) { company = (r[i + 1] || '').trim(); break; } } - const headerAt = rows.findIndex((r) => r.some((c) => { + let headerAt = rows.findIndex((r) => r.some((c) => { const n = norm(c); return n === 'linename' || n === 'content' || n.startsWith('contentdisplay') || n === 'destination'; })); + // Headers we do not recognise by name are still headers. If the top row is all + // words and something below it is a number, it is labelling columns, not data. + if (headerAt < 0) { + const first = rows.findIndex((r) => r.length && !r.every((c) => !c)); + if (first >= 0) { + const top = rows[first].filter((c) => c !== ''); + const below = rows.slice(first + 1).filter((r) => r.length && !r.every((c) => !c)); + const numericBelow = below.some((r) => r.some((c) => /^\d+(\.\d+)?$/.test(String(c).trim()))); + if (top.length > 1 && top.every((c) => !/^\d+(\.\d+)?$/.test(String(c).trim())) && numericBelow) { + headerAt = first; + } + } + } + const start = headerAt >= 0 ? headerAt + 1 : 0; + const body = rows.slice(start).filter((r) => r.length && !r.every((c) => !c)); - let nameCol = -1, textCol = -1, start = 0; + const width = Math.max(0, ...rows.map((r) => r.length)); + const columns = []; + for (let i = 0; i < width; i++) { + const values = body.map((r) => (r[i] ?? '').trim()).filter(Boolean); + columns.push({ + index: i, + label: headerAt >= 0 && rows[headerAt][i] ? String(rows[headerAt][i]).trim() : `Column ${colLabel(i)}`, + sample: values.slice(0, 3), + filled: values.length, + allNumeric: values.length > 0 && values.every((v) => /^\d+$/.test(v)), + }); + } + + let nameCol = -1, textCol = -1; if (headerAt >= 0) { const head = rows[headerAt].map(norm); - // Search by priority, not by column order: the shipped template has both a - // "Line" (a row number) and a "Line Name", and the name must win. + // By priority, not by column order: the shipped template has both a "Line" + // (a row number) and a "Line Name", and the name must win. const pick = (...wanted) => { for (const w of wanted) { const i = head.indexOf(w); if (i >= 0) return i; } return -1; }; nameCol = pick('linename', 'name', 'destinationname', 'route', 'line'); textCol = pick('contentdisplay', 'content', 'display', 'destination', 'text', 'message'); - start = headerAt + 1; } + if (nameCol < 0 && textCol < 0) { + // Nothing recognisable: the column carrying the longest words is the most + // likely destination text. + const wordy = columns.filter((c) => c.filled && !c.allNumeric); + const avg = (c) => c.sample.reduce((n, v) => n + v.length, 0) / (c.sample.length || 1); + const best = wordy.slice().sort((a, b) => avg(b) - avg(a))[0]; + textCol = best ? best.index : (columns.find((c) => c.filled)?.index ?? 0); + } + // One recognised column stands in for the other: a destination's name is + // usually exactly what the sign shows, so defaulting them to the same column + // is far safer than picking an unrelated one. + if (textCol < 0) textCol = nameCol; + if (nameCol < 0) nameCol = textCol; + // A bare row number is a line code, not something to show a driver. + if (columns[nameCol]?.allNumeric && nameCol !== textCol) nameCol = textCol; + return { company, headerAt, start, columns, nameCol, textCol, rowCount: body.length }; +} + +/** Turn a sheet into destinations using an explicit column mapping. */ +function applyMapping(rows, { start = 0, nameCol, textCol }) { const out = []; for (const r of rows.slice(start)) { if (!r.length || r.every((c) => !c)) continue; - const cells = r.filter((c) => c !== ''); - let name = nameCol >= 0 ? (r[nameCol] || '') : ''; - let text = textCol >= 0 ? (r[textCol] || '') : ''; - if (!name && !text) { // no recognisable header — take what is there - text = cells[cells.length - 1] || ''; - name = cells.length > 1 ? cells[0] : text; - } + let name = String(r[nameCol] ?? '').trim(); + let text = String(r[textCol] ?? '').trim(); if (!text) text = name; - // A bare row number is a line code, not something worth showing a driver. - if (/^\d+$/.test(name) && text && text !== name) name = text; if (!name) name = text; - if (!text.trim()) continue; + if (!text) continue; if (norm(name) === 'linename' || norm(text).startsWith('contentdisplay')) continue; - out.push({ name: name.trim().slice(0, 16), text: text.trim() }); + // Truncating can leave a trailing space, which would also stop this row + // matching an existing destination on a later import. + out.push({ name: name.slice(0, 16).trim(), text }); } - return { company, destinations: out }; + return out; +} + +/** analyse + apply the guesses, for callers that do not want to ask. */ +function mapRows(rows) { + const a = analyseSheet(rows); + return { company: a.company, destinations: applyMapping(rows, a) }; } @@ -1129,6 +1226,7 @@ const MODELS = [ { label: 'Other — 160 × 16', width: 160, height: 16 }, { label: 'Other — 192 × 16', width: 192, height: 16 }, { label: 'Other — 256 × 16', width: 256, height: 16 }, + { label: 'Custom size…', custom: true }, ]; const SYSTEM_FONTS = ['Impact', 'Arial Narrow', 'Arial Black', 'Arial', 'Helvetica', 'Verdana', 'Tahoma']; @@ -1592,17 +1690,18 @@ function renderHeader() { $('#company').value = state.company; const sel = $('#model'); if (!sel.options.length) { - for (const m of MODELS) sel.append(el('option', { value: m.width, textContent: m.label })); + for (const m of MODELS) { + sel.append(el('option', { value: m.custom ? 'custom' : String(m.width), textContent: m.label })); + } } - const known = MODELS.some((m) => m.width === state.screen.width); - if (!known) { - const label = `Custom — ${state.screen.width} × ${state.screen.height}`; - let opt = [...sel.options].find((o) => o.dataset.custom); - if (!opt) { opt = el('option', { value: state.screen.width }); opt.dataset.custom = '1'; sel.append(opt); } - opt.value = String(state.screen.width); - opt.textContent = label; + const preset = MODELS.find((m) => !m.custom && m.width === state.screen.width && m.height === state.screen.height); + sel.value = state.customSize || !preset ? 'custom' : String(state.screen.width); + const custom = sel.value === 'custom'; + $('#custom-size').hidden = !custom; + if (custom) { + $('#cust-w').value = state.screen.width; + $('#cust-h').value = state.screen.height; } - sel.value = String(state.screen.width); const has = state.destinations.length > 0; $('#btn-export').disabled = !has; $('#btn-export-tp5').disabled = !has; @@ -1938,16 +2037,71 @@ function openBulk() { $('#bulk-text').value = ''; showDialog($('#bulk')); } // ------------------------------------------------------------------- files -/** Bring in destinations from a spreadsheet, appending to whatever is loaded. */ +/** + * Show what the sheet is about to become before committing to it. Guessing the + * columns silently is how you end up with a list of destinations called + * "1", "2", "3" — so the guess is shown, and can be corrected. + */ +let pendingSheet = null; + function importRows(rows, label) { - const { company, destinations } = mapRows(rows); - if (!destinations.length) { - return toast(`No destinations found in that ${label}. Expected a column of destination text.`, true); + const info = analyseSheet(rows); + if (!info.rowCount) { + return toast(`No rows found in that ${label}.`, true); } - if (company && !state.company) state.company = company; + pendingSheet = { rows, info, label }; + + const nameSel = $('#map-name'), textSel = $('#map-text'); + nameSel.textContent = ''; textSel.textContent = ''; + for (const c of info.columns) { + if (!c.filled) continue; + const describe = `${c.label}${c.sample.length ? ` — ${c.sample.slice(0, 2).join(', ')}` : ''}`; + nameSel.append(el('option', { value: c.index, textContent: describe })); + textSel.append(el('option', { value: c.index, textContent: describe })); + } + nameSel.value = String(info.nameCol); + textSel.value = String(info.textCol); + nameSel.onchange = textSel.onchange = drawMappingPreview; + + drawMappingPreview(); + showDialog($('#mapdlg')); +} + +function drawMappingPreview() { + if (!pendingSheet) return; + const { rows, info } = pendingSheet; + const nameCol = Number($('#map-name').value); + const textCol = Number($('#map-text').value); + const found = applyMapping(rows, { start: info.start, nameCol, textCol }); + + const host = $('#map-preview'); + host.textContent = ''; + const table = el('table'); + const head = el('tr'); + head.append(el('th', { textContent: 'On the controller' }), el('th', { textContent: 'On the sign' })); + table.append(head); + for (const d of found.slice(0, 12)) { + const tr = el('tr'); + tr.append(el('td', { className: 'nm', textContent: d.name }), el('td', { className: 'led', textContent: d.text })); + table.append(tr); + } + host.append(table); + + $('#map-count').textContent = found.length + ? `${found.length} destination${found.length === 1 ? '' : 's'}` + (found.length > 12 ? ' — showing the first 12' : '') + : 'Nothing usable in those columns — try different ones.'; + $('#map-ok').disabled = found.length === 0; + pendingSheet.mapped = found; +} + +/** Commit the previewed mapping into the destination list. */ +function commitSheet() { + const { info, mapped, label } = pendingSheet ?? {}; + if (!mapped?.length) return; + if (info.company && !state.company) state.company = info.company; let added = 0, updated = 0; - for (const r of destinations) { + for (const r of mapped) { const existing = state.destinations.find((x) => x.name.trim().toUpperCase() === r.name.toUpperCase()); if (existing) { setName(existing, r.name); @@ -1964,6 +2118,7 @@ function importRows(rows, label) { } } state.selected = state.destinations[state.destinations.length - 1]?.id ?? state.selected; + pendingSheet = null; renderAll(); toast(`${label}: added ${added}${updated ? `, updated ${updated}` : ''} destination${added === 1 && !updated ? '' : 's'}.`); } @@ -2032,18 +2187,40 @@ $('#btn-sheet').onclick = () => { $('#file').dataset.sheet = '1'; $('#file').cli $('#file').onchange = (e) => { if (e.target.files[0]) openFile(e.target.files[0]); e.target.value = ''; }; $('#btn-add').onclick = addOne; $('#btn-clear').onclick = removeAll; +$('#map-cancel').onclick = () => { pendingSheet = null; closeDialog($('#mapdlg')); }; +$('#map-ok').onclick = () => { closeDialog($('#mapdlg')); commitSheet(); }; $('#btn-bulk').onclick = openBulk; $('#btn-export').onclick = exportTd5; $('#btn-export-tp5').onclick = exportTp5; $('#company').oninput = (e) => { state.company = e.target.value; }; -$('#model').onchange = (e) => { - const m = MODELS.find((x) => String(x.width) === e.target.value); - if (m) state.screen = { ...m }; +/** Re-render anything we drew ourselves; imported artwork is left alone. */ +function screenChanged() { for (const d of state.destinations) for (const p of d.pages) if (!p.pristine) p.bitmap = null; renderAll(); +} + +$('#model').onchange = (e) => { + if (e.target.value === 'custom') { + state.customSize = true; + } else { + const m = MODELS.find((x) => !x.custom && String(x.width) === e.target.value); + if (m) { state.screen = { width: m.width, height: m.height }; state.customSize = false; } + } + screenChanged(); }; +const applyCustomSize = () => { + const w = Math.max(8, Math.min(1024, Number($('#cust-w').value) || state.screen.width)); + const h = Math.max(8, Math.min(64, Number($('#cust-h').value) || state.screen.height)); + // The record stores the width in whole bytes, so it has to be a multiple of 8. + state.screen = { width: Math.round(w / 8) * 8, height: h }; + state.customSize = true; + screenChanged(); +}; +$('#cust-w').onchange = applyCustomSize; +$('#cust-h').onchange = applyCustomSize; + $('#bulk-cancel').onclick = () => closeDialog($('#bulk')); $('#bulk-ok').onclick = () => { const lines = $('#bulk-text').value.split('\n').map((s) => s.trim()).filter(Boolean); diff --git a/samples/odd-headers.xlsx b/samples/odd-headers.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..a0c26dadf152c49644c0eb8bd60896e02bdb9392 GIT binary patch literal 4972 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 literal 0 HcmV?d00001 diff --git a/src/codec/sheet.mjs b/src/codec/sheet.mjs index b8677a1..e997267 100644 --- a/src/codec/sheet.mjs +++ b/src/codec/sheet.mjs @@ -142,55 +142,113 @@ export function readCsv(text) { // -------------------------------------------------------------- mapping const norm = (s) => String(s ?? '').toLowerCase().replace(/[^a-z]/g, ''); +const colLabel = (i) => { + let n = i, out = ''; + do { out = String.fromCharCode(65 + (n % 26)) + out; n = Math.floor(n / 26) - 1; } while (n >= 0); + return out; +}; /** - * Work out which columns hold the controller name and the sign text. - * The template that ships with these buses uses - * `Line | Line Name | Description | Content (Display)`. + * Describe a sheet without committing to anything: where the header is, what + * columns exist with a sample of each, and a best guess at which column holds + * the controller name and which holds the sign text. + * + * The guess is only a starting point — the app shows it and lets you change it, + * because sheets in the wild do not all look like the shipped template + * (`Line | Line Name | Description | Content (Display)`), and silently guessing + * wrong is how you end up with destinations called "1", "2", "3". */ -export function mapRows(rows) { +export function analyseSheet(rows) { let company = ''; for (const r of rows.slice(0, 5)) { const i = r.findIndex((c) => norm(c).startsWith('companyname')); if (i >= 0) { company = (r[i + 1] || '').trim(); break; } } - const headerAt = rows.findIndex((r) => r.some((c) => { + let headerAt = rows.findIndex((r) => r.some((c) => { const n = norm(c); return n === 'linename' || n === 'content' || n.startsWith('contentdisplay') || n === 'destination'; })); + // Headers we do not recognise by name are still headers. If the top row is all + // words and something below it is a number, it is labelling columns, not data. + if (headerAt < 0) { + const first = rows.findIndex((r) => r.length && !r.every((c) => !c)); + if (first >= 0) { + const top = rows[first].filter((c) => c !== ''); + const below = rows.slice(first + 1).filter((r) => r.length && !r.every((c) => !c)); + const numericBelow = below.some((r) => r.some((c) => /^\d+(\.\d+)?$/.test(String(c).trim()))); + if (top.length > 1 && top.every((c) => !/^\d+(\.\d+)?$/.test(String(c).trim())) && numericBelow) { + headerAt = first; + } + } + } + const start = headerAt >= 0 ? headerAt + 1 : 0; + const body = rows.slice(start).filter((r) => r.length && !r.every((c) => !c)); - let nameCol = -1, textCol = -1, start = 0; + const width = Math.max(0, ...rows.map((r) => r.length)); + const columns = []; + for (let i = 0; i < width; i++) { + const values = body.map((r) => (r[i] ?? '').trim()).filter(Boolean); + columns.push({ + index: i, + label: headerAt >= 0 && rows[headerAt][i] ? String(rows[headerAt][i]).trim() : `Column ${colLabel(i)}`, + sample: values.slice(0, 3), + filled: values.length, + allNumeric: values.length > 0 && values.every((v) => /^\d+$/.test(v)), + }); + } + + let nameCol = -1, textCol = -1; if (headerAt >= 0) { const head = rows[headerAt].map(norm); - // Search by priority, not by column order: the shipped template has both a - // "Line" (a row number) and a "Line Name", and the name must win. + // By priority, not by column order: the shipped template has both a "Line" + // (a row number) and a "Line Name", and the name must win. const pick = (...wanted) => { for (const w of wanted) { const i = head.indexOf(w); if (i >= 0) return i; } return -1; }; nameCol = pick('linename', 'name', 'destinationname', 'route', 'line'); textCol = pick('contentdisplay', 'content', 'display', 'destination', 'text', 'message'); - start = headerAt + 1; } + if (nameCol < 0 && textCol < 0) { + // Nothing recognisable: the column carrying the longest words is the most + // likely destination text. + const wordy = columns.filter((c) => c.filled && !c.allNumeric); + const avg = (c) => c.sample.reduce((n, v) => n + v.length, 0) / (c.sample.length || 1); + const best = wordy.slice().sort((a, b) => avg(b) - avg(a))[0]; + textCol = best ? best.index : (columns.find((c) => c.filled)?.index ?? 0); + } + // One recognised column stands in for the other: a destination's name is + // usually exactly what the sign shows, so defaulting them to the same column + // is far safer than picking an unrelated one. + if (textCol < 0) textCol = nameCol; + if (nameCol < 0) nameCol = textCol; + // A bare row number is a line code, not something to show a driver. + if (columns[nameCol]?.allNumeric && nameCol !== textCol) nameCol = textCol; + return { company, headerAt, start, columns, nameCol, textCol, rowCount: body.length }; +} + +/** Turn a sheet into destinations using an explicit column mapping. */ +export function applyMapping(rows, { start = 0, nameCol, textCol }) { const out = []; for (const r of rows.slice(start)) { if (!r.length || r.every((c) => !c)) continue; - const cells = r.filter((c) => c !== ''); - let name = nameCol >= 0 ? (r[nameCol] || '') : ''; - let text = textCol >= 0 ? (r[textCol] || '') : ''; - if (!name && !text) { // no recognisable header — take what is there - text = cells[cells.length - 1] || ''; - name = cells.length > 1 ? cells[0] : text; - } + let name = String(r[nameCol] ?? '').trim(); + let text = String(r[textCol] ?? '').trim(); if (!text) text = name; - // A bare row number is a line code, not something worth showing a driver. - if (/^\d+$/.test(name) && text && text !== name) name = text; if (!name) name = text; - if (!text.trim()) continue; + if (!text) continue; if (norm(name) === 'linename' || norm(text).startsWith('contentdisplay')) continue; - out.push({ name: name.trim().slice(0, 16), text: text.trim() }); + // Truncating can leave a trailing space, which would also stop this row + // matching an existing destination on a later import. + out.push({ name: name.slice(0, 16).trim(), text }); } - return { company, destinations: out }; + return out; +} + +/** analyse + apply the guesses, for callers that do not want to ask. */ +export function mapRows(rows) { + const a = analyseSheet(rows); + return { company: a.company, destinations: applyMapping(rows, a) }; } diff --git a/test/sheet.mjs b/test/sheet.mjs index 7c6dcb0..893ff95 100644 --- a/test/sheet.mjs +++ b/test/sheet.mjs @@ -47,5 +47,13 @@ check('quoted fields with commas', mapRows(readCsv('Line Name,Content\n"CITY, VIA MALL","CITY, VIA MALL"\n')).destinations, [{ name: 'CITY, VIA MALL', text: 'CITY, VIA MALL' }]); +check('unrecognised headers are still headers, and Route beats the row number', + mapRows(readCsv('No.,Route,Notes\n1,SCHOOL BUS,am\n2,CARMEL COLLEGE,pm\n')).destinations, + [{ name: 'SCHOOL BUS', text: 'SCHOOL BUS' }, { name: 'CARMEL COLLEGE', text: 'CARMEL COLLEGE' }]); + +check('no recognisable columns at all — longest words win, name mirrors the sign', + mapRows(readCsv('A,B,C\n1,x,AIRPORT SHUTTLE VIA CITY\n2,y,DEPOT RUN\n')).destinations, + [{ name: 'AIRPORT SHUTTLE', text: 'AIRPORT SHUTTLE VIA CITY' }, { name: 'DEPOT RUN', text: 'DEPOT RUN' }]); + console.log(fails === 0 ? '\n✅ spreadsheet mapping correct' : `\n❌ ${fails} failed`); process.exit(fails ? 1 : 0);