const state = { modalities: [], groups: {}, selected: new Set(), }; const els = { form: document.getElementById("search-form"), q: document.getElementById("q"), modalities: document.getElementById("modalities"), board: document.getElementById("board"), statusTotal: document.getElementById("status-total"), statusMods: document.getElementById("status-mods"), statusTime: document.getElementById("status-time"), }; const COLUMN_ICONS = { "semantic-text": "✶", "bm25": "≣", "image": "▣", "video": "▶", }; async function loadModalities() { const r = await fetch("/api/modalities"); const data = await r.json(); state.modalities = data.modalities; state.groups = data.groups || {}; state.modalities.forEach((m) => state.selected.add(m)); renderPills(); } function renderPills() { els.modalities.innerHTML = ""; // group aliases first (e.g. `text`) Object.keys(state.groups).forEach((g) => { const members = state.groups[g]; const allOn = members.every((m) => state.selected.has(m)); const pill = makePill(g, allOn, true); pill.addEventListener("click", () => { const turnOn = !members.every((m) => state.selected.has(m)); members.forEach((m) => turnOn ? state.selected.add(m) : state.selected.delete(m), ); renderPills(); rerunIfQuery(); }); els.modalities.appendChild(pill); }); state.modalities.forEach((m) => { const pill = makePill(m, state.selected.has(m), false); pill.addEventListener("click", () => { if (state.selected.has(m)) state.selected.delete(m); else state.selected.add(m); renderPills(); rerunIfQuery(); }); els.modalities.appendChild(pill); }); } function rerunIfQuery() { if (els.q.value.trim()) runSearch(els.q.value); } function makePill(label, on, isGroup) { const el = document.createElement("button"); el.type = "button"; el.className = "pill" + (on ? " on" : "") + (isGroup ? " group" : ""); el.innerHTML = `${label}`; return el; } let inflight = null; // AbortController for the currently running search async function runSearch(query) { if (!query.trim()) { fadeOutBoard(); els.statusTotal.textContent = "—"; els.statusMods.textContent = "—"; els.statusTime.textContent = "—"; return; } const mods = [...state.selected]; if (mods.length === 0) { els.board.innerHTML = `
Pick at least one modality.
`; return; } // Cancel any in-flight searches so out-of-order responses can't clobber a newer one. if (inflight) inflight.abort(); inflight = new AbortController(); const myController = inflight; initBoard(mods); const t0 = performance.now(); let totalHits = 0; let done = 0; // Fan out one request per modality. Each column updates as soon as its // modality returns, so fast searchers (bm25) show before slow ones (CLIP). await Promise.all( mods.map(async (m) => { const url = new URL(`/api/search/${encodeURIComponent(m)}`, window.location.origin); url.searchParams.set("q", query); url.searchParams.set("k", "8"); let res; try { const r = await fetch(url, { signal: myController.signal }); res = await r.json(); } catch (e) { if (e.name === "AbortError") return; res = { modality: m, kind: "text", error: String(e), hits: [] }; } if (myController !== inflight) return; totalHits += (res.hits || []).length; done += 1; renderColumn(m, res); els.statusTotal.textContent = String(totalHits); els.statusTime.textContent = `${Math.round(performance.now() - t0)} ms${done < mods.length ? " …" : ""}`; }), ); } function fadeOutBoard() { withTransition(() => { els.board.innerHTML = ""; }); } function withTransition(mutate) { // Cross-fade old → new board state. View Transitions API where available, // direct render otherwise. if (document.startViewTransition) { document.startViewTransition(mutate); } else { mutate(); } } function initBoard(mods) { withTransition(() => { els.board.innerHTML = ""; mods.forEach((m) => { const col = makeColumn(m, "—"); col.dataset.modality = m; const body = document.createElement("div"); body.className = "column-body"; body.innerHTML = `
searching…
`; col.appendChild(body); els.board.appendChild(col); }); }); els.statusTotal.textContent = "0"; els.statusMods.textContent = String(mods.length); els.statusTime.textContent = "…"; } function renderColumn(modality, res) { const col = els.board.querySelector(`[data-modality="${cssEscape(modality)}"]`); if (!col) return; const body = col.querySelector(".column-body"); const count = col.querySelector(".column-count"); const hits = res.hits || []; count.textContent = hits.length; withTransition(() => { body.innerHTML = ""; if (res.error) { const err = document.createElement("div"); err.className = "empty"; err.textContent = `error: ${res.error}`; body.appendChild(err); return; } if (hits.length === 0) { const e = document.createElement("div"); e.className = "empty"; e.textContent = "no hits"; body.appendChild(e); return; } hits.forEach((h, i) => { // One dark card per column, like the highlighted card in the reference. const dark = i === 0 && hits.length > 1; const card = makeCard(h, res.kind, modality, dark); card.style.setProperty("--stagger", `${i * 35}ms`); body.appendChild(card); }); }); } function cssEscape(s) { return window.CSS && CSS.escape ? CSS.escape(s) : s.replace(/"/g, '\\"'); } function makeColumn(name, count) { const col = document.createElement("div"); col.className = "column"; const head = document.createElement("div"); head.className = "column-head"; head.innerHTML = `
${name}
${count} `; col.appendChild(head); return col; } function makeCard(hit, kind, modality, dark) { const card = document.createElement("div"); card.className = "card" + (dark ? " dark" : ""); // Video meta entries are stored as " @ s" — split them so the // file request uses the real path and the player can seek to that frame. const rawPath = hit.path; let filePath = rawPath; let timestamp = null; if (kind === "video") { const m = rawPath.match(/^(.*) @ (\d+(?:\.\d+)?)s$/); if (m) { filePath = m[1]; timestamp = parseFloat(m[2]); } } const name = rawPath.split("/").pop(); const parent = filePath.replace(/\/[^/]+$/, ""); if (kind === "text" && hit.excerpt) { card.appendChild(makeExcerpt(hit.excerpt)); } if (kind === "image") { const thumb = document.createElement("a"); thumb.className = "thumb"; thumb.href = fileUrl(filePath); thumb.target = "_blank"; thumb.rel = "noopener"; const img = document.createElement("img"); img.loading = "lazy"; img.alt = name; img.addEventListener("load", () => img.classList.add("loaded")); img.src = fileUrl(filePath); thumb.appendChild(img); card.appendChild(thumb); } else if (kind === "video") { const thumb = document.createElement("a"); thumb.className = "thumb"; const frag = timestamp != null ? `#t=${timestamp}` : ""; thumb.href = fileUrl(filePath) + frag; thumb.target = "_blank"; thumb.rel = "noopener"; const v = document.createElement("video"); v.muted = true; v.preload = "auto"; if (timestamp != null) { v.addEventListener("loadedmetadata", () => { try { v.currentTime = timestamp; } catch (_) {} }); v.addEventListener("seeked", () => v.classList.add("loaded")); } else { v.addEventListener("loadeddata", () => v.classList.add("loaded")); } v.src = fileUrl(filePath) + frag; thumb.appendChild(v); card.appendChild(thumb); } const nameEl = document.createElement("a"); nameEl.className = "card-name"; nameEl.href = fileUrl(filePath); nameEl.target = "_blank"; nameEl.rel = "noopener"; nameEl.style.color = "inherit"; nameEl.style.textDecoration = "none"; nameEl.textContent = name; card.appendChild(nameEl); const sub = document.createElement("div"); sub.className = "card-sub"; sub.textContent = parent; card.appendChild(sub); const meta = document.createElement("div"); meta.className = "card-meta"; const chip = document.createElement("span"); chip.className = "chip"; chip.textContent = modality; const score = document.createElement("span"); score.className = "score"; score.textContent = formatScore(hit.score); meta.appendChild(chip); meta.appendChild(score); card.appendChild(meta); return card; } function makeExcerpt({ before, match, after }) { // Three text spans: plain context, yellow-highlighted match, plain context. // The .match span is the only part the modality actually matched on. const pre = document.createElement("pre"); pre.className = "excerpt"; const addText = (text, cls) => { if (!text) return null; const span = document.createElement("span"); if (cls) span.className = cls; span.textContent = text; pre.appendChild(span); return span; }; if (before) { addText(before, ""); addText("\n", ""); } const matchSpan = addText(match || "", "match"); if (after) { addText("\n", ""); addText(after, ""); } // After layout, scroll the highlighted match into the visible window so it // never sits behind the top/bottom mask-fade. Runs on rAF because the // element hasn't been laid out yet at this point. if (matchSpan) { requestAnimationFrame(() => { const preRect = pre.getBoundingClientRect(); const matchRect = matchSpan.getBoundingClientRect(); const relTop = matchRect.top - preRect.top + pre.scrollTop; const target = relTop - pre.clientHeight / 2 + matchRect.height / 2; pre.scrollTop = Math.max(0, target); }); } return pre; } function fileUrl(path) { return `/api/file?path=${encodeURIComponent(path)}`; } function formatScore(s) { if (s == null || Number.isNaN(s)) return "—"; // dense models return ~[-1, 1]; bm25 can be much larger return Math.abs(s) >= 10 ? s.toFixed(1) : s.toFixed(3); } // Debounced live search: fire 500ms after the last keystroke. Submit/Enter // bypasses the debounce. const DEBOUNCE_MS = 500; let debounceTimer = null; function scheduleSearch() { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { debounceTimer = null; runSearch(els.q.value); }, DEBOUNCE_MS); } els.q.addEventListener("input", scheduleSearch); els.form.addEventListener("submit", (e) => { e.preventDefault(); if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } runSearch(els.q.value); }); loadModalities();