MediaWiki:Gadget-LabelScan.js: Unterschied zwischen den Versionen
Erscheinungsbild
Admin (Diskussion | Beiträge) Keine Bearbeitungszusammenfassung Markierung: Manuelle Zurücksetzung |
Admin (Diskussion | Beiträge) Keine Bearbeitungszusammenfassung |
||
| Zeile 3: | Zeile 3: | ||
'use strict'; | 'use strict'; | ||
// -------- | // -------- Config -------- | ||
const CFG = { | const CFG = { | ||
indexTitle: (window.LabelScanConfig && window.LabelScanConfig.indexTitle) || | indexTitle: (window.LabelScanConfig && window.LabelScanConfig.indexTitle) || | ||
'MediaWiki:Gadget-LabelScan-index.json', | 'MediaWiki:Gadget-LabelScan-index.json', | ||
// | topKShow: 8, // so viele Treffer anzeigen | ||
topKClip: 24, // so viele Kandidaten vor CLIP (per pHash oder einfach die ersten) | |||
// CLIP | maxSide: 1024, // Downscale lange Bildkante vorm CLIP | ||
transformersURL: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0', | transformersURL: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0/dist/transformers.min.js', | ||
modelId: 'Xenova/clip-vit-base-patch32', | modelId: 'Xenova/clip-vit-base-patch32', | ||
debug: true | debug: true | ||
}; | }; | ||
// -------- Utils -------- | |||
const $ = id => document.getElementById(id); | |||
const log = (...a) => { if (CFG.debug) console.log('[LabelScan]', ...a); }; | |||
const warn = (...a) => { if (CFG.debug) console.warn('[LabelScan]', ...a); }; | |||
const err = (...a) => console.error('[LabelScan]', ...a); | |||
function setStatus(t){ const el=$('ados-scan-status'); if(el) el.textContent=t||''; } | |||
function setProgress(p){ | |||
const bar=$('ados-scan-progress'); if(!bar) return; | |||
if(p==null){ bar.hidden=true; bar.value=0; } else { bar.hidden=false; bar.value=Math.max(0,Math.min(1,p)); } | |||
function setStatus( | |||
function setProgress(p) { | |||
const bar = | |||
if (p == null) { bar.hidden = true; bar.value = 0; } | |||
} | } | ||
function showPreview(file) { | function showPreview(file){ | ||
const url = URL.createObjectURL(file); | const url=URL.createObjectURL(file); | ||
const prev = | const prev=$('ados-scan-preview'); | ||
if (prev) { | if(prev){ | ||
prev.innerHTML = '<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px; | prev.innerHTML='<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px;">'; | ||
prev. | prev.querySelector('img').src=url; | ||
} | } | ||
} | } | ||
function esc(s){ return mw.html.escape(String(s||'')); } | |||
// -------- | // -------- Index laden -------- | ||
let INDEX=[], INDEX_EMB=[]; | |||
async function loadIndex(){ | |||
let INDEX = [] | if(INDEX.length) return INDEX; | ||
setStatus('Index laden …'); setProgress(0.03); | |||
const raw = mw.util.getUrl(CFG.indexTitle, { action:'raw', ctype:'application/json' }); | |||
async function loadIndex() { | const res = await fetch(raw, { cache:'reload' }); | ||
if (INDEX.length) return INDEX; | if(!res.ok) throw new Error('Index HTTP '+res.status); | ||
setStatus('Index laden …'); | const data = await res.json(); | ||
if(!Array.isArray(data)) throw new Error('Index ist kein Array'); | |||
INDEX = data.filter(x => x && x.title && x.thumb); | |||
INDEX_EMB = INDEX.map((it,i)=>{ | |||
const | if(typeof it.embed === 'string' && it.embed){ | ||
const res = await fetch( | try { return base64ToFloat32(it.embed); } catch(e){ warn('Embed decode', i, it.title, e); } | ||
if (!res.ok) throw new Error('Index | |||
const | |||
if (!Array.isArray( | |||
INDEX = | |||
INDEX_EMB = INDEX.map((it, i) => { | |||
if (typeof it.embed === 'string' && it.embed | |||
try { return base64ToFloat32(it.embed); } | |||
} | } | ||
return null; | return null; | ||
}); | }); | ||
log('Index geladen:', INDEX.length, 'Einträge'); | log('Index geladen:', INDEX.length, 'Einträge'); | ||
setProgress(0.06); | setProgress(0.06); | ||
| Zeile 78: | Zeile 58: | ||
} | } | ||
function base64ToFloat32(b64){ | |||
function base64ToFloat32(b64) { | const bin=atob(b64), buf=new ArrayBuffer(bin.length), u8=new Uint8Array(buf); | ||
const bin = atob(b64) | for(let i=0;i<bin.length;i++) u8[i]=bin.charCodeAt(i); | ||
for (let i=0;i< | |||
return new Float32Array(buf); | return new Float32Array(buf); | ||
} | } | ||
// ---------------- | // -------- pHash Helfer (optional) -------- | ||
function hexToBigInt(h){ try { return BigInt('0x'+String(h).trim()); } catch{ return null; } } | |||
function ham64(aHex,bHex){ | |||
const a=hexToBigInt(aHex), b=hexToBigInt(bHex); | |||
if(a===null||b===null) return 64; | |||
let x=a^b, d=0n; while(x){ d+=(x&1n); x>>=1n; } return Number(d); | |||
} | |||
function phashScore(a,b){ const d=ham64(a,b); return 1-(d/64); } // 1..0 | |||
setStatus('Modell laden …'); | // -------- CLIP laden -------- | ||
let _clipReady=null; | |||
async function ensureClip(){ | |||
if(_clipReady) return _clipReady; | |||
setStatus('Modell laden …'); setProgress(0.08); | |||
_clipReady = (async ()=>{ | |||
const mod = await import(/* webpackIgnore: true */ CFG.transformersURL); | |||
mod.env.localModelPath=null; | |||
mod.env.remoteModels=true; | |||
mod.env.allowRemoteModels=true; | |||
mod.env.useBrowserCache=true; | |||
const pipe = await mod.pipeline('image-feature-extraction', CFG.modelId, { quantized:true }); | |||
log('CLIP ready:', pipe.model?.constructor?.name||'unknown'); | |||
return { mod, pipe }; | |||
})(); | |||
return _clipReady; | |||
} | |||
// -------- Bild → Embedding -------- | |||
const | function fileToImage(file){ | ||
const | return new Promise((res,rej)=>{ | ||
const url=URL.createObjectURL(file); | |||
const img=new Image(); img.crossOrigin='anonymous'; | |||
img.onload=()=>{ URL.revokeObjectURL(url); res(img); }; | |||
img.onerror=e=>{ URL.revokeObjectURL(url); rej(e); }; | |||
img.src=url; | |||
}); | |||
} | |||
function urlToImage(url){ | |||
return new Promise((res,rej)=>{ | |||
const img=new Image(); img.crossOrigin='anonymous'; | |||
img.onload=()=>res(img); img.onerror=rej; img.src=url; | |||
}); | |||
} | |||
function toCanvas(img,maxSide){ | |||
const c=document.createElement('canvas'); | |||
let {width:w,height:h}=img; | |||
const s=Math.min(1, maxSide/Math.max(w,h)); w=Math.round(w*s); h=Math.round(h*s); | |||
c.width=w; c.height=h; c.getContext('2d').drawImage(img,0,0,w,h); | |||
return c; | |||
} | |||
function normalize(v){ let n=0; for(let i=0;i<v.length;i++) n+=v[i]*v[i]; n=Math.sqrt(n)||1; | |||
const out=new Float32Array(v.length); for(let i=0;i<v.length;i++) out[i]=v[i]/n; return out; } | |||
function cosine(a,b){ let s=0; const L=Math.min(a.length,b.length); for(let i=0;i<L;i++) s+=a[i]*b[i]; return s; } | |||
async function embedFile(file){ | |||
const { pipe } = await ensureClip(); | |||
setStatus('Bild vorbereiten …'); setProgress(0.20); | |||
const img = await fileToImage(file); | |||
const canvas = toCanvas(img, CFG.maxSide); | |||
setStatus('Bild analysieren …'); setProgress(0.38); | |||
const out = await pipe(canvas); | |||
const vec = out?.data instanceof Float32Array | |||
const pipe = await | ? out.data | ||
: new Float32Array(out?.data || out || []); | |||
return normalize(vec); | |||
} | |||
async function embedURL(url){ | |||
const { pipe } = await ensureClip(); | |||
const img = await urlToImage(url); | |||
const { pipe } = await | |||
const img = await | |||
const canvas = toCanvas(img, CFG.maxSide); | const canvas = toCanvas(img, CFG.maxSide); | ||
const out = await pipe(canvas); | |||
const vec = out?.data instanceof Float32Array | |||
? out.data | |||
: new Float32Array(out?.data || out || []); | |||
return normalize(vec); | |||
} | |||
// -------- Matching -------- | |||
async function matchImage(file){ | |||
await loadIndex(); | |||
showPreview(file); | |||
const | // 1) Query-Embedding | ||
const q = await embedFile(file); | |||
// | // 2) Kandidatenliste bestimmen | ||
// a) wenn Index pHash hat und du *auch* Upload-pHash hättest → vorfiltern. | |||
// (Wir haben keinen Upload-pHash → fallback: nimm die ersten N) | |||
// b) Oder wenn viele ohne Embed → nimm die ersten N | |||
let candidates = INDEX.map((it, i) => ({ i, it, p: (it.phash ? 0.5 : 0.5) })); | |||
// Leichte Sortierung: solche mit Embedding bevorzugen | |||
candidates.sort((a,b)=>{ | |||
const ae = INDEX_EMB[a.i] ? 1 : 0; | |||
const be = INDEX_EMB[b.i] ? 1 : 0; | |||
return be-ae; | |||
} | }); | ||
candidates = candidates.slice(0, Math.max(CFG.topKClip, CFG.topKShow)); | |||
// 3) Scoring: vorhandene Embeddings direkt; fehlende live aus Thumb | |||
setStatus('Kandidaten bewerten …'); setProgress(0.55); | |||
const | const scored=[]; | ||
let done=0; | |||
for ( | for(const c of candidates){ | ||
const | try{ | ||
const vec = INDEX_EMB[c.i] || await embedURL(c.it.thumb); | |||
const s = cosine(q, vec); | |||
scored.push({ i:c.i, score:s }); | |||
}catch(e){ | |||
// Thumb-Load-Fehler ignorieren | |||
}finally{ | |||
done++; setProgress(0.55 + 0.35*(done/candidates.length)); | |||
} | |||
} | } | ||
scored.sort((a,b)=> b.score-a.score); | |||
return scored.slice(0, CFG.topKShow); | |||
return | |||
} | } | ||
function renderResults(ranked) { | function renderResults(ranked){ | ||
const box = | const box=$('ados-scan-results'); if(!box) return; | ||
box.innerHTML=''; | |||
box.innerHTML = ''; | if(!ranked || !ranked.length){ | ||
box.innerHTML='<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder näher am Frontlabel.</div>'; | |||
if (!ranked || !ranked.length) { | |||
box.innerHTML = '<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder näher am Frontlabel.</div>'; | |||
return; | return; | ||
} | } | ||
ranked.forEach(({i,score})=>{ | |||
ranked.forEach(({ i, score }) => { | const it=INDEX[i]; const url=mw.util.getUrl((it.title||'').replace(/ /g,'_')); | ||
const it = INDEX[i]; | const div=document.createElement('div'); | ||
div.className='ados-hit'; | |||
const | div.style.display='grid'; | ||
div.style.gridTemplateColumns='60px 1fr auto'; | |||
div.style.gap='10px'; div.style.alignItems='center'; | |||
div.innerHTML = | |||
(it.thumb? `<img src="${it.thumb}" alt="" style="width:60px;height:auto;border-radius:6px;border:1px solid #eee;">` : '<div></div>') + | |||
`<div><b><a href="${url}">${esc(it.title||'')}</a></b></div>` + | |||
(thumb ? ` | |||
`<div><b><a href="${ | |||
`<div style="color:#666;font-variant-numeric:tabular-nums">${score.toFixed(3)}</div>`; | `<div style="color:#666;font-variant-numeric:tabular-nums">${score.toFixed(3)}</div>`; | ||
box.appendChild( | box.appendChild(div); | ||
}); | }); | ||
} | } | ||
// -------- UI binden -------- | |||
let BOUND=false; | |||
// -------- | function bind(){ | ||
if(BOUND) return; | |||
const btnCam=$('ados-scan-btn-camera'); | |||
let BOUND = false; | const btnGal=$('ados-scan-btn-gallery'); | ||
function | const inCam=$('ados-scan-file-camera'); | ||
if (BOUND) return; | const inGal=$('ados-scan-file-gallery'); | ||
const btnCam | const btnRun=$('ados-scan-run'); | ||
const btnGal | const btnReset=$('ados-scan-reset'); | ||
const inCam | const drop=$('ados-scan-drop'); | ||
const inGal | |||
const btnRun | |||
const btnReset= | |||
const drop | |||
if (!btnRun || !inCam || !inGal) return; | if(!btnRun || !inCam || !inGal){ warn('UI unvollständig'); return; } | ||
btnCam?.addEventListener('click', ()=> inCam.click()); | |||
btnGal?.addEventListener('click', ()=> inGal.click()); | |||
const onPick=e=>{ const f=e.target.files?.[0]; if(f) showPreview(f); }; | |||
inCam.addEventListener('change', onPick); | inCam.addEventListener('change', onPick); | ||
inGal.addEventListener('change', onPick); | inGal.addEventListener('change', onPick); | ||
if(drop){ | |||
if (drop) { | drop.addEventListener('dragover', e=>{ e.preventDefault(); drop.classList.add('is-over'); }); | ||
drop.addEventListener('dragover', | |||
drop.addEventListener('dragleave', ()=> drop.classList.remove('is-over')); | drop.addEventListener('dragleave', ()=> drop.classList.remove('is-over')); | ||
drop.addEventListener('drop', | drop.addEventListener('drop', e=>{ | ||
e.preventDefault(); drop.classList.remove('is-over'); | |||
const f = | const f=e.dataTransfer?.files?.[0]; | ||
if (f) { | if(f){ const dt=new DataTransfer(); dt.items.add(f); inGal.files=dt.files; showPreview(f); } | ||
}); | }); | ||
} | } | ||
btnReset?.addEventListener('click', ()=>{ | |||
inCam.value=''; inGal.value=''; | |||
const p = | const p=$('ados-scan-preview'); if(p) p.innerHTML='<div class="note">Noch keine Vorschau. Wähle ein Foto.</div>'; | ||
const r=$('ados-scan-results'); if(r) r.innerHTML='<div class="empty">Hier erscheinen passende Abfüllungen mit Link ins Wiki.</div>'; | |||
const r = | |||
setStatus('Bereit.'); setProgress(null); | setStatus('Bereit.'); setProgress(null); | ||
}); | }); | ||
btnRun.addEventListener('click', async ()=>{ | |||
btnRun.addEventListener('click', | try{ | ||
const f=inCam.files?.[0] || inGal.files?.[0]; | |||
if(!f){ alert('Bitte zuerst ein Foto aufnehmen oder auswählen.'); return; } | |||
btnRun.disabled=true; setStatus('Starte …'); setProgress(0.02); | |||
await loadIndex(); | |||
const ranked = await matchImage(f); | |||
renderResults(ranked); | |||
setStatus('Fertig.'); setProgress(null); | |||
}catch(e){ err(e); setStatus('Fehler bei Erkennung/Suche.'); setProgress(null); } | |||
finally{ btnRun.disabled=false; } | |||
}); | |||
BOUND = true; | BOUND=true; | ||
log('UI gebunden.'); | log('UI gebunden.'); | ||
} | } | ||
// -------- Init -------- | |||
function init(){ | |||
log('gadget file loaded'); | |||
if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', bind, {once:true}); } | |||
else { bind(); } | |||
setTimeout(bind,250); setTimeout(bind,1000); | |||
// Index vorwärmen | |||
// -------- | |||
function init() { | |||
if (document.readyState === 'loading') { | |||
setTimeout( | |||
// | |||
loadIndex().catch(err); | loadIndex().catch(err); | ||
} | } | ||
init(); | init(); | ||
})(); | })(); | ||
Version vom 8. November 2025, 18:45 Uhr
/* global mw */
(() => {
'use strict';
// -------- Config --------
const CFG = {
indexTitle: (window.LabelScanConfig && window.LabelScanConfig.indexTitle) ||
'MediaWiki:Gadget-LabelScan-index.json',
topKShow: 8, // so viele Treffer anzeigen
topKClip: 24, // so viele Kandidaten vor CLIP (per pHash oder einfach die ersten)
maxSide: 1024, // Downscale lange Bildkante vorm CLIP
transformersURL: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0/dist/transformers.min.js',
modelId: 'Xenova/clip-vit-base-patch32',
debug: true
};
// -------- Utils --------
const $ = id => document.getElementById(id);
const log = (...a) => { if (CFG.debug) console.log('[LabelScan]', ...a); };
const warn = (...a) => { if (CFG.debug) console.warn('[LabelScan]', ...a); };
const err = (...a) => console.error('[LabelScan]', ...a);
function setStatus(t){ const el=$('ados-scan-status'); if(el) el.textContent=t||''; }
function setProgress(p){
const bar=$('ados-scan-progress'); if(!bar) return;
if(p==null){ bar.hidden=true; bar.value=0; } else { bar.hidden=false; bar.value=Math.max(0,Math.min(1,p)); }
}
function showPreview(file){
const url=URL.createObjectURL(file);
const prev=$('ados-scan-preview');
if(prev){
prev.innerHTML='<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px;">';
prev.querySelector('img').src=url;
}
}
function esc(s){ return mw.html.escape(String(s||'')); }
// -------- Index laden --------
let INDEX=[], INDEX_EMB=[];
async function loadIndex(){
if(INDEX.length) return INDEX;
setStatus('Index laden …'); setProgress(0.03);
const raw = mw.util.getUrl(CFG.indexTitle, { action:'raw', ctype:'application/json' });
const res = await fetch(raw, { cache:'reload' });
if(!res.ok) throw new Error('Index HTTP '+res.status);
const data = await res.json();
if(!Array.isArray(data)) throw new Error('Index ist kein Array');
INDEX = data.filter(x => x && x.title && x.thumb);
INDEX_EMB = INDEX.map((it,i)=>{
if(typeof it.embed === 'string' && it.embed){
try { return base64ToFloat32(it.embed); } catch(e){ warn('Embed decode', i, it.title, e); }
}
return null;
});
log('Index geladen:', INDEX.length, 'Einträge');
setProgress(0.06);
return INDEX;
}
function base64ToFloat32(b64){
const bin=atob(b64), buf=new ArrayBuffer(bin.length), u8=new Uint8Array(buf);
for(let i=0;i<bin.length;i++) u8[i]=bin.charCodeAt(i);
return new Float32Array(buf);
}
// -------- pHash Helfer (optional) --------
function hexToBigInt(h){ try { return BigInt('0x'+String(h).trim()); } catch{ return null; } }
function ham64(aHex,bHex){
const a=hexToBigInt(aHex), b=hexToBigInt(bHex);
if(a===null||b===null) return 64;
let x=a^b, d=0n; while(x){ d+=(x&1n); x>>=1n; } return Number(d);
}
function phashScore(a,b){ const d=ham64(a,b); return 1-(d/64); } // 1..0
// -------- CLIP laden --------
let _clipReady=null;
async function ensureClip(){
if(_clipReady) return _clipReady;
setStatus('Modell laden …'); setProgress(0.08);
_clipReady = (async ()=>{
const mod = await import(/* webpackIgnore: true */ CFG.transformersURL);
mod.env.localModelPath=null;
mod.env.remoteModels=true;
mod.env.allowRemoteModels=true;
mod.env.useBrowserCache=true;
const pipe = await mod.pipeline('image-feature-extraction', CFG.modelId, { quantized:true });
log('CLIP ready:', pipe.model?.constructor?.name||'unknown');
return { mod, pipe };
})();
return _clipReady;
}
// -------- Bild → Embedding --------
function fileToImage(file){
return new Promise((res,rej)=>{
const url=URL.createObjectURL(file);
const img=new Image(); img.crossOrigin='anonymous';
img.onload=()=>{ URL.revokeObjectURL(url); res(img); };
img.onerror=e=>{ URL.revokeObjectURL(url); rej(e); };
img.src=url;
});
}
function urlToImage(url){
return new Promise((res,rej)=>{
const img=new Image(); img.crossOrigin='anonymous';
img.onload=()=>res(img); img.onerror=rej; img.src=url;
});
}
function toCanvas(img,maxSide){
const c=document.createElement('canvas');
let {width:w,height:h}=img;
const s=Math.min(1, maxSide/Math.max(w,h)); w=Math.round(w*s); h=Math.round(h*s);
c.width=w; c.height=h; c.getContext('2d').drawImage(img,0,0,w,h);
return c;
}
function normalize(v){ let n=0; for(let i=0;i<v.length;i++) n+=v[i]*v[i]; n=Math.sqrt(n)||1;
const out=new Float32Array(v.length); for(let i=0;i<v.length;i++) out[i]=v[i]/n; return out; }
function cosine(a,b){ let s=0; const L=Math.min(a.length,b.length); for(let i=0;i<L;i++) s+=a[i]*b[i]; return s; }
async function embedFile(file){
const { pipe } = await ensureClip();
setStatus('Bild vorbereiten …'); setProgress(0.20);
const img = await fileToImage(file);
const canvas = toCanvas(img, CFG.maxSide);
setStatus('Bild analysieren …'); setProgress(0.38);
const out = await pipe(canvas);
const vec = out?.data instanceof Float32Array
? out.data
: new Float32Array(out?.data || out || []);
return normalize(vec);
}
async function embedURL(url){
const { pipe } = await ensureClip();
const img = await urlToImage(url);
const canvas = toCanvas(img, CFG.maxSide);
const out = await pipe(canvas);
const vec = out?.data instanceof Float32Array
? out.data
: new Float32Array(out?.data || out || []);
return normalize(vec);
}
// -------- Matching --------
async function matchImage(file){
await loadIndex();
showPreview(file);
// 1) Query-Embedding
const q = await embedFile(file);
// 2) Kandidatenliste bestimmen
// a) wenn Index pHash hat und du *auch* Upload-pHash hättest → vorfiltern.
// (Wir haben keinen Upload-pHash → fallback: nimm die ersten N)
// b) Oder wenn viele ohne Embed → nimm die ersten N
let candidates = INDEX.map((it, i) => ({ i, it, p: (it.phash ? 0.5 : 0.5) }));
// Leichte Sortierung: solche mit Embedding bevorzugen
candidates.sort((a,b)=>{
const ae = INDEX_EMB[a.i] ? 1 : 0;
const be = INDEX_EMB[b.i] ? 1 : 0;
return be-ae;
});
candidates = candidates.slice(0, Math.max(CFG.topKClip, CFG.topKShow));
// 3) Scoring: vorhandene Embeddings direkt; fehlende live aus Thumb
setStatus('Kandidaten bewerten …'); setProgress(0.55);
const scored=[];
let done=0;
for(const c of candidates){
try{
const vec = INDEX_EMB[c.i] || await embedURL(c.it.thumb);
const s = cosine(q, vec);
scored.push({ i:c.i, score:s });
}catch(e){
// Thumb-Load-Fehler ignorieren
}finally{
done++; setProgress(0.55 + 0.35*(done/candidates.length));
}
}
scored.sort((a,b)=> b.score-a.score);
return scored.slice(0, CFG.topKShow);
}
function renderResults(ranked){
const box=$('ados-scan-results'); if(!box) return;
box.innerHTML='';
if(!ranked || !ranked.length){
box.innerHTML='<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder näher am Frontlabel.</div>';
return;
}
ranked.forEach(({i,score})=>{
const it=INDEX[i]; const url=mw.util.getUrl((it.title||'').replace(/ /g,'_'));
const div=document.createElement('div');
div.className='ados-hit';
div.style.display='grid';
div.style.gridTemplateColumns='60px 1fr auto';
div.style.gap='10px'; div.style.alignItems='center';
div.innerHTML =
(it.thumb? `<img src="${it.thumb}" alt="" style="width:60px;height:auto;border-radius:6px;border:1px solid #eee;">` : '<div></div>') +
`<div><b><a href="${url}">${esc(it.title||'')}</a></b></div>` +
`<div style="color:#666;font-variant-numeric:tabular-nums">${score.toFixed(3)}</div>`;
box.appendChild(div);
});
}
// -------- UI binden --------
let BOUND=false;
function bind(){
if(BOUND) return;
const btnCam=$('ados-scan-btn-camera');
const btnGal=$('ados-scan-btn-gallery');
const inCam=$('ados-scan-file-camera');
const inGal=$('ados-scan-file-gallery');
const btnRun=$('ados-scan-run');
const btnReset=$('ados-scan-reset');
const drop=$('ados-scan-drop');
if(!btnRun || !inCam || !inGal){ warn('UI unvollständig'); return; }
btnCam?.addEventListener('click', ()=> inCam.click());
btnGal?.addEventListener('click', ()=> inGal.click());
const onPick=e=>{ const f=e.target.files?.[0]; if(f) showPreview(f); };
inCam.addEventListener('change', onPick);
inGal.addEventListener('change', onPick);
if(drop){
drop.addEventListener('dragover', e=>{ e.preventDefault(); drop.classList.add('is-over'); });
drop.addEventListener('dragleave', ()=> drop.classList.remove('is-over'));
drop.addEventListener('drop', e=>{
e.preventDefault(); drop.classList.remove('is-over');
const f=e.dataTransfer?.files?.[0];
if(f){ const dt=new DataTransfer(); dt.items.add(f); inGal.files=dt.files; showPreview(f); }
});
}
btnReset?.addEventListener('click', ()=>{
inCam.value=''; inGal.value='';
const p=$('ados-scan-preview'); if(p) p.innerHTML='<div class="note">Noch keine Vorschau. Wähle ein Foto.</div>';
const r=$('ados-scan-results'); if(r) r.innerHTML='<div class="empty">Hier erscheinen passende Abfüllungen mit Link ins Wiki.</div>';
setStatus('Bereit.'); setProgress(null);
});
btnRun.addEventListener('click', async ()=>{
try{
const f=inCam.files?.[0] || inGal.files?.[0];
if(!f){ alert('Bitte zuerst ein Foto aufnehmen oder auswählen.'); return; }
btnRun.disabled=true; setStatus('Starte …'); setProgress(0.02);
await loadIndex();
const ranked = await matchImage(f);
renderResults(ranked);
setStatus('Fertig.'); setProgress(null);
}catch(e){ err(e); setStatus('Fehler bei Erkennung/Suche.'); setProgress(null); }
finally{ btnRun.disabled=false; }
});
BOUND=true;
log('UI gebunden.');
}
// -------- Init --------
function init(){
log('gadget file loaded');
if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', bind, {once:true}); }
else { bind(); }
setTimeout(bind,250); setTimeout(bind,1000);
// Index vorwärmen
loadIndex().catch(err);
}
init();
})();