Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/GALLERY.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ Explaining how code works — call trees, step-by-step traces, and complexity.
| <img alt="Big-O growth curves O(1) to O(n^2) on a symlog scale" src="gallery/domains/complexity.png" /> | |
| **Call hierarchy before/after (`calltree`) — one model, viewer-toggled** | |
| <img alt="call-hierarchy diff as a compact outline — handleRequest() tree with checkCache() added (green +), queryDB() modified (amber ~), validate() removed (red strikethrough), render() renamed; file paths + 120→41ms timings; a Tree/Graph toggle top-right" src="gallery/domains/call-hierarchy.png" /> | <img alt="the same call-hierarchy diff as a graph — handleRequest() root fanning to status-colored change nodes (added green, removed red, modified amber) with the Graph view of the toggle active" src="gallery/domains/call-hierarchy-graph.png" /> |
| **Sortable data table with spreadsheet export (`datatable`)** | |
| <img alt="cloud spend datatable — Service/Region/Monthly(USD)/Δ MoM/Owner columns with numeric columns right-aligned, a Copy (TSV→Sheets/Excel) and Download CSV control in the toolbar, sortable headers" src="gallery/domains/cost-table.png" /> | |

### Algorithm stepping (animated)

Expand Down
Binary file added docs/gallery/domains/cost-table.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
43 changes: 42 additions & 1 deletion packages/core/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,50 @@ export function validateCallNode(node: unknown, path = "roots[0]", depth = 0): s
return null;
}

const DT_ALIGN = new Set(["left", "right", "center"]);

/** Validate a `datatable` payload: `{ title?, columns, rows, note? }`. `columns` is a non-empty array
* of either a non-empty string label or `{ label, align? }`; `rows` is an array of arrays of
* primitives (string|number|boolean|null) no wider than the columns. Path-pointed messages. */
export function validateDataTable(v: unknown): string | null {
if (!isObj(v)) return "datatable content must be { columns: [...], rows: [[...]] }";
if (v.title !== undefined && typeof v.title !== "string") return "datatable.title must be a string";
if (v.note !== undefined && typeof v.note !== "string") return "datatable.note must be a string";
if (!Array.isArray(v.columns) || v.columns.length === 0)
return "datatable.columns must be a non-empty array of column labels";
for (let i = 0; i < v.columns.length; i++) {
const c = v.columns[i];
if (typeof c === "string") {
if (c.length === 0) return `datatable.columns[${i}] label must be a non-empty string`;
} else if (isObj(c)) {
if (typeof c.label !== "string" || c.label.length === 0)
return `datatable.columns[${i}].label must be a non-empty string`;
if (c.align !== undefined && (typeof c.align !== "string" || !DT_ALIGN.has(c.align)))
return `datatable.columns[${i}].align must be one of left|right|center`;
} else {
return `datatable.columns[${i}] must be a string or { label, align? }`;
}
}
if (!Array.isArray(v.rows)) return "datatable.rows must be an array of row arrays";
for (let i = 0; i < v.rows.length; i++) {
const row = v.rows[i];
if (!Array.isArray(row)) return `datatable.rows[${i}] must be an array of cells`;
if (row.length > v.columns.length)
return `datatable.rows[${i}] has ${row.length} cells but there are only ${v.columns.length} columns`;
for (let j = 0; j < row.length; j++) {
const cell = row[j];
const t = typeof cell;
if (cell !== null && t !== "string" && t !== "number" && t !== "boolean")
return `datatable.rows[${i}][${j}] must be a string, number, boolean, or null`;
}
}
return null;
}

/** Validate `content` for a given push `type`. Returns an error message or null (valid / freeform).
* `depth` bounds nested-panes recursion (a pane's content can itself be a `panes` payload). */
export function validateContent(type: string, content: string, depth = 0): string | null {
if (type !== "component" && type !== "panes" && type !== "flow" && type !== "vegalite" && type !== "calltree")
if (type !== "component" && type !== "panes" && type !== "flow" && type !== "vegalite" && type !== "calltree" && type !== "datatable")
return null;
if (depth > MAX_DEPTH) return `panes nested too deeply (> ${MAX_DEPTH} levels)`;
let v: unknown;
Expand All @@ -86,6 +126,7 @@ export function validateContent(type: string, content: string, depth = 0): strin
}
return null;
}
if (type === "datatable") return validateDataTable(v);
// panes
if (!isObj(v) || !Array.isArray(v.panes)) return "panes content must be { layout, panes: [...] }";
for (let i = 0; i < v.panes.length; i++) {
Expand Down
38 changes: 38 additions & 0 deletions packages/viewer/e2e/rich.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ try {
}));
await push("map", "component", ex("map-routes.component.json"));
await push("checklist", "component", ex("task-checklist.component.json"));
await push("datatable", "datatable", ex("cost-table.datatable.json"));
await push("md", "markdown", "# E2E\n\n- one\n- two\n\n`code`");

browser = await chromium.launch({ args: ["--no-sandbox", "--disable-dev-shm-usage"] });
Expand Down Expand Up @@ -504,6 +505,43 @@ try {
await p.waitForTimeout(100);
ok("sidebar expands", await p.evaluate(() => !document.body.classList.contains("sidebar-collapsed")));

// ---- datatable: render + sort + clipboard(TSV) + CSV download --------------------------------
// A dedicated context so we can grant clipboard permissions and capture a download event.
{
const dctx = await browser.newContext({ viewport: { width: 1100, height: 800 }, permissions: ["clipboard-read", "clipboard-write"], acceptDownloads: true });
const dp = await dctx.newPage();
await dp.goto(U, { waitUntil: "domcontentloaded", timeout: 20000 });
await dp.waitForFunction(() => document.querySelector("#status .live-dot"), null, { timeout: 10000 });
await dp.locator(`#scopes button[title="e2e/datatable"]`).click();
await dp.waitForFunction(() => document.querySelectorAll("#diagram .tc-datatable tbody tr").length === 8, null, { timeout: 20000 });
ok("datatable renders 8 rows + Copy/CSV controls", (await dp.locator("#diagram .tc-dt-btn").count()) === 2);

// Sort by "Monthly (USD)" (3rd col) → ascending numeric order.
const monthly = () => dp.$$eval("#diagram .tc-datatable tbody tr td:nth-child(3)", (els) => els.map((e) => Number(e.textContent)));
await dp.$$eval("#diagram .tc-datatable thead th", (ths) => ths[2].click());
await dp.waitForTimeout(150);
{
const col = await monthly();
const asc = [...col].sort((a, b) => a - b);
ok("datatable header click sorts the column (numeric asc)", JSON.stringify(col) === JSON.stringify(asc));
}

// Copy → clipboard holds TSV (tab-separated, header row matches).
await dp.locator("#diagram .tc-dt-btn", { hasText: "Copy" }).click();
await dp.waitForTimeout(150);
const clip = await dp.evaluate(() => navigator.clipboard.readText());
ok("Copy puts TSV on the clipboard (tabs, header row)", clip.split("\n")[0] === "Service\tRegion\tMonthly (USD)\tΔ MoM\tOwner");
ok("copied TSV reflects the sorted view (first data row = cheapest)", clip.split("\n")[1].startsWith("Pub/Sub\t"));

// Download CSV → a .csv file is offered.
const [dl] = await Promise.all([
dp.waitForEvent("download", { timeout: 8000 }),
dp.locator("#diagram .tc-dt-btn", { hasText: "Download CSV" }).click(),
]);
ok("Download CSV offers a .csv file", dl.suggestedFilename().endsWith(".csv"));
await dctx.close();
}

ok("no console/page errors", errors.length === 0 || (console.log(" errors:", errors.slice(0, 5)), false));
} catch (e) {
ok(`harness error: ${e.message}`, false);
Expand Down
1 change: 1 addition & 0 deletions packages/viewer/src/client/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const LOADERS: Record<string, () => Promise<{ mount: Mount }>> = {
vegalite: () => import("./renderers/vegalite.js"),
flow: () => import("./renderers/flow.js"),
calltree: () => import("./renderers/calltree.js"),
datatable: () => import("./renderers/datatable.js"),
};

/** Shown while a renderer chunk downloads (heavy chunks: mermaid, React/Mantine, vega). */
Expand Down
221 changes: 221 additions & 0 deletions packages/viewer/src/client/renderers/datatable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import type { Mount } from "./types.js";
import { errorBlock } from "./errors.js";

// `datatable` — a plain, scrollable table the human can sort, then pull into a spreadsheet. The point
// is the data round-trip: **Copy** puts TAB-separated text on the clipboard (Excel/Google Sheets read
// TSV natively, no quoting hassles), **Download CSV** saves an RFC-4180 file. Both reflect the current
// sort order. No React — a table + two buttons needs none; this keeps the lazy chunk tiny.

export type Cell = string | number | boolean | null;
export type Align = "left" | "right" | "center";
export interface Column { label: string; align: Align; }
export interface Table { title?: string; columns: Column[]; rows: Cell[][]; note?: string; }

type ColSpec = string | { label?: unknown; align?: unknown };
const ALIGNS = new Set<Align>(["left", "right", "center"]);
const isNum = (c: Cell): c is number => typeof c === "number";

/** Parse + normalize a datatable payload. Columns become `{label, align}`; a column with no explicit
* align is right-aligned when every non-null cell in it is a number (so numeric columns line up). */
export function parseDataTable(content: string): Table {
const v = JSON.parse(content) as { title?: unknown; note?: unknown; columns?: ColSpec[]; rows?: Cell[][] };
const rawCols: ColSpec[] = Array.isArray(v.columns) ? v.columns : [];
const rows: Cell[][] = Array.isArray(v.rows) ? v.rows.map((r) => (Array.isArray(r) ? r : [])) : [];
const columns: Column[] = rawCols.map((c, i) => {
const label = typeof c === "string" ? c : String((c && c.label) ?? "");
const explicit = typeof c === "object" && c && ALIGNS.has(c.align as Align) ? (c.align as Align) : undefined;
const colCells = rows.map((r) => r[i]).filter((x) => x !== null && x !== undefined);
const numeric = colCells.length > 0 && colCells.every(isNum);
return { label, align: explicit ?? (numeric ? "right" : "left") };
});
return {
title: typeof v.title === "string" ? v.title : undefined,
note: typeof v.note === "string" ? v.note : undefined,
columns,
rows,
};
}

/** Display/serialize a cell. Numbers are emitted raw (no locale grouping) so what you see equals what
* you copy/export — this is a data tool, not a formatted report. */
export function cellToString(c: Cell): string {
if (c === null || c === undefined) return "";
return typeof c === "boolean" ? (c ? "true" : "false") : String(c);
}

/** Order two cells: numbers numerically, nulls last, otherwise a numeric-aware string compare. */
export function compareCells(a: Cell, b: Cell): number {
const an = a === null || a === undefined, bn = b === null || b === undefined;
if (an && bn) return 0;
if (an) return 1;
if (bn) return -1;
if (isNum(a) && isNum(b)) return a - b;
return cellToString(a).localeCompare(cellToString(b), undefined, { numeric: true });
}

const padRow = (row: Cell[], n: number): Cell[] =>
row.length >= n ? row.slice(0, n) : [...row, ...Array<Cell>(n - row.length).fill(null)];

const escapeCSV = (s: string): string => (/[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);

/** RFC-4180 CSV: header + rows, CRLF line breaks, fields quoted only when needed. */
export function toCSV(t: Table): string {
const n = t.columns.length;
const head = t.columns.map((c) => escapeCSV(c.label)).join(",");
const body = t.rows.map((r) => padRow(r, n).map((c) => escapeCSV(cellToString(c))).join(","));
return [head, ...body].join("\r\n");
}

/** TSV for clipboard paste into Excel/Sheets: tab-delimited, newline rows. Embedded tabs/newlines are
* collapsed to spaces so a stray cell can't shift columns or split a row on paste. */
export function toTSV(t: Table): string {
const n = t.columns.length;
const clean = (s: string) => s.replace(/[\t\r\n]+/g, " ");
const head = t.columns.map((c) => clean(c.label)).join("\t");
const body = t.rows.map((r) => padRow(r, n).map((c) => clean(cellToString(c))).join("\t"));
return [head, ...body].join("\n");
}

const slug = (s: string): string =>
s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);

type SortState = { col: number; dir: "asc" | "desc" } | null;
const SORT_GLYPH: Record<"asc" | "desc", string> = { asc: " ▲", desc: " ▼" };

export const mount: Mount = (el, content) => {
let table: Table;
try {
table = parseDataTable(content);
} catch (e) {
el.innerHTML = errorBlock("datatable", (e as Error).message, content);
return { selfSizing: true };
}

let sort: SortState = null;
let copyTimer: ReturnType<typeof setTimeout> | undefined;

const view = (): Table => {
if (!sort) return table;
const { col, dir } = sort;
const rows = [...table.rows].sort((a, b) => compareCells(a[col], b[col]) * (dir === "asc" ? 1 : -1));
return { ...table, rows };
};

el.innerHTML = "";
const root = document.createElement("div");
root.className = "tc-datatable";

// Toolbar: title + export controls.
const bar = document.createElement("div");
bar.className = "tc-dt-toolbar";
if (table.title) {
const h = document.createElement("span");
h.className = "tc-dt-title";
h.textContent = table.title;
bar.appendChild(h);
}
const spacer = document.createElement("span");
spacer.className = "tc-dt-spacer";
bar.appendChild(spacer);

const copyBtn = document.createElement("button");
copyBtn.type = "button";
copyBtn.className = "tc-dt-btn";
copyBtn.textContent = "Copy";
copyBtn.title = "Copy as tab-separated values (paste into Excel / Google Sheets)";
copyBtn.onclick = async () => {
const tsv = toTSV(view());
try {
await navigator.clipboard.writeText(tsv);
copyBtn.textContent = "Copied ✓";
} catch {
copyBtn.textContent = "Copy failed";
}
clearTimeout(copyTimer);
copyTimer = setTimeout(() => { copyBtn.textContent = "Copy"; }, 1500);
};
bar.appendChild(copyBtn);

const csvBtn = document.createElement("button");
csvBtn.type = "button";
csvBtn.className = "tc-dt-btn";
csvBtn.textContent = "Download CSV";
csvBtn.title = "Download as a .csv file";
csvBtn.onclick = () => {
const blob = new Blob([toCSV(view())], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${slug(table.title || "") || "table"}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
};
bar.appendChild(csvBtn);
root.appendChild(bar);

// Table.
const scroll = document.createElement("div");
scroll.className = "tc-dt-scroll";
const tbl = document.createElement("table");
const thead = document.createElement("thead");
const htr = document.createElement("tr");
const ths: HTMLTableCellElement[] = [];
table.columns.forEach((c, i) => {
const th = document.createElement("th");
th.className = `tc-dt-${c.align}`;
th.textContent = c.label;
th.title = `Sort by ${c.label}`;
th.setAttribute("aria-sort", "none");
th.onclick = () => {
// Cycle: unsorted → asc → desc → unsorted. A new column starts at asc.
if (!sort || sort.col !== i) sort = { col: i, dir: "asc" };
else if (sort.dir === "asc") sort = { col: i, dir: "desc" };
else sort = null;
paint();
};
ths.push(th);
htr.appendChild(th);
});
thead.appendChild(htr);
tbl.appendChild(thead);
const tbody = document.createElement("tbody");
tbl.appendChild(tbody);
scroll.appendChild(tbl);
root.appendChild(scroll);

if (table.note) {
const note = document.createElement("div");
note.className = "tc-dt-note";
note.textContent = table.note;
root.appendChild(note);
}

const paint = () => {
// Header indicators.
table.columns.forEach((c, i) => {
const sorted = sort && sort.col === i;
ths[i].textContent = c.label + (sorted ? SORT_GLYPH[sort!.dir] : "");
ths[i].setAttribute("aria-sort", sorted ? (sort!.dir === "asc" ? "ascending" : "descending") : "none");
ths[i].classList.toggle("tc-dt-sorted", !!sorted);
});
// Body.
const rows = view().rows;
tbody.replaceChildren();
for (const r of rows) {
const tr = document.createElement("tr");
for (let j = 0; j < table.columns.length; j++) {
const td = document.createElement("td");
td.className = `tc-dt-${table.columns[j].align}`;
td.textContent = cellToString(r[j] ?? null);
tr.appendChild(td);
}
tbody.appendChild(tr);
}
};
paint();

el.appendChild(root);
return { selfSizing: true, teardown: () => clearTimeout(copyTimer) };
};
31 changes: 31 additions & 0 deletions packages/viewer/src/client/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -625,3 +625,34 @@ body.history-open #tc-history { display: flex; }
`panes` pane it falls back to flow's default min(70vh,600px) instead of overflowing the pane. */
.tc-ct-graph { flex: 1; min-height: 0; }
#diagram > .tc-calltree .tc-ct-graph .tc-flow-wrap { height: calc(100dvh - 168px); }

/* datatable — sortable table with Copy(TSV)/Download(CSV) export. */
.tc-datatable { display: flex; flex-direction: column; gap: 8px; min-width: 0; padding: 2px; }
.tc-dt-toolbar { display: flex; align-items: center; gap: 8px; }
.tc-dt-title { font-size: 15px; font-weight: 600; color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tc-dt-spacer { flex: 1; }
.tc-dt-btn {
flex-shrink: 0; min-height: 32px; padding: 5px 12px;
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
color: var(--fg); font: inherit; font-size: 12px; cursor: pointer;
transition: background .15s ease, border-color .15s ease, color .15s ease;
}
.tc-dt-btn:hover { background: var(--active); }
.tc-dt-btn:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
/* Horizontal scroll for wide tables; the table never forces the pane wider than its container. */
.tc-dt-scroll { overflow: auto; max-width: 100%; border: 1px solid var(--border); border-radius: 8px; }
.tc-datatable table { border-collapse: collapse; width: 100%; font-size: 13px; }
.tc-datatable th, .tc-datatable td { padding: 7px 12px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.tc-datatable thead th {
position: sticky; top: 0; z-index: 1; background: var(--surface); color: var(--fg);
font-weight: 600; cursor: pointer; user-select: none; border-bottom: 2px solid var(--border);
}
.tc-datatable thead th:hover { background: var(--active); }
.tc-datatable th.tc-dt-sorted { color: var(--accent); }
.tc-datatable tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surface) 45%, transparent); }
.tc-datatable tbody tr:hover { background: var(--surface); }
.tc-datatable td { color: var(--fg); }
.tc-dt-left { text-align: left; }
.tc-dt-right { text-align: right; font-variant-numeric: tabular-nums; }
.tc-dt-center { text-align: center; }
.tc-dt-note { font-size: 12px; color: var(--muted); }
1 change: 1 addition & 0 deletions packages/viewer/src/client/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const DEMO_INTROS: Record<string, string> = {
"ops/order-fulfillment": "An order-fulfillment process as swimlanes across Customer, Storefront, Order, and Fulfillment, showing every cross-team hand-off.",
"compare/headphones": "A side-by-side comparison of noise-cancelling headphones — photos, prices, ratings, ‘best for’ picks, and a link to each deal.",
"risk/pre-mortem": "A project pre-mortem — a likelihood × impact heat-grid scoring risks R1–R10, each with a mitigation.",
"finops/cloud-spend": "Cloud spend by service as a sortable data table — click a column to sort, then Copy (paste into Sheets/Excel) or Download CSV.",
"algo/binary-search": "A live binary search — watch the window halve, step by step, as the algorithm closes in on its target.",
};

Expand Down
Loading
Loading