timchen0618's picture
Initial: Open-WikiTable test split viewer (6,602 qids)
2da3a94 verified
Raw
History Blame Contribute Delete
16.1 kB
// Open-WikiTable viewer β€” static, single-page.
// State machine: load index.json β†’ render sidebar β†’ on click/nav fetch records/<qid>.json
// and render question + SQL + tables panel.
(function () {
'use strict';
// ── State ─────────────────────────────────────────────────────────
const state = {
index: [], // [{qid, dataset, question, n_hard, n_pos, n_neg}]
filtered: [], // indices into state.index
selectedQid: null,
datasetFilter: 'all',
search: '',
cache: new Map(), // qid -> record JSON
activeBucket: 'hard_positive',
};
// ── DOM ────────────────────────────────────────────────────────────
const dom = {
list: document.getElementById('qidList'),
main: document.getElementById('mainPane'),
search: document.getElementById('searchBox'),
filter: document.getElementById('datasetFilter'),
prev: document.getElementById('prevBtn'),
next: document.getElementById('nextBtn'),
counter: document.getElementById('counter'),
};
// ── Utils ──────────────────────────────────────────────────────────
const escapeHtml = (s) =>
String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const debounce = (fn, ms) => {
let t;
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
};
// Light SQL highlight β€” keywords / strings / numbers / functions only.
const SQL_KEYWORDS = new Set([
'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'NOT', 'IN', 'AS', 'ON', 'JOIN',
'INNER', 'LEFT', 'RIGHT', 'OUTER', 'GROUP', 'BY', 'ORDER', 'HAVING',
'LIMIT', 'OFFSET', 'DISTINCT', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END',
'IS', 'NULL', 'LIKE', 'BETWEEN', 'ASC', 'DESC', 'UNION', 'ALL', 'EXISTS',
'WITH',
]);
const SQL_FUNCS = new Set([
'COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'ROUND', 'ABS', 'COALESCE',
'LOWER', 'UPPER', 'LENGTH',
]);
function highlightSql(sql) {
// Tokenize: strings, comments are rare. Match string-literal, number, word.
const parts = [];
const re = /('[^']*'|"[^"]*"|\b\d+(?:\.\d+)?\b|[A-Za-z_][A-Za-z_0-9]*|[\s\S])/g;
let m;
while ((m = re.exec(sql)) !== null) {
const tok = m[0];
if (/^['"]/.test(tok)) {
parts.push(`<span class="sql-str">${escapeHtml(tok)}</span>`);
} else if (/^\d/.test(tok)) {
parts.push(`<span class="sql-num">${escapeHtml(tok)}</span>`);
} else if (/^[A-Za-z_]/.test(tok)) {
const upper = tok.toUpperCase();
if (SQL_KEYWORDS.has(upper)) {
parts.push(`<span class="sql-kw">${escapeHtml(tok)}</span>`);
} else if (SQL_FUNCS.has(upper)) {
parts.push(`<span class="sql-fn">${escapeHtml(tok)}</span>`);
} else {
parts.push(escapeHtml(tok));
}
} else {
parts.push(escapeHtml(tok));
}
}
return parts.join('');
}
// ── Filtering ──────────────────────────────────────────────────────
function recomputeFiltered() {
const q = state.search.trim().toLowerCase();
const ds = state.datasetFilter;
const filtered = [];
for (let i = 0; i < state.index.length; i++) {
const e = state.index[i];
if (ds !== 'all' && e.dataset !== ds) continue;
if (q) {
// Search qid / question. We don't pre-load SQL/answers in index, so this
// is a question/qid match only. Cheap and covers most needs.
const hay = (e.qid + ' ' + e.question).toLowerCase();
if (!hay.includes(q)) continue;
}
filtered.push(i);
}
state.filtered = filtered;
}
// ── Sidebar render ─────────────────────────────────────────────────
function renderSidebar() {
const html = [];
for (const i of state.filtered) {
const e = state.index[i];
const sel = e.qid === state.selectedQid ? ' selected' : '';
html.push(
`<li class="qid-row${sel}" data-qid="${escapeHtml(e.qid)}">` +
`<span class="qid-id"><span class="qid-tag ${e.dataset}">${e.dataset}</span>${escapeHtml(e.qid)}</span>` +
`<span class="qid-question">${escapeHtml(e.question)}</span>` +
`</li>`
);
}
dom.list.innerHTML = html.join('');
updateCounter();
}
function updateCounter() {
const total = state.index.length;
const shown = state.filtered.length;
const sel = state.selectedQid;
let pos = 0;
if (sel) {
const idx = state.filtered.findIndex((i) => state.index[i].qid === sel);
pos = idx >= 0 ? idx + 1 : 0;
}
if (pos) {
dom.counter.textContent = `${pos} / ${shown}` + (shown !== total ? ` (of ${total})` : '');
} else {
dom.counter.textContent = `${shown}` + (shown !== total ? ` of ${total}` : '');
}
dom.prev.disabled = !sel || pos <= 1;
dom.next.disabled = !sel || pos >= shown;
}
function scrollSelectedIntoView() {
const node = dom.list.querySelector('li.selected');
if (node && typeof node.scrollIntoView === 'function') {
node.scrollIntoView({ block: 'nearest' });
}
}
// ── Record fetching ───────────────────────────────────────────────
async function loadRecord(qid) {
if (state.cache.has(qid)) return state.cache.get(qid);
const res = await fetch(`records/${encodeURIComponent(qid)}.json`);
if (!res.ok) throw new Error(`fetch records/${qid}.json failed: ${res.status}`);
const rec = await res.json();
state.cache.set(qid, rec);
return rec;
}
// ── Main pane render ──────────────────────────────────────────────
function renderEmpty() {
dom.main.innerHTML = `<div class="placeholder">Select a question on the left to begin.</div>`;
}
function renderLoading() {
dom.main.innerHTML = `<div class="placeholder">Loading record…</div>`;
}
function pickInitialBucket(rec) {
if (rec.tables.hard_positive.length) return 'hard_positive';
if (rec.tables.positive.length) return 'positive';
if (rec.tables.negative.length) return 'negative';
return 'hard_positive';
}
function renderRecord(rec) {
state.activeBucket = pickInitialBucket(rec);
const answers = Array.isArray(rec.answer) ? rec.answer : [rec.answer];
const answersHtml = answers.length
? answers.map((a) => `<span class="answer-pill">${escapeHtml(a)}</span>`).join('')
: `<span class="answer-pill" style="background:#f3eedb;color:var(--ink-mute);font-style:italic">(empty)</span>`;
const html = `
<div class="record-header">
<div class="record-pills">
<span class="pill dataset ${escapeHtml(rec.dataset)}">${escapeHtml(rec.dataset)}</span>
<span class="pill mono" title="question_id">${escapeHtml(rec.question_id)}</span>
<span class="pill mono" title="original_table_id">πŸ“Š ${escapeHtml(rec.original_table_id)}</span>
<button class="copy-btn" id="copyQidBtn" title="Copy qid">πŸ“‹ copy qid</button>
</div>
<div class="question-text">${escapeHtml(rec.question)}</div>
<div class="answer-row">
<span class="answer-label">Answer${answers.length > 1 ? 's' : ''} (${answers.length})</span>
${answersHtml}
</div>
</div>
<div class="section">
<h3 class="section-title">Reference SQL</h3>
<pre class="sql-block"><code>${highlightSql(rec.sql || '')}</code></pre>
</div>
<div class="section">
<h3 class="section-title">Candidate tables</h3>
<div class="tables-tabs" id="bucketTabs">
${renderTabButton('hard_positive', '⭐ Hard positive', rec.tables.hard_positive.length)}
${renderTabButton('positive', 'βž• Positive', rec.tables.positive.length)}
${renderTabButton('negative', 'βž– Negative', rec.tables.negative.length)}
</div>
<div id="bucketBody">${renderBucket(rec, state.activeBucket)}</div>
</div>
`;
dom.main.innerHTML = html;
// wire copy button
const copyBtn = document.getElementById('copyQidBtn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(rec.question_id).then(() => {
copyBtn.textContent = 'βœ“ copied';
copyBtn.classList.add('copied');
setTimeout(() => {
copyBtn.textContent = 'πŸ“‹ copy qid';
copyBtn.classList.remove('copied');
}, 1200);
});
});
}
// wire bucket tabs
document.querySelectorAll('#bucketTabs .tables-tab').forEach((btn) => {
btn.addEventListener('click', () => {
const b = btn.getAttribute('data-bucket');
if (btn.disabled || b === state.activeBucket) return;
state.activeBucket = b;
document.querySelectorAll('#bucketTabs .tables-tab').forEach((x) => x.classList.toggle('active', x === btn));
document.getElementById('bucketBody').innerHTML = renderBucket(rec, b);
});
});
}
function renderTabButton(bucket, label, count) {
const active = bucket === state.activeBucket ? ' active' : '';
const disabled = count === 0 ? ' disabled' : '';
return (
`<button class="tables-tab${active}" data-bucket="${bucket}"${disabled}>` +
`${label}<span class="count">${count}</span>` +
`</button>`
);
}
function renderBucket(rec, bucket) {
const items = rec.tables[bucket] || [];
if (!items.length) {
const labelMap = {
hard_positive: 'No hard-positive chunk recorded for this question.',
positive: 'No partial-positive chunk recorded for this question.',
negative: 'No BM25-distractor recorded for this question.',
};
return `<div class="empty-bucket">${escapeHtml(labelMap[bucket] || 'empty')}</div>`;
}
return items.map((c) => renderChunk(c, bucket)).join('');
}
function renderChunk(c, bucket) {
const breadParts = [];
if (c.page_title) breadParts.push(`<span class="crumb-page">${escapeHtml(c.page_title)}</span>`);
if (c.section_title) breadParts.push(`<span class="crumb-section">${escapeHtml(c.section_title)}</span>`);
const bread = breadParts.join(`<span class="crumb-sep">β€Ί</span>`);
const caption = c.caption ? `<span class="crumb-caption">${escapeHtml(c.caption)}</span>` : '';
const head = (c.header || []).map((h) => `<th>${escapeHtml(h)}</th>`).join('');
const body = (c.rows || []).map(
(row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join('')}</tr>`
).join('');
return `
<div class="chunk-card ${bucket}">
<div class="chunk-head">
<div class="chunk-bread">${bread}${caption}</div>
<div class="chunk-meta">
<span class="chunk-id-label">chunk #${c.chunk_id}</span>
<span>${escapeHtml(c.name || '')}</span>
</div>
</div>
<div class="chunk-table-wrap">
<table class="chunk-table">
<thead><tr>${head}</tr></thead>
<tbody>${body}</tbody>
</table>
</div>
</div>
`;
}
// ── Navigation ────────────────────────────────────────────────────
async function selectQid(qid, pushHash = true) {
state.selectedQid = qid;
renderSidebar();
scrollSelectedIntoView();
if (pushHash) {
const h = `#${encodeURIComponent(qid)}`;
if (window.location.hash !== h) window.history.replaceState(null, '', h);
}
renderLoading();
try {
const rec = await loadRecord(qid);
// Make sure user hasn't selected something else while loading
if (state.selectedQid !== qid) return;
renderRecord(rec);
} catch (e) {
dom.main.innerHTML = `<div class="placeholder">Failed to load record: ${escapeHtml(e.message)}</div>`;
}
}
function step(delta) {
if (!state.selectedQid) {
if (state.filtered.length) selectQid(state.index[state.filtered[0]].qid);
return;
}
const cur = state.filtered.findIndex((i) => state.index[i].qid === state.selectedQid);
if (cur < 0) return;
const next = cur + delta;
if (next < 0 || next >= state.filtered.length) return;
selectQid(state.index[state.filtered[next]].qid);
}
// ── Wiring ────────────────────────────────────────────────────────
function wire() {
dom.list.addEventListener('click', (ev) => {
const li = ev.target.closest('li.qid-row');
if (li) selectQid(li.getAttribute('data-qid'));
});
dom.search.addEventListener('input', debounce(() => {
state.search = dom.search.value;
recomputeFiltered();
renderSidebar();
}, 120));
dom.filter.addEventListener('click', (ev) => {
const btn = ev.target.closest('.filter-pill');
if (!btn) return;
const ds = btn.getAttribute('data-dataset');
if (ds === state.datasetFilter) return;
state.datasetFilter = ds;
dom.filter.querySelectorAll('.filter-pill').forEach((b) => b.classList.toggle('active', b === btn));
recomputeFiltered();
renderSidebar();
});
dom.prev.addEventListener('click', () => step(-1));
dom.next.addEventListener('click', () => step(+1));
document.addEventListener('keydown', (ev) => {
if (ev.target && (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA')) {
if (ev.key === 'Escape') ev.target.blur();
return;
}
if (ev.key === 'ArrowLeft') { ev.preventDefault(); step(-1); }
else if (ev.key === 'ArrowRight') { ev.preventDefault(); step(+1); }
else if (ev.key === '/') { ev.preventDefault(); dom.search.focus(); dom.search.select(); }
else if (ev.key === '1' || ev.key === '2' || ev.key === '3') {
const map = { '1': 'hard_positive', '2': 'positive', '3': 'negative' };
const tab = document.querySelector(`#bucketTabs .tables-tab[data-bucket="${map[ev.key]}"]`);
if (tab && !tab.disabled) tab.click();
}
});
window.addEventListener('hashchange', () => {
const qid = decodeURIComponent(window.location.hash.slice(1));
if (qid && qid !== state.selectedQid) selectQid(qid, /*pushHash=*/false);
});
}
// ── Boot ──────────────────────────────────────────────────────────
async function boot() {
wire();
dom.main.innerHTML = `<div class="placeholder">Loading index…</div>`;
try {
const res = await fetch('index.json');
if (!res.ok) throw new Error(`index.json ${res.status}`);
state.index = await res.json();
} catch (e) {
dom.main.innerHTML = `<div class="placeholder">Failed to load index.json: ${escapeHtml(e.message)}</div>`;
return;
}
recomputeFiltered();
renderSidebar();
// Honour deep link if present, else show empty placeholder.
const hashQid = decodeURIComponent(window.location.hash.slice(1));
if (hashQid && state.index.some((e) => e.qid === hashQid)) {
selectQid(hashQid, /*pushHash=*/false);
} else {
renderEmpty();
}
}
boot();
})();