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 0000000..a0c26da Binary files /dev/null and b/samples/odd-headers.xlsx differ 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);