GroundingPI / assets /console.js
FanLR's picture
Restore original English demo UI exactly
019a613 verified
Raw History Blame Contribute Delete
17.3 kB
() => {
// Progressive enhancement: inference and view controls work without this script.
window.groundingConsoleCleanup?.();
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
const abort = new AbortController();
const listen = (el, event, fn) => el.addEventListener(event, fn, { signal: abort.signal });
let observer, resize, frame, interval, selected, targetRoot, image, lastSrc, lastDraw;
let waiting = false, waitStart = 0, ready = false, watchdog, instanceIndex = 0;
let clientPending = false, previousRunId = '', clientNotice;
let zoom = 1, panX = 0, panY = 0, drag, moved = false;
const $ = s => document.querySelector(s);
const svg = (tag, attrs = {}) => {
const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
Object.entries(attrs).forEach(([name, value]) => el.setAttribute(name, String(value)));
return el;
};
function removeLayer() {
$('#stage-image .focus-layer')?.remove();
$('#stage-image .focus-pixels')?.remove();
$('#canvas-viewport .focus-caption')?.remove();
lastDraw = null;
}
function clearSelection() {
selected = null;
instanceIndex = 0;
document.querySelectorAll('.target-chip[aria-pressed=true]').forEach(b => b.setAttribute('aria-pressed', 'false'));
const clear = $('.clear-focus');
if (clear) clear.hidden = true;
removeLayer();
const detail = $('.evidence-detail'); if (detail) detail.hidden = true;
}
function showDetail(coords) {
const detail = $('.evidence-detail'), preview = $('.detail-image');
if (!detail || !preview || !image?.naturalWidth) return;
detail.hidden = false;
const p = coords[instanceIndex];
const box = p.length === 4 ? p : [Math.max(0,p[0]-65), Math.max(0,p[1]-65), Math.min(999,p[0]+65), Math.min(999,p[1]+65)];
const iw = image.naturalWidth, ih = image.naturalHeight;
const cx = (box[0]+box[2])/1998*iw, cy = (box[1]+box[3])/1998*ih;
const cw = Math.max(32,(box[2]-box[0])/999*iw*1.3), ch = Math.max(32,(box[3]-box[1])/999*ih*1.3);
const scale = Math.min(preview.clientWidth/cw, preview.clientHeight/ch, 8);
const source = $('.focus-source');
preview.style.backgroundImage = `url(${JSON.stringify(source?.src || image.currentSrc || image.src)})`;
preview.style.backgroundSize = `${iw*scale}px ${ih*scale}px`;
preview.style.backgroundPosition = `${preview.clientWidth/2-cx*scale}px ${preview.clientHeight/2-cy*scale}px`;
$('.detail-coordinates').textContent = `Instance ${instanceIndex + 1} / ${coords.length} · [${p.join(', ')}]`;
}
function canvasTransform() {
const stage = $('#stage-image'); if (!stage) return;
const maxX = stage.clientWidth*(zoom-1)/2, maxY = stage.clientHeight*(zoom-1)/2;
panX = Math.max(-maxX,Math.min(maxX,panX)); panY = Math.max(-maxY,Math.min(maxY,panY));
stage.style.transform = `translate(${panX}px,${panY}px) scale(${zoom})`;
$('#canvas-viewport')?.classList.toggle('is-zoomed',zoom>1);
const value = $('.zoom-value'); if (value) value.textContent = `${Math.round(zoom*100)}%`;
const out = $('[data-canvas=out]'), up = $('[data-canvas=in]');
if (out) out.disabled = zoom<=1; if (up) up.disabled = zoom>=4;
}
function resetCanvas() { zoom=1; panX=panY=0; canvasTransform(); }
function drawSelection() {
const stage = $('#stage-image');
if (!selected?.isConnected || !image?.complete || !image.naturalWidth || waiting) return;
let coords;
try { coords = JSON.parse(selected.dataset.coordinates); } catch { clearSelection(); return; }
if (!Array.isArray(coords) || !coords.length || !coords.every(p => [2, 4].includes(p.length) && p.every(n => Number.isFinite(n) && n >= 0 && n <= 999))) {
clearSelection(); return;
}
const rect = image.getBoundingClientRect(), outer = stage.getBoundingClientRect();
const matrix = getComputedStyle(stage).transform;
const zoomNow = matrix === 'none' ? 1 : new DOMMatrixReadOnly(matrix).a;
const scale = Math.min(rect.width / zoomNow / image.naturalWidth, rect.height / zoomNow / image.naturalHeight);
const w = image.naturalWidth * scale, h = image.naturalHeight * scale;
if (w <= 0 || h <= 0) return;
const left = (rect.left - outer.left) / zoomNow + (rect.width / zoomNow - w) / 2;
const top = (rect.top - outer.top) / zoomNow + (rect.height / zoomNow - h) / 2;
instanceIndex = Math.max(0, Math.min(instanceIndex, coords.length - 1));
const signature = JSON.stringify([selected.dataset.label, instanceIndex, coords, image.currentSrc, left, top, w, h]);
if (signature === lastDraw) return;
const activeAction = document.activeElement?.closest('.focus-caption button')?.dataset.instance;
removeLayer(); lastDraw = signature;
const source = $('.focus-source');
if (source) {
const pixels = document.createElement('img');
pixels.className = 'focus-pixels'; pixels.alt = ''; pixels.setAttribute('aria-hidden', 'true'); pixels.src = source.src;
Object.assign(pixels.style, {left: `${left}px`, top: `${top}px`, width: `${w}px`, height: `${h}px`});
stage.append(pixels);
}
const layer = svg('svg', { viewBox: `0 0 ${w} ${h}`, 'aria-hidden': 'true', class: 'focus-layer' });
Object.assign(layer.style, { left: `${left}px`, top: `${top}px`, width: `${w}px`, height: `${h}px` });
const defs = svg('defs'), mask = svg('mask', { id: 'grounding-focus-mask', maskUnits: 'userSpaceOnUse', x: 0, y: 0, width: w, height: h });
mask.append(svg('rect', { width: w, height: h, fill: 'white' }));
const marks = [];
for (const p of [coords[instanceIndex]]) {
const attr = p.length === 4
? { x: p[0] / 999 * w, y: p[1] / 999 * h, width: (p[2] - p[0]) / 999 * w, height: (p[3] - p[1]) / 999 * h, rx: 2 }
: { cx: p[0] / 999 * w, cy: p[1] / 999 * h, r: 13 };
const tag = p.length === 4 ? 'rect' : 'circle';
mask.append(svg(tag, { ...attr, fill: 'black' }));
const outline = svg(tag, { ...attr, class: 'focus-outline', pathLength: 1 });
outline.style.stroke = getComputedStyle(selected.querySelector('i')).backgroundColor;
marks.push(svg(tag, { ...attr, class: 'focus-halo' }), outline);
}
defs.append(mask); layer.append(defs);
layer.append(svg('rect', { width: w, height: h, mask: 'url(#grounding-focus-mask)', class: 'focus-dimmer' }), ...marks);
const caption = document.createElement('div'); caption.className = 'focus-caption';
const label = document.createElement('span');
label.setAttribute('role', 'status'); label.setAttribute('aria-live', 'polite');
const evidence = 'Prediction';
label.textContent = `${evidence} · ${selected.dataset.label} · ${instanceIndex + 1} / ${coords.length}`;
label.title = label.textContent;
caption.append(label);
for (const [action, name, path] of [['prev', 'Previous instance', 'm12 5-5 5 5 5'], ['next', 'Next instance', 'm8 5 5 5-5 5']]) {
const button = document.createElement('button'); button.type = 'button'; button.dataset.instance = action;
button.setAttribute('aria-label', name); button.title = name; button.disabled = coords.length < 2;
const icon = svg('svg', {viewBox: '0 0 20 20', width: 16, height: 16, fill: 'none', 'aria-hidden': 'true'});
icon.append(svg('path', {d: path, stroke: 'currentColor', 'stroke-width': 1.7}));
button.append(icon); caption.append(button);
}
const close = document.createElement('button'); close.type = 'button'; close.className = 'focus-close'; close.setAttribute('aria-label', 'Close target focus');
const icon = svg('svg', { viewBox: '0 0 20 20', width: 16, height: 16, fill: 'none', 'aria-hidden': 'true' });
icon.append(svg('path', { d: 'm6 6 8 8m0-8-8 8', stroke: 'currentColor', 'stroke-width': 1.7, 'stroke-linecap': 'round' }));
close.title = 'Close focus and return to targets';
close.append(icon); caption.append(close); stage.append(layer); $('#canvas-viewport').append(caption);
if (activeAction) caption.querySelector(`[data-instance=${activeAction}]`)?.focus({preventScroll: true});
showDetail(coords);
}
function tick() {
if (document.hidden || !waiting) return;
const clock = $('.wait-clock');
if (clock) clock.textContent = `${((performance.now() - waitStart) / 1000).toFixed(1)} s`;
}
function setWaiting(value) {
if (value === waiting) return;
waiting = value;
$('#console').setAttribute('aria-busy', String(value));
for (const button of document.querySelectorAll('#run-button,#mobile-run-button')) {
button.disabled = value;
button.classList.toggle('is-waiting', value);
if (value) button.setAttribute('aria-label', 'Grounding in progress');
else button.removeAttribute('aria-label');
}
for (const id of ['controls', 'sandbox-panel', 'query-dock', 'upload-button', 'canvas-chrome']) {
const el = $(`#${id}`); if (el) el.inert = value;
}
clearInterval(interval);
if (!value) clearTimeout(watchdog);
if (value) { clearSelection(); resetCanvas(); waitStart = performance.now(); interval = setInterval(tick, 100); }
}
function clientStatus(text, phase) {
const node = document.createElement('div');
node.className = `stage-status ${phase}`; node.dataset.phase = phase;
node.setAttribute('role', 'status'); node.setAttribute('aria-live', 'polite');
const dot = document.createElement('i'); dot.setAttribute('aria-hidden', 'true');
const label = document.createElement('span'); label.textContent = text;
node.append(dot, label);
if (phase === 'busy') {
const clock = document.createElement('time'); clock.className = 'wait-clock';
clock.setAttribute('aria-hidden', 'true'); clock.textContent = '0.0 s'; node.append(clock);
}
// This node belongs to us, outside Gradio's HTML component. Never replace
// Svelte-owned children: doing so can leave an orphan busy status forever.
clientNotice?.remove(); clientNotice = node;
node.classList.add('client-notice');
$('#stage-toolbar').append(node);
$('#stage-toolbar').classList.add('client-pending');
}
// A reverse proxy can buffer the first SSE event. Acknowledge the actual click
// immediately; backend events still own final results and measured latency.
window.groundingConsoleBegin = () => {
if (!ready || waiting) return;
previousRunId = $('#stage-status .stage-status')?.dataset.runId || '';
clientPending = true;
clientStatus('Request submitted · Awaiting inference', 'busy'); setWaiting(true);
watchdog = setTimeout(() => {
if (!waiting) return;
clientStatus('No result received. Check the connection and refresh. The server request has not been cancelled.', 'error');
setWaiting(false);
}, 330000);
};
function schedule() {
if (frame) return;
frame = requestAnimationFrame(() => { frame = null; reconcile(); });
}
function reconcile() {
const stage = $('#stage-image'), targets = $('#targets-content');
if (!stage || !targets) return;
const server = $('#stage-status .stage-status');
if (clientPending && (server?.dataset.runId || '') !== previousRunId) {
clientPending = false;
clientNotice?.remove(); clientNotice = null;
$('#stage-toolbar').classList.remove('client-pending');
}
if (!clientPending) setWaiting(server?.dataset.phase === 'busy');
const chips = targets.querySelector('.target-chips');
if (targetRoot !== chips) { targetRoot = chips; clearSelection(); }
const nextImage = stage.querySelector('img');
if (image !== nextImage) { image = nextImage; if (image) listen(image, 'load', schedule); }
const src = image?.currentSrc || image?.src;
const download = $('.canvas-download');
if (download && src) download.href = src;
if (src !== lastSrc) {
clearSelection(); resetCanvas(); lastSrc = src;
}
const dimensions = $('.image-dimensions');
if (dimensions && image?.naturalWidth) dimensions.textContent = `${image.naturalWidth} × ${image.naturalHeight} px${zoom>1 ? ' / drag to pan' : ''}`;
drawSelection();
}
function onClick(event) {
// The native click has already reached Gradio's button handler. Keep its
// State inputs untouched; only enhance presentation during network latency.
if (event.target.closest?.('#run-button,#mobile-run-button')) window.groundingConsoleBegin?.();
const instance = event.target.closest?.('[data-instance]')?.dataset.instance;
if (instance && selected && !waiting) {
const count = JSON.parse(selected.dataset.coordinates).length;
instanceIndex = (instanceIndex + (instance === 'next' ? 1 : -1) + count) % count;
drawSelection(); return;
}
const action = event.target.closest?.('[data-canvas]')?.dataset.canvas;
if (action && !waiting) {
if (action === 'fit') resetCanvas();
else { zoom=Math.max(1,Math.min(4,zoom+(action==='in'?.5:-.5))); canvasTransform(); }
schedule(); return;
}
const chip = event.target.closest?.('#targets-content .target-chip');
if (chip && !waiting) {
const wasSelected = chip === selected;
clearSelection();
if (!wasSelected) {
selected = chip; chip.setAttribute('aria-pressed', 'true'); $('.clear-focus').hidden = false; drawSelection();
const bounds = $('#canvas-viewport').getBoundingClientRect();
if (bounds.top < 0 || bounds.bottom > innerHeight) {
$('#canvas-viewport').scrollIntoView({block: 'center', behavior: reduced.matches ? 'instant' : 'smooth'});
$('.focus-close')?.focus({preventScroll: true});
}
}
} else if (event.target.closest?.('.clear-focus,.focus-close')) {
const previous = selected; clearSelection(); previous?.focus();
}
// Select an actual returned box directly on the image; no invented geometry.
if (!waiting && !moved && event.target === image) {
const rect=image.getBoundingClientRect(), scale=Math.min(rect.width/image.naturalWidth,rect.height/image.naturalHeight);
const w=image.naturalWidth*scale, h=image.naturalHeight*scale;
const x=(event.clientX-rect.left-(rect.width-w)/2)/w*999, y=(event.clientY-rect.top-(rect.height-h)/2)/h*999;
const hits=[];
document.querySelectorAll('.target-chip').forEach(chip=>{
let coords; try{coords=JSON.parse(chip.dataset.coordinates);}catch{return;}
coords.forEach((p,index)=>{
if (p.length===4 && x>=p[0] && x<=p[2] && y>=p[1] && y<=p[3]) hits.push({chip,index,size:(p[2]-p[0])*(p[3]-p[1])});
if (p.length===2 && Math.hypot(x-p[0],y-p[1])<20) hits.push({chip,index,size:0});
});
});
const hit=hits.sort((a,b)=>a.size-b.size)[0];
if(hit){clearSelection();selected=hit.chip;instanceIndex=hit.index;selected.setAttribute('aria-pressed','true');$('.clear-focus').hidden=false;drawSelection();$('.focus-close')?.focus({preventScroll:true});}
}
moved=false;
}
function init() {
if (ready || !$('#console') || !$('#targets-content') || !$('#stage-status')) return;
ready = true; observer.disconnect();
listen(document, 'click', onClick);
listen(document, 'change', schedule);
listen(document, 'keydown', e => { if (e.key === 'Escape') {const previous=selected;clearSelection();resetCanvas();previous?.focus();} });
const viewport=$('#canvas-viewport');
listen(viewport,'pointerdown',e=>{
moved=false;
if(zoom<=1 || waiting || e.button!==0 || e.target.closest('button') || !e.target.closest('#stage-image')) return;
drag={x:e.clientX,y:e.clientY,px:panX,py:panY}; viewport.setPointerCapture(e.pointerId); viewport.classList.add('is-panning'); e.preventDefault();
});
listen(viewport,'pointermove',e=>{
if(!drag)return;
const dx=e.clientX-drag.x,dy=e.clientY-drag.y; moved ||= Math.hypot(dx,dy)>4;
panX=drag.px+dx;panY=drag.py+dy;canvasTransform();
});
const endDrag=()=>{drag=null;viewport.classList.remove('is-panning');};
listen(viewport,'pointerup',endDrag);listen(viewport,'pointercancel',endDrag);
listen(viewport,'dragstart',e=>{if(zoom>1)e.preventDefault();});
listen(document, 'visibilitychange', () => { $('#console')?.classList.toggle('tab-hidden', document.hidden); tick(); });
observer = new MutationObserver(records => {
if (records.some(r => !r.target.closest?.('.focus-layer,.focus-caption,.wait-clock'))) schedule();
});
for (const id of ['stage-image', 'targets-content', 'stage-status']) observer.observe($(`#${id}`), { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
resize = new ResizeObserver(schedule); resize.observe($('#stage-image')); resize.observe($('#controls')); reconcile();
document.fonts.ready.then(schedule);
}
observer = new MutationObserver(init); observer.observe(document.body, { childList: true, subtree: true }); init();
window.groundingConsoleCleanup = () => {
abort.abort(); observer?.disconnect(); resize?.disconnect(); cancelAnimationFrame(frame); clearInterval(interval); clearTimeout(watchdog);
if (ready) setWaiting(false); removeLayer();
clientNotice?.remove(); $('#stage-toolbar')?.classList.remove('client-pending');
delete window.groundingConsoleBegin;
};
}