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
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

View File

@ -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
<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="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>
<button id="btn-open">Open file…</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="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">
<div class="dlg-body">
<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 { 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);

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 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
<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="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>
<button id="btn-open">Open file…</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="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">
<div class="dlg-body">
<h3>Paste a list of destinations</h3>
@ -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);

BIN
samples/odd-headers.xlsx Normal file

Binary file not shown.

View File

@ -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) };
}

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,
[{ 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);