Group destinations, using the format's own idea of a group

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>
This commit is contained in:
type-two 2026-08-21 13:57:00 +10:00
parent f293885cd0
commit dabeb896e8
5 changed files with 330 additions and 97 deletions

View File

@ -23,6 +23,13 @@ That is what this does.
## What it does
- **Groups.** Destinations live in named groups — one per contract, depot or
bus — listed down the left; click one and its destinations appear. This is not
an invention of the editor: the file format calls them companies, holds up to
twelve, and TP5's own manual has you create one before adding any
destinations. A saved project keeps every group; a `.td5` for the bus is
written from the group you have selected, since that is what goes on one SD
card.
- **Opens the files the company already has.** Drop in a `.td5` (what goes on
the SD card) or a `.tp5` (the TP5 project). Drop in both and it uses the text
from the project with the exact artwork from the bus file.

View File

@ -46,10 +46,22 @@ header{
.field{display:flex; align-items:center; gap:7px}
.field label{margin:0}
.field input,.field select{width:auto; min-width:120px}
/* .field sets display:flex, which beats the [hidden] default of display:none */
#custom-size[hidden]{display:none}
.spacer{flex:1}
/* ---------- layout ---------- */
main{flex:1; display:grid; grid-template-columns:290px 1fr; min-height:0}
#groups{overflow-y:auto; padding:6px; max-height:34vh; flex:0 0 auto; border-bottom:1px solid var(--line)}
.grp{display:flex; align-items:center; gap:8px; padding:7px 9px; border-radius:8px; cursor:pointer; border:1px solid transparent}
.grp:hover{background:var(--panel2)}
.grp.sel{background:#20304a; border-color:#37517d}
.grp .gnm{flex:1; min-width:0; font-size:13px; font-weight:650; white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
.grp .gct{font-size:10px; color:var(--dim); font-variant-numeric:tabular-nums}
.grp .del{flex:0 0 auto; width:22px; height:22px; padding:0; line-height:1; font-size:15px; border-radius:6px;
background:transparent; border:1px solid transparent; color:var(--dim); opacity:0; transition:opacity .12s ease}
.grp:hover .del,.grp.sel .del{opacity:1}
.grp .del:hover{background:#3a2226; border-color:#7d3b43; color:#ffb3b3}
#sidebar{
background:var(--panel); border-right:1px solid var(--line);
display:flex; flex-direction:column; min-height:0;
@ -163,7 +175,7 @@ kbd{background:#12141a; border:1px solid var(--line); border-bottom-width:2px; b
<header>
<div class="brand"><b>DestoGod</b><span>bus destination signs</span></div>
<div class="field"><label for="company">Company</label><input type="text" id="company" maxlength="16" placeholder="Company"></div>
<div class="field"><label for="company">Group name</label><input type="text" id="company" maxlength="16" placeholder="Group name"></div>
<div class="field"><label for="model">Bus / sign</label><select id="model"></select></div>
<div class="field" id="custom-size" hidden>
<input type="number" id="cust-w" min="8" max="1024" step="8" title="Sign width in pixels" style="width:78px">
@ -181,6 +193,11 @@ kbd{background:#12141a; border:1px solid var(--line); border-bottom-width:2px; b
<main>
<aside id="sidebar">
<div class="side-head">
<h2>Groups (<span id="gcount">1</span>)</h2>
<button class="ghost" id="btn-group-add" title="New group">+</button>
</div>
<div id="groups"></div>
<div class="side-head">
<h2>Destinations (<span id="count">0</span>)</h2>
<div style="display:flex;gap:6px">

View File

@ -2,7 +2,7 @@
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 { 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).
@ -28,16 +28,42 @@ const SYSTEM_FONTS = ['Impact', 'Arial Narrow', 'Arial Black', 'Arial', 'Helveti
*/
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 = {
company: '',
screen: { ...MODELS[0] },
destinations: [],
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;
let uid = 1;
const nextId = () => `d${uid++}`;
// `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
@ -241,9 +267,10 @@ function newDestination(name = 'NEW DESTINATION') {
function loadTd5(bytes) {
const doc = parseTd5(bytes);
state.company = doc.company;
state.screen = { ...(MODELS.find((m) => m.width === doc.screen.width) ?? doc.screen) };
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,
@ -262,11 +289,12 @@ function loadTd5(bytes) {
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) };
const { screen, groups } = toGroups(doc);
state.screen = { width: screen.width, height: screen.height };
state.tp5Template = doc;
state.destinations = simple.destinations.map((d) => ({
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
@ -278,8 +306,11 @@ function loadTp5(text) {
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.`;
})); });
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. */
@ -369,12 +400,11 @@ function exportTd5() {
* 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);
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];
const company = template.companies[0];
company.name = state.company;
company.destinations = state.destinations.map((d) => {
// 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;
@ -397,9 +427,12 @@ function exportTp5() {
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.');
}) }));
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() {
@ -472,7 +505,58 @@ function closeDialog(dlg) {
const isBlank = (d) =>
d.pages.every((p) => !p.text.trim() && !(p.pristine && p.bitmap && p.bitmap.width));
function renderAll() { renderHeader(); renderList(); renderEditor(); }
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;
@ -490,9 +574,8 @@ function renderHeader() {
$('#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;
$('#btn-export').disabled = state.destinations.length === 0;
$('#btn-export-tp5').disabled = state.groups.every((g) => !g.destinations.length);
$('#count').textContent = state.destinations.length;
}
@ -975,13 +1058,14 @@ $('#btn-sheet').onclick = () => { $('#file').dataset.sheet = '1'; $('#file').cli
$('#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; };
$('#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;

209
dist/DestoGod.html vendored
View File

@ -51,10 +51,22 @@ header{
.field{display:flex; align-items:center; gap:7px}
.field label{margin:0}
.field input,.field select{width:auto; min-width:120px}
/* .field sets display:flex, which beats the [hidden] default of display:none */
#custom-size[hidden]{display:none}
.spacer{flex:1}
/* ---------- layout ---------- */
main{flex:1; display:grid; grid-template-columns:290px 1fr; min-height:0}
#groups{overflow-y:auto; padding:6px; max-height:34vh; flex:0 0 auto; border-bottom:1px solid var(--line)}
.grp{display:flex; align-items:center; gap:8px; padding:7px 9px; border-radius:8px; cursor:pointer; border:1px solid transparent}
.grp:hover{background:var(--panel2)}
.grp.sel{background:#20304a; border-color:#37517d}
.grp .gnm{flex:1; min-width:0; font-size:13px; font-weight:650; white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
.grp .gct{font-size:10px; color:var(--dim); font-variant-numeric:tabular-nums}
.grp .del{flex:0 0 auto; width:22px; height:22px; padding:0; line-height:1; font-size:15px; border-radius:6px;
background:transparent; border:1px solid transparent; color:var(--dim); opacity:0; transition:opacity .12s ease}
.grp:hover .del,.grp.sel .del{opacity:1}
.grp .del:hover{background:#3a2226; border-color:#7d3b43; color:#ffb3b3}
#sidebar{
background:var(--panel); border-right:1px solid var(--line);
display:flex; flex-direction:column; min-height:0;
@ -168,7 +180,7 @@ kbd{background:#12141a; border:1px solid var(--line); border-bottom-width:2px; b
<header>
<div class="brand"><b>DestoGod</b><span>bus destination signs</span></div>
<div class="field"><label for="company">Company</label><input type="text" id="company" maxlength="16" placeholder="Company"></div>
<div class="field"><label for="company">Group name</label><input type="text" id="company" maxlength="16" placeholder="Group name"></div>
<div class="field"><label for="model">Bus / sign</label><select id="model"></select></div>
<div class="field" id="custom-size" hidden>
<input type="number" id="cust-w" min="8" max="1024" step="8" title="Sign width in pixels" style="width:78px">
@ -186,6 +198,11 @@ kbd{background:#12141a; border:1px solid var(--line); border-bottom-width:2px; b
<main>
<aside id="sidebar">
<div class="side-head">
<h2>Groups (<span id="gcount">1</span>)</h2>
<button class="ghost" id="btn-group-add" title="New group">+</button>
</div>
<div id="groups"></div>
<div class="side-head">
<h2>Destinations (<span id="count">0</span>)</h2>
<div style="display:flex;gap:6px">
@ -921,37 +938,49 @@ function emitActs(out, label, acts) {
});
}
/** One destination, flattened to the fields the editor works with. */
function simpleDestination(d) {
return {
name: d.lineName,
lineName: d.lineName2 || d.lineName,
frames: d.screens.fore.up.map((act) => {
const icon = act.icons[0];
const obj = icon?.objs?.[0];
return {
text: obj?.text ?? '',
font: obj?.font ?? 'ASC0704',
useTrueType: obj?.useTrueType ?? 0,
ttfName: obj?.ttfName ?? '',
ptSize: obj?.ptSize ?? 20,
x: obj?.x ?? 0,
effect: Number(icon?.fields?.[4] ?? 9),
};
}),
_raw: d,
};
}
/**
* Flatten a parsed project into the simple per-destination view the editor uses:
* one entry per destination, with the front-screen text of each frame (act).
* A project can hold several companies, each with its own destination list.
* That is the format's own idea of a group, and what the editor presents as one.
*/
function toSimple(doc) {
const company = doc.companies[0];
function toGroups(doc) {
const [, , h, w] = doc.screenPara.map(Number);
return {
company: company?.name ?? '',
screen: { width: w || 112, height: h || 16 },
destinations: (company?.destinations ?? []).map((d) => ({
name: d.lineName,
lineName: d.lineName2 || d.lineName,
frames: d.screens.fore.up.map((act) => {
const icon = act.icons[0];
const obj = icon?.objs?.[0];
return {
text: obj?.text ?? '',
font: obj?.font ?? 'ASC0704',
useTrueType: obj?.useTrueType ?? 0,
ttfName: obj?.ttfName ?? '',
ptSize: obj?.ptSize ?? 20,
x: obj?.x ?? 0,
effect: Number(icon?.fields?.[4] ?? 9),
};
}),
_raw: d,
groups: doc.companies.map((c) => ({
name: c.name,
destinations: c.destinations.map(simpleDestination),
})),
};
}
/** The first company only, for callers that just want one list. */
function toSimple(doc) {
const { screen, groups } = toGroups(doc);
return { company: groups[0]?.name ?? '', screen, destinations: groups[0]?.destinations ?? [] };
}
// ===== src/codec/sheet.mjs =====
/**
@ -1240,16 +1269,42 @@ const SYSTEM_FONTS = ['Impact', 'Arial Narrow', 'Arial Black', 'Arial', 'Helveti
*/
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 = {
company: '',
screen: { ...MODELS[0] },
destinations: [],
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;
let uid = 1;
const nextId = () => `d${uid++}`;
// `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
@ -1453,9 +1508,10 @@ function newDestination(name = 'NEW DESTINATION') {
function loadTd5(bytes) {
const doc = parseTd5(bytes);
state.company = doc.company;
state.screen = { ...(MODELS.find((m) => m.width === doc.screen.width) ?? doc.screen) };
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,
@ -1474,11 +1530,12 @@ function loadTd5(bytes) {
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) };
const { screen, groups } = toGroups(doc);
state.screen = { width: screen.width, height: screen.height };
state.tp5Template = doc;
state.destinations = simple.destinations.map((d) => ({
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
@ -1490,8 +1547,11 @@ function loadTp5(text) {
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.`;
})); });
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. */
@ -1581,12 +1641,11 @@ function exportTd5() {
* 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);
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];
const company = template.companies[0];
company.name = state.company;
company.destinations = state.destinations.map((d) => {
// 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;
@ -1609,9 +1668,12 @@ function exportTp5() {
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.');
}) }));
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() {
@ -1684,7 +1746,58 @@ function closeDialog(dlg) {
const isBlank = (d) =>
d.pages.every((p) => !p.text.trim() && !(p.pristine && p.bitmap && p.bitmap.width));
function renderAll() { renderHeader(); renderList(); renderEditor(); }
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;
@ -1702,9 +1815,8 @@ function renderHeader() {
$('#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;
$('#btn-export').disabled = state.destinations.length === 0;
$('#btn-export-tp5').disabled = state.groups.every((g) => !g.destinations.length);
$('#count').textContent = state.destinations.length;
}
@ -2187,13 +2299,14 @@ $('#btn-sheet').onclick = () => { $('#file').dataset.sheet = '1'; $('#file').cli
$('#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; };
$('#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;

View File

@ -183,33 +183,45 @@ function emitActs(out, label, acts) {
});
}
/** One destination, flattened to the fields the editor works with. */
function simpleDestination(d) {
return {
name: d.lineName,
lineName: d.lineName2 || d.lineName,
frames: d.screens.fore.up.map((act) => {
const icon = act.icons[0];
const obj = icon?.objs?.[0];
return {
text: obj?.text ?? '',
font: obj?.font ?? 'ASC0704',
useTrueType: obj?.useTrueType ?? 0,
ttfName: obj?.ttfName ?? '',
ptSize: obj?.ptSize ?? 20,
x: obj?.x ?? 0,
effect: Number(icon?.fields?.[4] ?? 9),
};
}),
_raw: d,
};
}
/**
* Flatten a parsed project into the simple per-destination view the editor uses:
* one entry per destination, with the front-screen text of each frame (act).
* A project can hold several companies, each with its own destination list.
* That is the format's own idea of a group, and what the editor presents as one.
*/
export function toSimple(doc) {
const company = doc.companies[0];
export function toGroups(doc) {
const [, , h, w] = doc.screenPara.map(Number);
return {
company: company?.name ?? '',
screen: { width: w || 112, height: h || 16 },
destinations: (company?.destinations ?? []).map((d) => ({
name: d.lineName,
lineName: d.lineName2 || d.lineName,
frames: d.screens.fore.up.map((act) => {
const icon = act.icons[0];
const obj = icon?.objs?.[0];
return {
text: obj?.text ?? '',
font: obj?.font ?? 'ASC0704',
useTrueType: obj?.useTrueType ?? 0,
ttfName: obj?.ttfName ?? '',
ptSize: obj?.ptSize ?? 20,
x: obj?.x ?? 0,
effect: Number(icon?.fields?.[4] ?? 9),
};
}),
_raw: d,
groups: doc.companies.map((c) => ({
name: c.name,
destinations: c.destinations.map(simpleDestination),
})),
};
}
/** The first company only, for callers that just want one list. */
export function toSimple(doc) {
const { screen, groups } = toGroups(doc);
return { company: groups[0]?.name ?? '', screen, destinations: groups[0]?.destinations ?? [] };
}