"use strict";
/* Visualize: ask a question about a dataset, get a chart, and open the rows behind any mark.
Built for the small datasets a hosted run writes, so every chart is exact and instant views need no model. */
(function () {
const INKS = ["#3a4bff", "#ff5c8f", "#00838a", "#ffb000", "#ff6c2f", "#765ba7"];
const OTHER = "#8a8fa0";
const SIZES = [25, 50, 100, 200];
const STAGES = [["plan", "Planning the chart"], ["label", "Reading the rows"], ["chart", "Counting"], ["write", "Writing it up"]];
const V = {
data: null, rows: [], byId: {}, samples: [], maxRows: 200, n: 50, hub: "", loadBusy: "", loadErr: "",
q: "", asking: null, askErr: "", history: [], cur: -1, scope: null, sel: null, selShow: 30, sorts: {}, drawn: {},
};
const $ = (s, el = document) => el.querySelector(s);
const esc = (t) => String(t ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
const on = () => window.DI_MODE === "visualize";
const plural = (n, w) => `${n.toLocaleString()} ${w}${n === 1 ? "" : "s"}`;
const fmt = (x) => { if (x == null || isNaN(x)) return ""; const a = Math.abs(x); return a >= 1000 ? Math.round(x).toLocaleString() : a >= 10 ? String(Math.round(x * 10) / 10) : String(Math.round(x * 100) / 100); };
const reduced = () => window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
async function api(path, body) {
const r = await fetch(path, { method: body ? "POST" : "GET", headers: body ? { "Content-Type": "application/json" } : {}, body: body ? JSON.stringify(body) : undefined });
let data = null; try { data = await r.json(); } catch (e) { /* not json */ }
if (!r.ok) throw new Error((data && (data.detail || data.message)) || `Request failed (${r.status})`);
return data;
}
const cols = () => (V.data ? V.data.columns : []);
const colType = (name) => (cols().find((c) => c.name === name) || {}).type;
const textCols = () => cols().filter((c) => c.type === "text").map((c) => c.name);
const catCols = () => cols().filter((c) => c.type === "cat").map((c) => c.name);
/* ------------------------------------------------------------ charts (hand-set SVG, two inks) */
function niceTicks(lo, hi, want = 5) {
if (!(hi > lo)) { hi = lo + 1; }
const raw = (hi - lo) / want, e = Math.pow(10, Math.floor(Math.log10(raw)));
const step = [1, 2, 2.5, 5, 10].map((m) => m * e).find((s) => s >= raw) || 10 * e;
const out = []; for (let v = Math.floor(lo / step) * step; v <= hi + step * 1e-9; v += step) out.push(+v.toFixed(10));
if (out[out.length - 1] < hi) out.push(out[out.length - 1] + step);
return out;
}
function labelW(keys, w) { const longest = Math.max(4, ...keys.map((k) => String(k).length)); return Math.min(Math.round(w * 0.38), Math.max(70, longest * 7.4 + 16)); }
/* narrow charts set each label above its bar instead of clipping it beside the bar */
function narrow(marks, w) { const lw = labelW(marks.map((m) => m.key), w); const longest = Math.max(0, ...marks.map((m) => String(m.key).length)); return w < 560 && longest * 7.4 + 16 > lw; }
function labText(key, lw, y, nar, w) {
return nar ? `${esc(clip(key, w - 10))} ` : `${esc(clip(key, lw - 12))} `;
}
function clip(t, px) { const max = Math.max(4, Math.floor(px / 7.1)); t = String(t); return t.length > max ? t.slice(0, max - 1) + "…" : t; }
function colorMap(keys) { const m = {}; let i = 0; keys.forEach((k) => { m[k] = /^Other \(|^\(blank\)$|^unclear$/.test(k) ? OTHER : INKS[i++ % INKS.length]; }); return m; }
function mark(attrs, label, ids, inner) {
return `${inner} `;
}
function sorted(c, key) {
const how = V.sorts[key] || "value";
const m = (c.marks || []).slice();
if (how === "name") m.sort((a, b) => String(a.key).localeCompare(String(b.key), undefined, { numeric: true }));
return m;
}
function svgBar(c, w, key) {
const marks = sorted(c, key), nar = narrow(marks, w), row = nar ? 42 : 30, top = 6, lw = nar ? 0 : labelW(marks.map((m) => m.key), w), vw = 54, by = nar ? 14 : 0;
const bw = Math.max(60, w - lw - vw - 12), max = Math.max(...marks.map((m) => m.value), 1), h = top + marks.length * row + 4;
const yName = c.agg === "count" ? "rows" : `${c.agg} ${c.y}`;
const body = marks.map((m, i) => {
const y = top + i * row, len = Math.max(2, (m.value / max) * bw), lab = c.agg === "count" ? plural(m.value, "row") : `${fmt(m.value)} (${plural(m.n, "row")})`;
return mark(`style="--d:${i * 40}ms"`, `${c.x} = ${m.key}: ${lab}`, m.ids,
`${labText(m.key, lw, y, nar, w)}
${fmt(m.value)} `);
}).join("");
return { svg: `${body} `, axis: `${c.x} · ${yName}` };
}
function svgStack(c, w, key) {
const marks = sorted(c, key), nar = narrow(marks, w), row = nar ? 42 : 30, top = 6, lw = nar ? 0 : labelW(marks.map((m) => m.key), w), vw = 46, by = nar ? 14 : 0;
const bw = Math.max(60, w - lw - vw - 10), share = c.agg !== "count", maxN = Math.max(...marks.map((m) => m.n), 1), cm = colorMap(c.colors || []);
const h = top + marks.length * row + 4;
const body = marks.map((m, i) => {
const y = top + i * row, scale = share ? bw / Math.max(1, m.n) : bw / maxN; let x = lw;
const segs = m.parts.map((p) => {
const sw = p.value * scale, pct = Math.round((100 * p.value) / m.n);
const g = mark("", `${c.x} = ${m.key}, ${c.color} = ${p.key}: ${plural(p.value, "row")} (${pct}%)`, p.ids,
` ${sw > 30 ? `${share ? pct + "%" : p.value} ` : ""}`);
x += sw; return g;
}).join("");
return `${mark("", `${c.x} = ${m.key}: ${plural(m.n, "row")}`, m.ids, `${labText(m.key, lw, y, nar, w)} `)}${segs}${m.n} `;
}).join("");
const legend = (c.colors || []).map((k) => {
const ids = marks.flatMap((m) => (m.parts.find((p) => p.key === k) || { ids: [] }).ids);
return ` ${esc(k)} `;
}).join("");
return { svg: `${body} `, legend, axis: `${c.x} · ${share ? "share of rows" : "rows"}, split by ${c.color}` };
}
function jitter(id) { const s = Math.sin(id * 12.9898) * 43758.5453; return s - Math.floor(s) - 0.5; }
function svgBox(c, w, key) {
const marks = sorted(c, key), nar = c.x && narrow(marks, w), row = nar ? 54 : 38, top = 6, lw = c.x && !nar ? labelW(marks.map((m) => m.key), w) : 8, bw = Math.max(80, w - lw - 16), by = nar ? 12 : 0, amp = nar ? 16 : row - 16;
const all = marks.flatMap((m) => m.points.map((p) => p[1])), ticks = niceTicks(Math.min(...all), Math.max(...all));
const lo = ticks[0], hi = ticks[ticks.length - 1], X = (v) => lw + ((v - lo) / (hi - lo || 1)) * bw, h = top + marks.length * row + 26;
const grid = ticks.map((t) => `${fmt(t)} `).join("");
const body = marks.map((m, i) => {
const y = top + i * row, mid = y + row / 2 - 2 + by, thin = m.n < 3 ? " thin" : "";
const dots = m.points.map(([id, v]) => `Row ${id}: ${fmt(v)} `).join("");
return `${mark("", `${c.x ? `${c.x} = ${m.key}: ` : ""}median ${fmt(m.median)}, middle half ${fmt(m.q1)} to ${fmt(m.q3)}, ${plural(m.n, "row")}`, m.ids,
`${c.x ? (nar ? labText(m.key, 0, y, true, w) : `${esc(clip(m.key, lw - 12))} `) : ""}${m.n >= 4 ? `
` : ""} `)}
${dots} `;
}).join("");
return { svg: `${grid}${body} `, axis: `${c.y}${c.x ? ` by ${c.x}` : ""} · each dot is a row` };
}
function svgHist(c, w) {
const marks = c.marks || [], h = 210, left = 34, bottom = 26, top = 8, bw = (w - left - 8) / Math.max(1, marks.length);
const max = Math.max(...marks.map((m) => m.value), 1), yt = niceTicks(0, max, 4), Y = (v) => top + (1 - v / yt[yt.length - 1]) * (h - top - bottom);
const grid = yt.map((t) => `${fmt(t)} `).join("");
const body = marks.map((m, i) => {
const x = left + i * bw, y = Y(m.value);
return mark(`style="--d:${i * 35}ms"`, c.discrete ? `${c.x} = ${fmt(m.x0)}: ${plural(m.value, "row")}` : `${fmt(m.x0)} to ${fmt(m.x1)}: ${plural(m.value, "row")}`, m.ids,
`
`);
}).join("");
const every = Math.ceil(marks.length / Math.max(2, Math.floor((w - left) / 52)));
const xl = c.discrete ? marks.map((m, i) => `${fmt(m.x0)} `).join("") :
marks.map((m, i) => (i % every === 0 ? `${fmt(m.x0)} ` : "")).join("") +
(marks.length ? `${fmt(marks[marks.length - 1].x1)} ` : "");
return { svg: `${grid}${body}${xl} `, axis: `${c.x} · ${c.discrete ? "rows at each value" : "rows in each range"}` };
}
function svgScatter(c, w) {
const pts = c.marks || [], h = Math.min(360, Math.max(240, w * 0.55)), left = 40, bottom = 26, top = 8;
const xt = niceTicks(Math.min(...pts.map((p) => p.x)), Math.max(...pts.map((p) => p.x))), yt = niceTicks(Math.min(...pts.map((p) => p.y)), Math.max(...pts.map((p) => p.y)), 4);
const X = (v) => left + ((v - xt[0]) / (xt[xt.length - 1] - xt[0] || 1)) * (w - left - 10), Y = (v) => top + (1 - (v - yt[0]) / (yt[yt.length - 1] - yt[0] || 1)) * (h - top - bottom);
const cm = colorMap(c.colors || []);
const grid = xt.map((t) => `${fmt(t)} `).join("") +
yt.map((t) => `${fmt(t)} `).join("");
const dots = pts.map((p) => `Row ${p.id} `).join("");
const legend = (c.colors || []).map((k) => ` ${esc(k)} `).join("");
return { svg: `${grid}${dots} `, legend, axis: `${c.x} → · ${c.y} ↑${c.r !== undefined ? ` · r = ${c.r}` : ""}` };
}
function chartHtml(c, w, key, fresh) {
if (!c || !(c.marks || []).length) return `
Nothing to draw: no rows have values for this.
`;
const f = { bar: svgBar, stack: svgStack, box: svgBox, hist: svgHist, scatter: svgScatter }[c.type];
const out = f(c, Math.max(260, Math.floor(w)), key);
const sortable = ["bar", "stack", "box"].includes(c.type) && (c.marks || []).length > 2;
return `${out.legend ? `${out.legend}
` : ""}${out.svg}
${esc(out.axis)} ${sortable ? `${(V.sorts[key] || "value") === "value" ? "Sort by name" : "Sort by size"} ` : ""}
`;
}
/* ------------------------------------------------------------ pages */
function pickHtml() {
const job = window.DI_lastJob && window.DI_lastJob();
const card = (title, body) => `${title} ${body}
`;
const sizes = `${SIZES.filter((n) => n <= V.maxRows).map((n) => `${n} `).join("")}
`;
return `Ask your data a question
Get a chart back, with the rows behind every bar one click away. It is built for the small datasets you write here: every number is exact, and the first charts need no model at all.
${V.loadErr ? `${esc(V.loadErr)}
` : ""}
${job ? card("The rows you just wrote", `
${plural(job.n, "row")} from “${esc(job.request.slice(0, 90))}”
${V.loadBusy === "job" ? "Loading" : "Look at them"}
`) : ""}
${card("A sample dataset", `
Written with DataInventor and published on Hugging Face. Pick how many rows to load.
${sizes}
${V.samples.map((s) => `${esc(s.title)}${V.loadBusy === s.key ? " loading " : ""} `).join("")} `)}
${card("A Hugging Face dataset", `
Any public dataset the viewer can read. A spread of rows is loaded, not the first page.
${V.loadBusy === "hub" ? "Loading" : "Load"}
`)}
${card("A file", `
CSV with a header row, JSONL, or a JSON list of objects. Up to ${V.maxRows} rows are used.
${V.loadBusy === "file" ? "Reading" : "Choose a file"}`)}
`;
}
function stagesHtml() {
const a = V.asking; if (!a) return "";
const idx = STAGES.findIndex(([k]) => k === a.stage);
const items = STAGES.filter(([k]) => k !== "label" || a.total).map(([k, name]) => {
const i = STAGES.findIndex(([x]) => x === k), cls = i < idx ? "done" : i === idx ? "on" : "";
const extra = k === "label" && a.total ? ` ${a.done} of ${a.total}` : "";
return `${name}${extra} `;
}).join("");
return `${items} `;
}
function answerHtml() {
const h = V.history[V.cur]; if (!h) return "";
const r = h.result, key = `a${V.cur}`;
return `
${h.scopeLabel ? `About ${esc(h.scopeLabel)}
` : ""}
${esc(r.title)}
${r.summary ? `${esc(r.summary)}
` : V.asking && V.asking.stage === "write" ? `Writing it up…
` : `${(r.facts || []).slice(0, 6).map((f) => `${esc(f)} `).join("")} `}
${(r.notes || []).map((n) => `${esc(n)}
`).join("")}
${r.follow_ups && r.follow_ups.length ? `${r.follow_ups.map((q) => `${esc(q)} `).join("")}
` : ""}
`;
}
function allIds(c) {
if (c.type === "scatter") return c.marks.map((m) => m.id);
const s = new Set(); (c.marks || []).forEach((m) => (m.ids || []).forEach((i) => s.add(i))); return [...s];
}
function glanceHtml() {
const g = V.data.glance || []; if (!g.length) return "";
return `At a glance ${g.map((c, i) => `
`).join("")}
`;
}
function rowHtml(r) {
const long = cols().filter((c) => c.type === "text" && (c.avg_words || 0) >= 12).map((c) => c.name);
const short = textCols().filter((k) => !long.includes(k));
const head = long[0] || short[0], rest = long.slice(1);
const chips = short.filter((k) => k !== head).concat(catCols()).map((k) => r[k] ? `${esc(String(r[k]).slice(0, 80))} ` : "").join("");
const nums = cols().filter((c) => c.type === "num" && derivedCol(c.name) && r[c.name] != null).map((c) => `${esc(c.name)} ${fmt(r[c.name])} `).join("");
return `${r._id} ${head ? `
${esc(String(r[head] || "").slice(0, 320))}${String(r[head] || "").length > 320 ? "…" : ""}
` : ""}
${chips}${nums}
${rest.map((k) => `
Read the ${esc(k)} ${esc(r[k])}
`).join("")}
${head && String(r[head] || "").length > 320 ? `
Read the whole ${esc(head)} ${esc(r[head])}
` : ""}
`;
}
const derivedCol = (k) => { const c = cols().find((x) => x.name === k); return c && ["label", "score", "pattern", "count"].includes(c.derived); };
function selHtml() {
const s = V.sel; if (!s) return "";
const rows = s.ids.map((i) => V.byId[i]).filter(Boolean);
return `
${plural(rows.length, "row")} ${esc(s.label)}
${rows.length > 1 ? `
Ask about these rows ` : ""}
Download them Close
${rows.slice(0, V.selShow).map(rowHtml).join("")}
${rows.length > V.selShow ? `Show ${Math.min(30, rows.length - V.selShow)} more
` : ""}
`;
}
function askHtml() {
const sug = V.history.length ? [] : V.data.suggestions || [];
const busy = !!V.asking;
return `
${V.scope ? `
Asking about ${esc(V.scope.label)} (${plural(V.scope.ids.length, "row")}) Use all rows
` : ""}
Your question
${busy ? "Working on it" : "Ask"} ${stagesHtml()}
${V.askErr ? `
${esc(V.askErr)}
` : ""}
${sug.length ? `
${sug.map((q) => `${esc(q)} `).join("")}
` : ""}
`;
}
function mainHtml() {
return `${askHtml()}${answerHtml()}
${V.cur < 0 ? glanceHtml() : ""}${selHtml()}
`;
}
function ledgerHtml() {
if (!V.data) return `1 Pick the data 2 Ask `;
const items = V.history.map((h, i) => `${i + 1} ${esc(h.q.length > 64 ? h.q.slice(0, 62) + "…" : h.q)} `).join("");
return `· At a glance ${items}`;
}
function sheetHtml() {
if (!V.data) return `Your data Nothing loaded yet. Charts are drawn from every row you load, so a small sample is fast and exact.
`;
const d = V.data, group = (t, f) => { const list = cols().filter(f); return list.length ? `
${t} ${list.map((c) => `${esc(c.name)} `).join(" ")} ` : ""; };
return `Your data
Name ${esc(d.name)}
Rows ${d.n.toLocaleString()}
${group("Text", (c) => c.type === "text")}${group("Categories", (c) => c.type === "cat" && !derivedCol(c.name))}
${group("Numbers", (c) => c.type === "num" && !derivedCol(c.name))}${group("Made by the model", (c) => derivedCol(c.name))}
${d.note ? `${esc(d.note)}
` : ""}
Columns the model makes stay here, so follow-up questions reuse them instead of reading every row again.
Use different data
`;
}
function render() {
if (!on()) return;
$("#steps").innerHTML = ledgerHtml();
$("#sheet").innerHTML = sheetHtml();
const keep = document.activeElement && document.activeElement.id;
$("#panel").innerHTML = V.data ? mainHtml() : pickHtml();
if (V.data) { redrawCharts(); markSelection(); }
if (keep) { const el = document.getElementById(keep); if (el) { el.focus(); if (el.setSelectionRange && el.tagName === "TEXTAREA") { const l = el.value.length; el.setSelectionRange(l, l); } } }
}
/* charts need their container's width, which exists only after the first paint */
function redrawCharts() {
document.querySelectorAll("[data-vzkey]").forEach((el) => {
const key = el.dataset.vzkey, c = key.startsWith("g") ? V.data.glance[+key.slice(1)] : (V.history[+key.slice(1)] || {}).result?.chart;
if (c) { el.innerHTML = chartHtml(c, el.clientWidth || 600, key, !V.drawn[key] && !reduced()); V.drawn[key] = true; }
});
markSelection();
}
function markSelection() {
const ids = V.sel ? new Set(V.sel.ids) : null;
document.querySelectorAll("[data-vzids]").forEach((el) => {
const mine = el.dataset.vzids.split(",").filter(Boolean).map(Number);
const hit = ids && mine.length && mine.every((i) => ids.has(i)) && (V.sel.label === el.dataset.vzsel || el.tagName.toLowerCase() === "circle" && ids.has(mine[0]));
el.classList.toggle("sel", !!hit);
el.classList.toggle("dim", !!ids && !hit && !el.classList.contains("lg") && !mine.some((i) => ids.has(i)));
});
}
function refreshAsk() { const el = $(".askbar"); if (el) { el.outerHTML = askHtml(); } }
/* ------------------------------------------------------------ actions */
async function loadData(body, busyKey) {
V.loadBusy = busyKey; V.loadErr = ""; render();
try {
const d = await api("/api/vis/load", body);
V.data = d; V.rows = d.rows; V.byId = Object.fromEntries(d.rows.map((r) => [r._id, r]));
V.history = []; V.cur = -1; V.sel = null; V.scope = null; V.q = ""; V.askErr = ""; V.sorts = {}; V.drawn = {};
} catch (e) { V.loadErr = e.message; }
V.loadBusy = ""; render(); window.scrollTo({ top: 0 });
}
async function ask(q) {
q = (q || "").trim(); if (!q || V.asking || !V.data) return;
V.q = q; V.askErr = ""; V.asking = { stage: "plan", done: 0, total: 0 }; refreshAsk();
const scope = V.scope;
try {
const { ask: aid } = await api(`/api/vis/${V.data.id}/ask`, { question: q, scope: scope ? scope.ids : [], scope_label: scope ? scope.label : "" });
let shown = false;
for (;;) {
await new Promise((r) => setTimeout(r, 650));
const s = await api(`/api/vis/ask/${aid}`);
V.asking = s;
if (s.stage === "failed") throw new Error(s.error || "That question could not be answered.");
if (s.result && !shown) {
shown = true; mergeResult(s.result);
V.history.push({ q, result: s.result, scopeLabel: scope ? `${scope.label} (${plural(scope.ids.length, "row")})` : "" });
V.cur = V.history.length - 1; V.sel = null; render();
const out = $("#vz-out"); if (out && out.getBoundingClientRect().top > window.innerHeight * 0.6) out.scrollIntoView({ behavior: reduced() ? "auto" : "smooth", block: "start" });
} else if (shown) { V.history[V.history.length - 1].result = s.result; }
if (s.stage === "done") break;
if (!shown) refreshAsk(); else { const st = $(".vz-stages"); if (st) st.outerHTML = stagesHtml(); }
}
V.asking = null; V.q = ""; render();
} catch (e) { V.asking = null; V.askErr = e.message; render(); }
}
function mergeResult(r) {
Object.entries(r.derived || {}).forEach(([name, vals]) => Object.entries(vals).forEach(([id, v]) => { if (V.byId[id]) V.byId[id][name] = v; }));
if (r.columns) V.data.columns = r.columns;
}
function select(label, ids) {
if (V.sel && V.sel.label === label) { V.sel = null; } else { V.sel = { label, ids }; V.selShow = 30; }
const w = $("#vz-selwrap"); if (w) w.innerHTML = selHtml();
markSelection();
const d = $("#vz-sel"); if (d) d.scrollIntoView({ behavior: reduced() ? "auto" : "smooth", block: "nearest" });
}
function downloadSvg() {
const svg = $("#vz-chart svg"); if (!svg) return;
const css = `text{font:13px "Familjen Grotesk",system-ui,sans-serif;fill:#1d2036}.tick{font-size:11.5px;fill:#5d6376}.ink{fill:#3a4bff}.ghost{fill:#ff5c8f;opacity:.85}.grid{stroke:#c5c8d1}.bx{fill:#f1f2f4;stroke:#1d2036;stroke-width:1.5}.whisk{stroke:#1d2036;stroke-width:1.5}.med{stroke:#ff5c8f;stroke-width:3.5}.dot{fill:#3a4bff;opacity:.75}.hit{fill:transparent}.segv{fill:#fff;font-size:11.5px;font-weight:600}.val{font-weight:600}`;
const clone = svg.cloneNode(true); clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
clone.insertAdjacentHTML("afterbegin", ` `);
const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([clone.outerHTML], { type: "image/svg+xml" }));
a.download = (V.history[V.cur]?.result.title || "chart").toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 50) + ".svg"; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
}
document.addEventListener("click", (ev) => {
if (!on()) return;
const t = ev.target.closest("[data-vz],[data-vzsel],[data-vzq],[data-vzn],[data-vzsample],[data-vzh],[data-vzsort]"); if (!t) return;
if (t.dataset.vzn) { V.n = +t.dataset.vzn; return render(); }
if (t.dataset.vzsample) return loadData({ source: "sample", key: t.dataset.vzsample, n: V.n }, t.dataset.vzsample);
if (t.dataset.vzq) { V.q = t.dataset.vzq; return ask(V.q); }
if (t.dataset.vzh !== undefined) { V.cur = +t.dataset.vzh; V.sel = null; render(); return window.scrollTo({ top: 0 }); }
if (t.dataset.vzsort) { const k = t.dataset.vzsort; V.sorts[k] = (V.sorts[k] || "value") === "value" ? "name" : "value"; return redrawCharts(); }
if (t.dataset.vzsel !== undefined && t.dataset.vzids !== undefined && !t.dataset.vz) {
ev.stopPropagation();
return select(t.dataset.vzsel, t.dataset.vzids.split(",").filter(Boolean).map(Number));
}
const a = t.dataset.vz;
if (a === "load-job") { const j = window.DI_lastJob && window.DI_lastJob(); if (j) loadData({ source: "job", job: j.id }, "job"); }
else if (a === "load-hub") loadData({ source: "hub", dataset: V.hub.trim(), n: V.n }, "hub");
else if (a === "ask") ask(V.q);
else if (a === "clear") select(V.sel ? V.sel.label : "", []);
else if (a === "more") { V.selShow += 30; const w = $("#vz-selwrap"); if (w) w.innerHTML = selHtml(); }
else if (a === "scope" && V.sel) { V.scope = { label: V.sel.label, ids: V.sel.ids.slice() }; V.sel = null; render(); const q = $("#vz-q"); if (q) { q.focus(); q.scrollIntoView({ block: "center" }); } }
else if (a === "unscope") { V.scope = null; refreshAsk(); }
else if (a === "change") { V.data = null; V.history = []; V.cur = -1; V.sel = null; V.scope = null; render(); }
else if (a === "svg") downloadSvg();
});
/* marks are SVG groups: Enter or Space opens them like a button */
document.addEventListener("keydown", (ev) => {
if (!on()) return;
if ((ev.key === "Enter" || ev.key === " ") && ev.target.matches && ev.target.matches("g.mk, circle.mk1")) { ev.preventDefault(); ev.target.dispatchEvent(new MouseEvent("click", { bubbles: true })); }
if (ev.key === "Enter" && !ev.shiftKey && ev.target.id === "vz-q") { ev.preventDefault(); ask(V.q); }
if (ev.key === "Enter" && ev.target.id === "vz-hub" && V.hub.trim()) loadData({ source: "hub", dataset: V.hub.trim(), n: V.n }, "hub");
});
document.addEventListener("input", (ev) => {
if (!on()) return;
if (ev.target.id === "vz-q") { V.q = ev.target.value; const b = $('[data-vz="ask"]'); if (b) b.disabled = !!V.asking || !V.q.trim(); }
if (ev.target.id === "vz-hub") { V.hub = ev.target.value; const b = $('[data-vz="load-hub"]'); if (b) b.disabled = !!V.loadBusy || !V.hub.trim(); }
});
document.addEventListener("change", (ev) => {
if (!on() || ev.target.id !== "vz-file" || !ev.target.files.length) return;
const f = ev.target.files[0];
if (f.size > 6e6) { V.loadErr = "That file is over 6 MB. Upload a smaller sample."; return render(); }
const rd = new FileReader();
rd.onload = () => loadData({ source: "upload", text: String(rd.result), filename: f.name }, "file");
rd.onerror = () => { V.loadErr = "Could not read that file."; render(); };
rd.readAsText(f);
});
let rz = 0;
window.addEventListener("resize", () => { if (!on() || !V.data) return; clearTimeout(rz); rz = setTimeout(redrawCharts, 120); });
window.DI_renderVis = () => {
if (!V.samples.length) api("/api/vis/samples").then((d) => { V.samples = d.samples; V.maxRows = d.max_rows; if (on() && !V.data) render(); }).catch(() => {});
render();
};
/* "Visualize these rows" on a finished run */
window.DI_visualizeJob = (id) => { window.DI_setMode && window.DI_setMode("visualize"); loadData({ source: "job", job: id }, "job"); };
})();