FM100B = Sicomm SCT3258TD (документирован); Talker Alias, агент, SPI-инструменты, восстановление TX

Главное:
- Опознан DMR-чип: FM100B = Sicomm SCT3258TD. Датащит Packet Interface (126 стр) в docs/.
  Открывает калибровку/AMBE/вокодер по документации. Родня: UV-4R, китайские клоны.

Реализовано:
- Приём Talker Alias (cmd 0x60) — позывной крупно на экране (build_ta.py)
- Интерактивный агент — управление FM100B по COM без перешивки (build_agent.py + agent.py)
- Полная карта команд FM100B с живой рации (fm100b-commands.md)
- Работа с SPI: чтение/запись/восстановление codeplug и калибровки (spi_read/spi_wr/spi_restore)
- Конструктор дисплея + редактор прошивки (tools/display-editor)

Документация:
- docs/SCT3258_packet_interface.txt/.pdf — спека DMR-чипа
- docs/fm100b-commands.md — карта команд
- docs/dmr-tx-recovery.md — диагноз/восстановление DMR-передачи

Инструменты: flash_ps.py (прошивка через WSL/powershell), sniff.py (снифер кадров),
hotspot_rxoffset_sweep.py, apply_patch.py, build_* сборщики.

Важно: DMR TX-калибровка сбита в экспериментах со SET-командами модулю; восстановление
через offset-компенсацию / .updat Radtel / калибровку по прибору (docs/dmr-tx-recovery.md).
Этот коммит содержится в:
2026-07-24 02:52:51 +09:00
родитель 5f4d207aa5
Коммит 6d49ba3cba
21 изменённых файлов: 11662 добавлений и 0 удалений
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
+462
Просмотреть файл
@@ -0,0 +1,462 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>RT-4D — конструктор дисплея</title>
<style>
:root{
--lcd-off:#A7B5A4; --lcd-on:#161C18; --lcd-grid:#93A191; --lcd-edge:#7E8C7D;
--ground:#0F1214; --panel:#171B1D; --panel-2:#1E2426; --line:#2A3134;
--ink:#D6DCD8; --muted:#74827C; --accent:#E2803A; --accent-dim:#8A5326;
--warn:#D8B14A; --bad:#C2564A; --ok:#6FA88A;
--mono:ui-monospace,"SF Mono","Cascadia Mono","Roboto Mono",Consolas,monospace;
--sans:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
}
@media (prefers-color-scheme: light){
:root{ --ground:#E6E8E4; --panel:#F4F5F2; --panel-2:#EAECE7; --line:#CDD2CB;
--ink:#1C2220; --muted:#5F6C66; }
}
:root[data-theme="dark"]{ --ground:#0F1214; --panel:#171B1D; --panel-2:#1E2426;
--line:#2A3134; --ink:#D6DCD8; --muted:#74827C; }
:root[data-theme="light"]{ --ground:#E6E8E4; --panel:#F4F5F2; --panel-2:#EAECE7;
--line:#CDD2CB; --ink:#1C2220; --muted:#5F6C66; }
*{box-sizing:border-box}
body{margin:0;background:var(--ground);color:var(--ink);font:15px/1.5 var(--sans)}
.wrap{max-width:1240px;margin:0 auto;padding:20px 18px 56px}
header{display:flex;flex-wrap:wrap;align-items:baseline;gap:10px 16px;
padding-bottom:14px;border-bottom:1px solid var(--line);margin-bottom:18px}
h1{margin:0;font-size:19px;font-weight:650;letter-spacing:-.01em}
.sub{color:var(--muted);font-size:13px}
.spec{margin-left:auto;font:12px/1 var(--mono);color:var(--muted);
letter-spacing:.04em;text-transform:uppercase}
.cols{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:18px;align-items:start}
@media (max-width:900px){ .cols{grid-template-columns:minmax(0,1fr)} }
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px}
.card>h2{margin:0;padding:11px 14px;font-size:11px;font-weight:600;
letter-spacing:.09em;text-transform:uppercase;color:var(--muted);
border-bottom:1px solid var(--line)}
.card>.body{padding:14px}
/* --- дисплей --- */
.stage{padding:16px 14px 14px;overflow-x:auto}
.bezel{display:inline-block;background:var(--panel-2);border:1px solid var(--line);
border-radius:8px;padding:10px 12px 12px}
canvas{display:block;image-rendering:pixelated;cursor:crosshair;
border:1px solid var(--lcd-edge);border-radius:2px;touch-action:none}
.rulerinfo{display:flex;gap:14px;margin-top:9px;font:11px/1 var(--mono);
color:var(--muted);letter-spacing:.04em}
.rulerinfo b{color:var(--ink);font-weight:600}
.toolbar{display:flex;flex-wrap:wrap;gap:7px;padding:12px 14px;
border-bottom:1px solid var(--line)}
button{font:13px/1 var(--sans);color:var(--ink);background:var(--panel-2);
border:1px solid var(--line);border-radius:7px;padding:7px 11px;cursor:pointer}
button:hover{border-color:var(--muted)}
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
button.primary{background:var(--accent-dim);border-color:var(--accent);color:#FFF1E4}
button.ghost{background:transparent}
label.chk{display:inline-flex;align-items:center;gap:7px;font-size:13px;
color:var(--muted);cursor:pointer;padding:7px 4px}
/* --- список элементов --- */
ul.items{list-style:none;margin:0;padding:0;max-height:270px;overflow-y:auto}
ul.items li{display:flex;align-items:center;gap:9px;padding:8px 14px;
border-bottom:1px solid var(--line);cursor:pointer;font-size:13px}
ul.items li:last-child{border-bottom:0}
ul.items li[aria-selected="true"]{background:var(--panel-2);
box-shadow:inset 3px 0 0 var(--accent)}
.tag{font:10px/1 var(--mono);letter-spacing:.06em;text-transform:uppercase;
color:var(--muted);border:1px solid var(--line);border-radius:4px;padding:3px 5px}
.nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pos{font:11px/1 var(--mono);color:var(--muted);font-variant-numeric:tabular-nums}
.lock{font:10px/1 var(--mono);color:var(--warn)}
/* --- инспектор --- */
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.f{display:flex;flex-direction:column;gap:5px}
.f label{font:10px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;
color:var(--muted)}
input,select{font:13px/1 var(--mono);color:var(--ink);background:var(--panel-2);
border:1px solid var(--line);border-radius:6px;padding:8px 9px;width:100%;
font-variant-numeric:tabular-nums}
input:focus,select:focus{outline:2px solid var(--accent);outline-offset:1px}
.hint{color:var(--muted);font-size:12px;margin:10px 0 0}
.empty{color:var(--muted);font-size:13px;padding:4px 0}
/* --- вывод --- */
pre.out{margin:0;padding:14px;font:12.5px/1.65 var(--mono);color:var(--ink);
background:var(--panel-2);border-radius:0 0 9px 9px;overflow-x:auto;
white-space:pre;font-variant-numeric:tabular-nums}
.warnbar{display:flex;align-items:flex-start;gap:9px;padding:10px 14px;
border-bottom:1px solid var(--line);font-size:13px;color:var(--warn)}
.warnbar.ok{color:var(--ok)}
.warnbar .dot{width:7px;height:7px;border-radius:50%;background:currentColor;
margin-top:6px;flex:none}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Конструктор дисплея RT-4D</h1>
<span class="sub">шрифты и координаты — из самой прошивки</span>
<span class="spec">128 × 64 · 8 страниц по 8 строк</span>
</header>
<div class="cols">
<div>
<div class="card">
<div class="toolbar">
<button data-add="small">+ Текст 5×8</button>
<button data-add="big">+ Текст 7×16</button>
<button data-add="bar">+ Полоса</button>
<button data-add="rect">+ Рамка</button>
<button id="dup" class="ghost">Дублировать</button>
<button id="del" class="ghost">Удалить</button>
<label class="chk"><input type="checkbox" id="showStock" checked> занятое стоком</label>
<label class="chk"><input type="checkbox" id="showGrid" checked> сетка</label>
</div>
<div class="stage">
<div class="bezel"><canvas id="lcd" width="1040" height="536"></canvas></div>
<div class="rulerinfo">
<span>курсор <b id="cur"></b></span>
<span>страница <b id="curpg"></b></span>
<span>элементов <b id="cnt">0</b></span>
</div>
</div>
</div>
<div class="card" style="margin-top:16px">
<h2>Вызовы отрисовки</h2>
<div class="warnbar ok" id="warn"><span class="dot"></span><span id="warntext">коллизий нет</span></div>
<pre class="out" id="out"></pre>
</div>
</div>
<div>
<div class="card">
<h2>Элементы</h2>
<ul class="items" id="list"></ul>
</div>
<div class="card" style="margin-top:16px">
<h2>Свойства</h2>
<div class="body" id="insp"><div class="empty">Выберите элемент на дисплее.</div></div>
</div>
<div class="card" style="margin-top:16px">
<h2>Макет</h2>
<div class="body">
<div style="display:flex;gap:7px;flex-wrap:wrap">
<button id="preset" class="ghost">Загрузить сток</button>
<button id="clear" class="ghost">Очистить</button>
<button id="exp" class="primary">Скопировать JSON</button>
</div>
<p class="hint">Стрелками — сдвиг на 1 пиксель, с Shift — на 8. Текст по вертикали
встаёт только на границу страницы: вывод адресуется страницами, а не строками.</p>
</div>
</div>
</div>
</div>
</div>
<script>
const FONTS = {"small5x8": "AAAAAAAAAL4AAAAOAA4ASPxI/EgoVNZUKIhAIBCISLS0qEAAAIwAADhEggAAAACCRDiIUPxQiBAQ/hAQAIBgAAAQEBAQEAAAgAAAQCAQCAT+goKC/gAA/gAA8pKSkp6SkpKS/h4QEBD+npKSkvL+kpKS8gICAgL+/pKSkv6ekpKS/gAAKAAAgEAoAAAQKESCACgoKCgoAIJEKBAEArIKBHyyysq8+CQiJPj+kpKSbHyCgoJE/oKCRDj+kpKSkv4SEhISfIKSkuT+EBAQ/oKC/oKCYICCfgL+EChEgv6AgICA/gQIBP7+CBAg/nyCgoJ8/hISEgx8oqJCvP4SMlKMTJKSkmQCAv4CAn6AgIB+PkCAQD5+gH6AfsYoECjGBgjwCAbCopKKhv6CggAACBAgQIAAAIKC/gwCDAAAgICAgIACBAgAAGSUlGT4/pCQkGB4hISESGCQkJD+eJSUlFgQEPwSEpikpKR8/hAQ4AAAAOgAAGCAgHoA/iBQiAAAAP4AAPgE+AT4/AQE+AB4hISEeP4SEhIMDBISEv74IBAIAJiUtKRkCAh8iIh8gIBAfDxAgEA8fIB8gHyIUCBQiIyQUDwAiMiomIgQfIIAAAAA/gAAAACCfBAwCBAgGBA4fP4A", "big7x16": "AAAAAAAAAAAAAAAAAAAAAAAAcAD4DfgNcAAAAAAAOAA4AAAAAAA4ADgAAAAgAvgP+A8gAvgP+A8AADAEeAzOOI45GA8QBhgAPAYkA7wN2B5gEjAeAACwB/gPSAh4CTAHAA8AAAAAAAA4ADgAAAAAAAAAAADAB/AfODgIIAAAAAAAAAggODjwH8AHAAAAAIAAoALgA8AB4AOgAgAAgACAAOAD4AOAAIAAAAAAAAAAACwAPAAcAAAAAIAAgACAAIAAgACAAAAAAAAAAAAMAAwADAAAAAAAGAAegAfgAXgAGAAAAPAH+A+ICEgI+A/wBwAAAAAQAPgP+A8AAAAAAAAwDjgOCAuICfgIcAgAADAOOA6ICMgI+A9wBwACgAPAA3AC+A/4DwACAAD4DPgMSAhICMgPiAcAAPAH+A/ICEgI2A+QBwAACAAIDogP6AF4ABgAAABwD/gPyAjICPgPcAcAAPAM+A0ICQgJ+A/wBwAAAAAAAGAMYAxgDAAAAAAAAAAAYCxgPGAcAAAAAIAAwAFgAzAGGAwICAAAQAFAAUABQAFAAUABAAAICBgMMAZgA8ABgAAAADAAOACIDcgNeAAwAPAH+A8ICIgJyAtICvgLAADgD/APGAEYAfAP4A8AAPgP+A+ICIgI+A9wBwAA8Af4DwgICAg4DjAGAAD4D/gPCAgYDPAH4AMAAPgP+A+ICIgIiAgICAAA+A/4D4gAiACIAAgAAADwB/gPCAgICTgPMA8AAPgP+A+AAIAA+A/4DwAAAAAICPgP+A8ICAAAAAAABgAOAAgACPgP+AcAAPgP+A+AAOADeA8YDAAA+A/4DwAIAAgACAAIAAD4D/gPIADAASAA+A8AAPgP+A9gAMAAgAH4DwAA8Af4DwgICAj4D/AHAAD4D/gPiACIAPgAcAAAAPAH+A8ICAgY+D/wJwAA+A/4D4gAiAH4D3AOAAAwBHgMyAiICRgPEAYAAAgACAD4D/gPCAAIAAAA+Af4DwAIAAj4D/gHAAD4A/gHAAwADPgH+AMAAPgB+A8ADsABAA74DwAAGA44D+AAwAE4DxgOAAB4APgAgA+AD/gAeAAAAAgOCA+ICcgIeAg4CAAAAAD4f/h/CEAIQAAAAAAYAHgA4AGABwAeABgAAAAACEAIQPh/+H8AAAAACAAMAAYABgAMAAgAAEAAQABAAEAAQABAAEAAAAAAAgAGAA4ACAAAAAAAAAYgDyAJIAngD8APAAD4D/gPIAggCOAPwAcAAMAH4A8gCCAIYAxABAAAwAfgDyAIIAj4D/gPAADAB+APIAkgCeAJwAEAAIAA8A/4D4gAiACIAAAAwEfgTyBIIEjgf+A/AAD4D/gPIAAgAOAPwA8AACAIIAjsD+wPAAgACAAAAEAgQCBA7H/sPwAAAAD4D/gPAAGAA+AOYAwAAAgICAj4D/gPAAgACAAA4A/gDyAA4AcgAOAPAADgD+APIAAgAOAPwA8AAMAH4A8gCCAI4A/ABwAA4H/gfyAIIAjgD8AHAADAB+APIAggCOB/4H8AAOAP4A+AAEAAYABgAAAAwAjgCSAJIAkgDyAGAAAgAPgH+A8gCCAIIAgAAOAH4A8ACAAI4A/gDwAA4APgBwAMAAzgB+ADAADgA+APAAzAAwAM4A8AAGAM4A6AA4AD4A5gDABA4EfgTwBoADjgH+AHAAAgDCAOIAugCeAIYAgAAAABgAPwHng8CCAAAAAAAAAAAPh/+H8AAAAAAAAAAAggeDzwHoADAAEwABgACAAYADAAIAAwAAAAAAAAAAAAAAAAAAAA", "cyr": {"\u0410": "AADgD/APGAEYAfAP4A8=", "\u0412": "AAD4D/gPiAiICPgPcAc=", "\u0415": "AAD4D/gPiAiICIgICAg=", "\u0417": "AAAwDjgOiAjICPgPcAc=", "\u041a": "AAD4D/gPgADgA3gPGAw=", "\u041c": "AAD4D/gPIADAASAA+A8=", "\u041d": "AAD4D/gPgACAAPgP+A8=", "\u041e": "AADwB/gPCAgICPgP8Ac=", "\u0420": "AAD4D/gPiACIAPgAcAA=", "\u0421": "AADwB/gPCAgICDgOMAY=", "\u0422": "AAAIAAgA+A/4DwgACAA=", "\u0425": "AAAYDjgP4ADAATgPGA4=", "\u0411": "AAD4D/gPSAhICMgPiAc=", "\u0413": "AAD4D/gPCAAIAAgACAA=", "\u0414": "AAAADPgP+AcIBAgM+A8=", "\u0416": "AAAYDiAB+A8gARgOAAA=", "\u0418": "AAD4D/gPwAHgAPgPOA8=", "\u0419": "AADgD+gPmAHYAOgPYA8=", "\u041b": "AAAADvgP+AEIAAgA+A8=", "\u041f": "AAD4D/gPCAAIAPgP+A8=", "\u0423": "AAAYDDgG4AP4ARgAAAA=", "\u0424": "AADwARAB+A/4DxAB8AE=", "\u0426": "AAD4B/gHAAT4D/gPAAA=", "\u0427": "AAB4AHgAQABAAPgP+A8=", "\u0428": "AAD4DwAI+A/4DwAI+A8=", "\u0429": "AAD4BwAE+Af4BwAE+A8=", "\u042a": "AAAIAPgP+A+ACIAPAAc=", "\u042b": "AAD4D/gPgAiADwAA+A8=", "\u042c": "AAD4D/gPgAiACIAPAAc=", "\u042d": "AAAYDBgMSAhICPgP8Ac=", "\u042e": "AAD4D0AA8AdICAgI8Ac=", "\u042f": "AAB4CHgESAJIAfgA+A8=", "\u0401": "AADoD+APqAigCKAIIAg="}};
/* ---------- декодирование шрифтов ---------- */
function b64(s){const r=atob(s),a=new Uint8Array(r.length);
for(let i=0;i<r.length;i++)a[i]=r.charCodeAt(i);return a;}
const SMALL=b64(FONTS.small5x8), BIG=b64(FONTS.big7x16);
const CYR={}; for(const k in FONTS.cyr) CYR[k]=b64(FONTS.cyr[k]);
/* колонки символа: массив чисел-битовых масок, бит0 = верхняя строка */
function glyphSmall(ch){const c=ch.charCodeAt(0);
if(c<0x20||c>0x7F)return [0,0,0,0,0];
const o=(c-0x20)*5,out=[];for(let i=0;i<5;i++)out.push(SMALL[o+i]);return out;}
function glyphBig(ch){
if(CYR[ch]){const g=CYR[ch],out=[];for(let i=0;i<7;i++)out.push(g[2*i]|(g[2*i+1]<<8));return out;}
const c=ch.charCodeAt(0);
if(c<0x20||c>0x7F)return [0,0,0,0,0,0,0];
const o=(c-0x20)*14,out=[];for(let i=0;i<7;i++)out.push(BIG[o+i*2]|(BIG[o+i*2+1]<<8));return out;}
const FONT={small:{cols:5,stride:6,rows:8,pages:1,glyph:glyphSmall,call:"draw_str_spaced / small_char"},
big: {cols:7,stride:7,rows:16,pages:2,glyph:glyphBig, call:"draw_string"}};
/* ---------- модель ---------- */
const W=128,H=64,S=8; // масштаб
let items=[], sel=null, uid=1;
const STOCK=[
{name:"строка режима",page:1,x:0,w:96},
{name:"признак канала",page:1,x:104,w:24},
{name:"зона A",page:2,x:0,w:112,h:2},
{name:"зона B",page:5,x:0,w:112,h:2},
{name:"стрелки A/B",page:2,x:123,w:5},
{name:"стрелки A/B",page:5,x:123,w:5},
];
function mk(type){
const base={id:uid++,type,page:0,x:0,invert:0};
if(type==="small") return {...base,text:"8.4V",font:"small"};
if(type==="big") return {...base,text:"CH-01",font:"big",page:2};
if(type==="bar") return {...base,page:1,x:4,count:120};
return {...base,page:1,x:4,w:120,h:6};
}
/* ---------- растеризация ---------- */
function raster(){
const px=new Uint8Array(W*H);
const set=(x,y)=>{if(x>=0&&x<W&&y>=0&&y<H)px[y*W+x]=1;};
for(const it of items){
if(it.type==="small"||it.type==="big"){
const F=FONT[it.font];
[...it.text].forEach((ch,ci)=>{
const g=F.glyph(ch);
for(let c=0;c<F.cols;c++){
const x=it.x+ci*F.stride+c;
for(let r=0;r<F.rows;r++){
const on=(g[c]>>r)&1;
if(it.invert? !on : on) set(x, it.page*8+r);
}
}
});
} else if(it.type==="bar"){
for(let i=0;i<it.count;i++)for(let r=0;r<8;r++)
if((0x18>>r)&1) set(it.x+i, it.page*8+r);
} else {
const y0=it.page*8, y1=y0+it.h*8-1;
for(let i=0;i<it.w;i++){set(it.x+i,y0);set(it.x+i,y1);}
for(let y=y0;y<=y1;y++){set(it.x,y);set(it.x+it.w-1,y);}
}
}
return px;
}
/* ---------- отрисовка ---------- */
const cv=document.getElementById("lcd"), cx=cv.getContext("2d");
const OFFX=16, OFFY=16; // место под линейки
function css(v){return getComputedStyle(document.documentElement).getPropertyValue(v).trim();}
function draw(){
const off=css("--lcd-off"), on=css("--lcd-on"), grid=css("--lcd-grid"),
muted=css("--muted"), accent=css("--accent");
cx.clearRect(0,0,cv.width,cv.height);
// линейки
cx.font="9px "+css("--mono").split(",")[0]+", monospace";
cx.fillStyle=muted; cx.textBaseline="middle";
for(let p=0;p<8;p++){cx.textAlign="right";
cx.fillText(String(p),OFFX-4,OFFY+p*8*S+4*S);}
cx.textAlign="center";
for(let c=0;c<=W;c+=16) cx.fillText(String(c),OFFX+c*S,OFFY-7);
// стекло
cx.fillStyle=off; cx.fillRect(OFFX,OFFY,W*S,H*S);
// занятое стоком
if(document.getElementById("showStock").checked){
cx.save(); cx.globalAlpha=.20; cx.fillStyle=accent;
for(const s of STOCK) cx.fillRect(OFFX+s.x*S,OFFY+s.page*8*S,s.w*S,(s.h||1)*8*S);
cx.restore();
}
// сетка страниц
if(document.getElementById("showGrid").checked){
cx.strokeStyle=grid; cx.lineWidth=1;
cx.beginPath();
for(let c=8;c<W;c+=8){cx.moveTo(OFFX+c*S+.5,OFFY);cx.lineTo(OFFX+c*S+.5,OFFY+H*S);}
cx.stroke();
cx.strokeStyle=css("--lcd-edge"); cx.beginPath();
for(let p=1;p<8;p++){cx.moveTo(OFFX,OFFY+p*8*S+.5);cx.lineTo(OFFX+W*S,OFFY+p*8*S+.5);}
cx.stroke();
}
// пиксели
const px=raster(); cx.fillStyle=on;
for(let y=0;y<H;y++)for(let x=0;x<W;x++)
if(px[y*W+x]) cx.fillRect(OFFX+x*S,OFFY+y*S,S,S);
// выделение
if(sel){const b=bbox(sel);
cx.strokeStyle=accent;cx.lineWidth=2;
cx.strokeRect(OFFX+b.x*S-1,OFFY+b.y*S-1,b.w*S+2,b.h*S+2);}
}
function bbox(it){
if(it.type==="small"||it.type==="big"){
const F=FONT[it.font];
return {x:it.x,y:it.page*8,w:Math.max(1,it.text.length*F.stride-(F.stride-F.cols)),h:F.rows};
}
if(it.type==="bar") return {x:it.x,y:it.page*8,w:it.count,h:8};
return {x:it.x,y:it.page*8,w:it.w,h:it.h*8};
}
/* ---------- список и инспектор ---------- */
function label(it){
if(it.type==="small") return "«"+it.text+"» 5×8";
if(it.type==="big") return "«"+it.text+"» 7×16";
if(it.type==="bar") return "полоса "+it.count+" кол.";
return "рамка "+it.w+"×"+it.h*8;
}
function renderList(){
const ul=document.getElementById("list"); ul.innerHTML="";
document.getElementById("cnt").textContent=items.length;
if(!items.length){ul.innerHTML='<li style="cursor:default;color:var(--muted)">Пусто — добавьте элемент.</li>';return;}
for(const it of items){
const li=document.createElement("li");
li.setAttribute("aria-selected", sel&&sel.id===it.id ? "true":"false");
li.innerHTML=`<span class="tag">${it.type==="bar"?"BAR":it.type==="rect"?"BOX":it.font==="small"?"5×8":"7×16"}</span>
<span class="nm">${label(it).replace(/</g,"&lt;")}</span>
<span class="pos">p${it.page} · ${it.x}</span>`;
li.onclick=()=>{sel=it;sync();};
ul.appendChild(li);
}
}
function field(lb,key,attrs=""){
return `<div class="f"><label>${lb}</label><input data-k="${key}" ${attrs} value="${sel[key]}"></div>`;
}
function renderInsp(){
const el=document.getElementById("insp");
if(!sel){el.innerHTML='<div class="empty">Выберите элемент на дисплее.</div>';return;}
let h='<div class="grid2">'+field("страница","page",'type="number" min="0" max="7"')
+field("колонка","x",'type="number" min="0" max="127"')+'</div>';
if(sel.type==="small"||sel.type==="big"){
h+='<div class="f" style="margin-top:10px"><label>текст</label><input data-k="text" value="'+
sel.text.replace(/"/g,"&quot;")+'"></div>';
h+='<div class="grid2" style="margin-top:10px"><div class="f"><label>шрифт</label>'+
'<select data-k="font"><option value="small"'+(sel.font==="small"?" selected":"")+'>5×8 мелкий</option>'+
'<option value="big"'+(sel.font==="big"?" selected":"")+'>7×16 основной</option></select></div>'+
'<div class="f"><label>инверсия</label><select data-k="invert">'+
'<option value="0"'+(!sel.invert?" selected":"")+'>нет</option>'+
'<option value="1"'+(sel.invert?" selected":"")+'>да</option></select></div></div>';
} else if(sel.type==="bar"){
h+='<div class="grid2" style="margin-top:10px">'+field("длина","count",'type="number" min="1" max="128"')+'</div>';
} else {
h+='<div class="grid2" style="margin-top:10px">'+field("ширина","w",'type="number" min="2" max="128"')
+field("высота, стр.","h",'type="number" min="1" max="8"')+'</div>';
}
el.innerHTML=h;
el.querySelectorAll("[data-k]").forEach(inp=>{
inp.oninput=()=>{const k=inp.dataset.k;
sel[k] = (k==="text"||k==="font") ? inp.value : parseInt(inp.value||0,10)||0;
if(k==="page") sel.page=Math.max(0,Math.min(7,sel.page));
sync(false);};
});
}
/* ---------- вывод вызовов ---------- */
function output(){
const L=[];
for(const it of items){
if(it.type==="small"||it.type==="big"){
const F=FONT[it.font];
if(it.font==="small")
L.push(`draw_str_spaced(page=${it.page}, x=${it.x}, n=${it.text.length}, "${it.text}", mode=${it.invert});`);
else
L.push(`draw_string(page=${it.page}, x=${it.x}, "${it.text}", n=${it.text.length}, mode=${it.invert});`);
L.push(` // ${F.cols}px/симв, шаг ${F.stride}px, занимает стр. ${it.page}` +
(F.pages>1?`${it.page+1}`:"") + `, кол. ${it.x}${it.x+it.text.length*F.stride-1}`);
} else if(it.type==="bar"){
L.push(`fill_col_run(page=${it.page}, x=${it.x}, count=${it.count});`);
L.push(` // байт 0x18 — полоса 2px посередине страницы ${it.page}`);
} else {
L.push(`// рамка: полосы на стр. ${it.page} и ${it.page+it.h-1}, стенки на кол. ${it.x} и ${it.x+it.w-2}`);
L.push(`fill_col_run(page=${it.page}, x=${it.x}, count=${it.w});`);
L.push(`draw_box(col=${it.x}); draw_box(col=${it.x+it.w-2});`);
}
}
document.getElementById("out").textContent = L.length?L.join("\n"):"— добавьте элементы —";
// коллизии
const occ=new Map(); const bad=new Set();
const mark=(name,x,y,w,h)=>{
for(let yy=y;yy<y+h;yy++)for(let xx=x;xx<x+w;xx++){
const k=yy*W+xx; if(k<0||k>=W*H)continue;
if(occ.has(k)&&occ.get(k)!==name) bad.add(occ.get(k)+" ↔ "+name);
else occ.set(k,name);
}};
for(const it of items){const b=bbox(it);mark(label(it),b.x,b.y,b.w,b.h);}
if(document.getElementById("showStock").checked)
for(const s of STOCK) mark("сток: "+s.name,s.x,s.page*8,s.w,(s.h||1)*8);
const wb=document.getElementById("warn"), wt=document.getElementById("warntext");
if(bad.size){wb.className="warnbar";wt.textContent="перекрытие: "+[...bad].slice(0,3).join("; ");}
else{wb.className="warnbar ok";wt.textContent="коллизий нет";}
}
function sync(rebuild=true){ if(rebuild)renderInsp(); renderList(); draw(); output(); }
/* ---------- мышь ---------- */
let drag=null;
function hit(mx,my){
for(let i=items.length-1;i>=0;i--){const b=bbox(items[i]);
if(mx>=b.x&&mx<b.x+b.w&&my>=b.y&&my<b.y+b.h)return items[i];}
return null;
}
function pos(e){const r=cv.getBoundingClientRect();
const sx=cv.width/r.width;
return {x:Math.floor(((e.clientX-r.left)*sx-OFFX)/S), y:Math.floor(((e.clientY-r.top)*sx-OFFY)/S)};}
cv.addEventListener("pointerdown",e=>{
const p=pos(e); const it=hit(p.x,p.y);
sel=it; if(it){drag={dx:p.x-it.x, dy:p.y-it.page*8}; cv.setPointerCapture(e.pointerId);}
sync();
});
cv.addEventListener("pointermove",e=>{
const p=pos(e);
document.getElementById("cur").textContent =
(p.x>=0&&p.x<W&&p.y>=0&&p.y<H) ? p.x+", "+p.y : "—";
document.getElementById("curpg").textContent =
(p.y>=0&&p.y<H) ? Math.floor(p.y/8) : "—";
if(drag&&sel){
sel.x=Math.max(0,Math.min(127,p.x-drag.dx));
sel.page=Math.max(0,Math.min(7,Math.round((p.y-drag.dy)/8)));
sync(false);
}
});
addEventListener("pointerup",()=>{drag=null;});
addEventListener("keydown",e=>{
if(!sel||/INPUT|SELECT/.test(document.activeElement.tagName))return;
const step=e.shiftKey?8:1; let used=true;
if(e.key==="ArrowLeft")sel.x=Math.max(0,sel.x-step);
else if(e.key==="ArrowRight")sel.x=Math.min(127,sel.x+step);
else if(e.key==="ArrowUp")sel.page=Math.max(0,sel.page-1);
else if(e.key==="ArrowDown")sel.page=Math.min(7,sel.page+1);
else if(e.key==="Delete"||e.key==="Backspace"){items=items.filter(i=>i!==sel);sel=null;}
else used=false;
if(used){e.preventDefault();sync();}
});
/* ---------- кнопки ---------- */
document.querySelectorAll("[data-add]").forEach(b=>b.onclick=()=>{
const it=mk(b.dataset.add); items.push(it); sel=it; sync();});
document.getElementById("del").onclick=()=>{if(sel){items=items.filter(i=>i!==sel);sel=null;sync();}};
document.getElementById("dup").onclick=()=>{if(sel){const c={...sel,id:uid++,page:Math.min(7,sel.page+1)};
items.push(c);sel=c;sync();}};
document.getElementById("clear").onclick=()=>{items=[];sel=null;sync();};
document.getElementById("showStock").onchange=sync;
document.getElementById("showGrid").onchange=draw;
document.getElementById("preset").onclick=()=>{
items=[ {id:uid++,type:"small",font:"small",text:"8.4V",page:0,x:108,invert:0},
{id:uid++,type:"big",font:"big",text:"438.80000",page:2,x:20,invert:0},
{id:uid++,type:"big",font:"big",text:"CH-0002",page:5,x:20,invert:0},
{id:uid++,type:"small",font:"small",text:"DMR",page:1,x:0,invert:0} ];
sel=items[0]; sync();};
document.getElementById("exp").onclick=async()=>{
const btn=document.getElementById("exp");
try{ await navigator.clipboard.writeText(JSON.stringify(items,null,1));
btn.textContent="Скопировано"; }
catch{ btn.textContent="Не удалось"; }
setTimeout(()=>btn.textContent="Скопировать JSON",1400);};
document.getElementById("preset").click();
matchMedia("(prefers-color-scheme: dark)").addEventListener("change",draw);
new MutationObserver(draw).observe(document.documentElement,{attributes:true,attributeFilter:["data-theme"]});
</script>
</body>
</html>