import { z } from "zod";
import type { GenerativeUILibrary } from "../types";
import { toTextContent } from "./toTextContent";
const columnSchema = z.object({
label: z.string().describe("Column header label."),
});
const cellSchema = z
.union([z.string(), z.number(), z.boolean()])
.describe("A cell value.");
type TableColumn = { label: string };
type TableCell = string | number | boolean;
const isTableColumn = (column: unknown): column is TableColumn =>
column !== null &&
typeof column === "object" &&
"label" in column &&
typeof column.label === "string";
const isTableCell = (cell: unknown): cell is TableCell =>
typeof cell === "string" ||
typeof cell === "number" ||
typeof cell === "boolean";
const CHART_HEIGHT = 40;
const CHART_WIDTH = 100;
const clampValue = (value: unknown): number =>
typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
const yFor = (value: number, max: number): number =>
max > 0 ? CHART_HEIGHT - (value / max) * CHART_HEIGHT : CHART_HEIGHT;
const pointSchema = z.object({
label: z.string().optional().describe("Point label."),
value: z.number().describe("Point value."),
});
const seriesSchema = z.object({
label: z.string().optional().describe("Series label, shown in the legend."),
data: z.array(pointSchema).describe("Data points for this series."),
});
type ChartPoint = { label?: string; value: number };
type ChartSeriesInput = { label?: string; data: ChartPoint[] };
type NormalizedChartSeries = {
label: string | undefined;
values: number[];
labels: (string | undefined)[];
};
/** Pads every series to the longest series' length, treating a shorter series' missing points as `{ value: 0 }` so mismatched series never misalign or throw. */
function normalizeSeries(
seriesProp: unknown,
dataProp: unknown,
): { count: number; series: NormalizedChartSeries[] } {
const rawSeries: ChartSeriesInput[] =
Array.isArray(seriesProp) && seriesProp.length > 0
? (seriesProp as ChartSeriesInput[])
: [{ data: Array.isArray(dataProp) ? (dataProp as ChartPoint[]) : [] }];
const count = rawSeries.reduce((max, s) => {
const len = Array.isArray(s?.data) ? s.data.length : 0;
return Math.max(max, len);
}, 0);
const series = rawSeries.map((s) => {
const points = Array.isArray(s?.data) ? s.data : [];
const values: number[] = [];
const labels: (string | undefined)[] = [];
for (let i = 0; i < count; i++) {
const point = points[i];
values.push(clampValue(point?.value));
labels.push(typeof point?.label === "string" ? point.label : undefined);
}
return {
label: typeof s?.label === "string" ? s.label : undefined,
values,
labels,
};
});
return { count, series };
}
function computeMax(
series: NormalizedChartSeries[],
count: number,
stacked: boolean,
): number {
if (stacked) {
let max = 0;
for (let i = 0; i < count; i++) {
let sum = 0;
for (const s of series) sum += s.values[i] ?? 0;
max = Math.max(max, sum);
}
return max;
}
let max = 0;
for (const s of series) for (const v of s.values) max = Math.max(max, v);
return max;
}
const NICE_MULTIPLES = [1, 2, 5];
/** The smallest of `{1, 2, 5} × 10^n` that is `>= max`, so axis ticks land on round numbers. */
function niceMax(max: number): number {
if (max <= 0) return 0;
const exponent = Math.floor(Math.log10(max));
for (const multiple of NICE_MULTIPLES) {
const candidate = multiple * 10 ** exponent;
if (candidate >= max) return candidate;
}
return 10 ** (exponent + 1);
}
const TICK_COUNT = 5;
/** `TICK_COUNT` evenly spaced ticks from `max` down to `0`, for a top-to-bottom y-axis column. */
function tickValues(max: number): number[] {
const ticks: number[] = [];
for (let i = 0; i < TICK_COUNT; i++) {
ticks.push((max * (TICK_COUNT - 1 - i)) / (TICK_COUNT - 1));
}
return ticks;
}
function formatTick(value: number): string {
return (Math.round(value * 100) / 100).toLocaleString("en-US");
}
type ChartVariant = "bar" | "line" | "sparkline" | "area";
const SERIES_PALETTE_SIZE = 5;
function renderSeriesMarks(
variant: ChartVariant,
series: NormalizedChartSeries[],
count: number,
scaleMax: number,
stacked: boolean,
) {
if (variant === "bar") {
const slot = count > 0 ? CHART_WIDTH / count : 0;
const gap = slot * 0.2;
const groupWidth = slot - gap;
const seriesCount = series.length;
const perSeriesWidth =
stacked || seriesCount <= 1 ? groupWidth : groupWidth / seriesCount;
const cumulative = new Array(count).fill(0) as number[];
return series.map((s, seriesIndex) => {
const marks = s.values.map((value, i) => {
const height = scaleMax > 0 ? (value / scaleMax) * CHART_HEIGHT : 0;
const belowHeight =
stacked && scaleMax > 0
? ((cumulative[i] ?? 0) / scaleMax) * CHART_HEIGHT
: 0;
const x =
stacked || seriesCount <= 1
? i * slot + gap / 2
: i * slot + gap / 2 + seriesIndex * perSeriesWidth;
return (
| {isTableColumn(column) ? column.label : ""} | ))}
|---|
| {isTableCell(cell) ? String(cell) : ""} | ))}