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>
1034 lines
40 KiB
JavaScript
1034 lines
40 KiB
JavaScript
/** 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();
|