Files

185 lines
20 KiB
JavaScript
Raw Permalink 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.
// ---- найденные источники ----
// Вкладка: плитки, легенда, 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}}}