destogod/app/main.mjs
type-two f293885cd0 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>
2026-08-21 13:42:27 +10:00

1034 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** DestoGod — editor for Yutong / Guangzhou-Tongda bus destination signs. */
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, analyseSheet, applyMapping } from '../src/codec/sheet.mjs';
// Sign sizes from the Yutong programming manual (section 2.2).
const MODELS = [
{ label: 'ZK6930 / ZK6938 — 112 × 16', width: 112, height: 16 },
{ label: 'ZK6760 — 80 × 16', width: 80, height: 16 },
{ label: 'ZK6129 / T12 — 144 × 16', width: 144, height: 16 },
{ label: 'Other — 128 × 16', width: 128, height: 16 },
{ 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'];
/**
* TP5 hands the point size to Windows GDI, which maps it to a smaller em size
* than a browser canvas does for the same number. Measured against all 36
* destinations in the sample project: TP5 size 20 renders at 15.77 canvas px
* (spread 14.7-16.2, which is just per-string hinting noise). Keeping sizes in
* TP5's units means a .tp5 we write stays compatible with the old software.
*/
const TP5_SIZE_SCALE = 0.789;
let state = {
company: '',
screen: { ...MODELS[0] },
destinations: [],
selected: null,
tp5Template: null, // keeps the untouched project structure when a .tp5 was opened
};
let uid = 1;
const nextId = () => `d${uid++}`;
// --------------------------------------------------------------- rendering
/** Rasterise one page into {width, height, pixels}. */
function renderPage(page, screen) {
if (page.bitmap && page.pristine) return page.bitmap;
if (!page.text.trim()) return { width: 0, height: screen.height, pixels: new Uint8Array(0) };
const bmp = page.fontKind === 'builtin'
? renderBuiltin(page, screen)
: renderSystemFont(page, screen);
page.bitmap = bmp;
return bmp;
}
function renderBuiltin(page, screen) {
const json = FONTS[page.font] ?? FONTS.ASC1609;
const font = fontFromJSON(json);
const bmp = renderText(font, page.text, { tracking: page.tracking ?? 1 });
return fitVertically(trimX(bmp, 0), screen.height);
}
/**
* Render with a Windows/macOS font through a canvas and threshold to 1 bit.
* This is how the existing signs were made (TP5 hands the text to GDI), so it
* keeps a converted list looking like what the company already runs.
*/
function renderSystemFont(page, screen) {
const cal = state.calibration;
const useCal = cal && cal.family === page.ttfName;
const scale = useCal ? cal.scale : TP5_SIZE_SCALE;
const threshold = useCal ? cal.threshold : (page.threshold ?? 225);
const ink = rasteriseSystem(page.text, page.ttfName, (page.ptSize ?? 20) * scale, threshold, page.bold);
return fitVertically(ink, screen.height);
}
/** Draw text with a system font and cut it to 1 bit, trimmed to the ink. */
function rasteriseSystem(text, family, px, threshold, bold = false) {
const pad = 8;
const css = `${bold ? 'bold ' : ''}${px}px "${family}", sans-serif`;
const cv = document.createElement('canvas');
let ctx = cv.getContext('2d', { willReadFrequently: true });
ctx.font = css;
cv.width = Math.max(1, Math.ceil(ctx.measureText(text).width) + pad * 2);
cv.height = Math.max(1, Math.ceil(px * 3));
ctx = cv.getContext('2d', { willReadFrequently: true });
ctx.font = css;
ctx.textBaseline = 'middle';
ctx.fillStyle = '#fff';
ctx.fillText(text, pad, cv.height / 2);
const img = ctx.getImageData(0, 0, cv.width, cv.height).data;
const pixels = new Uint8Array(cv.width * cv.height);
for (let i = 0; i < pixels.length; i++) pixels[i] = img[i * 4 + 3] > threshold ? 1 : 0;
return trimX(trimY({ width: cv.width, height: cv.height, pixels }), 0);
}
const countLit = (b) => b.pixels.reduce((n, v) => n + v, 0);
function inkStats(bmp) {
const t = trimX(trimY(bmp), 0);
return { w: t.width, h: t.height, lit: countLit(t) };
}
/**
* The one part of this that depends on the computer is how the browser turns a
* system font into pixels — macOS, Windows and Linux all rasterise slightly
* differently, and TP5 itself used Windows GDI. So rather than trust a constant
* measured on one machine, measure against the company's own signs: we know the
* text (from the .tp5) and the exact artwork TP5 produced (from the .td5), so we
* can solve for the size and threshold that reproduce it on THIS machine.
*/
function calibrateSystemFont(samples) {
if (samples.length < 3) return null;
const family = samples[0].family;
let best = null;
for (const threshold of [128, 170, 200, 225, 245]) {
// first pass: what size reproduces the original widths?
const ratios = samples
.map((s) => {
const r = rasteriseSystem(s.text, family, s.ptSize * TP5_SIZE_SCALE, threshold);
return r.width ? s.w / r.width : 0;
})
.filter(Boolean)
.sort((a, b) => a - b);
if (!ratios.length) continue;
const scale = TP5_SIZE_SCALE * ratios[Math.floor(ratios.length / 2)];
// second pass: at that size, how close is the stroke weight?
let lit = 0, wErr = 0, hErr = 0, n = 0;
for (const s of samples) {
const r = rasteriseSystem(s.text, family, s.ptSize * scale, threshold);
if (!r.width || !s.lit) continue;
lit += countLit(r) / s.lit;
wErr += Math.abs(r.width - s.w) / s.w;
hErr += Math.abs(r.height - s.h) / s.h;
n++;
}
if (!n) continue;
const litRatio = lit / n;
const score = Math.abs(litRatio - 1) + wErr / n + hErr / n;
if (!best || score < best.score) {
best = { family, scale, threshold, litRatio, widthErr: wErr / n, heightErr: hErr / n, score, samples: n };
}
}
return best;
}
function trimY(b) {
let lo = b.height, hi = -1;
for (let y = 0; y < b.height; y++) {
for (let x = 0; x < b.width; x++) {
if (b.pixels[y * b.width + x]) { if (y < lo) lo = y; if (y > hi) hi = y; break; }
}
}
if (hi < 0) return { width: 0, height: 0, pixels: new Uint8Array(0) };
const h = hi - lo + 1;
const out = new Uint8Array(b.width * h);
out.set(b.pixels.subarray(lo * b.width, (hi + 1) * b.width));
return { width: b.width, height: h, pixels: out };
}
/** Centre a bitmap in a canvas `height` rows tall, cropping if it is too tall. */
function fitVertically(b, height) {
if (b.height === height) return b;
const out = new Uint8Array(b.width * height);
const y0 = Math.floor((height - b.height) / 2);
for (let y = 0; y < b.height; y++) {
const ty = y + y0;
if (ty < 0 || ty >= height) continue;
out.set(b.pixels.subarray(y * b.width, (y + 1) * b.width), ty * b.width);
}
return { width: b.width, height, pixels: out };
}
// ------------------------------------------------------------- LED display
/** Draw a bitmap as an LED matrix, windowed to the sign and optionally scrolling. */
function drawLED(canvas, bmp, screen, { scale = 4, offset = 0, gap = 1 } = {}) {
const cw = screen.width, chh = screen.height;
const cell = scale + gap;
canvas.width = cw * cell;
canvas.height = chh * cell;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const scrolling = bmp.width > cw;
// A message that fits is centred, exactly as the sign controller shows it.
const x0 = scrolling ? -offset : Math.floor((cw - bmp.width) / 2);
for (let y = 0; y < chh; y++) {
for (let x = 0; x < cw; x++) {
let sx = x - x0;
if (scrolling) sx = ((sx % (bmp.width + cw)) + (bmp.width + cw)) % (bmp.width + cw);
const on = sx >= 0 && sx < bmp.width && bmp.height > 0 && bmp.pixels[y * bmp.width + sx];
ctx.fillStyle = on ? '#ffb000' : '#1a1508';
ctx.fillRect(x * cell, y * cell, scale, scale);
}
}
return scrolling;
}
let animTimer = null;
function startPreview(canvas, bmp, screen, effect) {
clearInterval(animTimer);
const scrolls = bmp.width > screen.width && effect === EFFECT.SCROLL;
if (!scrolls) { drawLED(canvas, bmp, screen, { scale: 6 }); return; }
let off = 0;
const span = bmp.width + screen.width;
animTimer = setInterval(() => {
drawLED(canvas, bmp, screen, { scale: 6, offset: off });
off = (off + 1) % span;
}, 45);
}
// ------------------------------------------------------------------ import
function newPage(text = '') {
return {
text,
fontKind: 'system',
ttfName: 'Impact',
ptSize: 20,
bold: false,
threshold: 225,
font: 'ASC1609',
tracking: 1,
effect: EFFECT.SCROLL,
bitmap: null,
pristine: false,
};
}
function newDestination(name = 'NEW DESTINATION') {
return { id: nextId(), name: name.slice(0, 16), lineName: name.slice(0, 16), pages: [newPage(name)], blockId: null };
}
function loadTd5(bytes) {
const doc = parseTd5(bytes);
state.company = doc.company;
state.screen = { ...(MODELS.find((m) => m.width === doc.screen.width) ?? doc.screen) };
state.tp5Template = null;
state.destinations = doc.destinations.map((d) => ({
id: nextId(),
name: d.name,
lineName: d.name, // repairs a file whose two name copies drifted apart
blockId: d.blockId,
pages: d.frames.map((f, i) => {
const p = newPage('');
p.bitmap = { width: f.width, height: f.height, pixels: frameToPixels(f) };
p.pristine = true; // keep TP5's exact artwork until edited
p.effect = d.effects[i] ?? EFFECT.SCROLL;
return p;
}),
}));
return `Opened ${state.destinations.length} destinations from the bus file.`;
}
function loadTp5(text) {
const doc = parseTp5(text);
const simple = toSimple(doc);
state.company = simple.company;
state.screen = { ...(MODELS.find((m) => m.width === simple.screen.width) ?? simple.screen) };
state.tp5Template = doc;
state.destinations = simple.destinations.map((d) => ({
id: nextId(),
name: d.name,
lineName: d.name, // repairs a file whose two name copies drifted apart
blockId: null,
pages: d.frames.map((f) => {
const p = newPage(f.text);
p.effect = f.effect;
if (f.useTrueType && f.ttfName) { p.fontKind = 'system'; p.ttfName = f.ttfName; p.ptSize = f.ptSize; }
else { p.fontKind = 'builtin'; p.font = FONTS[f.font] ? f.font : 'ASC1609'; }
return p;
}),
}));
return `Opened ${state.destinations.length} destinations, with their text, from the project file.`;
}
/** A .tp5 carries the text; a .td5 carries the exact artwork. Merge by name. */
function mergeArtworkFromTd5(bytes) {
const doc = parseTd5(bytes);
let hits = 0;
const samples = [];
for (const d of doc.destinations) {
const target = state.destinations.find((x) => x.name.trim() === d.name.trim());
if (!target) continue;
d.frames.forEach((f, i) => {
const page = target.pages[i];
if (!page) return;
const bitmap = { width: f.width, height: f.height, pixels: frameToPixels(f) };
page.bitmap = bitmap;
page.pristine = true;
hits++;
if (page.fontKind === 'system' && page.text.trim()) {
const s = inkStats(bitmap);
if (s.w && s.lit) samples.push({ text: page.text, family: page.ttfName, ptSize: page.ptSize, ...s });
}
});
target.blockId = d.blockId;
}
// Calibrate the lettering against the signs this company already runs.
const dominant = samples.filter((s) => s.family === (samples[0]?.family ?? ''));
state.calibration = calibrateSystemFont(dominant.slice(0, 24));
return hits;
}
// ------------------------------------------------------------------ export
function toTd5Doc() {
const dests = state.destinations.map((d) => {
const frames = d.pages.map((p) => {
const bmp = renderPage(p, state.screen);
const width = Math.max(8, bmp.width);
const padded = bmp.width === width ? bmp
: { width, height: state.screen.height, pixels: new Uint8Array(width * state.screen.height) };
return pixelsToFrame(padded.pixels, padded.width, state.screen.height);
});
const nf = frames.length;
const effects = [];
for (let k = 0; k < nf * 2; k++) effects.push(d.pages[k % nf].effect);
return {
// Both copies always carry the same text, as TP5 writes them. The second
// is what the driver's controller displays.
name: d.name, lineName: d.name,
frames, effects, blockId: d.blockId ?? undefined,
};
});
return { company: state.company, screen: state.screen, destinations: dests };
}
function exportTd5() {
if (!state.destinations.length) return toast('Nothing to export yet.', true);
// Don't let a stray empty destination reach a bus: the driver would see a
// selectable entry that displays nothing.
const blanks = state.destinations.filter(isBlank);
if (blanks.length) {
return toast(
`${blanks.length} destination${blanks.length === 1 ? '' : 's'} ${blanks.length === 1 ? 'has' : 'have'} no text — ` +
`${blanks.length === 1 ? 'it' : 'they'} would show as blank on the sign.`,
true,
'Remove and export',
() => { for (const b of blanks) state.destinations.splice(state.destinations.indexOf(b), 1); renderAll(); exportTd5(); },
);
}
// TP5 makes you create a company before you can add destinations, so a file
// with a blank one is a shape the controller has never been given. Cheap to
// avoid: fill it in rather than shipping an empty field.
if (!state.company.trim()) {
state.company = 'DESTINATIONS';
renderHeader();
toast("Company name was empty, so 'DESTINATIONS' was used — change it at the top if you want something else.");
}
const bytes = buildTd5(toTd5Doc());
download(bytes, `${safeName(state.company || 'destinations')}.td5`, 'application/octet-stream');
toast(`Exported ${state.destinations.length} destinations. Copy this file to the SD card.`);
}
/**
* Save a .tp5 so the old TP5 software can still open the list. When the project
* was opened from a .tp5 we edit that structure in place, which keeps every
* field we do not model (icons, rear/side screens) exactly as it was.
*/
function exportTp5() {
if (!state.destinations.length) return toast('Nothing to save yet.', true);
const template = state.tp5Template ?? blankProject();
const proto = template.companies[0].destinations[0];
const company = template.companies[0];
company.name = state.company;
company.destinations = state.destinations.map((d) => {
const rec = deepClone(proto);
rec.lineName = d.name;
rec.lineName2 = d.name;
const protoAct = deepClone(proto.screens.fore.up[0]);
rec.screens.fore.up = d.pages.map((p) => {
const act = deepClone(protoAct);
const icon = act.icons[0];
icon.fields[4] = String(p.effect);
icon.fields[6] = '1';
const obj = icon.objs[0] ?? {
type: 'S', unknown1: '-3', x: 0, y: 0, font: 'ASC0704',
text: '', useTrueType: 1, ttfName: 'Impact', unknown2: '0', ptSize: 20, rest: [],
};
obj.text = p.text;
if (p.fontKind === 'system') { obj.useTrueType = 1; obj.ttfName = p.ttfName; obj.ptSize = p.ptSize; }
else { obj.useTrueType = 0; obj.font = p.font; }
const bmp = renderPage(p, state.screen);
obj.x = Math.max(0, Math.floor((state.screen.width - bmp.width) / 2));
icon.objs = [obj];
return act;
});
return rec;
});
download(new TextEncoder().encode(buildTp5(template)), `${safeName(state.company || 'destinations')}.tp5`, 'text/plain');
toast('Project saved. The old TP5 software can open this file too.');
}
function blankProject() {
const w = state.screen.width, h = state.screen.height;
const screen = (width) => ({ params: [String(width), String(h), '11316396', '0', '255', '1', '10', '6'], up: [], down: [] });
const act = {
fields: ['0', '10', '5', '3', '0', '0', '0'],
icons: [
{ fields: ['0', '0', String(w), String(h), '9', '0', '1'], objs: [] },
{ fields: ['0', '0', '0', '0', '3', '0', '0'], objs: [] },
{ fields: ['0', '0', '0', '0', '3', '0', '0'], objs: [] },
{ fields: ['0', '0', '256', String(h), '3', '0', '0'], objs: [] },
{ fields: ['0', '0', '256', String(h), '3', '0', '0'], objs: [] },
],
};
const dest = {
actIconFiles: [], srnIconFiles: [], lineName: '', lineName2: '',
screens: {
fore: { ...screen(w), up: [act] },
bcak: screen(256), side: screen(256), inner: screen(256), backside: screen(256),
},
};
return {
version: 'V1.5.3', flag: '0',
screenPara: ['1', '0', String(h), String(w), '0', '0', '8', '4', '0',
'1', String(h), '256', '0', '0', '8', '4', '0', '0',
'2', String(h), '256', '0', '0', '8', '4', '0', '0',
'4', String(h), '256', '0', '0', '8', '4', '0',
'3', String(h), '256', '0', '0', '8', '4'],
companies: [{ name: state.company, destinations: [dest] }],
};
}
// --------------------------------------------------------------------- UI
const $ = (s) => document.querySelector(s);
const el = (tag, props = {}, ...kids) => {
const n = Object.assign(document.createElement(tag), props);
for (const k of kids) n.append(k);
return n;
};
/** Depot PCs can be old, so avoid structuredClone (2022) and dialog fallbacks. */
function deepClone(v) {
if (v === null || typeof v !== 'object') return v;
if (v instanceof Uint8Array) return new Uint8Array(v);
if (Array.isArray(v)) return v.map(deepClone);
const out = {};
for (const k of Object.keys(v)) out[k] = deepClone(v[k]);
return out;
}
function showDialog(dlg) {
if (typeof dlg.showModal === 'function') return dlg.showModal();
dlg.setAttribute('open', '');
dlg.style.cssText = 'display:block;position:fixed;top:12%;left:50%;transform:translateX(-50%);z-index:200';
}
function closeDialog(dlg) {
if (typeof dlg.close === 'function' && dlg.open !== undefined && typeof dlg.showModal === 'function') return dlg.close();
dlg.removeAttribute('open');
dlg.style.display = 'none';
}
/**
* A destination with nothing to display — usually left behind by tapping "+"
* one time too many. It would still reach the bus as a selectable but blank
* entry, so it is flagged in the list and caught on export.
*/
const isBlank = (d) =>
d.pages.every((p) => !p.text.trim() && !(p.pristine && p.bitmap && p.bitmap.width));
function renderAll() { renderHeader(); renderList(); renderEditor(); }
function renderHeader() {
$('#company').value = state.company;
const sel = $('#model');
if (!sel.options.length) {
for (const m of MODELS) {
sel.append(el('option', { value: m.custom ? 'custom' : String(m.width), textContent: m.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;
}
const has = state.destinations.length > 0;
$('#btn-export').disabled = !has;
$('#btn-export-tp5').disabled = !has;
$('#count').textContent = state.destinations.length;
}
function renderList() {
const list = $('#list');
list.textContent = '';
if (!state.destinations.length) {
list.append(el('div', { className: 'empty', textContent: 'No destinations yet. Open a file, paste a list, or add one.' }));
return;
}
state.destinations.forEach((d, i) => {
const item = el('div', { className: 'item' + (d.id === state.selected ? ' sel' : '') });
item.onclick = () => { state.selected = d.id; renderAll(); };
const body = el('div', { className: 'body' });
body.append(el('div', { className: 'nm', textContent: d.name || '(new — type a name)' }));
const cv = el('canvas');
const bmp = renderPage(d.pages[0], state.screen);
drawLED(cv, bmp, state.screen, { scale: 1, gap: 0 });
cv.style.width = Math.min(230, state.screen.width) + 'px';
body.append(cv);
if (d.pages.length > 1) body.append(el('div', { className: 'tag', textContent: `${d.pages.length} pages` }));
if (isBlank(d)) body.append(el('div', { className: 'tag warn', textContent: 'empty — nothing to show' }));
const del = el('button', { className: 'del', textContent: '×', title: `Delete ${d.name}` });
del.onclick = (e) => { e.stopPropagation(); remove(d); };
item.append(el('div', { className: 'num', textContent: i + 1 }), body, del);
list.append(item);
});
}
/**
* A destination record stores its name twice, and the driver's controller reads
* the second copy. TP5 always writes the two identically (verified across every
* record in the sample file), so they must never be allowed to drift apart —
* if they do, the sign shows the right words but the controller shows the wrong
* ones, or none at all.
*/
function setName(d, value) {
d.name = value.slice(0, 16);
d.lineName = d.name;
}
/** The preview is its own card so it can be redrawn without rebuilding the form. */
function buildPreviewCard(d) {
const prev = el('div', { className: 'card', id: 'preview-card' });
prev.append(el('h3', { textContent: 'What the sign will show' }));
const stage = el('div', { className: 'led-stage' });
const canvas = el('canvas');
stage.append(canvas);
prev.append(stage);
const page0 = d.pages[state.previewPage ?? 0] ?? d.pages[0];
const bmp = renderPage(page0, state.screen);
startPreview(canvas, bmp, state.screen, page0.effect);
const meta = el('div', { className: 'meta' });
const over = bmp.width - state.screen.width;
meta.append(el('span', {}, el('b', { textContent: `${state.screen.width} × ${state.screen.height}` }), ' sign'));
meta.append(el('span', {}, el('b', { textContent: `${bmp.width}px` }), ' wide'));
if (over > 0) {
meta.append(el('span', { className: 'pill ' + (page0.effect === EFFECT.SCROLL ? 'scroll' : 'bad') },
page0.effect === EFFECT.SCROLL ? `Too long — scrolls (${over}px over)` : `Too long by ${over}px — set it to Scroll, or shorten it`));
} else {
meta.append(el('span', { className: 'pill ok', textContent: 'Fits on the sign' }));
}
if (page0.pristine) meta.append(el('span', { className: 'pill ok', textContent: 'Original artwork kept' }));
const cal = state.calibration;
if (cal && cal.family === page0.ttfName && !page0.pristine) {
meta.append(el('span', { className: 'pill ok',
title: `Matched to your existing signs on this computer: size ×${cal.scale.toFixed(3)}, threshold ${cal.threshold}, ` +
`from ${cal.samples} of them. Width within ${(cal.widthErr * 100).toFixed(1)}%, weight within ${(Math.abs(cal.litRatio - 1) * 100).toFixed(1)}%.`,
textContent: `Lettering matched to your signs (±${(cal.widthErr * 100).toFixed(1)}%)` }));
}
prev.append(meta);
if (d.pages.length > 1) {
const row = el('div', { className: 'meta' });
row.append(el('span', { textContent: 'Preview page:' }));
d.pages.forEach((_, i) => {
const b = el('button', { textContent: String(i + 1), style: 'padding:2px 10px' });
if ((state.previewPage ?? 0) === i) b.className = 'primary';
b.onclick = () => { state.previewPage = i; updatePreview(d); };
row.append(b);
});
prev.append(row);
}
return prev;
}
/** Redraw only the preview — typing must never rebuild the box being typed in. */
function updatePreview(d) {
const old = $('#preview-card');
if (old) old.replaceWith(buildPreviewCard(d));
}
/** Refresh one sidebar row in place, for the same reason. */
function updateListItem(d) {
const i = state.destinations.indexOf(d);
const item = $('#list').children[i];
if (!item) return renderList();
item.querySelector('.nm').textContent = d.name || '(new — type a name)';
const cv = item.querySelector('canvas');
if (cv) drawLED(cv, renderPage(d.pages[0], state.screen), state.screen, { scale: 1, gap: 0 });
const warn = item.querySelector('.tag.warn');
const blank = isBlank(d);
if (blank && !warn) {
item.querySelector('.body').append(el('div', { className: 'tag warn', textContent: 'empty — nothing to show' }));
} else if (!blank && warn) {
warn.remove();
}
}
function renderEditor() {
const host = $('#editor');
host.textContent = '';
const d = state.destinations.find((x) => x.id === state.selected);
if (!d) {
const w = el('div', { className: 'welcome' });
w.append(
el('h1', { textContent: state.destinations.length ? 'Pick a destination' : 'Welcome' }),
el('p', { textContent: state.destinations.length
? 'Choose one from the list on the left to edit it.'
: 'Open the destination file from the bus (.td5), or the TP5 project (.tp5), and everything will load here. You can also start from scratch.' }),
);
if (!state.destinations.length) {
const btns = el('div', { className: 'btns' });
btns.append(
el('button', { className: 'primary', textContent: 'Open a file…', onclick: () => $('#file').click() }),
el('button', { textContent: 'Import a spreadsheet', onclick: () => $('#file').click() }),
el('button', { textContent: 'Paste a list', onclick: openBulk }),
el('button', { textContent: 'Add one destination', onclick: addOne }),
);
w.append(btns);
}
host.append(el('div', { className: 'wrap' }, w));
return;
}
const wrap = el('div', { className: 'wrap' });
wrap.append(buildPreviewCard(d));
// ---- name
const nameCard = el('div', { className: 'card' });
nameCard.append(el('h3', { textContent: 'Destination' }));
const nrow = el('div', { className: 'row' });
const nameBox = el('div');
nameBox.append(el('label', { textContent: "Name on the driver's controller (16 characters)" }));
const nameIn = el('input', { type: 'text', value: d.name, maxLength: 16 });
// The controller reads its own copy of this name from the file, so both
// copies have to move together — see setName().
nameIn.oninput = () => { setName(d, nameIn.value); updateListItem(d); };
nameBox.append(nameIn);
nrow.append(nameBox);
nameCard.append(nrow);
const acts = el('div', { className: 'row', style: 'margin-top:14px' });
acts.append(
el('button', { textContent: 'Duplicate', onclick: () => duplicate(d) }),
el('button', { textContent: 'Move up', disabled: state.destinations.indexOf(d) === 0, onclick: () => move(d, -1) }),
el('button', { textContent: 'Move down', disabled: state.destinations.indexOf(d) === state.destinations.length - 1, onclick: () => move(d, 1) }),
el('button', { className: 'danger', textContent: 'Delete', onclick: () => remove(d) }),
);
nameCard.append(acts);
wrap.append(nameCard);
// ---- pages
const pagesCard = el('div', { className: 'card' });
pagesCard.append(el('h3', { textContent: d.pages.length > 1 ? 'Pages (the sign alternates between these)' : 'Message' }));
d.pages.forEach((p, i) => pagesCard.append(pageEditor(d, p, i)));
const addRow = el('div', { className: 'row' });
addRow.append(el('button', { textContent: '+ Add another page', onclick: () => { d.pages.push(newPage('')); renderAll(); } }));
pagesCard.append(addRow);
wrap.append(pagesCard);
host.append(wrap);
}
function pageEditor(d, p, i) {
const box = el('div', { className: 'page' });
const head = el('div', { className: 'page-head' });
if (d.pages.length > 1) head.append(el('span', { className: 'lbl', textContent: `Page ${i + 1}` }));
head.append(el('div', { style: 'flex:1' }));
if (d.pages.length > 1) {
head.append(el('button', { className: 'danger ghost', textContent: 'Remove page', onclick: () => { d.pages.splice(i, 1); renderAll(); } }));
}
box.append(head);
const textBox = el('div');
textBox.append(el('label', { textContent: 'Text shown on the sign' }));
const ti = el('input', { type: 'text', value: p.text });
// Typing must not re-render the editor, or the input is destroyed mid-keystroke
// and the caret is lost — redraw only the preview and the sidebar row.
ti.oninput = () => { p.text = ti.value; p.pristine = false; p.bitmap = null; refreshLight(d); };
textBox.append(ti);
box.append(textBox);
if (p.pristine && !p.text) {
box.append(el('div', { className: 'hint',
textContent: 'This came from the bus file, which only stores the finished picture — not the words. The sign will keep showing exactly what it shows now. Type the text above if you want to change it.' }));
}
const row = el('div', { className: 'row', style: 'margin-top:12px' });
const fontBox = el('div');
fontBox.append(el('label', { textContent: 'Lettering' }));
const fsel = el('select');
const og1 = el('optgroup', { label: 'Computer fonts (match your current signs)' });
for (const f of SYSTEM_FONTS) og1.append(el('option', { value: `sys:${f}`, textContent: f }));
const og2 = el('optgroup', { label: "Sign's own built-in fonts (sharpest)" });
for (const [name, j] of Object.entries(FONTS)) {
if (j.height > state.screen.height) continue;
og2.append(el('option', { value: `bi:${name}`, textContent: `${name} (${j.cellWidth}×${j.height})` }));
}
fsel.append(og1, og2);
fsel.value = p.fontKind === 'system' ? `sys:${p.ttfName}` : `bi:${p.font}`;
fsel.onchange = () => {
const [kind, val] = fsel.value.split(':');
if (kind === 'sys') { p.fontKind = 'system'; p.ttfName = val; } else { p.fontKind = 'builtin'; p.font = val; }
p.pristine = false; p.bitmap = null; refresh();
};
fontBox.append(fsel);
row.append(fontBox);
if (p.fontKind === 'system') {
const szBox = el('div');
szBox.append(el('label', { textContent: 'Size' }));
const si = el('input', { type: 'number', value: p.ptSize, min: 6, max: 60 });
si.oninput = () => { p.ptSize = Number(si.value) || 20; p.pristine = false; p.bitmap = null; refreshLight(d); };
szBox.append(si);
row.append(szBox);
}
const fxBox = el('div');
fxBox.append(el('label', { textContent: 'If the text is too long' }));
const seg = el('div', { className: 'seg' });
const mk = (label, value) => {
const b = el('button', { textContent: label, className: p.effect === value ? 'on' : '' });
b.onclick = () => { p.effect = value; refresh(); };
return b;
};
seg.append(mk('Hold still', EFFECT.HOLD), mk('Scroll', EFFECT.SCROLL));
fxBox.append(seg);
row.append(fxBox);
box.append(row);
return box;
}
/** Full rebuild — for changes that alter which controls are on screen. */
const refresh = () => { renderList(); renderEditor(); };
/** Preview-only update — for anything driven by typing. */
const refreshLight = (d) => { updatePreview(d); updateListItem(d); };
// ------------------------------------------------------------------ actions
/**
* A new destination starts genuinely empty rather than pre-filled with
* placeholder text — placeholder text is worse than blank here, because
* "NEW DESTINATION" will cheerfully print itself on the front of a bus.
* Empty means it gets flagged in the list and refused at export.
*/
function addOne() {
const d = newDestination('');
state.destinations.push(d);
state.selected = d.id;
renderAll();
const input = $('#editor input[type=text]');
if (input) input.focus();
}
function duplicate(d) {
const copy = deepClone({ ...d, id: undefined, blockId: null });
copy.id = nextId();
copy.pages.forEach((p) => { p.pristine = false; p.bitmap = null; });
state.destinations.splice(state.destinations.indexOf(d) + 1, 0, copy);
state.selected = copy.id;
renderAll();
}
function move(d, dir) {
const i = state.destinations.indexOf(d);
const j = i + dir;
if (j < 0 || j >= state.destinations.length) return;
state.destinations.splice(j, 0, state.destinations.splice(i, 1)[0]);
renderAll();
}
/**
* Deleting offers an undo rather than asking first. A confirm box on every
* delete is friction when you are tidying a list, and it still leaves you stuck
* if you confirm by mistake; undo covers both.
*/
function remove(d) {
const i = state.destinations.indexOf(d);
if (i < 0) return;
state.destinations.splice(i, 1);
if (state.selected === d.id) {
state.selected = state.destinations[Math.min(i, state.destinations.length - 1)]?.id ?? null;
}
renderAll();
toast(`Deleted "${d.name || 'unnamed'}".`, false, 'Undo', () => {
state.destinations.splice(Math.min(i, state.destinations.length), 0, d);
state.selected = d.id;
renderAll();
toast(`Put "${d.name || 'unnamed'}" back.`);
});
}
/** Remove every destination, undoably — for starting a list over. */
function removeAll() {
if (!state.destinations.length) return;
const previous = state.destinations;
const n = previous.length;
state.destinations = [];
state.selected = null;
renderAll();
toast(`Cleared all ${n} destinations.`, false, 'Undo', () => {
state.destinations = previous;
state.selected = previous[0].id;
renderAll();
toast(`Put all ${n} back.`);
});
}
function openBulk() { $('#bulk-text').value = ''; showDialog($('#bulk')); }
// ------------------------------------------------------------------- files
/**
* 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 info = analyseSheet(rows);
if (!info.rowCount) {
return toast(`No rows found in that ${label}.`, true);
}
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 mapped) {
const existing = state.destinations.find((x) => x.name.trim().toUpperCase() === r.name.toUpperCase());
if (existing) {
setName(existing, r.name);
const p = existing.pages[0];
p.text = r.text; p.pristine = false; p.bitmap = null;
updated++;
} else {
const d = newDestination(r.name);
setName(d, r.name);
d.pages[0].text = r.text;
d.pages[0].bitmap = null;
state.destinations.push(d);
added++;
}
}
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'}.`);
}
async function openFile(file) {
try {
const name = file.name.toLowerCase();
if (name.endsWith('.xlsx') || name.endsWith('.xlsm')) {
const rows = await readXlsx(new Uint8Array(await file.arrayBuffer()));
return importRows(rows, 'Spreadsheet');
}
if (name.endsWith('.csv') || name.endsWith('.txt')) {
return importRows(readCsv(await file.text()), 'CSV');
}
if (name.endsWith('.tp5')) {
const text = await file.text();
toast(loadTp5(text));
} else if (name.endsWith('.td5')) {
const bytes = new Uint8Array(await file.arrayBuffer());
if (state.destinations.length && state.tp5Template) {
const hits = mergeArtworkFromTd5(bytes);
toast(hits ? `Matched the original artwork for ${hits} pages.` : 'No matching destinations found in that file.');
} else {
toast(loadTd5(bytes));
}
} else {
return toast('Open a .td5, .tp5, .xlsx or .csv file.', true);
}
state.selected = state.destinations[0]?.id ?? null;
state.previewPage = 0;
renderAll();
} catch (err) {
toast(`Could not open that file: ${err.message}`, true);
}
}
function download(bytes, filename, type) {
const url = URL.createObjectURL(new Blob([bytes], { type }));
const a = el('a', { href: url, download: filename });
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
const safeName = (s) => s.replace(/[^\w.-]+/g, '_').replace(/^_+|_+$/g, '') || 'destinations';
let toastTimer;
function toast(msg, isError = false, actionLabel = null, actionFn = null) {
const t = $('#toast');
t.textContent = msg;
if (actionLabel && actionFn) {
const b = el('button', { textContent: actionLabel });
b.onclick = () => { t.className = ''; actionFn(); };
t.append(b);
}
t.className = 'on' + (isError ? ' err' : '');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.className = ''; }, actionLabel ? 9000 : 4200);
}
// -------------------------------------------------------------------- wire
$('#btn-open').onclick = () => $('#file').click();
$('#btn-sheet').onclick = () => { $('#file').dataset.sheet = '1'; $('#file').click(); };
$('#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; };
/** 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);
for (const line of lines) state.destinations.push(newDestination(line));
if (lines.length) state.selected = state.destinations[state.destinations.length - lines.length].id;
closeDialog($('#bulk'));
renderAll();
toast(`Added ${lines.length} destination${lines.length === 1 ? '' : 's'}.`);
};
let dragDepth = 0;
addEventListener('dragenter', (e) => { e.preventDefault(); if (++dragDepth === 1) $('#drop').classList.add('on'); });
addEventListener('dragleave', (e) => { e.preventDefault(); if (--dragDepth <= 0) { dragDepth = 0; $('#drop').classList.remove('on'); } });
addEventListener('dragover', (e) => e.preventDefault());
addEventListener('drop', (e) => {
e.preventDefault();
dragDepth = 0;
$('#drop').classList.remove('on');
if (e.dataTransfer.files[0]) openFile(e.dataTransfer.files[0]);
});
renderAll();