Show the spreadsheet column mapping instead of guessing at it

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-21 13:42:27 +10:00
parent 07977624ae
commit f293885cd0
7 changed files with 455 additions and 85 deletions

View File

@ -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 of the real sign, and scrolls exactly as it will on the bus. TP5 shows you
nothing until you export. nothing until you export.
- **Tells you when a message will not fit** and by how many pixels. - **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 - **Any sign size**, not just the three fitted models — pick "Custom size…" and
`Line | Line Name | Description | Content (Display)` layout of the template type it. Widths snap to a multiple of 8, because the record stores the width
these buses ship with — `Line Name` becomes what the driver sees on the in whole bytes.
controller, `Content (Display)` becomes what the sign shows. Names that - **Imports a spreadsheet.** Drop in an `.xlsx` (or CSV) and it shows you which
already exist are updated rather than duplicated. No library involved: the column it thinks is the controller name and which is the sign text, with a
`.xlsx` is unzipped and parsed by the browser itself. 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. - **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 - **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 list at once. Both offer an Undo instead of asking you to confirm first — a

View File

@ -129,6 +129,16 @@ dialog .dlg-body{padding:20px}
dialog h3{margin:0 0 6px; font-size:16px} dialog h3{margin:0 0 6px; font-size:16px}
dialog p{margin:0 0 14px; color:var(--dim); font-size:13px} 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)} 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} textarea{min-height:170px; resize:vertical; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px}
#drop{ #drop{
position:fixed; inset:0; background:#0b0e14e6; display:none; place-items:center; 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
<div class="brand"><b>DestoGod</b><span>bus destination signs</span></div> <div class="brand"><b>DestoGod</b><span>bus destination signs</span></div>
<div class="field"><label for="company">Company</label><input type="text" id="company" maxlength="16" placeholder="Company"></div> <div class="field"><label for="company">Company</label><input type="text" id="company" maxlength="16" placeholder="Company"></div>
<div class="field"><label for="model">Bus / sign</label><select id="model"></select></div> <div class="field"><label for="model">Bus / sign</label><select id="model"></select></div>
<div class="field" id="custom-size" hidden>
<input type="number" id="cust-w" min="8" max="1024" step="8" title="Sign width in pixels" style="width:78px">
<span style="color:var(--dim)">×</span>
<input type="number" id="cust-h" min="8" max="64" step="8" title="Sign height in pixels" style="width:66px">
<span style="color:var(--dim);font-size:12px">pixels</span>
</div>
<div class="spacer"></div> <div class="spacer"></div>
<button id="btn-open">Open file…</button> <button id="btn-open">Open file…</button>
<button id="btn-sheet" title="Import destinations from Excel or CSV">Import spreadsheet…</button> <button id="btn-sheet" title="Import destinations from Excel or CSV">Import spreadsheet…</button>
@ -181,6 +197,29 @@ kbd{background:#12141a; border:1px solid var(--line); border-bottom-width:2px; b
<div id="drop">Drop a .td5, .tp5, spreadsheet or CSV to open</div> <div id="drop">Drop a .td5, .tp5, spreadsheet or CSV to open</div>
<div id="toast"></div> <div id="toast"></div>
<dialog id="mapdlg">
<div class="dlg-body">
<h3>Import from spreadsheet</h3>
<p>Check the two columns below are the right ones, then import. Everything can still be edited afterwards.</p>
<div class="row">
<div>
<label for="map-name">Name on the driver's controller</label>
<select id="map-name"></select>
</div>
<div>
<label for="map-text">Text shown on the sign</label>
<select id="map-text"></select>
</div>
</div>
<div id="map-preview"></div>
<div id="map-count"></div>
</div>
<div class="dlg-foot">
<button id="map-cancel" class="ghost">Cancel</button>
<button id="map-ok" class="primary">Import</button>
</div>
</dialog>
<dialog id="bulk"> <dialog id="bulk">
<div class="dlg-body"> <div class="dlg-body">
<h3>Paste a list of destinations</h3> <h3>Paste a list of destinations</h3>

View File

@ -3,7 +3,7 @@ import { FONTS } from '../src/fonts.js';
import { fontFromJSON, renderText, trimX } from '../src/codec/bitfont.mjs'; import { fontFromJSON, renderText, trimX } from '../src/codec/bitfont.mjs';
import { parseTd5, buildTd5, frameToPixels, pixelsToFrame, EFFECT } from '../src/codec/td5.mjs'; import { parseTd5, buildTd5, frameToPixels, pixelsToFrame, EFFECT } from '../src/codec/td5.mjs';
import { parseTp5, buildTp5, toSimple } from '../src/codec/tp5.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). // Sign sizes from the Yutong programming manual (section 2.2).
const MODELS = [ const MODELS = [
@ -14,6 +14,7 @@ const MODELS = [
{ label: 'Other — 160 × 16', width: 160, height: 16 }, { label: 'Other — 160 × 16', width: 160, height: 16 },
{ label: 'Other — 192 × 16', width: 192, height: 16 }, { label: 'Other — 192 × 16', width: 192, height: 16 },
{ label: 'Other — 256 × 16', width: 256, 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']; const SYSTEM_FONTS = ['Impact', 'Arial Narrow', 'Arial Black', 'Arial', 'Helvetica', 'Verdana', 'Tahoma'];
@ -477,17 +478,18 @@ function renderHeader() {
$('#company').value = state.company; $('#company').value = state.company;
const sel = $('#model'); const sel = $('#model');
if (!sel.options.length) { 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); const preset = MODELS.find((m) => !m.custom && m.width === state.screen.width && m.height === state.screen.height);
if (!known) { sel.value = state.customSize || !preset ? 'custom' : String(state.screen.width);
const label = `Custom — ${state.screen.width} × ${state.screen.height}`; const custom = sel.value === 'custom';
let opt = [...sel.options].find((o) => o.dataset.custom); $('#custom-size').hidden = !custom;
if (!opt) { opt = el('option', { value: state.screen.width }); opt.dataset.custom = '1'; sel.append(opt); } if (custom) {
opt.value = String(state.screen.width); $('#cust-w').value = state.screen.width;
opt.textContent = label; $('#cust-h').value = state.screen.height;
} }
sel.value = String(state.screen.width);
const has = state.destinations.length > 0; const has = state.destinations.length > 0;
$('#btn-export').disabled = !has; $('#btn-export').disabled = !has;
$('#btn-export-tp5').disabled = !has; $('#btn-export-tp5').disabled = !has;
@ -823,16 +825,71 @@ function openBulk() { $('#bulk-text').value = ''; showDialog($('#bulk')); }
// ------------------------------------------------------------------- files // ------------------------------------------------------------------- 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) { function importRows(rows, label) {
const { company, destinations } = mapRows(rows); const info = analyseSheet(rows);
if (!destinations.length) { if (!info.rowCount) {
return toast(`No destinations found in that ${label}. Expected a column of destination text.`, true); 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; 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()); const existing = state.destinations.find((x) => x.name.trim().toUpperCase() === r.name.toUpperCase());
if (existing) { if (existing) {
setName(existing, r.name); setName(existing, r.name);
@ -849,6 +906,7 @@ function importRows(rows, label) {
} }
} }
state.selected = state.destinations[state.destinations.length - 1]?.id ?? state.selected; state.selected = state.destinations[state.destinations.length - 1]?.id ?? state.selected;
pendingSheet = null;
renderAll(); renderAll();
toast(`${label}: added ${added}${updated ? `, updated ${updated}` : ''} destination${added === 1 && !updated ? '' : 's'}.`); 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 = ''; }; $('#file').onchange = (e) => { if (e.target.files[0]) openFile(e.target.files[0]); e.target.value = ''; };
$('#btn-add').onclick = addOne; $('#btn-add').onclick = addOne;
$('#btn-clear').onclick = removeAll; $('#btn-clear').onclick = removeAll;
$('#map-cancel').onclick = () => { pendingSheet = null; closeDialog($('#mapdlg')); };
$('#map-ok').onclick = () => { closeDialog($('#mapdlg')); commitSheet(); };
$('#btn-bulk').onclick = openBulk; $('#btn-bulk').onclick = openBulk;
$('#btn-export').onclick = exportTd5; $('#btn-export').onclick = exportTd5;
$('#btn-export-tp5').onclick = exportTp5; $('#btn-export-tp5').onclick = exportTp5;
$('#company').oninput = (e) => { state.company = e.target.value; }; $('#company').oninput = (e) => { state.company = e.target.value; };
$('#model').onchange = (e) => { /** Re-render anything we drew ourselves; imported artwork is left alone. */
const m = MODELS.find((x) => String(x.width) === e.target.value); function screenChanged() {
if (m) state.screen = { ...m };
for (const d of state.destinations) for (const p of d.pages) if (!p.pristine) p.bitmap = null; for (const d of state.destinations) for (const p of d.pages) if (!p.pristine) p.bitmap = null;
renderAll(); 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-cancel').onclick = () => closeDialog($('#bulk'));
$('#bulk-ok').onclick = () => { $('#bulk-ok').onclick = () => {
const lines = $('#bulk-text').value.split('\n').map((s) => s.trim()).filter(Boolean); const lines = $('#bulk-text').value.split('\n').map((s) => s.trim()).filter(Boolean);

255
dist/DestoGod.html vendored
View File

@ -134,6 +134,16 @@ dialog .dlg-body{padding:20px}
dialog h3{margin:0 0 6px; font-size:16px} dialog h3{margin:0 0 6px; font-size:16px}
dialog p{margin:0 0 14px; color:var(--dim); font-size:13px} 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)} 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} textarea{min-height:170px; resize:vertical; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px}
#drop{ #drop{
position:fixed; inset:0; background:#0b0e14e6; display:none; place-items:center; 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
<div class="brand"><b>DestoGod</b><span>bus destination signs</span></div> <div class="brand"><b>DestoGod</b><span>bus destination signs</span></div>
<div class="field"><label for="company">Company</label><input type="text" id="company" maxlength="16" placeholder="Company"></div> <div class="field"><label for="company">Company</label><input type="text" id="company" maxlength="16" placeholder="Company"></div>
<div class="field"><label for="model">Bus / sign</label><select id="model"></select></div> <div class="field"><label for="model">Bus / sign</label><select id="model"></select></div>
<div class="field" id="custom-size" hidden>
<input type="number" id="cust-w" min="8" max="1024" step="8" title="Sign width in pixels" style="width:78px">
<span style="color:var(--dim)">×</span>
<input type="number" id="cust-h" min="8" max="64" step="8" title="Sign height in pixels" style="width:66px">
<span style="color:var(--dim);font-size:12px">pixels</span>
</div>
<div class="spacer"></div> <div class="spacer"></div>
<button id="btn-open">Open file…</button> <button id="btn-open">Open file…</button>
<button id="btn-sheet" title="Import destinations from Excel or CSV">Import spreadsheet…</button> <button id="btn-sheet" title="Import destinations from Excel or CSV">Import spreadsheet…</button>
@ -186,6 +202,29 @@ kbd{background:#12141a; border:1px solid var(--line); border-bottom-width:2px; b
<div id="drop">Drop a .td5, .tp5, spreadsheet or CSV to open</div> <div id="drop">Drop a .td5, .tp5, spreadsheet or CSV to open</div>
<div id="toast"></div> <div id="toast"></div>
<dialog id="mapdlg">
<div class="dlg-body">
<h3>Import from spreadsheet</h3>
<p>Check the two columns below are the right ones, then import. Everything can still be edited afterwards.</p>
<div class="row">
<div>
<label for="map-name">Name on the driver's controller</label>
<select id="map-name"></select>
</div>
<div>
<label for="map-text">Text shown on the sign</label>
<select id="map-text"></select>
</div>
</div>
<div id="map-preview"></div>
<div id="map-count"></div>
</div>
<div class="dlg-foot">
<button id="map-cancel" class="ghost">Cancel</button>
<button id="map-ok" class="primary">Import</button>
</div>
</dialog>
<dialog id="bulk"> <dialog id="bulk">
<div class="dlg-body"> <div class="dlg-body">
<h3>Paste a list of destinations</h3> <h3>Paste a list of destinations</h3>
@ -1059,57 +1098,115 @@ function readCsv(text) {
// -------------------------------------------------------------- mapping // -------------------------------------------------------------- mapping
const norm = (s) => String(s ?? '').toLowerCase().replace(/[^a-z]/g, ''); 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. * Describe a sheet without committing to anything: where the header is, what
* The template that ships with these buses uses * columns exist with a sample of each, and a best guess at which column holds
* `Line | Line Name | Description | Content (Display)`. * 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 = ''; let company = '';
for (const r of rows.slice(0, 5)) { for (const r of rows.slice(0, 5)) {
const i = r.findIndex((c) => norm(c).startsWith('companyname')); const i = r.findIndex((c) => norm(c).startsWith('companyname'));
if (i >= 0) { company = (r[i + 1] || '').trim(); break; } 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); const n = norm(c);
return n === 'linename' || n === 'content' || n.startsWith('contentdisplay') || n === 'destination'; 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) { if (headerAt >= 0) {
const head = rows[headerAt].map(norm); const head = rows[headerAt].map(norm);
// Search by priority, not by column order: the shipped template has both a // By priority, not by column order: the shipped template has both a "Line"
// "Line" (a row number) and a "Line Name", and the name must win. // (a row number) and a "Line Name", and the name must win.
const pick = (...wanted) => { const pick = (...wanted) => {
for (const w of wanted) { const i = head.indexOf(w); if (i >= 0) return i; } for (const w of wanted) { const i = head.indexOf(w); if (i >= 0) return i; }
return -1; return -1;
}; };
nameCol = pick('linename', 'name', 'destinationname', 'route', 'line'); nameCol = pick('linename', 'name', 'destinationname', 'route', 'line');
textCol = pick('contentdisplay', 'content', 'display', 'destination', 'text', 'message'); 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 = []; const out = [];
for (const r of rows.slice(start)) { for (const r of rows.slice(start)) {
if (!r.length || r.every((c) => !c)) continue; if (!r.length || r.every((c) => !c)) continue;
const cells = r.filter((c) => c !== ''); let name = String(r[nameCol] ?? '').trim();
let name = nameCol >= 0 ? (r[nameCol] || '') : ''; let text = String(r[textCol] ?? '').trim();
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;
}
if (!text) text = name; 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 (!name) name = text;
if (!text.trim()) continue; if (!text) continue;
if (norm(name) === 'linename' || norm(text).startsWith('contentdisplay')) 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 — 160 × 16', width: 160, height: 16 },
{ label: 'Other — 192 × 16', width: 192, height: 16 }, { label: 'Other — 192 × 16', width: 192, height: 16 },
{ label: 'Other — 256 × 16', width: 256, 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']; const SYSTEM_FONTS = ['Impact', 'Arial Narrow', 'Arial Black', 'Arial', 'Helvetica', 'Verdana', 'Tahoma'];
@ -1592,17 +1690,18 @@ function renderHeader() {
$('#company').value = state.company; $('#company').value = state.company;
const sel = $('#model'); const sel = $('#model');
if (!sel.options.length) { 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); const preset = MODELS.find((m) => !m.custom && m.width === state.screen.width && m.height === state.screen.height);
if (!known) { sel.value = state.customSize || !preset ? 'custom' : String(state.screen.width);
const label = `Custom — ${state.screen.width} × ${state.screen.height}`; const custom = sel.value === 'custom';
let opt = [...sel.options].find((o) => o.dataset.custom); $('#custom-size').hidden = !custom;
if (!opt) { opt = el('option', { value: state.screen.width }); opt.dataset.custom = '1'; sel.append(opt); } if (custom) {
opt.value = String(state.screen.width); $('#cust-w').value = state.screen.width;
opt.textContent = label; $('#cust-h').value = state.screen.height;
} }
sel.value = String(state.screen.width);
const has = state.destinations.length > 0; const has = state.destinations.length > 0;
$('#btn-export').disabled = !has; $('#btn-export').disabled = !has;
$('#btn-export-tp5').disabled = !has; $('#btn-export-tp5').disabled = !has;
@ -1938,16 +2037,71 @@ function openBulk() { $('#bulk-text').value = ''; showDialog($('#bulk')); }
// ------------------------------------------------------------------- files // ------------------------------------------------------------------- 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) { function importRows(rows, label) {
const { company, destinations } = mapRows(rows); const info = analyseSheet(rows);
if (!destinations.length) { if (!info.rowCount) {
return toast(`No destinations found in that ${label}. Expected a column of destination text.`, true); 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; 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()); const existing = state.destinations.find((x) => x.name.trim().toUpperCase() === r.name.toUpperCase());
if (existing) { if (existing) {
setName(existing, r.name); setName(existing, r.name);
@ -1964,6 +2118,7 @@ function importRows(rows, label) {
} }
} }
state.selected = state.destinations[state.destinations.length - 1]?.id ?? state.selected; state.selected = state.destinations[state.destinations.length - 1]?.id ?? state.selected;
pendingSheet = null;
renderAll(); renderAll();
toast(`${label}: added ${added}${updated ? `, updated ${updated}` : ''} destination${added === 1 && !updated ? '' : 's'}.`); 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 = ''; }; $('#file').onchange = (e) => { if (e.target.files[0]) openFile(e.target.files[0]); e.target.value = ''; };
$('#btn-add').onclick = addOne; $('#btn-add').onclick = addOne;
$('#btn-clear').onclick = removeAll; $('#btn-clear').onclick = removeAll;
$('#map-cancel').onclick = () => { pendingSheet = null; closeDialog($('#mapdlg')); };
$('#map-ok').onclick = () => { closeDialog($('#mapdlg')); commitSheet(); };
$('#btn-bulk').onclick = openBulk; $('#btn-bulk').onclick = openBulk;
$('#btn-export').onclick = exportTd5; $('#btn-export').onclick = exportTd5;
$('#btn-export-tp5').onclick = exportTp5; $('#btn-export-tp5').onclick = exportTp5;
$('#company').oninput = (e) => { state.company = e.target.value; }; $('#company').oninput = (e) => { state.company = e.target.value; };
$('#model').onchange = (e) => { /** Re-render anything we drew ourselves; imported artwork is left alone. */
const m = MODELS.find((x) => String(x.width) === e.target.value); function screenChanged() {
if (m) state.screen = { ...m };
for (const d of state.destinations) for (const p of d.pages) if (!p.pristine) p.bitmap = null; for (const d of state.destinations) for (const p of d.pages) if (!p.pristine) p.bitmap = null;
renderAll(); 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-cancel').onclick = () => closeDialog($('#bulk'));
$('#bulk-ok').onclick = () => { $('#bulk-ok').onclick = () => {
const lines = $('#bulk-text').value.split('\n').map((s) => s.trim()).filter(Boolean); const lines = $('#bulk-text').value.split('\n').map((s) => s.trim()).filter(Boolean);

BIN
samples/odd-headers.xlsx Normal file

Binary file not shown.

View File

@ -142,55 +142,113 @@ export function readCsv(text) {
// -------------------------------------------------------------- mapping // -------------------------------------------------------------- mapping
const norm = (s) => String(s ?? '').toLowerCase().replace(/[^a-z]/g, ''); 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. * Describe a sheet without committing to anything: where the header is, what
* The template that ships with these buses uses * columns exist with a sample of each, and a best guess at which column holds
* `Line | Line Name | Description | Content (Display)`. * 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 = ''; let company = '';
for (const r of rows.slice(0, 5)) { for (const r of rows.slice(0, 5)) {
const i = r.findIndex((c) => norm(c).startsWith('companyname')); const i = r.findIndex((c) => norm(c).startsWith('companyname'));
if (i >= 0) { company = (r[i + 1] || '').trim(); break; } 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); const n = norm(c);
return n === 'linename' || n === 'content' || n.startsWith('contentdisplay') || n === 'destination'; 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) { if (headerAt >= 0) {
const head = rows[headerAt].map(norm); const head = rows[headerAt].map(norm);
// Search by priority, not by column order: the shipped template has both a // By priority, not by column order: the shipped template has both a "Line"
// "Line" (a row number) and a "Line Name", and the name must win. // (a row number) and a "Line Name", and the name must win.
const pick = (...wanted) => { const pick = (...wanted) => {
for (const w of wanted) { const i = head.indexOf(w); if (i >= 0) return i; } for (const w of wanted) { const i = head.indexOf(w); if (i >= 0) return i; }
return -1; return -1;
}; };
nameCol = pick('linename', 'name', 'destinationname', 'route', 'line'); nameCol = pick('linename', 'name', 'destinationname', 'route', 'line');
textCol = pick('contentdisplay', 'content', 'display', 'destination', 'text', 'message'); 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 = []; const out = [];
for (const r of rows.slice(start)) { for (const r of rows.slice(start)) {
if (!r.length || r.every((c) => !c)) continue; if (!r.length || r.every((c) => !c)) continue;
const cells = r.filter((c) => c !== ''); let name = String(r[nameCol] ?? '').trim();
let name = nameCol >= 0 ? (r[nameCol] || '') : ''; let text = String(r[textCol] ?? '').trim();
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;
}
if (!text) text = name; 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 (!name) name = text;
if (!text.trim()) continue; if (!text) continue;
if (norm(name) === 'linename' || norm(text).startsWith('contentdisplay')) 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) };
} }

View File

@ -47,5 +47,13 @@ check('quoted fields with commas',
mapRows(readCsv('Line Name,Content\n"CITY, VIA MALL","CITY, VIA MALL"\n')).destinations, mapRows(readCsv('Line Name,Content\n"CITY, VIA MALL","CITY, VIA MALL"\n')).destinations,
[{ name: 'CITY, VIA MALL', text: 'CITY, VIA MALL' }]); [{ 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`); console.log(fails === 0 ? '\n✅ spreadsheet mapping correct' : `\n${fails} failed`);
process.exit(fails ? 1 : 0); process.exit(fails ? 1 : 0);