Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
788 lines
28 KiB
TypeScript
788 lines
28 KiB
TypeScript
/// <reference lib="dom" />
|
|
import * as Plot from "@observablehq/plot";
|
|
import { MAX_CHART_DATA_ROWS } from "@rakazo/contracts";
|
|
import { autoType, csvParse, tsvParse } from "d3-dsv";
|
|
|
|
/**
|
|
* Declarative, JSON-only surface over Observable Plot for the render_plot
|
|
* agent tool. Marks and transforms are allowlisted by name; specs carry plain
|
|
* data and options, never code, so rendering stays safe server-side.
|
|
*/
|
|
|
|
export type PlotMarkSpec = {
|
|
type: string;
|
|
/** Per-mark data override; falls back to the spec-level data. */
|
|
data?: unknown[];
|
|
/** Mark channel options: x, y, stroke, fill, fx, fy, sort, tip, … */
|
|
options?: Record<string, unknown>;
|
|
/** Optional transform wrapping the options, e.g. {name:"binX", outputs:{y:"count"}}. */
|
|
transform?: { name: string; outputs?: Record<string, unknown> };
|
|
};
|
|
|
|
export type PlotSpec = {
|
|
title?: string;
|
|
/** Rows may also arrive nested in the spec; treated like top-level data. */
|
|
data?: unknown[];
|
|
width?: number;
|
|
height?: number;
|
|
marginLeft?: number;
|
|
marginRight?: number;
|
|
marginTop?: number;
|
|
marginBottom?: number;
|
|
grid?: boolean;
|
|
inset?: number;
|
|
aspectRatio?: number;
|
|
x?: Record<string, unknown>;
|
|
y?: Record<string, unknown>;
|
|
fx?: Record<string, unknown>;
|
|
fy?: Record<string, unknown>;
|
|
facet?: Record<string, unknown>;
|
|
color?: Record<string, unknown>;
|
|
r?: Record<string, unknown>;
|
|
opacity?: Record<string, unknown>;
|
|
symbol?: Record<string, unknown>;
|
|
length?: Record<string, unknown>;
|
|
projection?: unknown;
|
|
style?: Record<string, unknown>;
|
|
marks: PlotMarkSpec[];
|
|
};
|
|
|
|
type MarkFactory = (data: unknown, options: Record<string, unknown>) => Plot.Markish;
|
|
|
|
const DATA_MARKS = {
|
|
area: Plot.area,
|
|
areaX: Plot.areaX,
|
|
areaY: Plot.areaY,
|
|
arrow: Plot.arrow,
|
|
barX: Plot.barX,
|
|
barY: Plot.barY,
|
|
boxX: Plot.boxX,
|
|
boxY: Plot.boxY,
|
|
cell: Plot.cell,
|
|
cellX: Plot.cellX,
|
|
cellY: Plot.cellY,
|
|
circle: Plot.circle,
|
|
cluster: Plot.cluster,
|
|
contour: Plot.contour as unknown as MarkFactory,
|
|
delaunayLink: Plot.delaunayLink,
|
|
delaunayMesh: Plot.delaunayMesh,
|
|
density: Plot.density,
|
|
dot: Plot.dot,
|
|
dotX: Plot.dotX,
|
|
dotY: Plot.dotY,
|
|
hull: Plot.hull,
|
|
line: Plot.line,
|
|
lineX: Plot.lineX,
|
|
lineY: Plot.lineY,
|
|
linearRegressionX: Plot.linearRegressionX,
|
|
linearRegressionY: Plot.linearRegressionY,
|
|
link: Plot.link,
|
|
raster: Plot.raster as unknown as MarkFactory,
|
|
rect: Plot.rect,
|
|
rectX: Plot.rectX,
|
|
rectY: Plot.rectY,
|
|
ruleX: Plot.ruleX,
|
|
ruleY: Plot.ruleY,
|
|
spike: Plot.spike,
|
|
text: Plot.text,
|
|
textX: Plot.textX,
|
|
textY: Plot.textY,
|
|
tickX: Plot.tickX,
|
|
tickY: Plot.tickY,
|
|
tree: Plot.tree,
|
|
vector: Plot.vector,
|
|
vectorX: Plot.vectorX,
|
|
vectorY: Plot.vectorY,
|
|
voronoi: Plot.voronoi,
|
|
voronoiMesh: Plot.voronoiMesh,
|
|
waffleX: Plot.waffleX,
|
|
waffleY: Plot.waffleY,
|
|
} as unknown as Record<string, MarkFactory>;
|
|
|
|
const DATALESS_MARKS = {
|
|
axisX: Plot.axisX,
|
|
axisY: Plot.axisY,
|
|
frame: Plot.frame,
|
|
gridX: Plot.gridX,
|
|
gridY: Plot.gridY,
|
|
hexgrid: Plot.hexgrid,
|
|
sphere: Plot.sphere,
|
|
graticule: Plot.graticule,
|
|
} as unknown as Record<string, (options: Record<string, unknown>) => Plot.Markish>;
|
|
|
|
type TransformFactory = (
|
|
outputs: Record<string, unknown>,
|
|
options: Record<string, unknown>,
|
|
) => Record<string, unknown>;
|
|
|
|
const TRANSFORMS = {
|
|
bin: Plot.bin,
|
|
binX: Plot.binX,
|
|
binY: Plot.binY,
|
|
group: Plot.group,
|
|
groupX: Plot.groupX,
|
|
groupY: Plot.groupY,
|
|
groupZ: Plot.groupZ,
|
|
hexbin: Plot.hexbin,
|
|
normalizeX: Plot.normalizeX,
|
|
normalizeY: Plot.normalizeY,
|
|
windowX: Plot.windowX,
|
|
windowY: Plot.windowY,
|
|
stackX: Plot.stackX,
|
|
stackY: Plot.stackY,
|
|
dodgeX: Plot.dodgeX,
|
|
dodgeY: Plot.dodgeY,
|
|
shiftX: Plot.shiftX,
|
|
select: Plot.select,
|
|
} as unknown as Record<string, TransformFactory>;
|
|
|
|
export function supportedPlotNames(): { marks: string[]; transforms: string[] } {
|
|
return {
|
|
marks: [...Object.keys(DATA_MARKS), ...Object.keys(DATALESS_MARKS)].sort(),
|
|
transforms: Object.keys(TRANSFORMS).sort(),
|
|
};
|
|
}
|
|
|
|
/** Bound all row arrays carried by a plot, including per-mark overrides. */
|
|
export function assertPlotDataWithinLimits(spec: PlotSpec, data: unknown[] | undefined): void {
|
|
const marks = Array.isArray(spec.marks) ? spec.marks : [];
|
|
const sources = [data, spec.data, ...marks.map((mark) => mark.data)].filter(
|
|
(rows): rows is unknown[] => Array.isArray(rows),
|
|
);
|
|
const rowCount = sources.reduce((total, rows) => total + rows.length, 0);
|
|
if (rowCount > MAX_CHART_DATA_ROWS) {
|
|
throw new Error(
|
|
`Plot data exceeds the ${MAX_CHART_DATA_ROWS.toLocaleString("en-US")}-row limit across top-level, spec, and mark data.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Channels where a string must name a column; a typo otherwise renders an
|
|
// empty chart with no error, which strands the calling model.
|
|
const COLUMN_CHANNELS = ["x", "y", "x1", "x2", "y1", "y2", "fx", "fy"] as const;
|
|
|
|
function assertChannelsMatchColumns(mark: PlotMarkSpec, data: unknown[]): void {
|
|
const first = data[0];
|
|
if (!first || typeof first !== "object" || Array.isArray(first)) return;
|
|
const keys = Object.keys(first);
|
|
const available = new Set(keys);
|
|
let namedChannels = 0;
|
|
for (const channel of COLUMN_CHANNELS) {
|
|
const value = mark.options?.[channel];
|
|
if (value !== undefined) namedChannels += 1;
|
|
if (typeof value === "string" && !available.has(value)) {
|
|
throw new Error(
|
|
`Mark "${mark.type}": ${channel} refers to "${value}" but the data columns are: ${keys.join(", ")}. Channel names must match data keys exactly.`,
|
|
);
|
|
}
|
|
}
|
|
if (namedChannels === 0 && !mark.transform) {
|
|
throw new Error(
|
|
`Mark "${mark.type}" has object rows but no position channels; set options like {"x": "<column>", "y": "<column>"} using these columns: ${keys.join(", ")}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Models also hand over data as CSV text lines; parse those into rows. */
|
|
function normalizeRows(rows: unknown[]): unknown[] {
|
|
if (
|
|
rows.length > 1 &&
|
|
rows.every((row) => typeof row === "string") &&
|
|
(rows[0] as string).includes(",")
|
|
) {
|
|
return parsePlotData("data.csv", rows.join("\n"));
|
|
}
|
|
return coerceNumericStrings(rows);
|
|
}
|
|
|
|
/** Models often send numbers as JSON strings; ordinal-typed values then stack
|
|
into one giant bar instead of measuring. Coerce numeric-looking strings. */
|
|
function coerceNumericStrings(rows: unknown[]): unknown[] {
|
|
return rows.map((row) => {
|
|
if (!row || typeof row !== "object" || Array.isArray(row)) return row;
|
|
const out: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(row)) {
|
|
out[key] = typeof value === "string" && isPlainNumber(value) ? Number(value) : value;
|
|
}
|
|
return out;
|
|
});
|
|
}
|
|
|
|
function isPlainNumber(value: string): boolean {
|
|
const trimmed = value.trim();
|
|
const parsed = Number(trimmed);
|
|
return trimmed !== "" && Number.isFinite(parsed) && String(parsed) === trimmed;
|
|
}
|
|
|
|
function buildMark(
|
|
mark: PlotMarkSpec,
|
|
sharedData: unknown[] | undefined,
|
|
normalizeData: (rows: unknown[]) => unknown[],
|
|
): Plot.Markish {
|
|
const options = { ...(mark.options ?? {}) };
|
|
const dataless = DATALESS_MARKS[mark.type];
|
|
if (dataless) return dataless(options);
|
|
const factory = DATA_MARKS[mark.type];
|
|
if (!factory) {
|
|
throw new Error(
|
|
`Unsupported mark type "${mark.type}". Supported marks: ${supportedPlotNames().marks.join(", ")}`,
|
|
);
|
|
}
|
|
const rawData = mark.data ?? sharedData;
|
|
if (!rawData) throw new Error(`Mark "${mark.type}" has no data and the spec has no shared data.`);
|
|
if (Array.isArray(rawData) && rawData.length === 0) {
|
|
throw new Error(`Mark "${mark.type}" received an empty data array; pass the rows in "data".`);
|
|
}
|
|
const data = normalizeData(rawData);
|
|
assertChannelsMatchColumns(mark, data);
|
|
let finalOptions: Record<string, unknown> = options;
|
|
if (mark.transform) {
|
|
const transform = TRANSFORMS[mark.transform.name];
|
|
if (!transform) {
|
|
throw new Error(
|
|
`Unsupported transform "${mark.transform.name}". Supported transforms: ${supportedPlotNames().transforms.join(", ")}`,
|
|
);
|
|
}
|
|
const outputs = mark.transform.outputs ?? {};
|
|
// normalize* takes a bare basis value ("sum", "extent", …), not an outputs object.
|
|
const first = mark.transform.name.startsWith("normalize")
|
|
? ((outputs as { basis?: unknown }).basis ?? "sum")
|
|
: outputs;
|
|
finalOptions = transform(first as Record<string, unknown>, options);
|
|
}
|
|
return factory(data, finalOptions);
|
|
}
|
|
|
|
export function parsePlotData(name: string, content: string): unknown[] {
|
|
if (/\.csv$/i.test(name)) return csvParse(content, autoType);
|
|
if (/\.tsv$/i.test(name)) return tsvParse(content, autoType);
|
|
const parsed = JSON.parse(content) as unknown;
|
|
if (!Array.isArray(parsed))
|
|
throw new Error("JSON data files must contain a top-level array of rows");
|
|
return parsed;
|
|
}
|
|
|
|
const XMLNS = "http://www.w3.org/2000/xmlns/";
|
|
|
|
function isSafePlotLink(href: string): boolean {
|
|
try {
|
|
// Use the URL parser's control-character and scheme handling. The fixed
|
|
// base allows relative links even in the server's about:blank document;
|
|
// retain the original attribute so clients resolve it against their page.
|
|
const { protocol } = new URL(href, "https://plot.invalid/");
|
|
return ["http:", "https:", "mailto:", "tel:"].includes(protocol);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function sanitizePlotLinks(plotted: Element): void {
|
|
// Inspect the resolved output: href can come from columns, per-mark arrays,
|
|
// channel objects, scales, or transforms. Check both href and xlink:href
|
|
// before either mounting the live plot or serializing it for export.
|
|
for (const anchor of Array.from(plotted.querySelectorAll("a"))) {
|
|
for (const attribute of Array.from(anchor.attributes)) {
|
|
if (attribute.localName === "href" && !isSafePlotLink(attribute.value)) {
|
|
anchor.removeAttributeNode(attribute);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export type PlotParts = {
|
|
/** The rendered element (an <svg>, or a <figure> wrapping one). */
|
|
plotted: Element;
|
|
/** The plot's own <svg>, for serialization. */
|
|
svg: Element;
|
|
title?: string;
|
|
swatches: { label: string; color: string }[];
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
/**
|
|
* Build the rendered plot element plus title and categorical legend metadata.
|
|
* Shared by the server (which composes a standalone SVG for PNG export) and
|
|
* the browser (which mounts the element live and draws title/legend as HTML).
|
|
*/
|
|
export function buildPlotParts(
|
|
spec: PlotSpec,
|
|
data: unknown[] | undefined,
|
|
document: Document,
|
|
overrides: { width?: number; height?: number } = {},
|
|
): PlotParts {
|
|
if (!Array.isArray(spec.marks) || spec.marks.length === 0) {
|
|
throw new Error("The spec needs a non-empty marks array.");
|
|
}
|
|
assertPlotDataWithinLimits(spec, data);
|
|
const { title, marks, data: nestedData, ...plotOptions } = spec;
|
|
const sharedData = data ?? (Array.isArray(nestedData) ? nestedData : undefined);
|
|
const normalizedData = new WeakMap<unknown[], unknown[]>();
|
|
const normalizeData = (rows: unknown[]) => {
|
|
const cached = normalizedData.get(rows);
|
|
if (cached) return cached;
|
|
const normalized = normalizeRows(rows);
|
|
normalizedData.set(rows, normalized);
|
|
return normalized;
|
|
};
|
|
const sanitizedPlotOptions = { ...plotOptions } as Record<string, unknown>;
|
|
for (const scale of ["color", "x", "y", "fx", "fy", "r", "opacity", "symbol"] as const) {
|
|
const options = sanitizedPlotOptions[scale];
|
|
// Plot renders legends/titles as HTML <figure> wrappers, which cannot
|
|
// rasterize to a standalone image; title and swatches are composed by the
|
|
// caller instead.
|
|
if (options && typeof options === "object") {
|
|
const scaleOptions = { ...(options as Record<string, unknown>) };
|
|
delete scaleOptions.legend;
|
|
sanitizedPlotOptions[scale] = scaleOptions;
|
|
}
|
|
}
|
|
const plotted = Plot.plot({
|
|
document,
|
|
...(sanitizedPlotOptions as Plot.PlotOptions),
|
|
...(overrides.width ? { width: overrides.width } : {}),
|
|
...(overrides.height ? { height: overrides.height } : {}),
|
|
marks: marks.map((mark) => buildMark(mark, sharedData, normalizeData)),
|
|
});
|
|
sanitizePlotLinks(plotted);
|
|
const svg = plotted.tagName === "svg" ? plotted : plotted.querySelector("svg");
|
|
if (!svg) throw new Error("Plot did not produce an SVG element");
|
|
const width = Number(svg.getAttribute("width") ?? overrides.width ?? spec.width ?? 640);
|
|
const height = Number(svg.getAttribute("height") ?? overrides.height ?? spec.height ?? 400);
|
|
const colorScale = (
|
|
plotted as unknown as { scale?: (name: string) => Plot.Scale | undefined }
|
|
).scale?.("color");
|
|
return { plotted, svg, title, swatches: categoricalSwatches(colorScale), width, height };
|
|
}
|
|
|
|
/** Render a spec to a standalone SVG string using the provided DOM document. */
|
|
export function renderPlotSpecToSvg(
|
|
spec: PlotSpec,
|
|
data: unknown[] | undefined,
|
|
document: Document,
|
|
): string {
|
|
const { svg, title, swatches, width, height } = buildPlotParts(spec, data, document);
|
|
const titleHeight = title ? 28 : 0;
|
|
const legendHeight = swatches.length > 0 ? 24 : 0;
|
|
const totalHeight = height + titleHeight + legendHeight;
|
|
|
|
svg.setAttributeNS(XMLNS, "xmlns", "http://www.w3.org/2000/svg");
|
|
svg.setAttributeNS(XMLNS, "xmlns:xlink", "http://www.w3.org/1999/xlink");
|
|
svg.setAttribute("y", String(titleHeight + legendHeight));
|
|
const header: string[] = [];
|
|
if (title) {
|
|
header.push(
|
|
`<text x="8" y="19" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="currentColor">${escapeXml(title)}</text>`,
|
|
);
|
|
}
|
|
let swatchX = 8;
|
|
for (const { label, color } of swatches) {
|
|
header.push(
|
|
`<rect x="${swatchX}" y="${titleHeight + 5}" width="11" height="11" fill="${escapeXml(color)}"></rect>`,
|
|
`<text x="${swatchX + 15}" y="${titleHeight + 15}" font-family="system-ui, sans-serif" font-size="12" fill="currentColor">${escapeXml(label)}</text>`,
|
|
);
|
|
swatchX += 15 + 8 + label.length * 7 + 14;
|
|
}
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${totalHeight}" viewBox="0 0 ${width} ${totalHeight}" font-family="system-ui, sans-serif" style="background:#ffffff;color:#1a1a1a">${header.join("")}${svg.outerHTML}</svg>`;
|
|
}
|
|
|
|
function categoricalSwatches(scale: Plot.Scale | undefined): { label: string; color: string }[] {
|
|
if (!scale || typeof scale.apply !== "function") return [];
|
|
const domain = Array.isArray(scale.domain) ? scale.domain : [];
|
|
if (domain.length === 0 || domain.length > 12) return [];
|
|
if (scale.type && scale.type !== "ordinal" && scale.type !== "categorical") return [];
|
|
return domain.map((value) => ({
|
|
label: String(value),
|
|
color: String(scale.apply(value)),
|
|
}));
|
|
}
|
|
|
|
function escapeXml(value: string): string {
|
|
return value
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
export type ChartCatalogEntry = {
|
|
name: string;
|
|
when: string;
|
|
keywords: string;
|
|
/** Complete runnable example: substitute your own rows and column names. */
|
|
spec: PlotSpec;
|
|
};
|
|
|
|
const SALES = [
|
|
{ quarter: "Q1", region: "east", sales: 120 },
|
|
{ quarter: "Q2", region: "east", sales: 185 },
|
|
{ quarter: "Q3", region: "east", sales: 143 },
|
|
{ quarter: "Q4", region: "east", sales: 210 },
|
|
{ quarter: "Q1", region: "west", sales: 90 },
|
|
{ quarter: "Q2", region: "west", sales: 132 },
|
|
{ quarter: "Q3", region: "west", sales: 166 },
|
|
{ quarter: "Q4", region: "west", sales: 158 },
|
|
];
|
|
|
|
const MEASURES = [
|
|
{ size: 4.2, weight: 31, kind: "a" },
|
|
{ size: 5.1, weight: 42, kind: "a" },
|
|
{ size: 6.3, weight: 51, kind: "b" },
|
|
{ size: 4.8, weight: 36, kind: "b" },
|
|
{ size: 5.9, weight: 48, kind: "a" },
|
|
{ size: 6.8, weight: 57, kind: "b" },
|
|
{ size: 5.4, weight: 44, kind: "a" },
|
|
{ size: 4.5, weight: 33, kind: "b" },
|
|
];
|
|
|
|
export const CHART_CATALOG: ChartCatalogEntry[] = [
|
|
{
|
|
name: "bar",
|
|
when: "Compare one value across categories",
|
|
keywords: "bar column comparison rank vendor category",
|
|
spec: {
|
|
title: "Sales by quarter",
|
|
data: SALES.slice(0, 4),
|
|
marks: [{ type: "barY", options: { x: "quarter", y: "sales" } }],
|
|
},
|
|
},
|
|
{
|
|
name: "horizontal bar",
|
|
when: "Compare categories with long names, sorted by value",
|
|
keywords: "bar horizontal rank sorted top",
|
|
spec: {
|
|
title: "Sales by quarter",
|
|
data: SALES.slice(0, 4),
|
|
marginLeft: 60,
|
|
marks: [{ type: "barX", options: { y: "quarter", x: "sales", sort: { y: "-x" } } }],
|
|
},
|
|
},
|
|
{
|
|
name: "stacked bar",
|
|
when: "Category totals split by a sub-category",
|
|
keywords: "stacked composition part-of-whole share breakdown",
|
|
spec: {
|
|
title: "Sales by quarter and region",
|
|
data: SALES,
|
|
marks: [{ type: "barY", options: { x: "quarter", y: "sales", fill: "region" } }],
|
|
},
|
|
},
|
|
{
|
|
name: "grouped bar",
|
|
when: "Compare sub-categories side by side within groups",
|
|
keywords: "grouped side-by-side clustered",
|
|
spec: {
|
|
title: "Sales by region per quarter",
|
|
data: SALES,
|
|
marks: [
|
|
{ type: "barY", options: { fx: "quarter", x: "region", y: "sales", fill: "region" } },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
name: "normalized bar",
|
|
when: "Compare percentage shares across categories",
|
|
keywords: "percent share normalized 100%",
|
|
spec: {
|
|
title: "Regional share per quarter",
|
|
data: SALES,
|
|
y: { percent: true },
|
|
marks: [
|
|
{
|
|
type: "barY",
|
|
transform: { name: "normalizeY", outputs: { basis: "sum" } },
|
|
options: { x: "quarter", y: "sales", fill: "region" },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
name: "histogram",
|
|
when: "Distribution of one numeric column",
|
|
keywords: "histogram distribution frequency bins spread",
|
|
spec: {
|
|
title: "Weight distribution",
|
|
data: MEASURES,
|
|
marks: [
|
|
{
|
|
type: "rectY",
|
|
transform: { name: "binX", outputs: { y: "count" } },
|
|
options: { x: "weight" },
|
|
},
|
|
{ type: "ruleY", data: [0] },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
name: "scatter",
|
|
when: "Relationship between two numeric columns",
|
|
keywords: "scatter correlation relationship points xy",
|
|
spec: {
|
|
title: "Weight vs size",
|
|
data: MEASURES,
|
|
grid: true,
|
|
marks: [{ type: "dot", options: { x: "size", y: "weight", stroke: "kind" } }],
|
|
},
|
|
},
|
|
{
|
|
name: "scatter with trend",
|
|
when: "Relationship plus a linear trend line",
|
|
keywords: "regression trend fit correlation",
|
|
spec: {
|
|
title: "Weight vs size with trend",
|
|
data: MEASURES,
|
|
marks: [
|
|
{ type: "dot", options: { x: "size", y: "weight" } },
|
|
{ type: "linearRegressionY", options: { x: "size", y: "weight", stroke: "red" } },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
name: "bubble",
|
|
when: "Scatter with a third value shown as dot size",
|
|
keywords: "bubble size magnitude three variables",
|
|
spec: {
|
|
title: "Sized by weight",
|
|
data: MEASURES,
|
|
marks: [
|
|
{
|
|
type: "dot",
|
|
options: { x: "size", y: "weight", r: "weight", fill: "kind", fillOpacity: 0.6 },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
name: "line",
|
|
when: "A value changing over an ordered axis (time)",
|
|
keywords: "line time series trend over-time",
|
|
spec: {
|
|
title: "Sales over quarters",
|
|
data: SALES.slice(0, 4),
|
|
marks: [
|
|
{ type: "lineY", options: { x: "quarter", y: "sales" } },
|
|
{ type: "dot", options: { x: "quarter", y: "sales" } },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
name: "multi line",
|
|
when: "One line per category over time",
|
|
keywords: "multi-series lines compare trends",
|
|
spec: {
|
|
title: "Sales by region",
|
|
data: SALES,
|
|
marks: [{ type: "lineY", options: { x: "quarter", y: "sales", stroke: "region" } }],
|
|
},
|
|
},
|
|
{
|
|
name: "area",
|
|
when: "Magnitude over time with filled area",
|
|
keywords: "area filled volume cumulative",
|
|
spec: {
|
|
title: "Sales area",
|
|
data: SALES.slice(0, 4),
|
|
marks: [
|
|
{ type: "areaY", options: { x: "quarter", y: "sales", fillOpacity: 0.4 } },
|
|
{ type: "lineY", options: { x: "quarter", y: "sales" } },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
name: "stacked area",
|
|
when: "Composition over time",
|
|
keywords: "stacked area composition over-time streamgraph",
|
|
spec: {
|
|
title: "Sales composition",
|
|
data: SALES,
|
|
marks: [{ type: "areaY", options: { x: "quarter", y: "sales", fill: "region" } }],
|
|
},
|
|
},
|
|
{
|
|
name: "heatmap",
|
|
when: "A value across two categorical axes",
|
|
keywords: "heatmap matrix grid cells intensity",
|
|
spec: {
|
|
title: "Sales heat",
|
|
data: SALES,
|
|
marks: [{ type: "cell", options: { x: "quarter", y: "region", fill: "sales" } }],
|
|
},
|
|
},
|
|
{
|
|
name: "box plot",
|
|
when: "Distribution summary per category",
|
|
keywords: "box whisker quartile median outliers",
|
|
spec: {
|
|
title: "Weight by kind",
|
|
data: MEASURES,
|
|
marks: [{ type: "boxY", options: { x: "kind", y: "weight" } }],
|
|
},
|
|
},
|
|
{
|
|
name: "strip plot",
|
|
when: "Raw value ticks per category",
|
|
keywords: "strip ticks raw values jitter",
|
|
spec: {
|
|
title: "Weights by kind",
|
|
data: MEASURES,
|
|
marks: [{ type: "tickX", options: { x: "weight", y: "kind" } }],
|
|
},
|
|
},
|
|
{
|
|
name: "waffle",
|
|
when: "Part-of-whole as counted squares (pie-chart alternative)",
|
|
keywords: "waffle pie donut share proportion",
|
|
spec: {
|
|
title: "Sales share",
|
|
data: SALES.slice(0, 4),
|
|
marks: [{ type: "waffleY", options: { x: "quarter", y: "sales", fill: "quarter" } }],
|
|
},
|
|
},
|
|
{
|
|
name: "small multiples",
|
|
when: "Same chart repeated per category",
|
|
keywords: "facets small-multiples panels grid per-category",
|
|
spec: {
|
|
title: "Per-region panels",
|
|
data: SALES,
|
|
marks: [{ type: "barY", options: { x: "quarter", y: "sales", fx: "region" } }],
|
|
},
|
|
},
|
|
{
|
|
name: "moving average",
|
|
when: "Smooth a noisy series",
|
|
keywords: "rolling moving average smooth window",
|
|
spec: {
|
|
title: "Smoothed sales",
|
|
data: SALES,
|
|
marks: [
|
|
{ type: "lineY", options: { x: "quarter", y: "sales", stroke: "#bbb" } },
|
|
{
|
|
type: "lineY",
|
|
transform: { name: "windowY", outputs: { k: 3, reduce: "mean" } },
|
|
options: { x: "quarter", y: "sales" },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
name: "annotated",
|
|
when: "Any chart plus reference lines and labels",
|
|
keywords: "annotation reference threshold target label",
|
|
spec: {
|
|
title: "Sales vs target",
|
|
data: SALES.slice(0, 4),
|
|
marks: [
|
|
{ type: "barY", options: { x: "quarter", y: "sales" } },
|
|
{ type: "ruleY", data: [150], options: { stroke: "red" } },
|
|
{
|
|
type: "text",
|
|
data: [{ quarter: "Q1", sales: 160 }],
|
|
options: { x: "quarter", y: "sales", text: ["target 150"], fill: "red" },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
];
|
|
|
|
export function searchChartCatalog(query?: string): ChartCatalogEntry[] {
|
|
if (!query || typeof query !== "string") return CHART_CATALOG;
|
|
const needles = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
const matches = CHART_CATALOG.filter((entry) => {
|
|
const haystack = `${entry.name} ${entry.when} ${entry.keywords}`.toLowerCase();
|
|
return needles.some((needle) => haystack.includes(needle));
|
|
});
|
|
return matches.length > 0 ? matches : CHART_CATALOG;
|
|
}
|
|
|
|
export const PLOT_TOOL_GUIDE = `# render_plot — deep data visualization skill
|
|
|
|
You can produce publication-quality charts with the render_plot tool, backed by
|
|
Observable Plot (the grammar behind the Observable Plot gallery). You describe
|
|
the chart as a JSON spec; the tool renders it to a PNG in your workspace and
|
|
attaches it to the chat.
|
|
|
|
## How to use the tool
|
|
|
|
1. Get or shape the data: an array of row objects. Either pass it inline as
|
|
"data", or write it to a .csv/.tsv/.json file in your home and pass
|
|
"data_path". CSV/TSV columns are auto-typed (numbers and dates are parsed).
|
|
2. Choose the chart form for the question. Call render_plot with
|
|
{"charts": true} to list every chart type with a complete runnable example
|
|
spec, or {"charts": "distribution"} to search by keyword. Copy the closest
|
|
example and substitute your rows and column names.
|
|
3. Call render_plot with {"spec": {...}, "data": [...]} or
|
|
{"spec": {...}, "data_path": "sales.csv"}.
|
|
4. The tool attaches the PNG to the chat automatically (attach: false to skip)
|
|
and returns the output path, for example charts/plot-1.png.
|
|
5. Iterate: adjust marks, scales, and facets until the chart answers the
|
|
question. Prefer several small focused charts over one crowded chart.
|
|
|
|
## Spec format
|
|
|
|
{
|
|
"title": "optional chart title",
|
|
"width": 640, "height": 400,
|
|
"grid": true,
|
|
"x": {"label": "…", "type": "log|linear|utc|band|point", "domain": […]},
|
|
"y": {…}, "color": {"scheme": "tableau10|blues|turbo|…", "type": "diverging"},
|
|
"fx": {…}, "fy": {…}, // facet scales
|
|
"marks": [
|
|
{"type": "dot", "options": {"x": "field", "y": "field", "stroke": "category", "tip": true}},
|
|
{"type": "ruleY", "data": [0]} // per-mark data for annotations
|
|
]
|
|
}
|
|
|
|
Channel values are column names ("x": "price"), constants ("fill": "#4269d0"),
|
|
or for faceting put "fx"/"fy" in a mark's options. Add "sort": {"x": "-y"} to
|
|
order categories by value. Transforms wrap a mark's options:
|
|
|
|
{"type": "rectY", "transform": {"name": "binX", "outputs": {"y": "count"}},
|
|
"options": {"x": "weight", "fill": "species"}}
|
|
|
|
## Chart catalog (from the Plot gallery)
|
|
|
|
- Bar chart: barY + {"x": category, "y": value}; horizontal: barX with y category.
|
|
- Grouped / stacked bars: add "fill": category (stacks automatically);
|
|
grouped: use "fx": category with barY.
|
|
- Histogram: rectY + transform binX with outputs {"y": "count"}.
|
|
- 2D histogram / heatmap: rect + transform bin with outputs {"fill": "count"};
|
|
categorical heatmap: cell + {"x", "y", "fill": value}.
|
|
- Hex density: dot with transform hexbin, outputs {"r": "count"} or {"fill": "count"}.
|
|
- Scatterplot: dot + {"x", "y"}; bubble: add "r": value; add
|
|
linearRegressionY for a trend line.
|
|
- Line chart: lineY + {"x": time, "y": value}; multi-series: add "stroke": category.
|
|
- Moving average: lineY + transform windowY; the window settings go in the
|
|
transform outputs, e.g. {"name": "windowY", "outputs": {"k": 7, "reduce": "mean"}}.
|
|
- Area / streamgraph: areaY + {"x", "y", "fill": category} (+ "offset": "wiggle"
|
|
in the y scale for streamgraphs).
|
|
- Box plot: boxY + {"x": category, "y": value}.
|
|
- Distribution ticks / strip plot: tickX + {"x": value, "y": category}.
|
|
- Slope / bump styles: line + point marks combined.
|
|
- Normalized shares: barY with transform normalizeY, or y scale {"percent": true}.
|
|
- Small multiples: any mark + "fx"/"fy" in its options (one panel per category).
|
|
- Candlestick-style ranges: ruleX with {"x", "y1", "y2"}.
|
|
- Tree / hierarchy: tree mark with path data like ["a/b/c", …].
|
|
- Network / flows: arrow or link marks with {"x1","y1","x2","y2"}.
|
|
- Contours / density: density for 2D point density, contour for gridded values.
|
|
- Waffle chart: waffleY + {"y": value, "fill": category}.
|
|
- Annotations: text marks for labels, ruleX/ruleY with per-mark "data" for
|
|
reference lines, frame for panel borders.
|
|
- Maps: projection option (e.g. "equal-earth") with geo-ready coordinates on
|
|
dot/spike/vector marks; sphere and graticule marks for context.
|
|
|
|
## Design rules
|
|
|
|
- Always label: set x/y {"label": …} when column names are cryptic; add a title.
|
|
- "tip": true on the primary mark documents exact values in the SVG structure.
|
|
- Use color only when it encodes something; categorical legends render
|
|
automatically for up to 12 categories.
|
|
- Continuous color scales render without a legend bar — encode the scale
|
|
meaning in the title or caption text instead.
|
|
- Sort categorical axes by value ("sort": {"x": "-y"}) unless order is inherent.
|
|
- Prefer binning/aggregation transforms over plotting tens of thousands of raw
|
|
points; raw dots beyond ~5k rows get slow and unreadable.
|
|
|
|
Call render_plot with {"help": true} anytime to reread this guide.
|
|
`;
|