/** 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();