destogod/app/index.html
type-two 07977624ae DestoGod: replacement for the TP5 bus destination sign software
TP5 is a 2012 Windows application supplied with the Guangzhou-Tongda LED
destination signs fitted to Yutong buses. It has no preview, so building a
destination list is guess-and-check, and it only runs on Windows.

The bus never talks to TP5 — the sign controller reads a .td5 file off an SD
card, and that is the whole interface. So this replaces the software without
touching any hardware or protocol: it just has to write byte-correct .td5.

Formats reverse-engineered from the sample files and TP5(En).exe, then verified
byte-for-byte:

  .td5   the file the bus reads. Fixed-layout binary; each destination block
         carries a CRC-16/ARC over block[3..len] and a rand() block id, which
         together looked like one 4-byte field because RAND_MAX is 0x7fff.
  .tp5   the editable project. Line-based text, UTF-16BE hex strings.
  .font  the sign's own bitmap fonts, each glyph row XORed with its char code.

The app is one self-contained HTML file: live LED preview at the real sign size
with real scrolling, spreadsheet/CSV import, multi-page destinations, undoable
delete, and export to both .td5 and .tp5.

Verified:
  - rebuilds a real 46,080-byte TP5 export byte-for-byte with a recomputed CRC
  - all 36 stored CRCs verify against the implementation
  - driven through its own UI, re-exporting the real file differs in 7 bytes,
    all of them the export timestamp
  - running on a real bus: signs and driver's controller both correct

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:06:25 +10:00

199 lines
9.2 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DestoGod — Bus Destination Sign Editor</title>
<style>
:root{
--bg:#14161a; --panel:#1c1f26; --panel2:#232732; --line:#31374a;
--ink:#e8ecf5; --dim:#98a2b8; --amber:#ffb000; --amber-dim:#3a2a05;
--accent:#4a9eff; --good:#3ddc84; --warn:#ffb74d; --bad:#ff6b6b;
--r:10px;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0; background:var(--bg); color:var(--ink);
font:14px/1.45 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
display:flex; flex-direction:column; overflow:hidden;
}
button,input,select,textarea{font:inherit;color:inherit}
button{
background:var(--panel2); border:1px solid var(--line); color:var(--ink);
padding:8px 14px; border-radius:8px; cursor:pointer; white-space:nowrap;
}
button:hover:not(:disabled){background:#2c3242; border-color:#465073}
button:disabled{opacity:.4; cursor:default}
button.primary{background:var(--accent); border-color:var(--accent); color:#08131f; font-weight:650}
button.primary:hover:not(:disabled){background:#68b0ff}
button.ghost{background:transparent}
button.danger:hover:not(:disabled){background:#3a2226; border-color:#7d3b43; color:#ffb3b3}
input[type=text],input[type=number],select,textarea{
background:#12141a; border:1px solid var(--line); border-radius:8px; padding:8px 10px; width:100%;
}
input:focus,select:focus,textarea:focus{outline:2px solid var(--accent); outline-offset:-1px; border-color:transparent}
label{display:block; font-size:12px; color:var(--dim); margin-bottom:5px; font-weight:600; letter-spacing:.02em}
/* ---------- top bar ---------- */
header{
display:flex; align-items:center; gap:14px; padding:12px 18px;
background:var(--panel); border-bottom:1px solid var(--line); flex:0 0 auto; flex-wrap:wrap;
}
.brand{display:flex; align-items:baseline; gap:9px; margin-right:4px}
.brand b{font-size:19px; letter-spacing:-.02em}
.brand span{font-size:11px; color:var(--dim)}
.field{display:flex; align-items:center; gap:7px}
.field label{margin:0}
.field input,.field select{width:auto; min-width:120px}
.spacer{flex:1}
/* ---------- layout ---------- */
main{flex:1; display:grid; grid-template-columns:290px 1fr; min-height:0}
#sidebar{
background:var(--panel); border-right:1px solid var(--line);
display:flex; flex-direction:column; min-height:0;
}
.side-head{
display:flex; align-items:center; justify-content:space-between; gap:8px;
padding:11px 12px; border-bottom:1px solid var(--line);
}
.side-head h2{margin:0; font-size:12px; text-transform:uppercase; letter-spacing:.08em; color:var(--dim)}
#list{overflow-y:auto; flex:1; padding:6px}
.item{
display:flex; align-items:center; gap:9px; padding:7px 9px; border-radius:8px;
cursor:pointer; border:1px solid transparent;
}
.item:hover{background:var(--panel2)}
.item.sel{background:#20304a; border-color:#37517d}
.item .num{font-size:10px; color:var(--dim); width:20px; text-align:right; flex:0 0 auto; font-variant-numeric:tabular-nums}
.item .body{min-width:0; flex:1}
.item .nm{font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:600}
.item canvas{display:block; image-rendering:pixelated; margin-top:3px; opacity:.85}
.item .tag{font-size:10px; color:var(--dim)}
.item .tag.warn{color:var(--warn)}
.item .del{
flex:0 0 auto; width:24px; height:24px; padding:0; line-height:1; font-size:16px;
border-radius:6px; background:transparent; border:1px solid transparent; color:var(--dim);
opacity:0; transition:opacity .12s ease;
}
.item:hover .del,.item.sel .del{opacity:1}
.item .del:hover{background:#3a2226; border-color:#7d3b43; color:#ffb3b3}
.empty{padding:26px 16px; text-align:center; color:var(--dim); font-size:13px}
#toast button{
margin-left:12px; padding:3px 11px; font-size:12px; font-weight:650;
background:var(--accent); border-color:var(--accent); color:#08131f;
}
/* ---------- editor ---------- */
#editor{overflow-y:auto; padding:20px 24px; min-height:0}
.wrap{max-width:900px; margin:0 auto}
.card{background:var(--panel); border:1px solid var(--line); border-radius:var(--r); padding:18px; margin-bottom:16px}
.card > h3{margin:0 0 14px; font-size:12px; text-transform:uppercase; letter-spacing:.08em; color:var(--dim)}
.row{display:flex; gap:12px; flex-wrap:wrap}
.row > *{flex:1; min-width:150px}
/* ---------- LED preview ---------- */
.led-stage{
background:#000; border:1px solid #2a2f3d; border-radius:8px; padding:14px;
display:flex; justify-content:center; overflow:hidden;
}
.led-stage canvas{display:block; image-rendering:pixelated}
.meta{display:flex; gap:14px; flex-wrap:wrap; margin-top:11px; font-size:12px; color:var(--dim)}
.meta b{color:var(--ink); font-weight:600}
.pill{
display:inline-flex; align-items:center; gap:5px; padding:2px 9px; border-radius:20px;
font-size:11px; font-weight:650; border:1px solid;
}
.pill.ok{color:var(--good); border-color:#235b3c; background:#12251b}
.pill.scroll{color:var(--warn); border-color:#6b4d1c; background:#241c0c}
.pill.bad{color:var(--bad); border-color:#6b2b2b; background:#241111}
/* ---------- pages ---------- */
.page{border:1px solid var(--line); border-radius:9px; padding:14px; margin-bottom:11px; background:var(--panel2)}
.page-head{display:flex; align-items:center; gap:10px; margin-bottom:11px}
.page-head .lbl{font-size:11px; text-transform:uppercase; letter-spacing:.07em; color:var(--dim); font-weight:700}
.seg{display:inline-flex; border:1px solid var(--line); border-radius:8px; overflow:hidden}
.seg button{border:0; border-radius:0; padding:7px 13px; background:transparent; font-size:13px}
.seg button.on{background:var(--accent); color:#08131f; font-weight:650}
.hint{font-size:12px; color:var(--dim); margin-top:7px}
.hint.warn{color:var(--warn)}
/* ---------- modal / drop ---------- */
dialog{
border:1px solid var(--line); background:var(--panel); color:var(--ink);
border-radius:var(--r); padding:0; max-width:560px; width:92vw;
}
dialog::backdrop{background:#000a; backdrop-filter:blur(2px)}
dialog .dlg-body{padding:20px}
dialog h3{margin:0 0 6px; font-size:16px}
dialog p{margin:0 0 14px; color:var(--dim); font-size:13px}
dialog .dlg-foot{display:flex; justify-content:flex-end; gap:9px; padding:14px 20px; border-top:1px solid var(--line)}
textarea{min-height:170px; resize:vertical; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px}
#drop{
position:fixed; inset:0; background:#0b0e14e6; display:none; place-items:center;
z-index:99; font-size:20px; color:var(--accent); border:3px dashed var(--accent); border-radius:14px;
}
#drop.on{display:grid}
#toast{
position:fixed; bottom:20px; left:50%; transform:translateX(-50%) translateY(80px);
background:var(--panel2); border:1px solid var(--line); padding:11px 18px; border-radius:9px;
transition:transform .22s ease, opacity .22s ease; opacity:0; z-index:100; font-size:13px; max-width:80vw;
}
#toast.on{transform:translateX(-50%) translateY(0); opacity:1}
#toast.err{border-color:#7d3b43; color:#ffc9c9}
.welcome{max-width:560px; margin:8vh auto; text-align:center}
.welcome h1{font-size:26px; margin:0 0 10px}
.welcome p{color:var(--dim); margin:0 0 22px}
.welcome .btns{display:flex; gap:10px; justify-content:center; flex-wrap:wrap}
kbd{background:#12141a; border:1px solid var(--line); border-bottom-width:2px; border-radius:5px; padding:1px 6px; font-size:11px}
</style>
</head>
<body>
<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="model">Bus / sign</label><select id="model"></select></div>
<div class="spacer"></div>
<button id="btn-open">Open file…</button>
<button id="btn-sheet" title="Import destinations from Excel or CSV">Import spreadsheet…</button>
<button id="btn-export-tp5">Save project (.tp5)</button>
<button class="primary" id="btn-export">Export for bus (.td5)</button>
<input type="file" id="file" accept=".td5,.tp5,.xlsx,.xlsm,.csv" hidden>
</header>
<main>
<aside id="sidebar">
<div class="side-head">
<h2>Destinations (<span id="count">0</span>)</h2>
<div style="display:flex;gap:6px">
<button class="ghost" id="btn-bulk" title="Add many at once">Paste list</button>
<button class="ghost" id="btn-add" title="Add one">+</button>
<button class="ghost danger" id="btn-clear" title="Delete every destination (you can undo)">Clear</button>
</div>
</div>
<div id="list"></div>
</aside>
<section id="editor"></section>
</main>
<div id="drop">Drop a .td5, .tp5, spreadsheet or CSV to open</div>
<div id="toast"></div>
<dialog id="bulk">
<div class="dlg-body">
<h3>Paste a list of destinations</h3>
<p>One per line. This is the fast way to build a whole list — far quicker than adding them one at a time.</p>
<textarea id="bulk-text" placeholder="SCHOOL BUS&#10;CHARTER&#10;RAIL BUS&#10;CARMEL COLLEGE"></textarea>
</div>
<div class="dlg-foot">
<button id="bulk-cancel" class="ghost">Cancel</button>
<button id="bulk-ok" class="primary">Add them</button>
</div>
</dialog>
<script type="module" src="./main.mjs"></script>
</body>
</html>