Spaces:
Running
Running
Download static/visualize.js from codelion/data-inventor: direct link, hf CLI and curl.
- Browser
- Download file 33.7 kB
-
https://huggingface.co/spaces/codelion/data-inventor/resolve/main/static/visualize.js
- Command line
-
hf download hf://spaces/codelion/data-inventor/static/visualize.js
-
curl -L -o visualize.js https://huggingface.co/spaces/codelion/data-inventor/resolve/main/static/visualize.js
33.7 kB
| ; | |
| /* 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 ? `<text class="lab" x="0" y="${y + 13}">${esc(clip(key, w - 10))}</text>` : `<text class="lab" x="${lw - 10}" y="${y + 19}" text-anchor="end">${esc(clip(key, lw - 12))}</text>`; | |
| } | |
| 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 `<g class="mk" tabindex="0" role="button" aria-label="${esc(label)}" data-vzsel="${esc(label.split(": ")[0])}" data-vzids="${ids.join(",")}" ${attrs}>${inner}</g>`; | |
| } | |
| 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)} | |
| <g class="grow" style="transform-origin:${lw}px 0"><rect class="ghost" x="${lw + 3}" y="${y + by + 7}" width="${len}" height="19"></rect> | |
| <rect class="ink" x="${lw}" y="${y + by + 4}" width="${len}" height="19"></rect></g> | |
| <text class="val" x="${lw + len + 8}" y="${y + by + 19}">${fmt(m.value)}</text><rect class="hit" x="0" y="${y}" width="${w}" height="${row}"></rect>`); | |
| }).join(""); | |
| return { svg: `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="Bar chart of ${esc(yName)} by ${esc(c.x)}">${body}</svg>`, 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, | |
| `<rect class="seg" x="${x}" y="${y + by + 4}" width="${Math.max(1, sw - 1)}" height="19" fill="${cm[p.key]}"></rect>${sw > 30 ? `<text class="segv" x="${x + 5}" y="${y + by + 17.5}">${share ? pct + "%" : p.value}</text>` : ""}`); | |
| x += sw; return g; | |
| }).join(""); | |
| return `<g class="grow" style="--d:${i * 40}ms;transform-origin:${lw}px 0">${mark("", `${c.x} = ${m.key}: ${plural(m.n, "row")}`, m.ids, `${labText(m.key, lw, y, nar, w)}<rect class="hit" x="0" y="${y}" width="${nar ? w : lw}" height="${nar ? 15 : row}"></rect>`)}${segs}<text class="val" x="${lw + (share ? bw : m.n * scale) + 8}" y="${y + by + 19}">${m.n}</text></g>`; | |
| }).join(""); | |
| const legend = (c.colors || []).map((k) => { | |
| const ids = marks.flatMap((m) => (m.parts.find((p) => p.key === k) || { ids: [] }).ids); | |
| return `<button class="lg" data-vzsel="${esc(`${c.color} = ${k}`)}" data-vzids="${ids.join(",")}"><i style="background:${cm[k]}"></i>${esc(k)}</button>`; | |
| }).join(""); | |
| return { svg: `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="Stacked bars of ${esc(c.color)} within each ${esc(c.x)}">${body}</svg>`, 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) => `<line class="grid" x1="${X(t)}" x2="${X(t)}" y1="${top}" y2="${h - 22}"></line><text class="tick" x="${X(t)}" y="${h - 6}" text-anchor="middle">${fmt(t)}</text>`).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]) => `<circle class="dot mk1" cx="${X(v)}" cy="${mid + jitter(id) * amp}" r="3.4" data-vzsel="row ${id}" data-vzids="${id}" tabindex="-1"><title>Row ${id}: ${fmt(v)}</title></circle>`).join(""); | |
| return `<g class="grow-o${thin}" style="--d:${i * 50}ms">${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) : `<text class="lab" x="${lw - 10}" y="${mid + 4}" text-anchor="end">${esc(clip(m.key, lw - 12))}</text>`) : ""}${m.n >= 4 ? `<line class="whisk" x1="${X(m.lo)}" x2="${X(m.hi)}" y1="${mid}" y2="${mid}"></line> | |
| <rect class="bx" x="${X(m.q1)}" y="${mid - 9}" width="${Math.max(2, X(m.q3) - X(m.q1))}" height="18"></rect>` : ""}<rect class="hit" x="0" y="${y}" width="${nar ? w : lw}" height="${nar ? 16 : row}"></rect>`)} | |
| ${dots}<line class="med" x1="${X(m.median)}" x2="${X(m.median)}" y1="${mid - 11}" y2="${mid + 11}"></line></g>`; | |
| }).join(""); | |
| return { svg: `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="Box plot of ${esc(c.y)}${c.x ? ` by ${esc(c.x)}` : ""}">${grid}${body}</svg>`, 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) => `<line class="grid" x1="${left}" x2="${w - 4}" y1="${Y(t)}" y2="${Y(t)}"></line><text class="tick" x="${left - 6}" y="${Y(t) + 4}" text-anchor="end">${fmt(t)}</text>`).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, | |
| `<g class="grow-y" style="transform-origin:0 ${h - bottom}px"><rect class="ghost" x="${x + 4}" y="${y + 3}" width="${Math.max(1, bw - 4)}" height="${Math.max(0, h - bottom - y)}"></rect> | |
| <rect class="ink" x="${x + 1}" y="${y}" width="${Math.max(1, bw - 4)}" height="${Math.max(0, h - bottom - y)}"></rect></g> | |
| <rect class="hit" x="${x}" y="${top}" width="${bw}" height="${h - top - bottom}"></rect>`); | |
| }).join(""); | |
| const every = Math.ceil(marks.length / Math.max(2, Math.floor((w - left) / 52))); | |
| const xl = c.discrete ? marks.map((m, i) => `<text class="tick" x="${left + i * bw + bw / 2}" y="${h - 8}" text-anchor="middle">${fmt(m.x0)}</text>`).join("") : | |
| marks.map((m, i) => (i % every === 0 ? `<text class="tick" x="${left + i * bw}" y="${h - 8}" text-anchor="middle">${fmt(m.x0)}</text>` : "")).join("") + | |
| (marks.length ? `<text class="tick" x="${left + marks.length * bw}" y="${h - 8}" text-anchor="end">${fmt(marks[marks.length - 1].x1)}</text>` : ""); | |
| return { svg: `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="Histogram of ${esc(c.x)}">${grid}${body}${xl}</svg>`, 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) => `<line class="grid" x1="${X(t)}" x2="${X(t)}" y1="${top}" y2="${h - bottom}"></line><text class="tick" x="${X(t)}" y="${h - 8}" text-anchor="middle">${fmt(t)}</text>`).join("") + | |
| yt.map((t) => `<line class="grid" x1="${left}" x2="${w - 10}" y1="${Y(t)}" y2="${Y(t)}"></line><text class="tick" x="${left - 6}" y="${Y(t) + 4}" text-anchor="end">${fmt(t)}</text>`).join(""); | |
| const dots = pts.map((p) => `<circle class="dot mk1" cx="${X(p.x)}" cy="${Y(p.y)}" r="4.2" style="${p.c ? `fill:${cm[p.c]}` : ""}" data-vzsel="row ${p.id}" data-vzids="${p.id}" tabindex="0" role="button" aria-label="Row ${p.id}: ${esc(c.x)} ${fmt(p.x)}, ${esc(c.y)} ${fmt(p.y)}"><title>Row ${p.id}</title></circle>`).join(""); | |
| const legend = (c.colors || []).map((k) => `<button class="lg" data-vzsel="${esc(`${c.color} = ${k}`)}" data-vzids="${pts.filter((p) => p.c === k).map((p) => p.id).join(",")}"><i style="background:${cm[k]}"></i>${esc(k)}</button>`).join(""); | |
| return { svg: `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="Scatter of ${esc(c.y)} against ${esc(c.x)}">${grid}${dots}</svg>`, legend, axis: `${c.x} → · ${c.y} ↑${c.r !== undefined ? ` · r = ${c.r}` : ""}` }; | |
| } | |
| function chartHtml(c, w, key, fresh) { | |
| if (!c || !(c.marks || []).length) return `<p class="hint">Nothing to draw: no rows have values for this.</p>`; | |
| 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 ? `<div class="legend">${out.legend}</div>` : ""}<div class="svgwrap${fresh ? " fresh" : ""}">${out.svg}</div> | |
| <div class="axisline"><span>${esc(out.axis)}</span>${sortable ? `<button class="linkish sm" data-vzsort="${esc(key)}">${(V.sorts[key] || "value") === "value" ? "Sort by name" : "Sort by size"}</button>` : ""}</div>`; | |
| } | |
| /* ------------------------------------------------------------ pages */ | |
| function pickHtml() { | |
| const job = window.DI_lastJob && window.DI_lastJob(); | |
| const card = (title, body) => `<div class="vsrc"><b>${title}</b>${body}</div>`; | |
| const sizes = `<div class="seg" role="group" aria-label="Rows to load">${SIZES.filter((n) => n <= V.maxRows).map((n) => `<button data-vzn="${n}" aria-pressed="${V.n === n}">${n}</button>`).join("")}</div>`; | |
| return `<h1>Ask your data a question</h1> | |
| <p class="lede">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.</p> | |
| ${V.loadErr ? `<p class="note bad">${esc(V.loadErr)}</p>` : ""} | |
| <div class="vsrcs"> | |
| ${job ? card("The rows you just wrote", `<p class="hint">${plural(job.n, "row")} from “${esc(job.request.slice(0, 90))}”</p><div class="actions"><button class="btn go" data-vz="load-job" ${V.loadBusy ? "disabled" : ""}>${V.loadBusy === "job" ? "Loading" : "Look at them"}</button></div>`) : ""} | |
| ${card("A sample dataset", `<p class="hint">Written with DataInventor and published on Hugging Face. Pick how many rows to load.</p>${sizes} | |
| <ul class="vsamples">${V.samples.map((s) => `<li><button class="vsample" data-vzsample="${esc(s.key)}" ${V.loadBusy ? "disabled" : ""}>${esc(s.title)}${V.loadBusy === s.key ? " <em>loading</em>" : ""}</button></li>`).join("")}</ul>`)} | |
| ${card("A Hugging Face dataset", `<p class="hint">Any public dataset the viewer can read. A spread of rows is loaded, not the first page.</p> | |
| <div class="addrow"><input class="in" id="vz-hub" placeholder="owner/name" value="${esc(V.hub)}" spellcheck="false" autocomplete="off"><button class="btn" data-vz="load-hub" ${V.loadBusy || !V.hub.trim() ? "disabled" : ""}>${V.loadBusy === "hub" ? "Loading" : "Load"}</button></div>`)} | |
| ${card("A file", `<p class="hint">CSV with a header row, JSONL, or a JSON list of objects. Up to ${V.maxRows} rows are used.</p> | |
| <label class="btn filebtn"><input type="file" id="vz-file" accept=".csv,.jsonl,.json,.txt" ${V.loadBusy ? "disabled" : ""}>${V.loadBusy === "file" ? "Reading" : "Choose a file"}</label>`)} | |
| </div>`; | |
| } | |
| 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 `<li class="${cls}">${name}${extra}</li>`; | |
| }).join(""); | |
| return `<ol class="stages vz-stages" aria-live="polite">${items}</ol>`; | |
| } | |
| function answerHtml() { | |
| const h = V.history[V.cur]; if (!h) return ""; | |
| const r = h.result, key = `a${V.cur}`; | |
| return `<article class="answer" id="vz-answer"> | |
| ${h.scopeLabel ? `<p class="hint">About ${esc(h.scopeLabel)}</p>` : ""} | |
| <h2>${esc(r.title)}</h2> | |
| ${r.summary ? `<p class="prose">${esc(r.summary)}</p>` : V.asking && V.asking.stage === "write" ? `<p class="prose hint">Writing it up…</p>` : `<ul class="facts">${(r.facts || []).slice(0, 6).map((f) => `<li>${esc(f)}</li>`).join("")}</ul>`} | |
| <div class="chart" id="vz-chart" data-vzkey="${key}"></div> | |
| ${(r.notes || []).map((n) => `<p class="hint vnote">${esc(n)}</p>`).join("")} | |
| <div class="vtools"><button class="linkish sm" data-vz="svg">Download the chart</button>${r.chart.n ? `<a class="linkish sm" href="/api/vis/${V.data.id}/download?ids=${allIds(r.chart).join(",")}">Download its rows</a>` : ""}</div> | |
| ${r.follow_ups && r.follow_ups.length ? `<div class="chips">${r.follow_ups.map((q) => `<button class="chip" data-vzq="${esc(q)}">${esc(q)}</button>`).join("")}</div>` : ""} | |
| </article>`; | |
| } | |
| 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 `<section class="glance"><h3>At a glance</h3><div class="gl-grid">${g.map((c, i) => `<div class="gl"><b>${esc(c.title)}</b><div class="chart small" id="vz-g${i}" data-vzkey="g${i}"></div></div>`).join("")}</div></section>`; | |
| } | |
| 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] ? `<span class="tagc${derivedCol(k) ? " made" : ""}" title="${esc(k)}">${esc(String(r[k]).slice(0, 80))}</span>` : "").join(""); | |
| const nums = cols().filter((c) => c.type === "num" && derivedCol(c.name) && r[c.name] != null).map((c) => `<span class="tagc made" title="${esc(c.name)}">${esc(c.name)} ${fmt(r[c.name])}</span>`).join(""); | |
| return `<div class="trow"><span class="id">${r._id}</span><div>${head ? `<div class="p">${esc(String(r[head] || "").slice(0, 320))}${String(r[head] || "").length > 320 ? "…" : ""}</div>` : ""} | |
| <div class="tags">${chips}${nums}</div> | |
| ${rest.map((k) => `<details><summary>Read the ${esc(k)}</summary><div class="full">${esc(r[k])}</div></details>`).join("")} | |
| ${head && String(r[head] || "").length > 320 ? `<details><summary>Read the whole ${esc(head)}</summary><div class="full">${esc(r[head])}</div></details>` : ""}</div></div>`; | |
| } | |
| 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 `<section class="drawer" id="vz-sel" aria-label="Selected rows"> | |
| <div class="drawhead"><div><b>${plural(rows.length, "row")}</b> <span class="hint">${esc(s.label)}</span></div> | |
| <div class="sctl">${rows.length > 1 ? `<button class="btn sm" data-vz="scope">Ask about these rows</button>` : ""}<a class="btn sm" href="/api/vis/${V.data.id}/download?ids=${s.ids.join(",")}" style="text-decoration:none">Download them</a><button class="linkish sm" data-vz="clear">Close</button></div></div> | |
| <div class="table">${rows.slice(0, V.selShow).map(rowHtml).join("")}</div> | |
| ${rows.length > V.selShow ? `<p style="margin-top:12px"><button class="linkish" data-vz="more">Show ${Math.min(30, rows.length - V.selShow)} more</button></p>` : ""} | |
| </section>`; | |
| } | |
| function askHtml() { | |
| const sug = V.history.length ? [] : V.data.suggestions || []; | |
| const busy = !!V.asking; | |
| return `<div class="askbar"> | |
| ${V.scope ? `<div class="scopechip">Asking about ${esc(V.scope.label)} (${plural(V.scope.ids.length, "row")}) <button class="linkish sm" data-vz="unscope" aria-label="Ask about all rows again">Use all rows</button></div>` : ""} | |
| <label class="f" style="margin-top:0"><span class="vh">Your question</span> | |
| <textarea class="in vq" id="vz-q" rows="2" placeholder="How long are the replies in each area?" ${busy ? "disabled" : ""}>${esc(V.q)}</textarea></label> | |
| <div class="actions" style="margin-top:12px"><button class="btn go" data-vz="ask" ${busy || !V.q.trim() ? "disabled" : ""}>${busy ? "Working on it" : "Ask"}</button>${stagesHtml()}</div> | |
| ${V.askErr ? `<p class="note bad">${esc(V.askErr)}</p>` : ""} | |
| ${sug.length ? `<div class="chips">${sug.map((q) => `<button class="chip" data-vzq="${esc(q)}" ${busy ? "disabled" : ""}>${esc(q)}</button>`).join("")}</div>` : ""} | |
| </div>`; | |
| } | |
| function mainHtml() { | |
| return `${askHtml()}<div id="vz-out">${answerHtml()}</div>${V.cur < 0 ? glanceHtml() : ""}<div id="vz-selwrap">${selHtml()}</div>`; | |
| } | |
| function ledgerHtml() { | |
| if (!V.data) return `<li class="on"><button aria-current="step"><span class="n">1</span><span>Pick the data</span></button></li><li><button disabled><span class="n">2</span><span>Ask</span></button></li>`; | |
| const items = V.history.map((h, i) => `<li class="${i === V.cur ? "on" : "done"}"><button data-vzh="${i}" ${i === V.cur ? 'aria-current="true"' : ""}><span class="n">${i + 1}</span><span>${esc(h.q.length > 64 ? h.q.slice(0, 62) + "…" : h.q)}</span></button></li>`).join(""); | |
| return `<li class="${V.cur < 0 ? "on" : "done"}"><button data-vzh="-1"><span class="n">·</span><span>At a glance</span></button></li>${items}`; | |
| } | |
| function sheetHtml() { | |
| if (!V.data) return `<h2>Your data</h2><p class="hint">Nothing loaded yet. Charts are drawn from every row you load, so a small sample is fast and exact.</p>`; | |
| const d = V.data, group = (t, f) => { const list = cols().filter(f); return list.length ? `<div class="line"><dt>${t}</dt><dd>${list.map((c) => `<span class="tagc${derivedCol(c.name) ? " made" : ""}">${esc(c.name)}</span>`).join(" ")}</dd></div>` : ""; }; | |
| return `<h2>Your data</h2><dl> | |
| <div class="line"><dt>Name</dt><dd>${esc(d.name)}</dd></div> | |
| <div class="line"><dt>From</dt><dd>${/\//.test(d.source) ? `<a href="https://huggingface.co/datasets/${esc(d.source)}" target="_blank" rel="noopener">${esc(d.source)}</a>` : esc(d.source)}</dd></div> | |
| <div class="line"><dt>Rows</dt><dd>${d.n.toLocaleString()}</dd></div> | |
| ${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))} | |
| </dl>${d.note ? `<p class="hint" style="margin-top:12px">${esc(d.note)}</p>` : ""} | |
| <p class="hint" style="margin-top:14px">Columns the model makes stay here, so follow-up questions reuse them instead of reading every row again.</p> | |
| <div class="actions" style="margin-top:18px"><button class="btn" data-vz="change">Use different data</button></div>`; | |
| } | |
| 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", `<style>${css}</style><rect width="100%" height="100%" fill="#e7e8ea"></rect>`); | |
| 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"); }; | |
| })(); | |