Files
viktor 6d49ba3cba 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

463 строки
24 KiB
HTML
Исходник Постоянная ссылка Ответственный История

Этот файл содержит неоднозначные символы Юникода
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="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>