Destinations were one flat list, so building a second set meant overwriting the
first. They now live in named groups, listed down the left: click a group and
its destinations appear.
This is not a new concept bolted on. The .td5 header carries a company count
with room for twelve, each with its own name and its own pointer table, the
.tp5 has "Company Sum:", and TP5's manual has you create a company before you
can add any destinations. A group is that company.
- a saved project (.tp5) holds every group, which the old software can still
open, and reloading one restores them all
- a .td5 is written from the selected group, since one file is what goes on
one SD card. That keeps every exported file the exact shape already proven
against the bus, rather than a multi-company layout with no reference file
to check against
- deleting a group takes its destinations with it, so it is undoable too
state.destinations and state.company are now accessors onto the active group,
so everything built on the flat list keeps working untouched.
Also fixes the custom sign size inputs showing when a preset was selected:
.field sets display:flex, which beats the [hidden] attribute's display:none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1118 lines
44 KiB
JavaScript
1118 lines
44 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, toGroups } 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 uid = 1;
|
||
const nextId = () => `d${uid++}`;
|
||
|
||
const newGroup = (name = '', destinations = []) => ({ id: nextId(), name, destinations });
|
||
|
||
/**
|
||
* Destinations live in groups. That is not an invention of this editor — the
|
||
* file format calls them companies, holds up to twelve of them, and TP5's own
|
||
* manual has you create one before you can add any destinations. A group is a
|
||
* separate destination list: one per contract, per depot, per bus, however the
|
||
* operator wants to split them up.
|
||
*/
|
||
let state = {
|
||
groups: [newGroup()],
|
||
activeGroupId: null,
|
||
screen: { width: MODELS[0].width, height: MODELS[0].height },
|
||
selected: null,
|
||
tp5Template: null, // keeps the untouched project structure when a .tp5 was opened
|
||
};
|
||
state.activeGroupId = state.groups[0].id;
|
||
|
||
// `destinations` and `company` read and write the active group, so everything
|
||
// that worked on a single flat list still does.
|
||
Object.defineProperties(state, {
|
||
group: {
|
||
get() { return this.groups.find((g) => g.id === this.activeGroupId) ?? this.groups[0]; },
|
||
},
|
||
destinations: {
|
||
get() { return this.group.destinations; },
|
||
set(v) { this.group.destinations = v; },
|
||
},
|
||
company: {
|
||
get() { return this.group.name; },
|
||
set(v) { this.group.name = v; },
|
||
},
|
||
});
|
||
|
||
// --------------------------------------------------------------- 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.screen = { width: doc.screen.width, height: doc.screen.height };
|
||
state.tp5Template = null;
|
||
state.groups = [newGroup(doc.company)];
|
||
state.activeGroupId = state.groups[0].id;
|
||
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 { screen, groups } = toGroups(doc);
|
||
state.screen = { width: screen.width, height: screen.height };
|
||
state.tp5Template = doc;
|
||
state.groups = groups.map((g) => newGroup(g.name));
|
||
state.activeGroupId = state.groups[0].id;
|
||
groups.forEach((g, gi) => { state.groups[gi].destinations = g.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;
|
||
}),
|
||
})); });
|
||
const total = state.groups.reduce((n, g) => n + g.destinations.length, 0);
|
||
return state.groups.length > 1
|
||
? `Opened ${total} destinations in ${state.groups.length} groups, with their text.`
|
||
: `Opened ${total} 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.groups.every((g) => !g.destinations.length)) return toast('Nothing to save yet.', true);
|
||
const template = state.tp5Template ?? blankProject();
|
||
const proto = template.companies[0].destinations[0];
|
||
// Every group is written, so a project file keeps the whole collection.
|
||
template.companies = state.groups.map((g) => ({ name: g.name, destinations: g.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;
|
||
}) }));
|
||
const name = state.groups.length > 1 ? 'destinations' : (state.company || 'destinations');
|
||
download(new TextEncoder().encode(buildTp5(template)), `${safeName(name)}.tp5`, 'text/plain');
|
||
toast(state.groups.length > 1
|
||
? `Project saved — all ${state.groups.length} groups. The old TP5 software can open this file too.`
|
||
: '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(); renderGroups(); renderList(); renderEditor(); }
|
||
|
||
function renderGroups() {
|
||
const host = $('#groups');
|
||
host.textContent = '';
|
||
$('#gcount').textContent = state.groups.length;
|
||
state.groups.forEach((g) => {
|
||
const row = el('div', { className: 'grp' + (g.id === state.activeGroupId ? ' sel' : '') });
|
||
row.onclick = () => selectGroup(g);
|
||
row.append(
|
||
el('div', { className: 'gnm', textContent: g.name || '(unnamed group)' }),
|
||
el('div', { className: 'gct', textContent: g.destinations.length }),
|
||
);
|
||
const del = el('button', { className: 'del', textContent: '×', title: `Delete the group "${g.name}"` });
|
||
del.onclick = (e) => { e.stopPropagation(); removeGroup(g); };
|
||
row.append(del);
|
||
host.append(row);
|
||
});
|
||
}
|
||
|
||
function selectGroup(g) {
|
||
state.activeGroupId = g.id;
|
||
state.selected = g.destinations[0]?.id ?? null;
|
||
state.previewPage = 0;
|
||
renderAll();
|
||
}
|
||
|
||
function addGroup() {
|
||
const g = newGroup('');
|
||
state.groups.push(g);
|
||
selectGroup(g);
|
||
$('#company').focus();
|
||
}
|
||
|
||
/** Deleting a group takes its destinations with it, so it is undoable too. */
|
||
function removeGroup(g) {
|
||
const i = state.groups.indexOf(g);
|
||
if (i < 0) return;
|
||
const wasActive = state.activeGroupId === g.id;
|
||
state.groups.splice(i, 1);
|
||
if (!state.groups.length) state.groups.push(newGroup());
|
||
if (wasActive) state.activeGroupId = state.groups[Math.min(i, state.groups.length - 1)].id;
|
||
state.selected = state.group.destinations[0]?.id ?? null;
|
||
renderAll();
|
||
const n = g.destinations.length;
|
||
toast(`Deleted the group "${g.name || 'unnamed'}"${n ? ` and its ${n} destination${n === 1 ? '' : 's'}` : ''}.`,
|
||
false, 'Undo', () => {
|
||
state.groups.splice(Math.min(i, state.groups.length), 0, g);
|
||
selectGroup(g);
|
||
toast(`Put "${g.name || 'unnamed'}" back.`);
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
$('#btn-export').disabled = state.destinations.length === 0;
|
||
$('#btn-export-tp5').disabled = state.groups.every((g) => !g.destinations.length);
|
||
$('#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;
|
||
$('#btn-group-add').onclick = addGroup;
|
||
$('#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; renderGroups(); };
|
||
/** 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();
|