destogod/app/main.mjs
type-two 07977624ae DestoGod: replacement for the TP5 bus destination sign software
TP5 is a 2012 Windows application supplied with the Guangzhou-Tongda LED
destination signs fitted to Yutong buses. It has no preview, so building a
destination list is guess-and-check, and it only runs on Windows.

The bus never talks to TP5 — the sign controller reads a .td5 file off an SD
card, and that is the whole interface. So this replaces the software without
touching any hardware or protocol: it just has to write byte-correct .td5.

Formats reverse-engineered from the sample files and TP5(En).exe, then verified
byte-for-byte:

  .td5   the file the bus reads. Fixed-layout binary; each destination block
         carries a CRC-16/ARC over block[3..len] and a rand() block id, which
         together looked like one 4-byte field because RAND_MAX is 0x7fff.
  .tp5   the editable project. Line-based text, UTF-16BE hex strings.
  .font  the sign's own bitmap fonts, each glyph row XORed with its char code.

The app is one self-contained HTML file: live LED preview at the real sign size
with real scrolling, spreadsheet/CSV import, multi-page destinations, undoable
delete, and export to both .td5 and .tp5.

Verified:
  - rebuilds a real 46,080-byte TP5 export byte-for-byte with a recomputed CRC
  - all 36 stored CRCs verify against the implementation
  - driven through its own UI, re-exporting the real file differs in 7 bytes,
    all of them the export timestamp
  - running on a real bus: signs and driver's controller both correct

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:06:25 +10:00

954 lines
38 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, mapRows } 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 },
];
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.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;
}
sel.value = String(state.screen.width);
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
/** Bring in destinations from a spreadsheet, appending to whatever is loaded. */
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);
}
if (company && !state.company) state.company = company;
let added = 0, updated = 0;
for (const r of destinations) {
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;
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;
$('#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 };
for (const d of state.destinations) for (const p of d.pages) if (!p.pristine) p.bitmap = null;
renderAll();
};
$('#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();