Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
20 KiB
JavaScript
185 lines
20 KiB
JavaScript
// ---- найденные источники ----
|
||
// Вкладка: плитки, легенда, 4 обзорных карточки диапазонов, большой спектр выбранного диапазона (зум выделением,
|
||
// tooltip по ближайшему источнику), фильтры и список. Данные: /api/sources. Общий скрипт-область с app.js
|
||
// (используются $, LS, esc); порядок подключения — pages.py.
|
||
const KIND={active:"прерывистый",carrier:"несущая/спур",continuous:"непрерывный",wide:"широкополосный"};
|
||
let srcData=null,showCarriers=LS.get("carriers",false);
|
||
let srcBand=LS.get("srcBand",null),srcSort=LS.get("srcSort","last");
|
||
let srcZoom=null; // {bi,a,b} МГц — только в памяти, переживает автообновление
|
||
const SBANDS=[[118,138,"Авиа","avia"],[144,147,"Любители 2 м","ham"],[148,175,"Служебные · морские","marine"],[420,450,"70 см · PMR · LPD","pmr"]];
|
||
// цвет = состояние: в сканере / активный, ещё не в сканере / широкий / несущая. Форма дублирует цвет (см. sxPlot).
|
||
const SCOL={scan:"#3ddc84",act:"#7cc4ff",wide:"#ffb454",car:"#5a5a5a"};
|
||
const bandOf=f=>SBANDS.findIndex(([a,b])=>f>=a*1e6&&f<=b*1e6);
|
||
const isCar=x=>x.kind==="carrier"||x.kind==="continuous";
|
||
const srcCol=x=>x.in_scan?SCOL.scan:x.kind==="wide"?SCOL.wide:isCar(x)?SCOL.car:SCOL.act;
|
||
function fmtAgo(t){if(!t)return"—";const s=Date.now()/1000-t;if(s<90)return"только что";if(s<3600)return Math.round(s/60)+" мин назад";if(s<86400)return Math.round(s/3600)+" ч назад";
|
||
return new Date(t*1000).toLocaleString("ru-RU",{day:"numeric",month:"short",hour:"2-digit",minute:"2-digit"})}
|
||
let sxDrag=false; // идёт выделение — автообновление не перерисовывает
|
||
async function loadSources(){try{const r=await fetch("/api/sources",{cache:"no-store"});srcData=await r.json()}catch(e){srcData=null}
|
||
if(!sxDrag)renderSources()}
|
||
|
||
// Шкала высоты: ЛИНЕЙНАЯ по дБ (дБ уже логарифм — длина стебля честно сравнима), от 0 до верха по данным:
|
||
// 99-й перцентиль, округлённый вверх до 10 дБ, в пределах 30–60. Одна шкала на все карточки; выше — обрезается.
|
||
function sxDbMax(all){const v=all.map(x=>x.max_db||0).sort((p,q)=>p-q);const p=v.length?v[Math.floor(v.length*.99)]:30;return Math.max(30,Math.min(60,Math.ceil(p/10)*10))}
|
||
const SX_STEPS=[.005,.01,.02,.05,.1,.2,.25,.5,1,2,5,10,20];
|
||
let sxCtx={vis0:[],dmax:40,sc:[]},sxPlots=[];
|
||
|
||
// SVG спектра в реальных пикселях (W×H), без искажения линий. o:{W,H,a,b,xs,sc,dmax,big,shade}
|
||
function sxPlot(o){
|
||
const big=!!o.big,ml=big?44:2,mr=big?8:2,top=big?10:4,bot=big?36:24;
|
||
const px0=ml,px1=o.W-mr,base=o.H-bot,ph=base-top,span=o.b-o.a;
|
||
const X=f=>px0+(f/1e6-o.a)/span*(px1-px0),Y=db=>base-Math.min(1,Math.max(0,(db||0)/o.dmax))*ph;
|
||
const f2=v=>v.toFixed(1);
|
||
let g="";
|
||
// сетка: тонкая, еле заметная; у большого — ещё и горизонтали с подписями дБ
|
||
const want=(px1-px0)/(big?84:58),step=SX_STEPS.find(s=>span/s<=want)||20;
|
||
for(let i=Math.ceil(o.a/step-1e-9);i*step<=o.b+1e-9;i++){const t=i*step,x=X(t*1e6);
|
||
g+=`<line x1="${f2(x)}" x2="${f2(x)}" y1="${top}" y2="${base}" class="gl"/><text x="${f2(x)}" y="${o.H-(big?8:3)}" class="tl" text-anchor="${x<px0+14?"start":x>px1-14?"end":"middle"}">${+t.toFixed(3)}</text>`}
|
||
if(big){const ys=o.dmax<=30?5:10;for(let v=0;v<=o.dmax;v+=ys){const y=Y(v);
|
||
g+=`<line x1="${px0}" x2="${px1}" y1="${f2(y)}" y2="${f2(y)}" class="gl${v?"":" z"}"/><text x="${px0-5}" y="${f2(y+3.5)}" class="tl" text-anchor="end">${v}${v===o.dmax?" дБ":""}</text>`}}
|
||
else g+=`<line x1="${px0}" x2="${px1}" y1="${f2(Y(o.dmax/2))}" y2="${f2(Y(o.dmax/2))}" class="gl"/>`;
|
||
// зона зума на карточке
|
||
if(o.shade)g+=`<rect x="${f2(X(o.shade[0]*1e6))}" y="${top}" width="${Math.max(2,X(o.shade[1]*1e6)-X(o.shade[0]*1e6)).toFixed(1)}" height="${ph}" class="zs"/>`;
|
||
g+=`<line x1="${px0}" x2="${px1}" y1="${base}" y2="${base}" class="bl"/>`;
|
||
// метки: порядок рисования = несущие, широкие, активные, затем «в сканере» поверх
|
||
const xs=o.xs.filter(x=>x.f/1e6>=o.a&&x.f/1e6<=o.b);
|
||
const rank=x=>x.in_scan?3:isCar(x)?0:x.kind==="wide"?1:2;
|
||
for(const x of xs.slice().sort((p,q)=>rank(p)-rank(q))){
|
||
const c=srcCol(x),cx=X(x.f),y=Y(x.max_db),h=Math.max(3,base-y);
|
||
if(x.kind==="wide"){ // широкий / цифра: полупрозрачный прямоугольник шириной в полосу
|
||
const w=Math.max(3,(x.bw_khz||25)/1000/span*(px1-px0)),l=Math.max(px0,cx-w/2),r=Math.min(px1,cx+w/2);
|
||
g+=`<rect x="${f2(l)}" y="${f2(base-h)}" width="${Math.max(1,r-l).toFixed(1)}" height="${f2(h)}" fill="${c}" fill-opacity=".26"/><line x1="${f2(l)}" x2="${f2(r)}" y1="${f2(base-h)}" y2="${f2(base-h)}" stroke="${c}" stroke-width="1.5"/>`}
|
||
else if(isCar(x)){const hc=Math.min(h,ph*.32); // несущая/спур: короткий тонкий серый стебель
|
||
g+=`<line x1="${f2(cx)}" x2="${f2(cx)}" y1="${base}" y2="${f2(base-hc)}" stroke="${c}" stroke-width="1.5"/>`}
|
||
else g+=`<line x1="${f2(cx)}" x2="${f2(cx)}" y1="${base}" y2="${f2(base-h)}" stroke="${c}" stroke-width="2"/>`}
|
||
// охват сканера: ▲ под осью на каждый канал таблицы сканирования
|
||
let tri="";const ts=big?5.5:4.5;
|
||
for(const c of o.sc){if(c.f/1e6<o.a||c.f/1e6>o.b)continue;const x=X(c.f);tri+=`M${f2(x)} ${base+3}l${-ts/2} ${ts}h${ts}z`}
|
||
if(tri)g+=`<path d="${tri}" fill="${SCOL.scan}"/>`;
|
||
const P={o,xs,X,px0,px1,base,top,ph,inv:px=>(o.a+(px-px0)/(px1-px0)*span)};
|
||
const svg=`<svg width="${o.W}" height="${o.H}" viewBox="0 0 ${o.W} ${o.H}" class="sxs${big?" big":""}">${g}`+
|
||
`<g class="cur" style="display:none"><line y1="${top}" y2="${base}"/><circle r="4"/></g><rect class="brush" y="${top}" height="${ph}" style="display:none"/></svg>`;
|
||
return {svg,P}}
|
||
|
||
// --- tooltip (один HTML-элемент на странице, position:fixed — не обрезается) ---
|
||
let sxTipEl=null;
|
||
function sxHide(){if(sxTipEl)sxTipEl.style.display="none"}
|
||
function sxTip(html,cx,cy){if(!sxTipEl){sxTipEl=document.createElement("div");sxTipEl.id="sx-tip";document.body.appendChild(sxTipEl)}
|
||
const t=sxTipEl;t.innerHTML=html;t.style.display="block";const w=t.offsetWidth,h=t.offsetHeight;
|
||
t.style.left=Math.max(6,Math.min(innerWidth-w-6,cx+14))+"px";t.style.top=(cy+16+h>innerHeight?Math.max(6,cy-h-12):cy+16)+"px"}
|
||
const sxStatus=x=>x.in_scan?'<span class="ok">в сканере</span>':x.promoted?'<span class="q">в очереди</span>':"—";
|
||
function sxTipSrc(x,full){const act=x.act!=null?Math.round(x.act*100)+"%":"—";
|
||
const head=`<b>${(x.f/1e6).toFixed(4)} МГц</b><div class="t1">${esc(x.label||"без метки")}</div>`;
|
||
if(!full)return head+`<div class="t2">${esc(KIND[x.kind]||x.kind||"")}${x.max_db!=null?" · "+x.max_db.toFixed(0)+" дБ":""} · ${sxStatus(x)}</div>`;
|
||
return head+`<dl><dt>тип</dt><dd>${esc(KIND[x.kind]||x.kind||"—")}</dd><dt>модуляция</dt><dd>${esc((x.mod||"—").toUpperCase())}</dd>`+
|
||
`<dt>полоса</dt><dd>${x.bw_khz!=null?x.bw_khz+" кГц":"—"}</dd><dt>над шумом</dt><dd>${x.max_db!=null?x.max_db.toFixed(1)+" дБ":"—"}</dd>`+
|
||
`<dt>активность</dt><dd>${act}</dd><dt>прогонов</dt><dd>${x.runs||0}</dd><dt>последний раз</dt><dd>${fmtAgo(x.last)}</dd><dt>сканер</dt><dd>${sxStatus(x)}</dd></dl>`}
|
||
|
||
// --- наведение, выделение (зум), клик ---
|
||
function sxPick(P,px,py){ // ближайший источник по частоте (в пределах snap), либо ▲-канал при наведении под осью
|
||
if(P.o.big&&py>P.base+1){let best=null,bd=1e9;for(const c of P.o.sc){const d=Math.abs(P.X(c.f)-px);if(d<bd){bd=d;best=c}}return best&&bd<10?{ch:best}:null}
|
||
let best=null,bd=1e9;for(const x of P.xs){const d=Math.abs(P.X(x.f)-px)+(x.max_db||0)*1e-4*-1;if(d<bd){bd=d;best=x}}
|
||
return best&&Math.abs(P.X(best.f)-px)<=(P.o.big?22:14)?{x:best}:null}
|
||
function sxHover(P,e,px,py){const t=sxPick(P,px,py),cur=P.cur;
|
||
if(!t){cur.style.display="none";sxHide();return}
|
||
const ln=cur.querySelector("line"),ci=cur.querySelector("circle");cur.style.display="";
|
||
if(t.ch){const x=P.X(t.ch.f);ln.setAttribute("x1",x);ln.setAttribute("x2",x);ci.style.display="none";
|
||
sxTip(`<b>${(t.ch.f/1e6).toFixed(4)} МГц</b><div class="t1">${esc(t.ch.label||"")}</div><div class="t2">канал сканера${t.ch.group?" · "+esc(t.ch.group):""}</div>`,e.clientX,e.clientY);return}
|
||
const x=P.X(t.x.f);ln.setAttribute("x1",x);ln.setAttribute("x2",x);ci.style.display="";ci.setAttribute("cx",x);
|
||
ci.setAttribute("cy",P.base-Math.max(3,P.ph*Math.min(1,(t.x.max_db||0)/P.o.dmax)));
|
||
sxTip(sxTipSrc(t.x,P.o.big),e.clientX,e.clientY)}
|
||
function sxJump(f){const row=$("#src").querySelector(`.sx-r[data-f="${f}"]`);if(!row)return;
|
||
row.scrollIntoView({block:"center",behavior:"smooth"});row.classList.remove("hl");void row.offsetWidth;row.classList.add("hl");setTimeout(()=>row.classList.remove("hl"),2200)}
|
||
function sxSetZoom(a,b){const [ba,bb]=SBANDS[srcBand];a=Math.max(ba,a);b=Math.min(bb,b);
|
||
srcZoom=(b-a<(bb-ba)-1e-9)&&b-a>=0.01?{bi:srcBand,a,b}:null;renderSources()}
|
||
function sxAttach(el,P){const svg=el.firstChild;P.cur=svg.querySelector(".cur");P.brush=svg.querySelector(".brush");
|
||
const pos=e=>{const r=svg.getBoundingClientRect();return [e.clientX-r.left,e.clientY-r.top]};
|
||
svg.addEventListener("pointerleave",()=>{if(!P.drag){P.cur.style.display="none";sxHide()}});
|
||
if(!P.o.big){svg.addEventListener("pointermove",e=>{const [px,py]=pos(e);sxHover(P,e,px,py)});return}
|
||
let timer=0;
|
||
svg.addEventListener("pointerdown",e=>{const [px,py]=pos(e);sxHover(P,e,px,py);
|
||
if(e.pointerType==="touch"||e.button!==0)return;P.drag={x0:Math.max(P.px0,Math.min(P.px1,px)),x1:null};sxDrag=true;svg.setPointerCapture(e.pointerId)});
|
||
svg.addEventListener("pointermove",e=>{const [px,py]=pos(e);
|
||
if(P.drag){const x1=Math.max(P.px0,Math.min(P.px1,px));P.drag.x1=x1;const l=Math.min(P.drag.x0,x1),w=Math.abs(x1-P.drag.x0);
|
||
P.brush.setAttribute("x",l);P.brush.setAttribute("width",w);P.brush.style.display=w>3?"":"none";P.cur.style.display="none";sxHide();return}
|
||
sxHover(P,e,px,py)});
|
||
svg.addEventListener("pointerup",e=>{const d=P.drag;if(!d)return;P.drag=null;sxDrag=false;P.brush.style.display="none";
|
||
if(d.x1!=null&&Math.abs(d.x1-d.x0)>6){P.brushed=true;setTimeout(()=>P.brushed=false,60);
|
||
const f0=P.inv(Math.min(d.x0,d.x1)),f1=P.inv(Math.max(d.x0,d.x1));sxSetZoom(f0,f1)}});
|
||
svg.addEventListener("pointercancel",()=>{P.drag=null;sxDrag=false;P.brush.style.display="none"});
|
||
svg.addEventListener("click",e=>{if(P.brushed)return;const [px,py]=pos(e),t=sxPick(P,px,py);if(!t||!t.x)return;
|
||
clearTimeout(timer);timer=setTimeout(()=>sxJump(t.x.f),240)}); // ждём: двойной клик = сброс масштаба, а не прыжок
|
||
svg.addEventListener("dblclick",()=>{clearTimeout(timer);if(srcZoom){srcZoom=null;renderSources()}})}
|
||
function sxDraw(){sxPlots=[];sxHide();const E=$("#src");if(E.hidden)return;const narrow=matchMedia("(max-width:560px)").matches;
|
||
for(const el of E.querySelectorAll(".sx-plot")){const W=Math.floor(el.clientWidth);if(W<60)continue;
|
||
const big=el.dataset.big==="1",bi=big?srcBand:+el.dataset.b,[a,b]=SBANDS[bi],z=srcZoom&&srcZoom.bi===bi?srcZoom:null;
|
||
const {svg,P}=sxPlot({W,H:big?(narrow?190:220):(narrow?66:86),a:big&&z?z.a:a,b:big&&z?z.b:b,
|
||
xs:sxCtx.vis0.filter(x=>bandOf(x.f)===bi),sc:sxCtx.sc,dmax:sxCtx.dmax,big,shade:!big&&z?[z.a,z.b]:null});
|
||
el.innerHTML=svg;sxAttach(el,P);sxPlots.push(P)}}
|
||
// перерисовка при смене ширины (поворот экрана, окно, появление полосы прокрутки)
|
||
let sxRT=0,sxW=0;new ResizeObserver(()=>{const E=$("#src"),w=E.clientWidth;if(E.hidden||w===sxW)return;sxW=w;clearTimeout(sxRT);sxRT=setTimeout(sxDraw,100)}).observe($("#src"));
|
||
|
||
// --- легенда ---
|
||
function sxLegend(){const sw={
|
||
stem:c=>`<svg width="10" height="14"><line x1="5" x2="5" y1="1" y2="13" stroke="${c}" stroke-width="2"/></svg>`,
|
||
rect:`<svg width="16" height="14"><rect x="1" y="3" width="14" height="10" fill="${SCOL.wide}" fill-opacity=".26"/><line x1="1" x2="15" y1="3" y2="3" stroke="${SCOL.wide}" stroke-width="1.5"/></svg>`,
|
||
car:`<svg width="10" height="14"><line x1="5" x2="5" y1="7" y2="13" stroke="${SCOL.car}" stroke-width="1.5"/></svg>`,
|
||
tri:`<svg width="12" height="14"><path d="M6 4l-4.5 8h9z" fill="${SCOL.scan}"/></svg>`};
|
||
const it=(s,t)=>`<span class="lg">${s}${t}</span>`;
|
||
return `<div class="sx-leg">${it(sw.stem(SCOL.scan),"в сканере")}${it(sw.stem(SCOL.act),"активный")}${it(sw.rect,"широкий / цифра")}`+
|
||
(showCarriers?it(sw.car,"несущая"):"")+it(sw.tri,"канал сканера")+
|
||
`<span class="lg mut">высота = дБ над шумом (0–${sxCtx.dmax}, линейно)</span></div>`}
|
||
|
||
function renderSources(){const E=$("#src");sxHide();if(!srcData){E.innerHTML='<p class="empty">Нет данных.</p>';return}
|
||
const all=srcData.sources||[],vis0=all.filter(x=>showCarriers||x.kind==="active"||x.kind==="wide");
|
||
const sc=srcData.scan_channels||[],queued=srcData.queued||[],nAdded=(srcData.promoted||[]).length;
|
||
const nAct=all.filter(x=>x.kind==="active"||x.kind==="wide").length,nScan=all.filter(x=>x.in_scan).length,nQ=queued.length;
|
||
sxCtx={vis0,dmax:sxDbMax(all),sc};
|
||
if(srcZoom&&srcZoom.bi!==srcBand)srcZoom=null;
|
||
const z=srcBand!=null?srcZoom:null,inZ=x=>!z||(x.f/1e6>=z.a&&x.f/1e6<=z.b);
|
||
const vis=vis0.filter(x=>srcBand==null||(bandOf(x.f)===srcBand&&inZ(x)));
|
||
const sorters={last:(a,b)=>(b.last||0)-(a.last||0),db:(a,b)=>(b.max_db||0)-(a.max_db||0),act:(a,b)=>(b.act||0)-(a.act||0),f:(a,b)=>a.f-b.f};
|
||
vis.sort(sorters[srcSort]||sorters.last);
|
||
const qn=queued.slice(0,3).map(p=>esc((p.label||"").replace(/^\d+\.\d+\s*/,"")||(p.f/1e6).toFixed(4))).join(", ")+(nQ>3?" …":"");
|
||
let h=`<div class="sx-top">`+
|
||
`<div class="sx-t"><b>${nAct}</b><span>активных источников</span></div>`+
|
||
`<div class="sx-t"><b>${srcData.runs||0}</b><span>прогонов · последний ${fmtAgo(srcData.last_run)}</span></div>`+
|
||
`<div class="sx-t"><b>${nScan}</b><span>найденных уже в сканере · каналов в таблице: ${sc.length}</span></div>`+
|
||
`<div class="sx-t${nQ?" q":""}" title="promoted.json — все каналы, добавленные из найденных; уже работающие в сканере в очередь не входят"><b>${nQ}</b><span>${nQ?"в очереди на сканер: "+qn:"в очереди на сканер — пусто"}</span><span>добавлено из найденных: ${nAdded}</span></div>`+
|
||
`<p class="sx-note">Раз в час (в :07) сканер на ~35 с уступает HackRF панорамному обзору этих диапазонов.</p></div>`;
|
||
h+=sxLegend();
|
||
h+=`<div class="sx-spec">${SBANDS.map(([a,b,name,g],bi)=>`<button class="sx-band ${g}${srcBand===bi?" on":""}" data-b="${bi}"><div class="sb-h"><b>${name}</b><span>${a}–${b} МГц</span><em>${vis0.filter(x=>bandOf(x.f)===bi).length}</em></div><div class="sx-plot" data-b="${bi}"></div></button>`).join("")}</div>`;
|
||
h+=`<div class="sx-bar"><div class="sx-seg">`+
|
||
`<button data-b="" class="${srcBand==null?"on":""}">Все диапазоны <i>${vis0.length}</i></button>`+
|
||
SBANDS.map(([a,b,name],bi)=>`<button data-b="${bi}" class="${srcBand===bi?"on":""}">${name} <i>${vis0.filter(x=>bandOf(x.f)===bi).length}</i></button>`).join("")+`</div>`+
|
||
`<span class="sw ${showCarriers?"on":""}" id="sw-car"><i></i>несущие и непрерывные (${all.length-nAct})</span>`+
|
||
`<label class="sx-sort">сортировка <select id="sx-sort">${[["last","свежие"],["db","сильные"],["act","активные"],["f","по частоте"]].map(([k,v])=>`<option value="${k}"${srcSort===k?" selected":""}>${v}</option>`).join("")}</select></label></div>`;
|
||
if(srcBand!=null){const [a,b,name]=SBANDS[srcBand];
|
||
h+=`<div class="sx-det"><div class="sd-h"><b>${name}</b><span>${z?z.a.toFixed(3)+"–"+z.b.toFixed(3):a+"–"+b} МГц${z?" · масштаб, источников: "+vis.length:""}</span><span class="sp"></span>`+
|
||
`<button id="sx-zm" title="отдалить">−</button><button id="sx-zp" title="приблизить">+</button>${z?'<button id="sx-zr">сбросить масштаб</button>':""}</div>`+
|
||
`<div class="sx-plot" data-big="1"></div><p class="sd-hint">Выделите диапазон мышью — приблизить (двойной щелчок — сбросить); щелчок по источнику — перейти к строке списка. Выше ${sxCtx.dmax} дБ обрезается.</p></div>`}
|
||
if(!vis.length){h+='<p class="empty">В этом диапазоне пока ничего не найдено.</p>'}
|
||
else{h+=`<div class="sx-list"><div class="sx-hd"><span>частота</span><span>источник</span><span>тип</span><span>над шумом</span><span>активность</span><span>последний раз</span><span></span></div>`;
|
||
const groups=srcSort==="f"||srcBand!=null?[[srcBand,vis]]:SBANDS.map((_,bi)=>[bi,vis.filter(x=>bandOf(x.f)===bi)]).filter(g=>g[1].length);
|
||
for(const [bi,xs] of groups){
|
||
if(srcBand==null&&srcSort!=="f")h+=`<div class="sx-gh ${SBANDS[bi][3]}"><b>${SBANDS[bi][2]}</b><span>${SBANDS[bi][0]}–${SBANDS[bi][1]} МГц · ${xs.length}</span></div>`;
|
||
for(const x of xs){const db=x.max_db!=null?Math.max(2,Math.min(100,x.max_db/sxCtx.dmax*100)):0,act=x.act!=null?Math.round(x.act*100):null;
|
||
h+=`<div class="sx-r ${esc(x.kind)}" data-f="${x.f}"><span class="sf">${(x.f/1e6).toFixed(4)}</span>`+
|
||
`<span class="sl"><b>${esc(x.label||"без метки")}</b><i>${[(x.mod||"").toUpperCase(),x.bw_khz!=null?x.bw_khz+" кГц":"",x.runs?x.runs+" прогонов":""].filter(Boolean).join(" · ")}</i></span>`+
|
||
`<span class="sk"><em class="k ${esc(x.kind)}">${esc(KIND[x.kind]||x.kind)}</em></span>`+
|
||
`<span class="sm"><i><b style="width:${db}%"></b></i><em>${x.max_db!=null?x.max_db.toFixed(0)+" дБ":"—"}</em></span>`+
|
||
`<span class="sm a"><i><b style="width:${act||0}%"></b></i><em>${act!=null?act+"%":"—"}</em></span>`+
|
||
`<span class="st2" title="впервые ${fmtAgo(x.first)}">${fmtAgo(x.last)}</span>`+
|
||
`<span class="sa">${x.in_scan?'<span class="ok">в сканере</span>':x.promoted?'<span class="ok">в очереди</span>':(x.mod==="am"||x.mod==="nfm")?`<button class="pr" data-f="${x.f}">В сканер</button>`:""}</span></div>`}}
|
||
h+="</div>"}
|
||
E.innerHTML=h;sxDraw();
|
||
$("#sw-car").onclick=()=>{showCarriers=!showCarriers;LS.set("carriers",showCarriers);renderSources()};
|
||
for(const b of E.querySelectorAll(".sx-band,.sx-seg button"))b.onclick=()=>{const v=b.dataset.b===""?null:+b.dataset.b;srcBand=srcBand===v&&b.classList.contains("sx-band")?null:v;srcZoom=null;LS.set("srcBand",srcBand);renderSources()};
|
||
$("#sx-sort").onchange=e=>{srcSort=e.target.value;LS.set("srcSort",srcSort);renderSources()};
|
||
if(srcBand!=null){const [a,b]=SBANDS[srcBand],za=z?z.a:a,zb=z?z.b:b,c=(za+zb)/2,s=zb-za;
|
||
$("#sx-zp").onclick=()=>sxSetZoom(c-s/4,c+s/4);$("#sx-zm").onclick=()=>sxSetZoom(c-s,c+s);
|
||
if(z)$("#sx-zr").onclick=()=>{srcZoom=null;renderSources()}}
|
||
for(const b of E.querySelectorAll("button.pr"))b.onclick=async()=>{b.disabled=true;
|
||
try{const r=await fetch("/api/promote",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({f:+b.dataset.f})});
|
||
const j=await r.json();b.outerHTML=`<span class="ok">${esc(j.msg||"")}</span>`;setTimeout(loadSources,2500)}catch(e){b.disabled=false}}}
|