///
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;
/** Optional transform wrapping the options, e.g. {name:"binX", outputs:{y:"count"}}. */
transform?: { name: string; outputs?: Record };
};
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;
y?: Record;
fx?: Record;
fy?: Record;
facet?: Record;
color?: Record;
r?: Record;
opacity?: Record;
symbol?: Record;
length?: Record;
projection?: unknown;
style?: Record;
marks: PlotMarkSpec[];
};
type MarkFactory = (data: unknown, options: Record) => 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;
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) => Plot.Markish>;
type TransformFactory = (
outputs: Record,
options: Record,
) => Record;
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;
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": "", "y": ""} 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 = {};
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 = 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, 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