Files
samplez/tunie/viewer/template.html
uhryniuk c8c10d8977 Add tunie map/tune viewer: self-contained HTML, no deps
A single double-click-to-open HTML viewer (no server, no external refs) with
three tabs: browse/filter the 1811-map catalogue (with a "my bike" filter for
mechanical-odo Keihin Bonneville maps), see TuneECU's full editable surface
(tunable parameters, table axes, toggleable devices with SAI/O2/exhaust flagged
as mod targets, live sensors, actuator tests) extracted from the APK resources,
and a drag-drop .bin/.hex table viewer that renders bytes as a configurable
heatmap grid + hex dump for when a ROM dump lands.

build_viewer.py regenerates the HTML from arrays.xml + maps.json.
2026-08-10 15:24:45 -05:00

280 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>tunie — Triumph tuning viewer</title>
<style>
:root{
--bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2a323d;
--fg:#e6edf3; --dim:#8b949e; --accent:#4cc4ff; --hot:#ff5a52; --ok:#51d88a;
--target:#ffb454;
}
*{box-sizing:border-box}
body{margin:0;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
background:var(--bg);color:var(--fg)}
header{padding:14px 20px;border-bottom:1px solid var(--line);
display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;background:var(--panel)}
header h1{margin:0;font-size:17px;letter-spacing:.3px}
header .sub{color:var(--dim);font-size:12px}
nav{display:flex;gap:4px;padding:8px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
nav button{background:none;border:0;color:var(--dim);padding:8px 14px;border-radius:7px;
cursor:pointer;font-size:13px}
nav button:hover{background:var(--panel2);color:var(--fg)}
nav button.active{background:var(--accent);color:#04121c;font-weight:600}
main{padding:18px 20px;max-width:1200px}
.tab{display:none} .tab.active{display:block}
.controls{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
input,select{background:var(--panel2);border:1px solid var(--line);color:var(--fg);
padding:7px 10px;border-radius:7px;font-size:13px}
input[type=text]{min-width:220px}
.chip{background:var(--panel2);border:1px solid var(--line);border-radius:20px;padding:6px 12px;
cursor:pointer;font-size:12px;color:var(--dim)}
.chip.on{background:var(--accent);color:#04121c;border-color:var(--accent);font-weight:600}
.count{color:var(--dim);font-size:12px;margin-left:auto}
table{border-collapse:collapse;width:100%;font-size:13px}
th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top}
th{color:var(--dim);font-weight:600;position:sticky;top:0;background:var(--bg);cursor:pointer;user-select:none}
tr:hover td{background:var(--panel)}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.id{color:var(--accent);font-weight:600}
.mine{box-shadow:inset 3px 0 0 var(--ok)}
.desc{color:var(--dim);white-space:pre-line;font-size:12px}
.grid-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
.card h3{margin:0 0 4px;font-size:14px}
.card .note{color:var(--dim);font-size:12px;margin-bottom:10px}
.item{padding:4px 0;border-bottom:1px solid var(--line);display:flex;gap:8px}
.item:last-child{border:0}
.item .n{color:var(--dim);width:26px;text-align:right;flex:none}
.item.target{color:var(--target);font-weight:600}
.badge{font-size:10px;background:var(--target);color:#1a1206;border-radius:4px;padding:1px 5px;margin-left:6px}
.drop{border:2px dashed var(--line);border-radius:12px;padding:40px;text-align:center;color:var(--dim);
cursor:pointer;margin-bottom:16px}
.drop.hover{border-color:var(--accent);color:var(--fg);background:var(--panel)}
.tv-controls{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin-bottom:14px}
.tv-controls label{display:flex;flex-direction:column;gap:3px;font-size:11px;color:var(--dim)}
.heat{display:grid;gap:1px;margin-top:10px;overflow:auto}
.cell{padding:4px 2px;text-align:center;font-size:10px;border-radius:2px;color:#0a0a0a;font-family:ui-monospace,monospace}
.hexdump{font-family:ui-monospace,monospace;font-size:12px;color:var(--dim);white-space:pre;
background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px;overflow:auto;max-height:340px;margin-top:14px}
.hint{color:var(--dim);font-size:12px;margin-top:8px}
a{color:var(--accent)}
</style>
</head>
<body>
<header>
<h1>tunie</h1>
<span class="sub" id="meta"></span>
</header>
<nav>
<button data-tab="maps" class="active">Map catalogue</button>
<button data-tab="caps">Tune capabilities</button>
<button data-tab="table">Table viewer</button>
</nav>
<main>
<section id="tab-maps" class="tab active">
<div class="controls">
<input type="text" id="q" placeholder="search model, VIN, market…">
<select id="ecu"><option value="">All ECUs</option></select>
<select id="odo">
<option value="">Any odometer</option>
<option value="mechanical odometer">Mechanical odo</option>
<option value="lcd odometer">LCD odo</option>
</select>
<span class="chip" id="mineChip">★ My bike (Keihin · mechanical · Bonneville)</span>
<span class="count" id="mapCount"></span>
</div>
<table id="mapTable">
<thead><tr>
<th data-k="id">Map</th><th data-k="ecu">ECU</th><th data-k="model">Details</th>
</tr></thead>
<tbody></tbody>
</table>
</section>
<section id="tab-caps" class="tab">
<p class="hint">TuneECU's full editable surface, extracted from the APK. The
<span style="color:var(--target)">highlighted</span> devices are the SAI / O2 / exhaust
items this project plans to delete.</p>
<div class="grid-cards" id="caps"></div>
</section>
<section id="tab-table" class="tab">
<div class="drop" id="drop">Drop a map file here (<span class="mono">.bin</span> or Intel <span class="mono">.hex</span>) — or click to choose.
<input type="file" id="file" style="display:none">
</div>
<div id="tvBody" style="display:none">
<div class="tv-controls">
<label>Offset (hex)<input type="text" id="off" value="0"></label>
<label>Columns<input type="number" id="cols" value="16" min="1"></label>
<label>Rows<input type="number" id="rows" value="16" min="1"></label>
<label>Cell bytes
<select id="cell"><option value="1">1 (8-bit)</option><option value="2" selected>2 (16-bit)</option></select>
</label>
<label>Endian
<select id="endian"><option value="be" selected>big</option><option value="le">little</option></select>
</label>
<label>Scale ×<input type="text" id="scale" value="1"></label>
</div>
<div id="tvInfo" class="hint"></div>
<div id="heat" class="heat"></div>
<div id="hex" class="hexdump"></div>
</div>
<p class="hint">No dump yet? This becomes useful after <span class="mono">tunie</span> reads a ROM.
Meanwhile you can drop any TuneECU <span class="mono">.hex</span> map to eyeball its bytes,
and hand-locate tables by diffing two maps.</p>
</section>
</main>
<script id="data" type="application/json">/*__DATA__*/</script>
<script>
const DATA = JSON.parse(document.getElementById('data').textContent);
document.getElementById('meta').textContent =
`${DATA.meta.mapCount} maps · ${DATA.meta.source}`;
// ---- tabs ----
document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>{
document.querySelectorAll('nav button').forEach(x=>x.classList.remove('active'));
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
b.classList.add('active');
document.getElementById('tab-'+b.dataset.tab).classList.add('active');
});
// ---- maps ----
const maps = DATA.maps;
const ecuSel = document.getElementById('ecu');
[...new Set(maps.map(m=>m.ecu))].sort().forEach(e=>{
const o=document.createElement('option'); o.value=e;
o.textContent='Type '+e+(e==='0'?' (Triumph Keihin)':e==='1'?' (Sagem)':e==='P'?' (Bosch)':'');
ecuSel.appendChild(o);
});
let mineOnly=false;
const q=document.getElementById('q'), odo=document.getElementById('odo'),
tbody=document.querySelector('#mapTable tbody'), mapCount=document.getElementById('mapCount');
document.getElementById('mineChip').onclick=e=>{mineOnly=!mineOnly;e.target.classList.toggle('on',mineOnly);render();};
[q,ecuSel,odo].forEach(el=>el.oninput=render);
function isMine(m){
const b=m.description.join(' ').toLowerCase();
return m.ecu==='0' && b.includes('mechanical odometer') && b.includes('bonneville');
}
function render(){
const needle=q.value.toLowerCase(), e=ecuSel.value, od=odo.value;
let rows=maps.filter(m=>{
const b=m.description.join(' ').toLowerCase();
if(needle && !b.includes(needle) && !m.id.includes(needle)) return false;
if(e && m.ecu!==e) return false;
if(od && !b.includes(od)) return false;
if(mineOnly && !isMine(m)) return false;
return true;
});
tbody.innerHTML='';
rows.slice(0,600).forEach(m=>{
const tr=document.createElement('tr');
if(isMine(m)) tr.className='mine';
tr.innerHTML=`<td class="id mono">${m.id}</td><td class="mono">${m.ecu}</td>`+
`<td><b>${m.description[0]||''}</b><div class="desc">${m.description.slice(1).join('\n')}</div></td>`;
tbody.appendChild(tr);
});
mapCount.textContent=`${rows.length} match${rows.length===1?'':'es'}`+
(rows.length>600?' (showing 600)':'');
}
render();
// ---- capabilities ----
const targets=new Set(DATA.modTargets);
const capWrap=document.getElementById('caps');
Object.values(DATA.definitions).forEach(d=>{
if(!d.items.length) return;
const c=document.createElement('div'); c.className='card';
c.innerHTML=`<h3>${d.label}</h3><div class="note">${d.note}</div>`;
d.items.forEach(it=>{
const isT=targets.has(it.text);
const row=document.createElement('div'); row.className='item'+(isT?' target':'');
row.innerHTML=`<span class="n">${it.i}</span><span>${it.text}${isT?'<span class="badge">mod target</span>':''}</span>`;
c.appendChild(row);
});
capWrap.appendChild(c);
});
// ---- table viewer ----
let BYTES=null;
const drop=document.getElementById('drop'), fileIn=document.getElementById('file');
drop.onclick=()=>fileIn.click();
drop.ondragover=e=>{e.preventDefault();drop.classList.add('hover');};
drop.ondragleave=()=>drop.classList.remove('hover');
drop.ondrop=e=>{e.preventDefault();drop.classList.remove('hover');load(e.dataTransfer.files[0]);};
fileIn.onchange=e=>load(e.target.files[0]);
function load(f){
if(!f) return;
const r=new FileReader();
r.onload=()=>{
let buf=new Uint8Array(r.result);
if(f.name.toLowerCase().endsWith('.hex')) buf=parseIntelHex(new TextDecoder().decode(buf));
BYTES=buf;
drop.textContent=`${f.name} — ${BYTES.length.toLocaleString()} bytes loaded`;
document.getElementById('tvBody').style.display='block';
draw();
};
r.readAsArrayBuffer(f);
}
function parseIntelHex(txt){
const out=[]; let base=0;
txt.split(/\r?\n/).forEach(l=>{
if(l[0]!==':') return;
const len=parseInt(l.substr(1,2),16), addr=parseInt(l.substr(3,4),16), type=parseInt(l.substr(7,2),16);
if(type===0x04){ base=parseInt(l.substr(9,4),16)<<16; }
else if(type===0x00){
for(let i=0;i<len;i++){ out[base+addr+i]=parseInt(l.substr(9+i*2,2),16); }
}
});
const max=out.length; const b=new Uint8Array(max);
for(let i=0;i<max;i++) b[i]=out[i]||0;
return b;
}
['off','cols','rows','cell','endian','scale'].forEach(id=>document.getElementById(id).oninput=draw);
function draw(){
if(!BYTES) return;
const off=parseInt(document.getElementById('off').value,16)||0;
const cols=+document.getElementById('cols').value, rows=+document.getElementById('rows').value;
const cw=+document.getElementById('cell').value, be=document.getElementById('endian').value==='be';
const scale=parseFloat(document.getElementById('scale').value)||1;
const vals=[]; let p=off;
for(let i=0;i<cols*rows;i++){
let v;
if(cw===1){ v=BYTES[p]; p+=1; }
else { v = be ? (BYTES[p]<<8)|BYTES[p+1] : (BYTES[p+1]<<8)|BYTES[p]; p+=2; }
vals.push(v*scale);
}
const mn=Math.min(...vals), mx=Math.max(...vals), span=mx-mn||1;
const heat=document.getElementById('heat');
heat.style.gridTemplateColumns=`repeat(${cols},1fr)`;
heat.innerHTML='';
vals.forEach(v=>{
const t=(v-mn)/span; // blue(low) -> red(high)
const hue=(1-t)*220;
const cell=document.createElement('div'); cell.className='cell';
cell.style.background=`hsl(${hue},75%,${35+t*25}%)`;
cell.textContent = Number.isInteger(v)?v:v.toFixed(1);
heat.appendChild(cell);
});
document.getElementById('tvInfo').textContent=
`range ${mn.toLocaleString()} … ${mx.toLocaleString()} over ${cols}×${rows} cells from 0x${off.toString(16)}`;
// hex dump around the offset
let dump=''; const start=off & ~0xf;
for(let a=start;a<start+ (rows*cols*cw)+16 && a<BYTES.length; a+=16){
let hex='',asc='';
for(let i=0;i<16;i++){ const b=BYTES[a+i]; if(b===undefined)break;
hex+=b.toString(16).padStart(2,'0')+' '; asc+=(b>=32&&b<127)?String.fromCharCode(b):'.'; }
dump+=a.toString(16).padStart(6,'0')+' '+hex.padEnd(48)+' '+asc+'\n';
}
document.getElementById('hex').textContent=dump;
}
</script>
</body>
</html>