diff --git a/mcp/create-chart/src/handler.test.ts b/mcp/create-chart/src/handler.test.ts index 75d27bab..10319938 100644 --- a/mcp/create-chart/src/handler.test.ts +++ b/mcp/create-chart/src/handler.test.ts @@ -209,6 +209,8 @@ describe("handleRenderChart", () => { // up and mangle. expect(ready).not.toMatch(/\\"/); expect(meta.type).toBe("pie"); + expect(meta.schema_version).toBe(1); + expect(meta.artifact_kind).toBe("chart_spec"); expect(typeof meta.chart_id).toBe("string"); expect((meta.chart_id as string).startsWith("pie-")).toBe(true); expect(typeof meta.bytes).toBe("number"); @@ -232,6 +234,7 @@ describe("handleRenderChart", () => { const inner = ready.replace(/^```chart\n/, "").replace(/\n```$/, ""); const spec = JSON.parse(inner); expect(spec.type).toBe("bar"); + expect(spec.schema_version).toBe(1); expect(spec.data.series[0].values).toEqual([10, 20]); expect(spec.title).toBe("Demo"); expect(spec).not.toHaveProperty("extra_garbage"); @@ -247,10 +250,11 @@ describe("handleRenderChart", () => { const expectedDir = path.resolve(tmp, "chart-render"); expect(existsSync(expectedDir)).toBe(true); const expectedFile = path.join(expectedDir, `${meta.chart_id as string}.json`); - expect(meta.svg_path).toBe(expectedFile); + expect(meta.svg_path).toBe(""); expect(meta.spec_path).toBe(expectedFile); expect(existsSync(expectedFile)).toBe(true); const onDisk = JSON.parse(readFileSync(expectedFile, "utf8")); + expect(onDisk.schema_version).toBe(1); expect(onDisk.type).toBe("line"); expect(onDisk.data.series[0].points).toEqual([{ x: 1, y: 2 }]); expect(readdirSync(expectedDir)).toContain(`${meta.chart_id as string}.json`); diff --git a/mcp/create-chart/src/handler.ts b/mcp/create-chart/src/handler.ts index c026c17c..80172171 100644 --- a/mcp/create-chart/src/handler.ts +++ b/mcp/create-chart/src/handler.ts @@ -2,6 +2,8 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import type { RenderChartArgs, RenderChartResult } from "./types.js"; +const CHART_SPEC_VERSION = 1; + export const RENDER_CHART_INPUT_SCHEMA = { type: "object", required: ["type", "data"], @@ -46,7 +48,7 @@ export async function handleRenderChart(rawArgs: unknown): Promise<{ const args = validate(rawArgs); const id = newChartId(args.type); - const spec = JSON.stringify(args); + const spec = JSON.stringify({ ...args, schema_version: CHART_SPEC_VERSION }); const markdownEmbed = "```chart\n" + spec + "\n```"; let specPath: string | undefined; @@ -60,10 +62,12 @@ export async function handleRenderChart(rawArgs: unknown): Promise<{ } const result: RenderChartResult = { + schema_version: CHART_SPEC_VERSION, chart_id: id, type: args.type, + artifact_kind: "chart_spec", spec_path: specPath ?? "", - svg_path: specPath ?? "", + svg_path: "", bytes: Buffer.byteLength(spec, "utf8"), embed_instructions: "Paste the READY_TO_PASTE block above verbatim into your reply where the chart should appear. Do not modify the JSON, add backslashes, escape non-ASCII characters, convert to ```svg, or inline an .", diff --git a/mcp/create-chart/src/types.ts b/mcp/create-chart/src/types.ts index 34efedeb..67eaf112 100644 --- a/mcp/create-chart/src/types.ts +++ b/mcp/create-chart/src/types.ts @@ -19,6 +19,7 @@ export interface LineSeries { } export interface ChartCommonOpts { + schema_version?: 1; title?: string; width?: number; height?: number; @@ -35,9 +36,15 @@ export type RenderChartArgs = | ({ type: "line"; data: { series: LineSeries[] } } & ChartCommonOpts); export interface RenderChartResult { + schema_version: 1; chart_id: string; type: "pie" | "bar" | "line"; + artifact_kind: "chart_spec"; spec_path: string; + /** + * Kept for backwards-compatible metadata shape. Empty because render_chart + * persists a JSON chart spec; the portal renders SVG client-side. + */ svg_path: string; bytes: number; embed_instructions: string; diff --git a/portal-web/src/components/chat/ChartRenderer.test.ts b/portal-web/src/components/chat/ChartRenderer.test.ts index 5aa2d908..27405079 100644 --- a/portal-web/src/components/chat/ChartRenderer.test.ts +++ b/portal-web/src/components/chat/ChartRenderer.test.ts @@ -7,6 +7,7 @@ describe("tryParseChartSpec", () => { '{"type":"pie","data":{"slices":[{"label":"a","value":1},{"label":"b","value":2}]}}', ) expect(spec?.type).toBe("pie") + expect(spec?.schema_version).toBe(1) expect(spec).toMatchObject({ data: { slices: [{ label: "a", value: 1 }, { label: "b", value: 2 }] } }) }) @@ -30,6 +31,15 @@ describe("tryParseChartSpec", () => { expect(tryParseChartSpec('{"type":"pie"}')).toBeNull() }) + it("accepts missing schema_version as v1 but rejects unknown versions", () => { + expect( + tryParseChartSpec('{"schema_version":1,"type":"pie","data":{"slices":[{"label":"a","value":1}]}}')?.schema_version, + ).toBe(1) + expect( + tryParseChartSpec('{"schema_version":2,"type":"pie","data":{"slices":[{"label":"a","value":1}]}}'), + ).toBeNull() + }) + // The chart spec round-trips through the LLM as text; the model sometimes // double-escapes non-ASCII, leaving literal \uXXXX sequences after JSON.parse. it("decodes stray \\uXXXX escapes in title and labels", () => { diff --git a/portal-web/src/components/chat/ChartRenderer.tsx b/portal-web/src/components/chat/ChartRenderer.tsx index 9137dde7..9c60c6f2 100644 --- a/portal-web/src/components/chat/ChartRenderer.tsx +++ b/portal-web/src/components/chat/ChartRenderer.tsx @@ -1,5 +1,5 @@ /** - * Client-side chart renderer. + * Client-side chart renderer (React + SVG). * * Receives a JSON spec (emitted by mcp `render_chart` tool) and draws * pie/bar/line charts as inline SVG via JSX. Colors use CSS classes so the @@ -10,70 +10,85 @@ * copy a real image to the clipboard or download a PNG file (shareable on * WeChat / QQ / etc.) — copying the bubble text would only yield the JSON spec. * - * This file is the contract shared with MCP — the MCP tool emits a chart spec - * matching the ChartSpec union below, fenced as ```chart in markdown. + * Interactivity (hover tooltip + crosshair, responsive resize, log-scale + * toggle) is layered on top of the static SVG: the tooltip and crosshair are + * rendered as a SEPARATE overlay (HTML div + a second pointer-events-none SVG) + * so they never end up in the rasterised PNG, which clones only `svgRef`. + * + * All DOM-free logic (the ChartSpec contract, number/axis math, legend layout, + * plot geometry, canvas sizing, the spec parser) lives in `chart-utils.ts` and + * is unit-tested there. This file is JSX + browser APIs only. */ -import { type CSSProperties, type ReactNode, useRef, useState, useCallback } from "react" - -type PieSlice = { label: string; value: number } -type BarSeries = { name: string; values: number[] } -type LinePoint = { x: number | string; y: number } -type LineSeries = { name: string; points: LinePoint[] } - -interface CommonOpts { - title?: string - x_label?: string - y_label?: string - width?: number - height?: number -} - -export type ChartSpec = - | ({ type: "pie"; data: { slices: PieSlice[] } } & CommonOpts) - | ({ type: "bar"; data: { categories: string[]; series: BarSeries[] } } & CommonOpts) - | ({ type: "line"; data: { series: LineSeries[] } } & CommonOpts) - -// No grey in the categorical palette — grey is reserved for the "Others" -// bucket so it reads as a residual rather than a real category. 11 distinct -// hues cover the max 11 real slices a collapsed pie can have. -const PALETTE = [ - "#4e79a7", "#f28e2b", "#e15759", "#76b7b2", "#59a14f", - "#edc948", "#b07aa1", "#ff9da7", "#9c755f", "#b6992d", "#d37295", -] -// Neutral grey for the collapsed "Others" slice — legible on both themes. -const OTHERS_COLOR = "#9aa0a6" - -const TITLE_SIZE = 18 -const LEGEND_SIZE = 13 -const AXIS_LABEL_SIZE = 13 -const TICK_SIZE = 12 -const PIE_LABEL_SIZE = 12 - -// High-cardinality pie data (e.g. a per-namespace Pod count with 20+ entries) -// renders as a fan of unreadable slivers. Keep the largest slices and roll the -// long tail into a single "Others" bucket so the chart stays legible. -const PIE_MAX_SLICES = 12 - -// othersIndex is the index of the collapsed "Others" slice, or -1 when the -// data was small enough to render as-is. The caller colours that slice grey. -export function collapsePieSlices( - slices: PieSlice[], - max = PIE_MAX_SLICES, -): { slices: PieSlice[]; othersIndex: number } { - if (slices.length <= max) return { slices, othersIndex: -1 } - const sorted = [...slices].sort((a, b) => b.value - a.value) - const head = sorted.slice(0, max - 1) - const tail = sorted.slice(max - 1) - const tailTotal = tail.reduce((a, s) => a + Math.max(0, s.value), 0) - return { - slices: [...head, { label: `Others (${tail.length})`, value: tailTotal }], - othersIndex: head.length, +import { + type CSSProperties, + type ReactNode, + useRef, + useState, + useCallback, + useEffect, + useMemo, +} from "react" +import { + type ChartSpec, + type Axis, + type Plot, + type MarkerShapeKind, + PALETTE, + TITLE_SIZE, + LEGEND_SIZE, + LEGEND_LINE_H, + AXIS_LABEL_SIZE, + TICK_SIZE, + PIE_LABEL_SIZE, + LEGEND_SWATCH, + LEGEND_GAP, + LEGEND_BETWEEN, + LEGEND_MARGIN, + seriesDash, + seriesShape, + barTickLayout, + collapsePieSlices, + pieSliceColor, + approxTextWidth, + fmtNumber, + niceAxis, + logAxis, + axisFrac, + logPossible, + logBeneficial, + collectChartValues, + layoutLegendRows, + computePlot, + describeChart, + chartCanvasSize, +} from "./chart-utils" + +// Re-exported so existing import sites (Markdown.tsx, PilotArea.tsx, the test +// file) can keep importing from "./ChartRenderer" — keeps the refactor's blast +// radius to this file pair. +export { collapsePieSlices, tryParseChartSpec, chartSpecLooksIncomplete } from "./chart-utils" +export type { ChartSpec } from "./chart-utils" + +function MarkerShape({ + shape, cx, cy, size, color, +}: { shape: MarkerShapeKind; cx: number; cy: number; size: number; color: string }) { + if (shape === "square") { + return } -} - -function pieSliceColor(index: number, othersIndex: number): string { - return index === othersIndex ? OTHERS_COLOR : PALETTE[index % PALETTE.length] + if (shape === "triangle") { + return ( + + ) + } + if (shape === "diamond") { + return ( + + ) + } + return } // Tailwind class strings centralised so palette tweaks live in one place. @@ -105,110 +120,79 @@ const THEME_CLASSES = [ "dark:[&_.chart-slice-sep]:stroke-slate-900", ].join(" ") -function approxTextWidth(text: string, fontSize: number): number { - let w = 0 - for (const ch of text) { - // CJK ideographs + fullwidth forms render roughly square (one em wide). - if (/[\u4e00-\u9fff\uff00-\uffef]/.test(ch)) w += fontSize - else if (/[A-Z0-9]/.test(ch)) w += fontSize * 0.62 - else w += fontSize * 0.52 - } - return w -} - -function fmtNumber(n: number): string { - if (!Number.isFinite(n)) return String(n) - const abs = Math.abs(n) - if (abs >= 1000 || (abs > 0 && abs < 0.01)) return n.toExponential(2) - return Number.isInteger(n) ? n.toString() : n.toFixed(2) -} - -function niceNum(range: number, round: boolean): number { - const exp = Math.floor(Math.log10(range || 1)) - const f = range / Math.pow(10, exp) - let nf: number - if (round) { - if (f < 1.5) nf = 1 - else if (f < 3) nf = 2 - else if (f < 7) nf = 5 - else nf = 10 - } else if (f <= 1) nf = 1 - else if (f <= 2) nf = 2 - else if (f <= 5) nf = 5 - else nf = 10 - return nf * Math.pow(10, exp) -} - -interface Axis { min: number; max: number; ticks: number[] } -function niceAxis(min: number, max: number, count = 5): Axis { - if (!Number.isFinite(min) || !Number.isFinite(max)) { - return { min: 0, max: 1, ticks: [0, 0.5, 1] } - } - if (min === max) { - const pad = Math.abs(min) > 0 ? Math.abs(min) * 0.1 : 1 - min -= pad; max += pad - } - const range = niceNum(max - min, false) - const step = niceNum(range / (count - 1), true) - const niceMin = Math.floor(min / step) * step - const niceMax = Math.ceil(max / step) * step - const ticks: number[] = [] - for (let v = niceMin; v <= niceMax + step / 2; v += step) { - ticks.push(Number(v.toFixed(10))) - } - return { min: niceMin, max: niceMax, ticks } -} - -interface Plot { left: number; right: number; top: number; bottom: number; w: number; h: number } -function computePlot( - width: number, height: number, - hasTitle: boolean, hasLegend: boolean, - hasXLabels: boolean, rotateXTicks: boolean, - hasYAxisLabel: boolean, hasXAxisLabel: boolean, -): Plot { - const titleH = hasTitle ? 30 : 6 - const legendH = hasLegend ? 26 : 0 - const xTickH = hasXLabels ? (rotateXTicks ? 50 : 26) : 8 - const xLabelH = hasXAxisLabel ? 22 : 0 - const yLabelW = hasYAxisLabel ? 22 : 0 - const top = titleH + legendH + 8 - const bottom = height - xTickH - xLabelH - 8 - const left = 60 + yLabelW - const right = width - 24 - return { left, right, top, bottom, w: right - left, h: bottom - top } -} - function Title({ text, width }: { text?: string; width: number }) { if (!text) return null + // Truncate with an ellipsis when the title is wider than the canvas — a long + // title used to spill past both edges at narrow widths. + const maxW = width - 32 + let shown = text + if (approxTextWidth(shown, TITLE_SIZE) > maxW) { + while (shown.length > 1 && approxTextWidth(shown + "…", TITLE_SIZE) > maxW) { + shown = shown.slice(0, -1) + } + shown = shown.replace(/\s+$/, "") + "…" + } return ( {text} + className="chart-title">{shown} ) } -function LegendRow({ width, y, names }: { width: number; y: number; names: string[] }) { - const swatch = 12, gap = 6, between = 18 - const widths = names.map(n => swatch + gap + approxTextWidth(n, LEGEND_SIZE)) - const totalW = widths.reduce((a, b) => a + b, 0) + (names.length - 1) * between - let x = Math.max(16, (width - totalW) / 2) +// Renders the legend, wrapping to multiple centred rows when the series don't +// fit on one line. `y` is the baseline of the first row; callers must reserve +// `layoutLegendRows(...).length` rows of vertical space via computePlot. When +// `swatch` is supplied it draws the per-series marker (used by line charts to +// echo the dash + point shape); otherwise a plain colour rect is drawn. +function LegendRow({ + width, y, names, swatch, +}: { + width: number + y: number + names: string[] + swatch?: (index: number, cx: number, cy: number) => ReactNode +}) { + const rows = layoutLegendRows(names, width) const out: ReactNode[] = [] - names.forEach((n, i) => { - const color = PALETTE[i % PALETTE.length] - out.push( - , - {n}, - ) - x += widths[i] + between + let idx = 0 + rows.forEach((row, ri) => { + const widths = row.map((n) => LEGEND_SWATCH + LEGEND_GAP + approxTextWidth(n, LEGEND_SIZE)) + const totalW = widths.reduce((a, b) => a + b, 0) + (row.length - 1) * LEGEND_BETWEEN + let x = Math.max(LEGEND_MARGIN, (width - totalW) / 2) + const rowY = y + ri * LEGEND_LINE_H + row.forEach((n, i) => { + const color = PALETTE[idx % PALETTE.length] + const swCx = x + LEGEND_SWATCH / 2 + const swCy = rowY - LEGEND_SWATCH / 2 + 2 + out.push( + swatch + ? {swatch(idx, swCx, swCy)} + : , + {n}, + ) + x += widths[i] + LEGEND_BETWEEN + idx++ + }) }) return <>{out} } +function lineLegendSwatch(index: number, cx: number, cy: number): ReactNode { + const color = PALETTE[index % PALETTE.length] + return ( + <> + + + + ) +} + function YAxis({ plot, axis, label }: { plot: Plot; axis: Axis; label?: string }) { const out: ReactNode[] = [] axis.ticks.forEach((t, i) => { - const y = plot.bottom - ((t - axis.min) / (axis.max - axis.min)) * plot.h + const y = plot.bottom - axisFrac(axis, t) * plot.h out.push( , @@ -216,22 +200,26 @@ function YAxis({ plot, axis, label }: { plot: Plot; axis: Axis; label?: string } className="chart-tick">{fmtNumber(t)}, ) }) - out.push( - , - , - ) if (label) { const cy = plot.top + plot.h / 2 + const text = axis.log ? `${label} (log)` : label out.push( {label}, + transform={`rotate(-90 18 ${cy})`} className="chart-axis-label">{text}, ) } return <>{out} } +// A fully-closed rectangular border around the plot area — the scientific +// plotting convention (Origin / MATLAB) — instead of just left + bottom axes. +function PlotFrame({ plot }: { plot: Plot }) { + return ( + + ) +} + function XAxisTitle({ plot, height, label }: { plot: Plot; height: number; label?: string }) { if (!label) return null return ( @@ -240,23 +228,51 @@ function XAxisTitle({ plot, height, label }: { plot: Plot; height: number; label ) } -function PieChart({ spec, width, height }: { spec: Extract; width: number; height: number }) { +function NoData({ width, height }: { width: number; height: number }) { + return ( + no data + ) +} + +// ---- Hover model ----------------------------------------------------------- +// Each chart renderer returns the drawn SVG plus a hover model the overlay +// uses to snap a crosshair / tooltip to the data without re-deriving geometry. + +interface HoverRow { name: string; color: string; valueLabel: string; yPx?: number } +interface HoverColumn { xPx: number; xLabel: string; rows: HoverRow[] } +interface PieHoverSlice { + start: number // radians, 0 = top, clockwise + end: number + label: string + valueLabel: string + pct: string + color: string +} +type HoverModel = + | { kind: "columns"; plotTop: number; plotBottom: number; columns: HoverColumn[] } + | { kind: "pie"; cx: number; cy: number; r: number; slices: PieHoverSlice[] } + | null + +interface ChartRender { els: ReactNode; hover: HoverModel } + +function renderPie( + spec: Extract, width: number, height: number, +): ChartRender { const { slices, othersIndex } = collapsePieSlices(spec.data.slices) const total = slices.reduce((a, s) => a + Math.max(0, s.value), 0) - if (total <= 0) { - return ( - no data - ) - } + if (total <= 0) return { els: , hover: null } + const titleH = spec.title ? 30 : 6 const padY = 16 const top = titleH + padY const bottom = height - padY const innerH = bottom - top - const labels = slices.map(s => `${s.label} (${fmtNumber(s.value)}, ${(s.value / total * 100).toFixed(1)}%)`) + const labels = slices.map( + (s) => `${s.label} (${fmtNumber(s.value)}, ${((s.value / total) * 100).toFixed(1)}%)`, + ) const legendW = Math.min( - Math.max(...labels.map(l => approxTextWidth(l, LEGEND_SIZE))) + 28, + Math.max(...labels.map((l) => approxTextWidth(l, LEGEND_SIZE))) + 28, width * 0.42, ) const pieAreaW = width - legendW - 32 @@ -265,7 +281,9 @@ function PieChart({ spec, width, height }: { spec: Extract { const v = Math.max(0, s.value) if (v === 0) return @@ -293,14 +311,23 @@ function PieChart({ spec, width, height }: { spec: Extract - {(v / total * 100).toFixed(1)}% + {((v / total) * 100).toFixed(1)}% , ) } + hoverSlices.push({ + start: acc, + end: acc + sweep, + label: s.label, + valueLabel: fmtNumber(s.value), + pct: `${((v / total) * 100).toFixed(1)}%`, + color, + }) + acc += sweep angle = a2 }) - // Legend column on the right + // Legend column on the right. const legX = width - legendW - 8 const lineH = LEGEND_SIZE + 10 const legBlockH = labels.length * lineH @@ -317,51 +344,65 @@ function PieChart({ spec, width, height }: { spec: Extract{slicesEls}{legendEls} + return { + els: <>{slicesEls}{legendEls}, + hover: { kind: "pie", cx, cy, r, slices: hoverSlices }, + } } -function BarChart({ spec, width, height }: { spec: Extract; width: number; height: number }) { +function renderBar( + spec: Extract, + width: number, height: number, useLog: boolean, legendRows: number, +): ChartRender { const { categories, series } = spec.data if (!categories.length || !series.length) { - return ( - no data - ) + return { els: , hover: null } } const hasLegend = series.length > 1 - const approxGroupW = (width - 80) / categories.length - const longest = Math.max(...categories.map(c => approxTextWidth(c, TICK_SIZE))) - const rotate = longest + 4 > approxGroupW - const plot = computePlot(width, height, !!spec.title, hasLegend, true, rotate, !!spec.y_label, !!spec.x_label) - - let dataMin = 0, dataMax = 0 - series.forEach(s => s.values.forEach(v => { - if (!Number.isFinite(v)) return - if (v < dataMin) dataMin = v - if (v > dataMax) dataMax = v - })) - if (dataMax === 0 && dataMin === 0) dataMax = 1 - const axis = niceAxis(dataMin, dataMax) + const legendNames = series.map((s) => s.name) + const { rotate, tickBandH } = barTickLayout(categories, width) + const plot = computePlot( + width, height, !!spec.title, legendRows, true, rotate, + !!spec.y_label, !!spec.x_label, tickBandH, + ) + + const finiteVals: number[] = [] + series.forEach((s) => s.values.forEach((v) => { if (Number.isFinite(v)) finiteVals.push(v) })) + + let axis: Axis + if (useLog) { + const pos = finiteVals.filter((v) => v > 0) + axis = pos.length ? logAxis(Math.min(...pos), Math.max(...pos)) : niceAxis(0, 1) + } else { + let dataMin = 0, dataMax = 0 + finiteVals.forEach((v) => { if (v < dataMin) dataMin = v; if (v > dataMax) dataMax = v }) + if (dataMax === 0 && dataMin === 0) dataMax = 1 + axis = niceAxis(dataMin, dataMax) + } const groupW = plot.w / categories.length const groupPad = Math.min(20, groupW * 0.22) const barW = (groupW - groupPad) / series.length - const zeroY = plot.bottom - ((0 - axis.min) / (axis.max - axis.min)) * plot.h + const zeroFrac = axis.log ? 0 : axisFrac(axis, 0) + const zeroY = plot.bottom - zeroFrac * plot.h const els: ReactNode[] = [] + const columns: HoverColumn[] = [] categories.forEach((cat, gi) => { const gx = plot.left + gi * groupW + groupPad / 2 + const rows: HoverRow[] = [] series.forEach((s, si) => { const v = s.values[gi] ?? 0 if (!Number.isFinite(v)) return - const y = plot.bottom - ((v - axis.min) / (axis.max - axis.min)) * plot.h + const y = plot.bottom - axisFrac(axis, v) * plot.h const top = Math.min(y, zeroY) const h = Math.abs(y - zeroY) const x = gx + si * barW const color = PALETTE[si % PALETTE.length] els.push( - , ) + rows.push({ name: s.name, color, valueLabel: fmtNumber(v) }) }) const cxLabel = gx + (groupW - groupPad) / 2 if (rotate) { @@ -376,69 +417,92 @@ function BarChart({ spec, width, height }: { spec: Extract{cat}, ) } + columns.push({ xPx: plot.left + gi * groupW + groupW / 2, xLabel: cat, rows }) }) - return ( - <> - {hasLegend && s.name)} />} - - {els} - - - ) + return { + els: ( + <> + {hasLegend && } + + {els} + + + + ), + hover: { kind: "columns", plotTop: plot.top, plotBottom: plot.bottom, columns }, + } } -function LineChart({ spec, width, height }: { spec: Extract; width: number; height: number }) { +function renderLine( + spec: Extract, + width: number, height: number, useLog: boolean, legendRows: number, +): ChartRender { const { series } = spec.data - const allPoints = series.flatMap(s => s.points) - if (!allPoints.length) { - return ( - no data - ) - } + const allPoints = series.flatMap((s) => s.points) + if (!allPoints.length) return { els: , hover: null } + const hasLegend = series.length > 1 + const legendNames = series.map((s) => s.name) - const xsNumeric = allPoints.every(p => typeof p.x === "number") + const xsNumeric = allPoints.every((p) => typeof p.x === "number") let xMin = 0, xMax = 1 let categories: string[] | undefined if (xsNumeric) { - xMin = Math.min(...allPoints.map(p => p.x as number)) - xMax = Math.max(...allPoints.map(p => p.x as number)) + xMin = Math.min(...allPoints.map((p) => p.x as number)) + xMax = Math.max(...allPoints.map((p) => p.x as number)) if (xMin === xMax) xMax = xMin + 1 } else { const seen = new Map() - allPoints.forEach(p => { const k = String(p.x); if (!seen.has(k)) seen.set(k, seen.size) }) + allPoints.forEach((p) => { const k = String(p.x); if (!seen.has(k)) seen.set(k, seen.size) }) categories = Array.from(seen.keys()) - xMin = 0; xMax = Math.max(1, categories.length - 1) + xMin = 0 + xMax = Math.max(1, categories.length - 1) } - const plot = computePlot(width, height, !!spec.title, hasLegend, true, false, !!spec.y_label, !!spec.x_label) + const plot = computePlot(width, height, !!spec.title, legendRows, true, false, !!spec.y_label, !!spec.x_label) - let yMin = Infinity, yMax = -Infinity - allPoints.forEach(p => { if (!Number.isFinite(p.y)) return; if (p.y < yMin) yMin = p.y; if (p.y > yMax) yMax = p.y }) - if (!Number.isFinite(yMin)) { yMin = 0; yMax = 1 } - const yAxis = niceAxis(yMin, yMax) + const finiteYs = allPoints.map((p) => p.y).filter((y) => Number.isFinite(y)) + let yAxis: Axis + if (useLog) { + const pos = finiteYs.filter((y) => y > 0) + yAxis = pos.length ? logAxis(Math.min(...pos), Math.max(...pos)) : niceAxis(0, 1) + } else { + let yMin = Infinity, yMax = -Infinity + finiteYs.forEach((y) => { if (y < yMin) yMin = y; if (y > yMax) yMax = y }) + if (!Number.isFinite(yMin)) { yMin = 0; yMax = 1 } + yAxis = niceAxis(yMin, yMax) + } const xToPx = (x: number | string) => { const xv = xsNumeric ? (x as number) : categories!.indexOf(String(x)) return plot.left + ((xv - xMin) / (xMax - xMin)) * plot.w } - const yToPx = (y: number) => plot.bottom - ((y - yAxis.min) / (yAxis.max - yAxis.min)) * plot.h + const yToPx = (y: number) => plot.bottom - axisFrac(yAxis, y) * plot.h + + // epoch-seconds heuristic: render large numeric x as UTC HH:MM (UTC is the + // product-wide default — sample timestamps are stored and shown in UTC). + const formatX = (x: number | string): string => { + if (!xsNumeric) return String(x) + const t = x as number + if (t > 1e9) { + const d = new Date(t * 1000) + return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}` + } + return fmtNumber(t) + } + // X tick labels + a faint vertical gridline at every tick (scientific + // plotting convention — Origin / MATLAB draw both axes' gridlines). const xTickCount = Math.min(8, xsNumeric ? 6 : Math.max(2, categories!.length)) const xTicks: ReactNode[] = [] for (let i = 0; i < xTickCount; i++) { const t = xMin + ((xMax - xMin) * i) / (xTickCount - 1) const px = plot.left + ((t - xMin) / (xMax - xMin)) * plot.w - let label: string - if (xsNumeric) { - if (t > 1e9) { - const d = new Date(t * 1000) - label = `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}` - } else label = fmtNumber(t) - } else label = categories![Math.round(t)] ?? "" + const label = xsNumeric ? formatX(t) : (categories![Math.round(t)] ?? "") xTicks.push( + , , { const color = PALETTE[si % PALETTE.length] - const pts = s.points.filter(p => Number.isFinite(p.y)) + const pts = s.points + .filter((p) => Number.isFinite(p.y)) + .slice() + .sort((a, b) => xToPx(a.x) - xToPx(b.x)) if (!pts.length) return - const d = pts.map((p, i) => `${i === 0 ? "M" : "L"} ${xToPx(p.x).toFixed(2)} ${yToPx(p.y).toFixed(2)}`).join(" ") + const d = pts + .map((p, i) => `${i === 0 ? "M" : "L"} ${xToPx(p.x).toFixed(2)} ${yToPx(p.y).toFixed(2)}`) + .join(" ") lines.push( , ) if (pts.length <= 80) { + const shape = seriesShape(si) pts.forEach((p, i) => lines.push( - , + , )) } }) - return ( - <> - {hasLegend && s.name)} />} - - {xTicks} - {lines} - - - ) -} + // Hover columns: one per distinct x value, in axis order. + const columnXs: Array = xsNumeric + ? Array.from(new Set(allPoints.map((p) => p.x as number))).sort((a, b) => a - b) + : categories! + const seriesMaps = series.map((s) => { + const m = new Map() + s.points.forEach((p) => { if (Number.isFinite(p.y)) m.set(p.x, p.y) }) + return m + }) + const columns: HoverColumn[] = columnXs.map((x) => { + const rows: HoverRow[] = [] + series.forEach((s, si) => { + const y = seriesMaps[si].get(x) + if (y == null) return + rows.push({ name: s.name, color: PALETTE[si % PALETTE.length], valueLabel: fmtNumber(y), yPx: yToPx(y) }) + }) + return { xPx: xToPx(x), xLabel: formatX(x), rows } + }) -function defaultSize(type: ChartSpec["type"]): { width: number; height: number } { - if (type === "pie") return { width: 760, height: 480 } - return { width: 900, height: 520 } + return { + els: ( + <> + {hasLegend && ( + + )} + + {xTicks} + {lines} + + + + ), + hover: { kind: "columns", plotTop: plot.top, plotBottom: plot.bottom, columns }, + } } // CSS properties whose values vary by theme and must be copied from the live @@ -545,10 +640,27 @@ async function svgToPngBlob(svg: SVGSVGElement, scale = 2): Promise { ctx.drawImage(img, 0, 0, canvas.width, canvas.height) return await new Promise((resolve, reject) => { - canvas.toBlob(b => (b ? resolve(b) : reject(new Error("toBlob returned null"))), "image/png") + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob returned null"))), "image/png") + }) +} + +function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result)) + reader.onerror = () => reject(new Error("blob -> data URL failed")) + reader.readAsDataURL(blob) }) } +// Rasterise a live chart SVG to a PNG data URL. Used by the message-level copy +// button (PilotArea) to embed real images in the text/html clipboard payload — +// copying the bubble's markdown alone would only yield the raw chart JSON. +export async function svgChartToPngDataUrl(svg: SVGSVGElement, scale = 2): Promise { + const blob = await svgToPngBlob(svg, scale) + return await blobToDataUrl(blob) +} + function downloadBlob(blob: Blob, name: string) { const url = URL.createObjectURL(blob) const a = document.createElement("a") @@ -570,18 +682,72 @@ async function copyBlobToClipboard(blob: Blob): Promise { } } +const TOOLBAR_BTN = + "inline-flex h-7 items-center justify-center rounded-md bg-white/90 dark:bg-slate-800/90 " + + "text-gray-700 dark:text-gray-100 border border-gray-200 dark:border-slate-700 shadow-sm " + + "hover:bg-white dark:hover:bg-slate-800" + interface ChartRendererProps { spec: ChartSpec className?: string style?: CSSProperties } +// Active hover target: which column / pie slice, plus where to place the +// tooltip (px relative to the chart-area wrapper, already edge-clamped). +interface HoverState { index: number; left: number; top: number } + export function ChartRenderer({ spec, className, style }: ChartRendererProps) { - const def = defaultSize(spec.type) - const width = spec.width ?? def.width - const height = spec.height ?? def.height + const hostRef = useRef(null) const svgRef = useRef(null) const [status, setStatus] = useState(null) + const [hover, setHover] = useState(null) + + // Responsive: render the chart at the container's real width (capped at the + // spec's ideal width so it never upscales) instead of letting a fixed-size + // viewBox shrink the whole thing — that shrank the fonts into illegibility + // inside a narrow chat bubble. Re-rendering at the measured width keeps text + // at a constant on-screen size. + const [measuredW, setMeasuredW] = useState(null) + useEffect(() => { + const el = hostRef.current + if (!el || typeof ResizeObserver === "undefined") return + const ro = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect.width + if (w && w > 0) { + setMeasuredW(w) + setHover(null) + } + }) + ro.observe(el) + return () => ro.disconnect() + }, []) + + // Single source of truth for sizing — width, canvas height, and the legend + // row count that renderBar/renderLine reuse via computePlot. Keeping these + // three in one pure function is what prevents the plot-collapse regression. + const { width, height, legendRows } = useMemo( + () => chartCanvasSize(spec, measuredW), + [spec, measuredW], + ) + + // Log scale: bar and line charts both expose a linear/log toolbar toggle + // whenever a log axis is *possible* (all-positive data). In "auto" mode the + // axis defaults to log only when it's also *beneficial* (wide spread); the + // toggle then pins linear/log explicitly. + const chartValues = useMemo(() => collectChartValues(spec), [spec]) + const logAvailable = useMemo(() => logPossible(chartValues), [chartValues]) + const logAutoOn = useMemo(() => logBeneficial(chartValues), [chartValues]) + const [yScalePref, setYScalePref] = useState<"auto" | "linear" | "log">("auto") + const effectiveLog = + (spec.type === "bar" || spec.type === "line") && + (yScalePref === "log" || (yScalePref === "auto" && logAutoOn)) + + const chart = useMemo(() => { + if (spec.type === "pie") return renderPie(spec, width, height) + if (spec.type === "bar") return renderBar(spec, width, height, effectiveLog, legendRows) + return renderLine(spec, width, height, effectiveLog, legendRows) + }, [spec, width, height, effectiveLog, legendRows]) const flash = useCallback((kind: "ok" | "err", text: string) => { setStatus({ kind, text }) @@ -615,37 +781,146 @@ export function ChartRenderer({ spec, className, style }: ChartRendererProps) { } }, [flash]) + // Map a pointer event to the nearest data column / pie slice and place the + // tooltip. Coordinates are converted from on-screen px into viewBox units so + // the snapping stays correct at any rendered size. + const onMove = (e: React.MouseEvent) => { + const svg = svgRef.current + const model = chart.hover + if (!svg || !model) return + const rect = svg.getBoundingClientRect() + if (!rect.width || !rect.height) return + const sx = ((e.clientX - rect.left) / rect.width) * width + const sy = ((e.clientY - rect.top) / rect.height) * height + let left = e.clientX - rect.left + 14 + let top = e.clientY - rect.top + 12 + if (left > rect.width - 190) left = e.clientX - rect.left - 190 + if (top > rect.height - 96) top = rect.height - 96 + left = Math.max(4, left) + top = Math.max(4, top) + + if (model.kind === "columns") { + if (!model.columns.length) return + let best = 0 + let bd = Infinity + model.columns.forEach((c, i) => { + const d = Math.abs(c.xPx - sx) + if (d < bd) { bd = d; best = i } + }) + setHover({ index: best, left, top }) + } else { + const dx = sx - model.cx + const dy = sy - model.cy + if (Math.hypot(dx, dy) > model.r) { setHover(null); return } + // Normalise the pointer angle to 0 = top, clockwise — matching how the + // slices accumulate their start/end in renderPie. + let a = Math.atan2(dy, dx) + Math.PI / 2 + while (a < 0) a += Math.PI * 2 + while (a >= Math.PI * 2) a -= Math.PI * 2 + const found = model.slices.findIndex((s) => a >= s.start && a < s.end) + if (found < 0) { setHover(null); return } + setHover({ index: found, left, top }) + } + } + + // Crosshair / highlight overlay drawn in chart coords, but in a SEPARATE + // pointer-events-none SVG so it is never picked up by the PNG rasteriser + // (which clones svgRef only). + let overlay: ReactNode = null + let tooltip: ReactNode = null + if (hover && chart.hover) { + if (chart.hover.kind === "columns") { + const col = chart.hover.columns[hover.index] + if (col) { + overlay = ( + <> + + {col.rows.map((r, i) => + r.yPx != null ? ( + + ) : null, + )} + + ) + const shown = col.rows.slice(0, 12) + tooltip = ( + <> +
{col.xLabel}
+ {shown.map((r, i) => ( +
+ + {r.name} + + {r.valueLabel} + +
+ ))} + {col.rows.length > shown.length && ( +
+{col.rows.length - shown.length} more
+ )} + + ) + } + } else { + const s = chart.hover.slices[hover.index] + if (s) { + const { cx, cy, r } = chart.hover + const rr = r + 3 + const a1 = s.start - Math.PI / 2 + const a2 = s.end - Math.PI / 2 + const large = s.end - s.start > Math.PI ? 1 : 0 + const d = + `M ${(cx + rr * Math.cos(a1)).toFixed(2)} ${(cy + rr * Math.sin(a1)).toFixed(2)} ` + + `A ${rr} ${rr} 0 ${large} 1 ${(cx + rr * Math.cos(a2)).toFixed(2)} ${(cy + rr * Math.sin(a2)).toFixed(2)}` + overlay = + tooltip = ( + <> +
+ + {s.label} +
+
+ {s.valueLabel} · {s.pct} +
+ + ) + } + } + } + + const a11yLabel = spec.title || `${spec.type} chart` + return (
- - - - {spec.type === "pie" && } - {spec.type === "bar" && } - {spec.type === "line" && } - + {/* Toolbar sits in its own flow row above the chart — never overlaps the + title or legend the way an absolutely-positioned overlay did. */}
+ {(spec.type === "bar" || spec.type === "line") && logAvailable && ( + + )}
+ +
setHover(null)}> + + {a11yLabel} + {describeChart(spec)} + + + {chart.els} + + + {overlay && ( + + )} + + {tooltip && hover && ( +
+ {tooltip} +
+ )} +
+ {status && (
) } - -// Heuristic: does the text look like a fully-streamed JSON object, or are we -// still mid-stream? During streaming ReactMarkdown re-parses on every token, -// so the chart fence is rendered before the closing `}` arrives — without -// this check tryParseChartSpec would return null and Markdown.tsx would flash -// the red "parse failed" box for every chart until streaming completes. -// Returns true when the text is empty, doesn't end with `}`, or has more -// opening braces than closing ones (ignoring braces inside JSON strings). -export function chartSpecLooksIncomplete(raw: string): boolean { - const text = raw.trim() - if (!text || !text.endsWith("}")) return true - let depth = 0 - let inStr = false - let esc = false - for (let i = 0; i < text.length; i++) { - const ch = text[i] - if (esc) { esc = false; continue } - if (inStr) { - if (ch === "\\") esc = true - else if (ch === '"') inStr = false - continue - } - if (ch === '"') inStr = true - else if (ch === "{") depth++ - else if (ch === "}") depth-- - } - return depth !== 0 || inStr -} - -// The chart spec round-trips through the LLM as plain text in the final reply. -// On a small fraction of generations the model double-escapes non-ASCII — -// emits a literal backslash-u-XXXX sequence instead of the character (or a -// single \u escape JSON.parse would decode). JSON.parse then yields the -// literal 6-char string. Decode such stray escapes so CJK titles/labels render. -function decodeStrayUnicodeEscapes(s: string): string { - if (!s.includes("\\u")) return s - return s.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) -} - -// String coercion for model-emitted leaf text: the renderer iterates these -// with `for..of` (would throw on a number) and draws them verbatim (would -// show stray unicode escapes). Both hazards are normalised here. -function toCleanString(v: unknown): string { - return decodeStrayUnicodeEscapes(String(v)) -} - -function pickCommonOpts(obj: Record): CommonOpts { - const out: CommonOpts = {} - for (const k of ["title", "x_label", "y_label"] as const) { - if (typeof obj[k] === "string") out[k] = decodeStrayUnicodeEscapes(obj[k] as string) - } - for (const k of ["width", "height"] as const) { - if (typeof obj[k] === "number" && Number.isFinite(obj[k])) out[k] = obj[k] as number - } - return out -} - -// Coerce a leaf value to a finite number the way the backend handler does -// (accepts numeric strings), or return null when it can't — JSON.stringify -// would otherwise serialise a NaN as `null` and the renderer would silently -// draw wrong data. -function toFiniteNumber(v: unknown): number | null { - const n = typeof v === "number" ? v : Number(v) - return Number.isFinite(n) ? n : null -} - -// Deep validation + normalisation mirroring backend handler.ts `validate()`. -// The chart JSON is re-emitted by the LLM in its final reply (not the original -// tool response), so it crosses a trust boundary even though the MCP tool -// itself validated input — the model can rewrite, truncate, or corrupt fields -// during paste. Beyond shape checks this also normalises leaf values: category -// / series-name fields are coerced to strings (the renderer iterates them with -// `for..of` and would throw on a number) and numeric fields are coerced to -// finite numbers (a non-finite value rejects the whole spec). Returning null -// lets Markdown.tsx fall back to instead of crashing the -// chat bubble inside PieChart/BarChart/LineChart. -export function tryParseChartSpec(raw: string): ChartSpec | null { - try { - const obj = JSON.parse(raw) as Record - if (!obj || typeof obj !== "object") return null - const data = obj.data as Record | undefined - if (!data || typeof data !== "object") return null - const common = pickCommonOpts(obj) - - if (obj.type === "pie") { - const rawSlices = data.slices - if (!Array.isArray(rawSlices) || !rawSlices.length) return null - const slices: PieSlice[] = [] - for (let i = 0; i < rawSlices.length; i++) { - const s = rawSlices[i] as { label?: unknown; value?: unknown } - if (!s || typeof s !== "object") return null - const value = toFiniteNumber(s.value) - if (value === null) return null - slices.push({ label: toCleanString(s.label ?? `slice ${i}`), value }) - } - return { type: "pie", data: { slices }, ...common } - } - - if (obj.type === "bar") { - const rawCats = data.categories - const rawSeries = data.series - if (!Array.isArray(rawCats) || !rawCats.length) return null - if (!Array.isArray(rawSeries) || !rawSeries.length) return null - const categories = rawCats.map(toCleanString) - const series: BarSeries[] = [] - for (let i = 0; i < rawSeries.length; i++) { - const s = rawSeries[i] as { name?: unknown; values?: unknown } - if (!s || typeof s !== "object" || !Array.isArray(s.values)) return null - if (s.values.length !== categories.length) return null - const values: number[] = [] - for (const v of s.values) { - const n = toFiniteNumber(v) - if (n === null) return null - values.push(n) - } - series.push({ name: toCleanString(s.name ?? `series ${i}`), values }) - } - return { type: "bar", data: { categories, series }, ...common } - } - - if (obj.type === "line") { - const rawSeries = data.series - if (!Array.isArray(rawSeries) || !rawSeries.length) return null - const series: LineSeries[] = [] - for (let i = 0; i < rawSeries.length; i++) { - const s = rawSeries[i] as { name?: unknown; points?: unknown } - if (!s || typeof s !== "object" || !Array.isArray(s.points) || !s.points.length) return null - const points: LinePoint[] = [] - for (const p of s.points) { - const pt = p as { x?: unknown; y?: unknown } - if (!pt || typeof pt !== "object") return null - const y = toFiniteNumber(pt.y) - if (y === null) return null - const x = typeof pt.x === "number" ? pt.x : toCleanString(pt.x) - points.push({ x, y }) - } - series.push({ name: toCleanString(s.name ?? `series ${i}`), points }) - } - return { type: "line", data: { series }, ...common } - } - - return null - } catch { - return null - } -} diff --git a/portal-web/src/components/chat/Markdown.tsx b/portal-web/src/components/chat/Markdown.tsx index 1b1e7eb4..7c601328 100644 --- a/portal-web/src/components/chat/Markdown.tsx +++ b/portal-web/src/components/chat/Markdown.tsx @@ -6,6 +6,55 @@ interface MarkdownProps { children: string } +// CommonMark renders any line indented 4+ spaces as an "indented code block" — +// a grey monospace box. LLM chat output never intends these (it always fences +// code with ```), but a stray leading indent on an ordinary sentence makes a +// plain message render as a code block. This remark plugin rewrites every +// *indented* code block back into a normal paragraph; *fenced* blocks (which +// includes ```chart) are detected via the source text and left untouched. +function remarkDemoteIndentedCode() { + return (tree: unknown, file: { value?: unknown }) => { + const source = typeof file.value === "string" ? file.value : "" + const isFenced = (node: { + position?: { start?: { offset?: number } } + }): boolean => { + const off = node.position?.start?.offset + if (off == null) return true // can't tell — keep it as code, the safe default + const head = source.slice(off, off + 16).replace(/^[ \t]*/, "") + return head.startsWith("```") || head.startsWith("~~~") + } + const walk = (node: unknown): void => { + if (!node || typeof node !== "object") return + const children = (node as { children?: unknown }).children + if (!Array.isArray(children)) return + for (const child of children) { + if ( + child && + typeof child === "object" && + (child as { type?: string }).type === "code" && + !isFenced(child as { position?: { start?: { offset?: number } } }) + ) { + const c = child as { + type: string + value?: string + lang?: unknown + meta?: unknown + children?: unknown + } + c.type = "paragraph" + c.children = [{ type: "text", value: c.value ?? "" }] + delete c.value + delete c.lang + delete c.meta + } else { + walk(child) + } + } + } + walk(tree) + } +} + /** * Escape underscores between word characters to prevent markdown from * interpreting them as emphasis markers (e.g. roll_dice.py → italic). @@ -34,6 +83,10 @@ function permissiveUrlTransform(uri: string): string { return "" } +function hasLanguageClass(className: string | undefined, language: string): boolean { + return className?.split(/\s+/).includes(`language-${language}`) ?? false +} + function ChartLoading() { return (
dark background, no SVG echo from the model). + // + // Every other fenced block — language-tagged or not — is rendered here + // by extracting the raw text and drawing one soft-styled
. We do
+        // NOT pass `children` (the inner  element) through, because the
+        // `code` component below can't tell block code from inline code (a
+        // no-language fence's  carries no className) and would render it
+        // as orange inline text on a slate-900 box — the "black box, yellow
+        // text" artifact. Handling all block code here keeps `code` purely for
+        // inline spans.
         pre({ children }) {
           const child = Array.isArray(children) ? children[0] : children
-          if (
-            child &&
-            typeof child === "object" &&
-            "props" in child &&
-            (child as { props: { className?: string } }).props.className === "language-chart"
-          ) {
-            const raw = (child as { props: { children?: unknown } }).props.children
-            const text = (Array.isArray(raw) ? raw.join("") : String(raw ?? "")).trim()
-            const spec = tryParseChartSpec(text)
+          const isElement =
+            !!child && typeof child === "object" && "props" in child
+          const className = isElement
+            ? (child as { props: { className?: string } }).props.className
+            : undefined
+          const rawChildren = isElement
+            ? (child as { props: { children?: unknown } }).props.children
+            : children
+          const text = Array.isArray(rawChildren)
+            ? rawChildren.join("")
+            : String(rawChildren ?? "")
+
+          if (hasLanguageClass(className, "chart")) {
+            const trimmed = text.trim()
+            const spec = tryParseChartSpec(trimmed)
             if (spec) return 
             // Don't show the red parse-failed box mid-stream — ReactMarkdown
             // re-renders on every token, so an unclosed chart fence flashes
             // an error for every chart until streaming finishes. Only treat
             // it as a real error once the JSON has finished arriving.
-            if (chartSpecLooksIncomplete(text)) return 
-            return 
+            if (chartSpecLooksIncomplete(trimmed)) return 
+            return 
           }
+
           return (
-            
-              {children}
+            
+              {text}
             
) }, - code({ className, children, ...props }) { - const isInline = !className - if (isInline) { - return ( - - {children} - - ) - } + // Inline code only — block code never reaches here (see `pre` above). + code({ children, ...props }) { return ( - + {children} ) diff --git a/portal-web/src/components/chat/PilotArea.tsx b/portal-web/src/components/chat/PilotArea.tsx index 9dbd8e67..19d5f7bc 100644 --- a/portal-web/src/components/chat/PilotArea.tsx +++ b/portal-web/src/components/chat/PilotArea.tsx @@ -21,6 +21,7 @@ import { } from "lucide-react" import { cn } from "./cn" import { Markdown } from "./Markdown" +import { svgChartToPngDataUrl, tryParseChartSpec } from "./ChartRenderer" import { InputArea } from "./InputArea" import { SkillCard } from "./SkillCard" import { ScheduleCard } from "./ScheduleCard" @@ -310,7 +311,7 @@ export function PilotArea({
{visibleForCopy.length > 0 && (
- +
)}
@@ -1189,20 +1190,25 @@ async function copyTextToClipboard(text: string): Promise { // Tracks "copied" feedback state with a self-cancelling timer so back-to-back // clicks don't leave a dangling timeout that clears the green check too early. -function useCopyFeedback(): [boolean, (text: string) => Promise] { +// `flash` lets callers that copy via a custom path (e.g. rich text/html with +// rasterised charts) still surface the same green-check feedback. +function useCopyFeedback(): [boolean, (text: string) => Promise, () => void] { const [copied, setCopied] = useState(false) const timerRef = useRef | null>(null) useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current) }, []) - const copy = useCallback(async (text: string) => { - const ok = await copyTextToClipboard(text) - if (!ok) return + const flash = useCallback(() => { setCopied(true) if (timerRef.current) clearTimeout(timerRef.current) timerRef.current = setTimeout(() => setCopied(false), 2000) }, []) - return [copied, copy] + const copy = useCallback(async (text: string) => { + const ok = await copyTextToClipboard(text) + if (!ok) return + flash() + }, [flash]) + return [copied, copy, flash] } function serializeSessionToText(messages: PilotMessage[]): string { @@ -1213,7 +1219,7 @@ function serializeSessionToText(messages: PilotMessage[]): string { if (m.role === "user") { lines.push(`You:\n${m.content.trim()}`) } else if (m.role === "assistant") { - const body = (m.content ?? "").trim() + const body = stripChartFences(m.content ?? "") if (body) lines.push(`Assistant:\n${body}`) } else if (m.role === "tool") { const name = m.toolName ?? "tool" @@ -1227,9 +1233,108 @@ function serializeSessionToText(messages: PilotMessage[]): string { return lines.join("\n\n") } -function CopySessionButton({ messages }: { messages: PilotMessage[] }) { - const [copied, copy] = useCopyFeedback() - const handleCopy = () => { +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") +} + +// Build a text/html rendition of the whole session with each ```chart fenced +// block swapped for its rasterised PNG. The PNGs are passed in DOM order; an +// assistant message's fences are mapped to them left-to-right, skipping fences +// whose JSON failed to parse (those rendered no chart in the DOM, so they did +// not consume a PNG). +// +// KNOWN LIMITATION — this PNG↔fence mapping is best-effort, not exact. It +// assumes `tryParseChartSpec` succeeding here implies the fence rendered a real +// `.chart-host svg` in the DOM. That can drift if a fence parsed but the chart +// did not actually mount (e.g. it was still in the streaming/loading state, or +// future render-gating changes), in which case later images attach to the +// wrong message. Acceptable for a copy-to-clipboard convenience; if it ever +// needs to be exact, walk per-message DOM nodes instead of re-parsing text. +function serializeSessionToHtml(messages: PilotMessage[], chartUrls: string[]): string { + const fenceRe = /```chart\s*([\s\S]*?)```/g + let chartIdx = 0 + const parts: string[] = [] + for (const m of messages) { + if (m.hidden) continue + if (m.metadata?.kind === "delegation_status_notice") continue + if (m.role === "user") { + parts.push(`

You:

${escapeHtml(m.content.trim()).replace(/\n/g, "
")}

`) + } else if (m.role === "assistant") { + const body = (m.content ?? "").trim() + if (!body) continue + let html = "

Assistant:

" + let last = 0 + let match: RegExpExecArray | null + fenceRe.lastIndex = 0 + while ((match = fenceRe.exec(body)) !== null) { + const before = body.slice(last, match.index).trim() + if (before) html += `

${escapeHtml(before).replace(/\n/g, "
")}

` + const parsed = tryParseChartSpec(match[1].trim()) + if (parsed && chartIdx < chartUrls.length) { + html += `

chart

` + } else { + html += "

[chart]

" + } + last = match.index + match[0].length + } + const tail = body.slice(last).trim() + if (tail) html += `

${escapeHtml(tail).replace(/\n/g, "
")}

` + parts.push(html) + } else if (m.role === "tool") { + const name = m.toolName ?? "tool" + const out = (m.content ?? "").trim() + parts.push( + `

[${escapeHtml(name)}]

` + + (out ? `
${escapeHtml(out)}
` : ""), + ) + } else if (m.role === "error") { + parts.push(`

Error: ${escapeHtml((m.content ?? "").trim())}

`) + } + } + return `
${parts.join("")}
` +} + +async function copySessionWithCharts( + container: HTMLElement, + messages: PilotMessage[], +): Promise { + const svgs = Array.from(container.querySelectorAll('.chart-host svg[role="img"]')) + if (svgs.length === 0) return false + if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) return false + try { + const chartUrls = await Promise.all(svgs.map((svg) => svgChartToPngDataUrl(svg))) + const html = serializeSessionToHtml(messages, chartUrls) + const plain = serializeSessionToText(messages) + await navigator.clipboard.write([ + new ClipboardItem({ + "text/html": new Blob([html], { type: "text/html" }), + "text/plain": new Blob([plain], { type: "text/plain" }), + }), + ]) + return true + } catch (err) { + console.warn("[copy] rich session copy failed, falling back to text:", err) + return false + } +} + +function CopySessionButton({ + messages, + containerRef, +}: { + messages: PilotMessage[] + containerRef: React.RefObject +}) { + const [copied, copy, flashCopied] = useCopyFeedback() + const handleCopy = async () => { + const container = containerRef.current + if (container && (await copySessionWithCharts(container, messages))) { + flashCopied() + return + } void copy(serializeSessionToText(messages)) } return ( @@ -1273,10 +1378,59 @@ function CopyIconButton({ ) } -function CopyableMessage({ isUser, content }: { isUser: boolean; content: string }) { - const [copied, copy] = useCopyFeedback() +// Plain-text fallback for the clipboard: a ```chart fenced JSON block is noise +// when pasted as text, so swap it for a readable placeholder. +function stripChartFences(markdown: string): string { + return markdown.replace(/```chart\s*[\s\S]*?```/g, "[chart]").replace(/\n{3,}/g, "\n\n").trim() +} + +// Copy a rendered message bubble as rich text/html with charts rasterised to +// inline PNGs, plus a text/plain fallback. Returns false when there are no +// charts or the browser can't do a rich clipboard write, so the caller can +// fall back to a plain markdown copy. Without this, "copy answer" yielded the +// raw chart JSON spec instead of the picture the user actually sees. +async function copyBubbleWithCharts(bubble: HTMLElement, content: string): Promise { + const charts = Array.from(bubble.querySelectorAll('.chart-host svg[role="img"]')) + if (charts.length === 0) return false + if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) return false + try { + const dataUrls = await Promise.all(charts.map((svg) => svgChartToPngDataUrl(svg))) + const clone = bubble.cloneNode(true) as HTMLElement + const cloneHosts = Array.from(clone.querySelectorAll(".chart-host")) + cloneHosts.forEach((host, i) => { + const img = document.createElement("img") + img.src = dataUrls[i] ?? "" + img.style.maxWidth = "100%" + img.style.height = "auto" + host.replaceWith(img) + }) + // Drop any leftover interactive controls (chart toolbars etc.). + clone.querySelectorAll("button").forEach((b) => b.remove()) + const html = `
${clone.innerHTML}
` + const plain = stripChartFences(content) + await navigator.clipboard.write([ + new ClipboardItem({ + "text/html": new Blob([html], { type: "text/html" }), + "text/plain": new Blob([plain], { type: "text/plain" }), + }), + ]) + return true + } catch (err) { + console.warn("[copy] rich chart copy failed, falling back to text:", err) + return false + } +} - const handleCopy = () => { +function CopyableMessage({ isUser, content }: { isUser: boolean; content: string }) { + const [copied, copy, flashCopied] = useCopyFeedback() + const bubbleRef = useRef(null) + + const handleCopy = async () => { + const bubble = bubbleRef.current + if (bubble && (await copyBubbleWithCharts(bubble, content))) { + flashCopied() + return + } void copy(content) } @@ -1286,6 +1440,7 @@ function CopyableMessage({ isUser, content }: { isUser: boolean; content: string return (
{ + it("formats everyday integers with thousands separators, no scientific notation", () => { + expect(fmtNumber(0)).toBe("0") + expect(fmtNumber(42)).toBe("42") + expect(fmtNumber(1000)).toBe("1,000") + expect(fmtNumber(1005)).toBe("1,005") + expect(fmtNumber(1200)).toBe("1,200") + expect(fmtNumber(-1500)).toBe("-1,500") + }) + + it("uses SI suffixes for millions and above", () => { + expect(fmtNumber(1_000_000)).toBe("1M") + expect(fmtNumber(1_500_000)).toBe("1.5M") + expect(fmtNumber(2_000_000_000)).toBe("2G") + }) + + it("keeps significant digits for sub-1 values instead of rounding to 0 (regression)", () => { + // toFixed(2) used to turn 0.003 into "0.00" -> "0"; that hid real data. + expect(fmtNumber(0.003)).not.toBe("0") + expect(fmtNumber(0.003)).toBe("0.003") + expect(fmtNumber(0.5)).toBe("0.5") + expect(fmtNumber(0.12345)).toBe("0.12") + expect(fmtNumber(12.345)).toBe("12.35") + }) + + it("falls back to exponential only for values outside any sane chart range", () => { + expect(fmtNumber(1e-9)).toMatch(/e-9$/) + expect(fmtNumber(1e20)).toMatch(/e/) + }) + + it("passes non-finite values through", () => { + expect(fmtNumber(NaN)).toBe("NaN") + expect(fmtNumber(Infinity)).toBe("Infinity") + }) +}) + +describe("niceAxis", () => { + it("produces a covering range with sorted ticks", () => { + const a = niceAxis(0, 1005) + expect(a.min).toBeLessThanOrEqual(0) + expect(a.max).toBeGreaterThanOrEqual(1005) + expect(a.ticks.length).toBeGreaterThanOrEqual(2) + expect([...a.ticks]).toEqual([...a.ticks].sort((x, y) => x - y)) + }) + + it("does not collapse when min === max", () => { + const a = niceAxis(5, 5) + expect(a.max).toBeGreaterThan(a.min) + }) + + it("returns a safe default for non-finite input", () => { + expect(niceAxis(NaN, NaN)).toEqual({ min: 0, max: 1, ticks: [0, 0.5, 1] }) + }) +}) + +describe("logAxis", () => { + it("spans whole powers of ten with power-of-ten ticks", () => { + const a = logAxis(10, 1000) + expect(a.log).toBe(true) + expect(a.min).toBe(10) + expect(a.max).toBe(1000) + expect(a.ticks).toEqual([10, 100, 1000]) + }) +}) + +describe("axisFrac", () => { + it("maps linearly for a linear axis", () => { + const a = niceAxis(0, 100) + expect(axisFrac(a, a.min)).toBe(0) + expect(axisFrac(a, a.max)).toBe(1) + }) + + it("maps logarithmically for a log axis", () => { + const a = logAxis(10, 1000) + expect(axisFrac(a, 10)).toBeCloseTo(0) + expect(axisFrac(a, 100)).toBeCloseTo(0.5) + expect(axisFrac(a, 1000)).toBeCloseTo(1) + }) +}) + +describe("logPossible", () => { + it("is true whenever every value is positive and there are >=2 of them", () => { + // Permissive on purpose: a modest spread still gets the toolbar toggle so + // line charts expose linear/log the same way bar charts do. + expect(logPossible([10, 20, 30])).toBe(true) + expect(logPossible([1, 1000])).toBe(true) + }) + + it("is false for a zero, a negative, or too few points", () => { + expect(logPossible([0, 1000])).toBe(false) + expect(logPossible([1000, -5])).toBe(false) + expect(logPossible([1000])).toBe(false) + }) +}) + +describe("logBeneficial", () => { + it("is true when every value is positive and the spread is large", () => { + expect(logBeneficial([1, 1000])).toBe(true) + expect(logBeneficial([5, 17, 1005])).toBe(true) + }) + + it("is false for a small spread, a zero, a negative, or too few points", () => { + expect(logBeneficial([10, 20, 30])).toBe(false) + expect(logBeneficial([0, 1000])).toBe(false) + expect(logBeneficial([1000, -5])).toBe(false) + expect(logBeneficial([1000])).toBe(false) + }) +}) + +const barSpec = (seriesCount: number, nameLen = 4): ChartSpec => ({ + type: "bar", + data: { + categories: ["a", "b"], + series: Array.from({ length: seriesCount }, (_, i) => ({ + name: `s${i}`.padEnd(nameLen, "x"), + values: [1, 2], + })), + }, +}) + +const lineSpec = (seriesCount: number, nameLen = 16): ChartSpec => ({ + type: "line", + data: { + series: Array.from({ length: seriesCount }, (_, i) => ({ + name: `series-${i}`.padEnd(nameLen, "x"), + points: [{ x: 0, y: 1 }, { x: 1, y: 2 }], + })), + }, +}) + +const pieSpec: ChartSpec = { + type: "pie", + data: { slices: [{ label: "a", value: 1 }, { label: "b", value: 2 }] }, +} + +describe("layoutLegendRows", () => { + it("keeps a short legend on one row", () => { + expect(layoutLegendRows(["aa", "bb", "cc"], 1000)).toHaveLength(1) + }) + + it("wraps a legend that does not fit the width", () => { + const names = Array.from({ length: 20 }, (_, i) => `long-series-name-${i}`) + expect(layoutLegendRows(names, 300).length).toBeGreaterThan(1) + }) +}) + +describe("computePlot", () => { + it("yields a non-empty plot rectangle inside the canvas", () => { + const p = computePlot(900, 520, true, 1, true, false, true, true) + expect(p.left).toBeGreaterThan(0) + expect(p.right).toBeLessThan(900) + expect(p.top).toBeGreaterThan(0) + expect(p.bottom).toBeLessThan(520) + expect(p.w).toBeGreaterThan(0) + expect(p.h).toBeGreaterThan(0) + }) + + it("reserves more vertical space as legend rows grow", () => { + const one = computePlot(900, 520, true, 1, true, false, false, false) + const four = computePlot(900, 520, true, 4, true, false, false, false) + expect(four.top).toBeGreaterThan(one.top) + }) +}) + +describe("barTickLayout", () => { + it("keeps short labels horizontal in a 26px band", () => { + const l = barTickLayout(["a", "b", "c"], 900) + expect(l.rotate).toBe(false) + expect(l.tickBandH).toBe(26) + }) + + it("rotates crowded labels and sizes the band to the overhang (no clip regression)", () => { + // Many long category names at a narrow width: labels must rotate, and the + // band must be tall enough to contain a -30deg label's vertical overhang — + // a fixed 50px band used to let long labels spill past the canvas bottom. + const cats = Array.from({ length: 10 }, (_, i) => `namespace-prefix-${i}-long`) + const l = barTickLayout(cats, 720) + expect(l.rotate).toBe(true) + expect(l.tickBandH).toBeGreaterThan(50) + }) + + it("makes chartCanvasSize grow the canvas to fit a tall rotated tick band", () => { + const cats = Array.from({ length: 10 }, (_, i) => `namespace-prefix-${i}-long`) + const spec: ChartSpec = { + type: "bar", + data: { categories: cats, series: [{ name: "s", values: cats.map(() => 1) }] }, + } + const { height } = chartCanvasSize(spec, 720) + expect(height).toBeGreaterThan(520) + }) +}) + +describe("legendRowsFor", () => { + it("returns 0 for a single-series chart and for pie", () => { + expect(legendRowsFor(barSpec(1), 900)).toBe(0) + expect(legendRowsFor(pieSpec, 900)).toBe(0) + }) + + it("returns >=1 for a multi-series bar/line chart", () => { + expect(legendRowsFor(barSpec(3), 900)).toBeGreaterThanOrEqual(1) + expect(legendRowsFor(lineSpec(3), 900)).toBeGreaterThanOrEqual(1) + }) +}) + +describe("chartCanvasSize", () => { + it("falls back to the spec width before the container is measured", () => { + expect(chartCanvasSize(lineSpec(2), null).width).toBe(900) + }) + + it("clamps the measured width to [300, specWidth]", () => { + expect(chartCanvasSize(lineSpec(2), 100).width).toBe(300) + expect(chartCanvasSize(lineSpec(2), 2000).width).toBe(900) + expect(chartCanvasSize(lineSpec(2), 480).width).toBe(480) + }) + + it("never shrinks height below the spec height — the plot-collapse regression guard", () => { + // A narrow width forces the 10-series legend to wrap; height must GROW to + // keep the plot area, never scale down (which collapsed it to a sliver). + for (const w of [320, 400, 600, 900, null]) { + const { height } = chartCanvasSize(lineSpec(10), w) + expect(height).toBeGreaterThanOrEqual(520) + } + }) + + it("grows height when the legend wraps onto extra rows", () => { + const wide = chartCanvasSize(lineSpec(10), 900) + const narrow = chartCanvasSize(lineSpec(10), 320) + expect(narrow.legendRows).toBeGreaterThan(wide.legendRows) + expect(narrow.height).toBeGreaterThan(wide.height) + }) + + it("returns a legendRows count consistent with legendRowsFor at the resolved width", () => { + const { width, legendRows } = chartCanvasSize(lineSpec(8), 500) + expect(legendRows).toBe(legendRowsFor(lineSpec(8), width)) + }) +}) + +describe("collectChartValues", () => { + it("flattens bar values and line y-values, and is empty for pie", () => { + expect(collectChartValues(barSpec(2)).sort()).toEqual([1, 1, 2, 2]) + expect(collectChartValues(lineSpec(1))).toEqual([1, 2]) + expect(collectChartValues(pieSpec)).toEqual([]) + }) +}) + +describe("series style cycling", () => { + it("cycles dash patterns and marker shapes by index", () => { + expect(seriesDash(0)).toBe(seriesDash(5)) + expect(seriesShape(0)).toBe(seriesShape(4)) + expect(seriesShape(0)).not.toBe(seriesShape(1)) + }) +}) diff --git a/portal-web/src/components/chat/chart-utils.ts b/portal-web/src/components/chat/chart-utils.ts new file mode 100644 index 00000000..6eff5a80 --- /dev/null +++ b/portal-web/src/components/chat/chart-utils.ts @@ -0,0 +1,497 @@ +/** + * Pure (DOM-free) logic for the chart renderer: the ChartSpec contract, number + * / axis math, legend layout, plot geometry, canvas sizing, and the trust- + * boundary spec parser. + * + * This file deliberately holds NO JSX and NO browser APIs so every function + * here is unit-testable headlessly (see chart-utils.test.ts). ChartRenderer.tsx + * imports from here and keeps only the React components + DOM helpers. + */ + +export type PieSlice = { label: string; value: number } +export type BarSeries = { name: string; values: number[] } +export type LinePoint = { x: number | string; y: number } +export type LineSeries = { name: string; points: LinePoint[] } + +export const CHART_SPEC_VERSION = 1 + +export interface CommonOpts { + schema_version?: typeof CHART_SPEC_VERSION + title?: string + x_label?: string + y_label?: string + width?: number + height?: number +} + +export type ChartSpec = + | ({ type: "pie"; data: { slices: PieSlice[] } } & CommonOpts) + | ({ type: "bar"; data: { categories: string[]; series: BarSeries[] } } & CommonOpts) + | ({ type: "line"; data: { series: LineSeries[] } } & CommonOpts) + +// No grey in the categorical palette — grey is reserved for the "Others" +// bucket so it reads as a residual rather than a real category. 11 distinct +// hues cover the max 11 real slices a collapsed pie can have. +export const PALETTE = [ + "#4e79a7", "#f28e2b", "#e15759", "#76b7b2", "#59a14f", + "#edc948", "#b07aa1", "#ff9da7", "#9c755f", "#b6992d", "#d37295", +] +// Neutral grey for the collapsed "Others" slice — legible on both themes. +export const OTHERS_COLOR = "#9aa0a6" + +export const TITLE_SIZE = 18 +export const LEGEND_SIZE = 13 +// Vertical advance between wrapped legend rows. +export const LEGEND_LINE_H = LEGEND_SIZE + 9 +export const AXIS_LABEL_SIZE = 13 +export const TICK_SIZE = 12 +export const PIE_LABEL_SIZE = 12 + +// Per-series line dash patterns + marker shapes. Colour alone is not enough to +// tell series apart for colour-blind viewers or in greyscale prints, so each +// line series also gets a distinct dash and a distinct point marker. +export const LINE_DASHES = ["", "7 4", "2 4", "10 4 2 4", "1 5"] +export type MarkerShapeKind = "circle" | "square" | "triangle" | "diamond" +export const MARKER_SHAPES: MarkerShapeKind[] = ["circle", "square", "triangle", "diamond"] +export function seriesDash(i: number): string { + return LINE_DASHES[i % LINE_DASHES.length] +} +export function seriesShape(i: number): MarkerShapeKind { + return MARKER_SHAPES[i % MARKER_SHAPES.length] +} + +// High-cardinality pie data (e.g. a per-namespace Pod count with 20+ entries) +// renders as a fan of unreadable slivers. Keep the largest slices and roll the +// long tail into a single "Others" bucket so the chart stays legible. +export const PIE_MAX_SLICES = 12 + +// othersIndex is the index of the collapsed "Others" slice, or -1 when the +// data was small enough to render as-is. The caller colours that slice grey. +export function collapsePieSlices( + slices: PieSlice[], + max = PIE_MAX_SLICES, +): { slices: PieSlice[]; othersIndex: number } { + if (slices.length <= max) return { slices, othersIndex: -1 } + const sorted = [...slices].sort((a, b) => b.value - a.value) + const head = sorted.slice(0, max - 1) + const tail = sorted.slice(max - 1) + const tailTotal = tail.reduce((a, s) => a + Math.max(0, s.value), 0) + return { + slices: [...head, { label: `Others (${tail.length})`, value: tailTotal }], + othersIndex: head.length, + } +} + +export function pieSliceColor(index: number, othersIndex: number): string { + return index === othersIndex ? OTHERS_COLOR : PALETTE[index % PALETTE.length] +} + +export function approxTextWidth(text: string, fontSize: number): number { + let w = 0 + for (const ch of text) { + // CJK ideographs + fullwidth forms render roughly square (one em wide). + if (/[\u4e00-\u9fff\uff00-\uffef]/.test(ch)) w += fontSize + else if (/[A-Z0-9]/.test(ch)) w += fontSize * 0.62 + else w += fontSize * 0.52 + } + return w +} + +// Compact, human-readable number formatting for axis ticks and tooltips. +// Everyday magnitudes must NOT use scientific notation — a Pod count of 1200 +// should read "1,200", not "1.20e+3". SI suffixes cover the genuinely large; +// values below 1 keep significant-digit precision (so 0.003 stays "0.003" and +// is not rounded down to "0" by a fixed 2-decimal format); exponential is the +// last resort for values outside any sane chart range. +export function fmtNumber(n: number): string { + if (!Number.isFinite(n)) return String(n) + if (n === 0) return "0" + const abs = Math.abs(n) + if (abs >= 1e15 || abs < 1e-4) return n.toExponential(1).replace("e+", "e") + if (abs >= 1e6) { + const units: Array<[string, number]> = [["T", 1e12], ["G", 1e9], ["M", 1e6]] + for (const [u, d] of units) { + if (abs >= d) { + const v = n / d + return (Number.isInteger(v) ? String(v) : v.toFixed(1)) + u + } + } + } + if (abs >= 1000) return Math.round(n).toLocaleString("en-US") + if (Number.isInteger(n)) return String(n) + if (abs >= 1) return String(Number(n.toFixed(2))) + // abs in [1e-4, 1): two significant digits, no scientific notation. + return String(Number(n.toPrecision(2))) +} + +function niceNum(range: number, round: boolean): number { + const exp = Math.floor(Math.log10(range || 1)) + const f = range / Math.pow(10, exp) + let nf: number + if (round) { + if (f < 1.5) nf = 1 + else if (f < 3) nf = 2 + else if (f < 7) nf = 5 + else nf = 10 + } else if (f <= 1) nf = 1 + else if (f <= 2) nf = 2 + else if (f <= 5) nf = 5 + else nf = 10 + return nf * Math.pow(10, exp) +} + +export interface Axis { min: number; max: number; ticks: number[]; log?: boolean } + +export function niceAxis(min: number, max: number, count = 5): Axis { + if (!Number.isFinite(min) || !Number.isFinite(max)) { + return { min: 0, max: 1, ticks: [0, 0.5, 1] } + } + if (min === max) { + const pad = Math.abs(min) > 0 ? Math.abs(min) * 0.1 : 1 + min -= pad; max += pad + } + const range = niceNum(max - min, false) + const step = niceNum(range / (count - 1), true) + const niceMin = Math.floor(min / step) * step + const niceMax = Math.ceil(max / step) * step + const ticks: number[] = [] + for (let v = niceMin; v <= niceMax + step / 2; v += step) { + ticks.push(Number(v.toFixed(10))) + } + return { min: niceMin, max: niceMax, ticks } +} + +// Log-scale axis spanning whole powers of ten. Used when a linear axis would +// flatten small series into an unreadable band along the baseline. +export function logAxis(dataMin: number, dataMax: number): Axis { + const loExp = Math.floor(Math.log10(dataMin)) + const hiExp = Math.max(Math.ceil(Math.log10(dataMax)), loExp + 1) + const ticks: number[] = [] + for (let e = loExp; e <= hiExp + 1e-9; e++) ticks.push(Math.pow(10, e)) + return { min: Math.pow(10, loExp), max: Math.pow(10, hiExp), ticks, log: true } +} + +// Fraction (0..1) of a value's position along its axis, log-aware. +export function axisFrac(axis: Axis, v: number): number { + if (axis.log) { + const lmin = Math.log10(axis.min) + const lmax = Math.log10(axis.max) + const lv = Math.log10(Math.max(v, axis.min)) + return lmax > lmin ? (lv - lmin) / (lmax - lmin) : 0 + } + return axis.max > axis.min ? (v - axis.min) / (axis.max - axis.min) : 0 +} + +// A log scale is *possible* — and the toolbar toggle worth offering — whenever +// every value is positive (log of <=0 is undefined) and there are at least two +// values to span. This is intentionally permissive: the user can switch to log +// even on a modest spread, the same way bar and line charts both expose it. +export function logPossible(values: number[]): boolean { + const finite = values.filter((v) => Number.isFinite(v)) + const pos = finite.filter((v) => v > 0) + return pos.length >= 2 && pos.length === finite.length +} + +// A log scale is *beneficial* — worth auto-selecting in "auto" mode — only when +// it's possible AND the spread is large enough that a linear axis would flatten +// small values into an unreadable band along the baseline. +export function logBeneficial(values: number[]): boolean { + if (!logPossible(values)) return false + const pos = values.filter((v) => Number.isFinite(v) && v > 0) + return Math.max(...pos) / Math.min(...pos) >= 50 +} + +export function collectChartValues(spec: ChartSpec): number[] { + if (spec.type === "bar") return spec.data.series.flatMap((s) => s.values) + if (spec.type === "line") return spec.data.series.flatMap((s) => s.points.map((p) => p.y)) + return [] +} + +// Legend swatch/spacing geometry, shared by layout + render so wrapping math +// and drawn positions never drift. +export const LEGEND_SWATCH = 12 +export const LEGEND_GAP = 6 +export const LEGEND_BETWEEN = 18 +export const LEGEND_MARGIN = 16 + +// Pack legend entries into rows that each fit within the chart width. A single +// long row used to overflow the viewBox and silently clip every entry past the +// right edge — wrapping keeps all series visible. +export function layoutLegendRows(names: string[], width: number): string[][] { + const maxW = width - LEGEND_MARGIN * 2 + const itemW = (n: string) => LEGEND_SWATCH + LEGEND_GAP + approxTextWidth(n, LEGEND_SIZE) + const rows: string[][] = [] + let cur: string[] = [] + let curW = 0 + for (const n of names) { + const w = itemW(n) + const add = cur.length ? LEGEND_BETWEEN + w : w + if (cur.length && curW + add > maxW) { + rows.push(cur) + cur = [] + curW = 0 + } + cur.push(n) + curW += cur.length === 1 ? w : LEGEND_BETWEEN + w + } + if (cur.length) rows.push(cur) + return rows +} + +// Number of legend rows the chart will need at a given width. Single source of +// truth: both the canvas-height calculation and computePlot must agree on this, +// otherwise the legend eats space the plot thinks it has and the plot collapses +// to a sliver. Pie charts use a vertical side-legend, so they need 0 rows here. +export function legendRowsFor(spec: ChartSpec, width: number): number { + if (spec.type === "bar" || spec.type === "line") { + const names = spec.data.series.map((s) => s.name) + return names.length > 1 ? layoutLegendRows(names, width).length : 0 + } + return 0 +} + +export interface Plot { left: number; right: number; top: number; bottom: number; w: number; h: number } +// `rotatedTickH` is the height of the x-tick band when ticks are rotated; the +// caller measures it from the actual longest label (see barTickLayout) so a +// long category label never overhangs past the canvas bottom. Defaults to the +// historic fixed value for callers that don't rotate. +export function computePlot( + width: number, height: number, + hasTitle: boolean, legendRows: number, + hasXLabels: boolean, rotateXTicks: boolean, + hasYAxisLabel: boolean, hasXAxisLabel: boolean, + rotatedTickH = 50, +): Plot { + const titleH = hasTitle ? 30 : 6 + const legendH = legendRows > 0 ? legendRows * LEGEND_LINE_H + 6 : 0 + const xTickH = hasXLabels ? (rotateXTicks ? rotatedTickH : 26) : 8 + const xLabelH = hasXAxisLabel ? 22 : 0 + const yLabelW = hasYAxisLabel ? 22 : 0 + const top = titleH + legendH + 8 + const bottom = height - xTickH - xLabelH - 8 + const left = 60 + yLabelW + const right = width - 24 + return { left, right, top, bottom, w: right - left, h: bottom - top } +} + +// Bar x-tick geometry — single source of truth shared by chartCanvasSize (which +// grows the canvas to fit) and renderBar (which draws + reserves the band). +// Category labels rotate to -30° when they'd otherwise overlap; a rotated label +// overhangs the axis by labelWidth*sin(30°), so the band must be tall enough to +// contain that overhang or the labels get clipped at the canvas bottom. +export const BAR_TICK_ROTATE_DEG = 30 +// Gap from the plot bottom to the rotated label's anchor (matches renderBar). +const BAR_TICK_GAP = 16 +// Historic fixed rotated-tick band height; canvas growth is measured against it. +export const BAR_TICK_BASE_H = 50 + +export function barTickLayout( + categories: string[], width: number, +): { rotate: boolean; tickBandH: number } { + const longest = categories.reduce( + (m, c) => Math.max(m, approxTextWidth(c, TICK_SIZE)), + 0, + ) + const approxGroupW = categories.length ? (width - 80) / categories.length : width + const rotate = longest + 4 > approxGroupW + if (!rotate) return { rotate: false, tickBandH: 26 } + const overhang = longest * Math.sin((BAR_TICK_ROTATE_DEG * Math.PI) / 180) + return { rotate: true, tickBandH: Math.ceil(BAR_TICK_GAP + overhang + 8) } +} + +export function defaultSize(type: ChartSpec["type"]): { width: number; height: number } { + if (type === "pie") return { width: 760, height: 480 } + return { width: 900, height: 520 } +} + +export function describeChart(spec: ChartSpec): string { + if (spec.type === "pie") return `Pie chart with ${spec.data.slices.length} segment(s).` + if (spec.type === "bar") { + return `Bar chart with ${spec.data.categories.length} categories and ${spec.data.series.length} series.` + } + return `Line chart with ${spec.data.series.length} series.` +} + +// Single source of truth for chart canvas dimensions. +// +// width: the container's measured width, clamped to [300, specWidth] so the +// chart fills a narrow chat bubble without ever upscaling past its +// ideal size. Falls back to specWidth before the first measurement. +// height: the spec height is a FLOOR, never scaled down. A narrow width forces +// the legend to wrap onto more rows; shrinking the canvas at the same +// time would collapse the plot to a sliver. Instead the canvas GROWS +// by one line per extra wrapped legend row. +// legendRows: returned so renderBar/renderLine reuse the exact same count via +// computePlot — they must not recompute it independently. +export function chartCanvasSize( + spec: ChartSpec, + measuredW: number | null, +): { width: number; height: number; legendRows: number } { + const def = defaultSize(spec.type) + const specWidth = spec.width ?? def.width + const specHeight = spec.height ?? def.height + const width = measuredW + ? Math.round(Math.min(Math.max(measuredW, 300), specWidth)) + : specWidth + const legendRows = legendRowsFor(spec, width) + let height = specHeight + Math.max(0, legendRows - 1) * LEGEND_LINE_H + // Grow the canvas when bar category labels rotate and overhang past the + // historic fixed tick band — same "canvas GROWS, plot never collapses" + // philosophy as the legend-row growth above. + if (spec.type === "bar") { + const { tickBandH } = barTickLayout(spec.data.categories, width) + height += Math.max(0, tickBandH - BAR_TICK_BASE_H) + } + return { width, height, legendRows } +} + +// ---- Trust-boundary spec parser ------------------------------------------- + +// Heuristic: does the text look like a fully-streamed JSON object, or are we +// still mid-stream? During streaming ReactMarkdown re-parses on every token, +// so the chart fence is rendered before the closing `}` arrives — without +// this check tryParseChartSpec would return null and Markdown.tsx would flash +// the red "parse failed" box for every chart until streaming completes. +// Returns true when the text is empty, doesn't end with `}`, or has more +// opening braces than closing ones (ignoring braces inside JSON strings). +export function chartSpecLooksIncomplete(raw: string): boolean { + const text = raw.trim() + if (!text || !text.endsWith("}")) return true + let depth = 0 + let inStr = false + let esc = false + for (let i = 0; i < text.length; i++) { + const ch = text[i] + if (esc) { esc = false; continue } + if (inStr) { + if (ch === "\\") esc = true + else if (ch === '"') inStr = false + continue + } + if (ch === '"') inStr = true + else if (ch === "{") depth++ + else if (ch === "}") depth-- + } + return depth !== 0 || inStr +} + +// The chart spec round-trips through the LLM as plain text in the final reply. +// On a small fraction of generations the model double-escapes non-ASCII — +// emits a literal backslash-u-XXXX sequence instead of the character (or a +// single \u escape JSON.parse would decode). JSON.parse then yields the +// literal 6-char string. Decode such stray escapes so CJK titles/labels render. +function decodeStrayUnicodeEscapes(s: string): string { + if (!s.includes("\\u")) return s + return s.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) +} + +// String coercion for model-emitted leaf text: the renderer iterates these +// with `for..of` (would throw on a number) and draws them verbatim (would +// show stray unicode escapes). Both hazards are normalised here. +function toCleanString(v: unknown): string { + return decodeStrayUnicodeEscapes(String(v)) +} + +function pickCommonOpts(obj: Record): CommonOpts | null { + const rawVersion = obj.schema_version + if (rawVersion !== undefined && rawVersion !== CHART_SPEC_VERSION) return null + const out: CommonOpts = { schema_version: CHART_SPEC_VERSION } + for (const k of ["title", "x_label", "y_label"] as const) { + if (typeof obj[k] === "string") out[k] = decodeStrayUnicodeEscapes(obj[k] as string) + } + for (const k of ["width", "height"] as const) { + if (typeof obj[k] === "number" && Number.isFinite(obj[k])) out[k] = obj[k] as number + } + return out +} + +// Coerce a leaf value to a finite number the way the backend handler does +// (accepts numeric strings), or return null when it can't — JSON.stringify +// would otherwise serialise a NaN as `null` and the renderer would silently +// draw wrong data. +function toFiniteNumber(v: unknown): number | null { + const n = typeof v === "number" ? v : Number(v) + return Number.isFinite(n) ? n : null +} + +// Deep validation + normalisation mirroring backend handler.ts `validate()`. +// The chart JSON is re-emitted by the LLM in its final reply (not the original +// tool response), so it crosses a trust boundary even though the MCP tool +// itself validated input — the model can rewrite, truncate, or corrupt fields +// during paste. Beyond shape checks this also normalises leaf values: category +// / series-name fields are coerced to strings (the renderer iterates them with +// `for..of` and would throw on a number) and numeric fields are coerced to +// finite numbers (a non-finite value rejects the whole spec). Returning null +// lets Markdown.tsx fall back to instead of crashing the +// chat bubble inside the chart renderers. +export function tryParseChartSpec(raw: string): ChartSpec | null { + try { + const obj = JSON.parse(raw) as Record + if (!obj || typeof obj !== "object") return null + const data = obj.data as Record | undefined + if (!data || typeof data !== "object") return null + const common = pickCommonOpts(obj) + if (!common) return null + + if (obj.type === "pie") { + const rawSlices = data.slices + if (!Array.isArray(rawSlices) || !rawSlices.length) return null + const slices: PieSlice[] = [] + for (let i = 0; i < rawSlices.length; i++) { + const s = rawSlices[i] as { label?: unknown; value?: unknown } + if (!s || typeof s !== "object") return null + const value = toFiniteNumber(s.value) + if (value === null) return null + slices.push({ label: toCleanString(s.label ?? `slice ${i}`), value }) + } + return { type: "pie", data: { slices }, ...common } + } + + if (obj.type === "bar") { + const rawCats = data.categories + const rawSeries = data.series + if (!Array.isArray(rawCats) || !rawCats.length) return null + if (!Array.isArray(rawSeries) || !rawSeries.length) return null + const categories = rawCats.map(toCleanString) + const series: BarSeries[] = [] + for (let i = 0; i < rawSeries.length; i++) { + const s = rawSeries[i] as { name?: unknown; values?: unknown } + if (!s || typeof s !== "object" || !Array.isArray(s.values)) return null + if (s.values.length !== categories.length) return null + const values: number[] = [] + for (const v of s.values) { + const n = toFiniteNumber(v) + if (n === null) return null + values.push(n) + } + series.push({ name: toCleanString(s.name ?? `series ${i}`), values }) + } + return { type: "bar", data: { categories, series }, ...common } + } + + if (obj.type === "line") { + const rawSeries = data.series + if (!Array.isArray(rawSeries) || !rawSeries.length) return null + const series: LineSeries[] = [] + for (let i = 0; i < rawSeries.length; i++) { + const s = rawSeries[i] as { name?: unknown; points?: unknown } + if (!s || typeof s !== "object" || !Array.isArray(s.points) || !s.points.length) return null + const points: LinePoint[] = [] + for (const p of s.points) { + const pt = p as { x?: unknown; y?: unknown } + if (!pt || typeof pt !== "object") return null + const y = toFiniteNumber(pt.y) + if (y === null) return null + const x = typeof pt.x === "number" ? pt.x : toCleanString(pt.x) + points.push({ x, y }) + } + series.push({ name: toCleanString(s.name ?? `series ${i}`), points }) + } + return { type: "line", data: { series }, ...common } + } + + return null + } catch { + return null + } +}