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:
parent
f293885cd0
commit
dabeb896e8
@ -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.
|
||||
|
||||
@ -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">
|
||||
|
||||
136
app/main.mjs
136
app/main.mjs
@ -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
209
dist/DestoGod.html
vendored
@ -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;
|
||||
|
||||
@ -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 ?? [] };
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user