From bc6446b16cff6bb1984950f944564c5b29ffffe1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 18:16:09 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E2=9C=A8=20support=20the=20data=5F?= =?UTF-8?q?visualization=20block=20(line,=20bar,=20area,=20pie=20charts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the new Slack data_visualization Block Kit block introduced in the 2026-06-16 Block Kit release. - Types: DataVisualizationBlock plus DataVizChart (cartesian line/bar/area via series + axis_config, and pie via segments) added to the Block union. - Renderer: a dependency-free inline-SVG chart component covering line, grouped bar, stacked-style area, and pie. Handles multi-series, negative values (zero baseline), "nice" auto-scaled Y ticks, axis/category labels, a color-swatch legend, and Slack's accessible aria-label patterns. Works in both light and dark mode. - Affordances: a "view as table" toggle and a CSV download, mirroring Slack's chart action buttons (SSR-safe). - Docs/playground: readme Supported Blocks row and a charts fixture. - Tests: renders the Block Kit example fixtures and locks in the h3 title, aria-labels, legend, axis labels, negative handling, and pie percentages. https://claude.ai/code/session_018T7Lik8eFCV8DMtTckZC6a --- playground/src/fixtures.ts | 115 ++++ readme.md | 1 + src/components/blocks/data_visualization.tsx | 602 +++++++++++++++++++ src/components/blocks/index.ts | 1 + src/components/index.tsx | 2 + src/style.css | 13 + src/types/layout.ts | 129 ++++ test/data_visualization.test.mjs | 179 ++++++ 8 files changed, 1042 insertions(+) create mode 100644 src/components/blocks/data_visualization.tsx create mode 100644 test/data_visualization.test.mjs diff --git a/playground/src/fixtures.ts b/playground/src/fixtures.ts index 25bbee0..c278203 100644 --- a/playground/src/fixtures.ts +++ b/playground/src/fixtures.ts @@ -238,4 +238,119 @@ export const FIXTURES: Fixture[] = [ }, ], }, + { + id: "data-visualization", + label: "Data visualization · charts", + blocks: [ + { + type: "data_visualization", + block_id: "viz-line-multi", + title: "Weekly active users by platform", + chart: { + type: "line", + series: [ + { + name: "Desktop", + data: [ + { label: "Mon", value: 800 }, + { label: "Tue", value: 920 }, + { label: "Wed", value: 880 }, + { label: "Thu", value: 1010 }, + { label: "Fri", value: 1120 }, + ], + }, + { + name: "Mobile", + data: [ + { label: "Mon", value: 400 }, + { label: "Tue", value: 530 }, + { label: "Wed", value: 500 }, + { label: "Thu", value: 590 }, + { label: "Fri", value: 600 }, + ], + }, + ], + axis_config: { + categories: ["Mon", "Tue", "Wed", "Thu", "Fri"], + x_label: "Day", + y_label: "Users", + }, + }, + }, + { + type: "data_visualization", + block_id: "viz-bar-negative", + title: "Net headcount change", + chart: { + type: "bar", + series: [ + { + name: "Delta", + data: [ + { label: "Mon", value: 4 }, + { label: "Tue", value: -2 }, + { label: "Wed", value: 6 }, + { label: "Thu", value: -1 }, + { label: "Fri", value: 3 }, + ], + }, + ], + axis_config: { + categories: ["Mon", "Tue", "Wed", "Thu", "Fri"], + x_label: "Day", + y_label: "People (delta)", + }, + }, + }, + { + type: "data_visualization", + block_id: "viz-area-multi", + title: "Concurrent users by platform", + chart: { + type: "area", + series: [ + { + name: "Desktop", + data: [ + { label: "Mon", value: 2800 }, + { label: "Tue", value: 3000 }, + { label: "Wed", value: 3400 }, + { label: "Thu", value: 3200 }, + { label: "Fri", value: 3500 }, + ], + }, + { + name: "Mobile", + data: [ + { label: "Mon", value: 1400 }, + { label: "Tue", value: 1500 }, + { label: "Wed", value: 1700 }, + { label: "Thu", value: 1600 }, + { label: "Fri", value: 1800 }, + ], + }, + ], + axis_config: { + categories: ["Mon", "Tue", "Wed", "Thu", "Fri"], + x_label: "Day", + y_label: "Users", + }, + }, + }, + { + type: "data_visualization", + block_id: "viz-pie-multi", + title: "Plan distribution by tier", + chart: { + type: "pie", + segments: [ + { label: "Free", value: 4200 }, + { label: "Pro", value: 2300 }, + { label: "Business+", value: 1100 }, + { label: "Enterprise", value: 480 }, + ], + }, + }, + ], + }, ]; diff --git a/readme.md b/readme.md index 6b54028..d020a53 100644 --- a/readme.md +++ b/readme.md @@ -142,6 +142,7 @@ const blocks: Block[] = [ | Markdown | ✅ Full | Standard markdown with GFM (tables, task lists, code blocks) | | Plan | ✅ Full | Sequential task display with status indicators | | Task Card | ✅ Full | Individual task with title, details, output, sources, status | +| Data Visualization | ✅ Full | Line, bar, area, and pie charts with legend, axes, and "view as table" / CSV export | ## Supported Elements diff --git a/src/components/blocks/data_visualization.tsx b/src/components/blocks/data_visualization.tsx new file mode 100644 index 0000000..22acc29 --- /dev/null +++ b/src/components/blocks/data_visualization.tsx @@ -0,0 +1,602 @@ +import { ReactNode, useState } from "react"; +import { + DataVisualizationBlock, + DataVizCartesianChart, + DataVizPieChart, + DataVizSeries, +} from "../../types"; + +type DataVisualizationProps = { + data: DataVisualizationBlock; +}; + +// Slack cycles this palette across series (line/bar/area) and pie segments. +const PALETTE = ["rgb(191, 77, 26)", "rgb(38, 59, 156)", "rgb(16, 111, 77)", "rgb(217, 76, 117)"]; +const colorAt = (i: number) => PALETTE[i % PALETTE.length]!; + +// #region helpers --------------------------------------------------------------- + +const niceNum = (range: number, round: boolean) => { + const exp = Math.floor(Math.log10(range || 1)); + const fraction = range / Math.pow(10, exp); + let nice: number; + if (round) { + if (fraction < 1.5) nice = 1; + else if (fraction < 3) nice = 2; + else if (fraction < 7) nice = 5; + else nice = 10; + } else { + if (fraction <= 1) nice = 1; + else if (fraction <= 2) nice = 2; + else if (fraction <= 5) nice = 5; + else nice = 10; + } + return nice * Math.pow(10, exp); +}; + +// "Nice", evenly-spaced axis ticks that span [lo, hi]. +const niceTicks = (lo: number, hi: number, maxTicks = 5): number[] => { + if (!isFinite(lo) || !isFinite(hi)) return [0, 1]; + if (lo === hi) { + if (lo === 0) return [0, 1]; + lo = Math.min(0, lo); + hi = Math.max(0, hi); + } + const range = niceNum(hi - lo, false); + const spacing = niceNum(range / Math.max(1, maxTicks - 1), true); + const niceLo = Math.floor(lo / spacing) * spacing; + const niceHi = Math.ceil(hi / spacing) * spacing; + const ticks: number[] = []; + for (let v = niceLo; v <= niceHi + spacing * 0.5; v += spacing) { + ticks.push(Math.round(v * 1e6) / 1e6); + } + return ticks; +}; + +const strip = (n: number) => String(Math.round(n * 100) / 100); + +// Compact axis-tick label: keeps small numbers grouped (1,200) and shortens big ones (15k, 2M). +const formatTick = (n: number): string => { + if (n === 0) return "0"; + const abs = Math.abs(n); + if (abs >= 1_000_000) return strip(n / 1_000_000) + "M"; + if (abs >= 10_000) return strip(n / 1_000) + "k"; + return n.toLocaleString("en-US"); +}; + +// "a", "a and b", "a, b, and c" (oxford) / "a, b and c". +const joinList = (items: string[], oxford: boolean): string => { + if (items.length === 0) return ""; + if (items.length === 1) return items[0]!; + if (items.length === 2) return `${items[0]} and ${items[1]}`; + const head = items.slice(0, -1).join(", "); + return `${head}${oxford ? "," : ""} and ${items[items.length - 1]}`; +}; + +const csvCell = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v); + +const downloadCsv = (filename: string, header: string[], rows: string[][]) => { + if (typeof document === "undefined") return; + const csv = [header, ...rows].map((row) => row.map(csvCell).join(",")).join("\n"); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +}; + +// #endregion + +// #region models ---------------------------------------------------------------- + +type CartesianModel = { + categories: string[]; + series: { name: string; color: string; values: (number | null)[] }[]; + xLabel?: string; + yLabel?: string; +}; + +const buildCartesianModel = (chart: DataVizCartesianChart): CartesianModel => { + const seriesIn = chart.series || []; + const axis = chart.axis_config || {}; + + let categories = axis.categories && axis.categories.length > 0 ? axis.categories.slice() : []; + if (categories.length === 0) { + // Fall back to the labels of the longest series, preserving their order. + let longest: DataVizSeries | undefined; + for (const s of seriesIn) { + if (!longest || (s.data?.length || 0) > (longest.data?.length || 0)) longest = s; + } + categories = (longest?.data || []).map((d) => d.label); + } + + const series = seriesIn.map((s, i) => { + const byLabel = new Map(); + (s.data || []).forEach((d) => byLabel.set(d.label, d.value)); + const values = categories.map((category, idx) => { + if (byLabel.has(category)) return byLabel.get(category)!; + const point = (s.data || [])[idx]; + return point ? point.value : null; + }); + return { name: s.name ?? `Series ${i + 1}`, color: colorAt(i), values }; + }); + + return { categories, series, xLabel: axis.x_label, yLabel: axis.y_label }; +}; + +type PieModel = { + segments: { label: string; value: number; color: string; pct: number }[]; + total: number; +}; + +const buildPieModel = (chart: DataVizPieChart): PieModel => { + const segs = (chart.segments || []).filter((s) => typeof s.value === "number"); + const total = segs.reduce((sum, s) => sum + Math.max(0, s.value), 0); + const segments = segs.map((s, i) => ({ + label: s.label, + value: s.value, + color: colorAt(i), + pct: total > 0 ? Math.round((Math.max(0, s.value) / total) * 100) : 0, + })); + return { segments, total }; +}; + +// #endregion + +// #region cartesian chart ------------------------------------------------------- + +const VIEW_W = 520; +const VIEW_H = 300; +const MARGIN = { top: 14, right: 16, bottom: 48, left: 60 }; + +const CartesianChart = (props: { + model: CartesianModel; + type: "line" | "bar" | "area"; + ariaLabel: string; +}) => { + const { model, type, ariaLabel } = props; + const { categories, series, xLabel, yLabel } = model; + + const plotLeft = MARGIN.left; + const plotRight = VIEW_W - MARGIN.right; + const plotTop = MARGIN.top; + const plotBottom = VIEW_H - MARGIN.bottom; + const plotW = plotRight - plotLeft; + const plotH = plotBottom - plotTop; + + const values: number[] = []; + series.forEach((s) => s.values.forEach((v) => v != null && values.push(v))); + let dataMin = values.length ? Math.min(...values) : 0; + let dataMax = values.length ? Math.max(...values) : 1; + // Bars and areas are anchored at zero; lines reference zero only when they cross it. + if (type !== "line") { + dataMin = Math.min(0, dataMin); + dataMax = Math.max(0, dataMax); + } else if (dataMin < 0) { + dataMax = Math.max(0, dataMax); + } + + const ticks = niceTicks(dataMin, dataMax, 5); + const domainMin = ticks[0]!; + const domainMax = ticks[ticks.length - 1]!; + const span = domainMax - domainMin || 1; + + const yOf = (v: number) => plotTop + (1 - (v - domainMin) / span) * plotH; + const count = categories.length; + const xPoint = (i: number) => + count <= 1 ? plotLeft + plotW / 2 : plotLeft + (i / (count - 1)) * plotW; + const bandWidth = count > 0 ? plotW / count : plotW; + const xBand = (i: number) => plotLeft + (i + 0.5) * bandWidth; + + const baselineY = yOf(Math.min(Math.max(0, domainMin), domainMax)); + const showZeroLine = domainMin < 0 && domainMax > 0; + + const groupWidth = bandWidth * 0.7; + const barWidth = series.length > 0 ? groupWidth / series.length : groupWidth; + + return ( + + {/* gridlines + y-axis ticks */} + {ticks.map((t, i) => { + const y = yOf(t); + return ( + + + + {formatTick(t)} + + + ); + })} + + {/* area fills (drawn under the lines) */} + {type === "area" && + series.map((s, si) => { + const pts = s.values + .map((v, i) => (v == null ? null : ([xPoint(i), yOf(v)] as [number, number]))) + .filter((p): p is [number, number] => p !== null); + if (pts.length === 0) return null; + const line = pts.map(([x, y], i) => `${i === 0 ? "M" : "L"} ${x} ${y}`).join(" "); + const area = `${line} L ${pts[pts.length - 1]![0]} ${baselineY} L ${pts[0]![0]} ${baselineY} Z`; + return ( + + + + + ); + })} + + {/* lines */} + {type === "line" && + series.map((s, si) => { + const pts = s.values + .map((v, i) => (v == null ? null : ([xPoint(i), yOf(v)] as [number, number]))) + .filter((p): p is [number, number] => p !== null); + if (pts.length === 0) return null; + const line = pts.map(([x, y], i) => `${i === 0 ? "M" : "L"} ${x} ${y}`).join(" "); + return ( + + + {pts.map(([x, y], i) => ( + + ))} + + ); + })} + + {/* bars */} + {type === "bar" && + series.map((s, si) => ( + + {s.values.map((v, i) => { + if (v == null) return null; + const groupStart = xBand(i) - groupWidth / 2; + const x = groupStart + si * barWidth; + const valueY = yOf(v); + const y = Math.min(valueY, baselineY); + const h = Math.max(1, Math.abs(valueY - baselineY)); + return ( + + ); + })} + + ))} + + {/* x-axis category labels */} + {categories.map((c, i) => ( + + {c} + + ))} + + {/* axis titles */} + {xLabel && ( + + {xLabel} + + )} + {yLabel && ( + + {yLabel} + + )} + + ); +}; + +// #endregion + +// #region pie chart ------------------------------------------------------------- + +const polar = (cx: number, cy: number, r: number, deg: number): [number, number] => { + const a = ((deg - 90) * Math.PI) / 180; + return [cx + r * Math.cos(a), cy + r * Math.sin(a)]; +}; + +const pieSlice = (cx: number, cy: number, r: number, start: number, end: number) => { + const [sx, sy] = polar(cx, cy, r, start); + const [ex, ey] = polar(cx, cy, r, end); + const large = end - start > 180 ? 1 : 0; + return `M ${cx} ${cy} L ${sx} ${sy} A ${r} ${r} 0 ${large} 1 ${ex} ${ey} Z`; +}; + +const PieChart = (props: { model: PieModel; ariaLabel: string }) => { + const { model, ariaLabel } = props; + const cx = 110; + const cy = 110; + const r = 92; + + const segs = model.segments.filter((s) => Math.max(0, s.value) > 0); + if (segs.length === 0 || model.total <= 0) return null; + + let angle = 0; + const arcs = segs.map((s) => { + const fraction = Math.max(0, s.value) / model.total; + const start = angle; + const end = angle + fraction * 360; + angle = end; + return { color: s.color, start, end, full: fraction >= 0.999 }; + }); + + return ( + + {arcs.map((a, i) => + a.full ? ( + + ) : ( + + ), + )} + + ); +}; + +// #endregion + +// #region shared UI ------------------------------------------------------------- + +const Legend = (props: { items: { name: string; color: string }[] }) => { + if (props.items.length === 0) return null; + return ( + + ); +}; + +const DataTable = (props: { header: string[]; rows: string[][] }) => ( +
+ + + + {props.header.map((h, i) => ( + + ))} + + + + {props.rows.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {h} +
+ {cell} +
+
+); + +const TableIcon = () => ( + +); + +const DownloadIcon = () => ( + +); + +// #endregion + +export const DataVisualization = (props: DataVisualizationProps) => { + const { title, chart, block_id } = props.data; + const [showTable, setShowTable] = useState(false); + + if (!chart || !chart.type) return null; + + let chartNode: ReactNode = null; + let legendItems: { name: string; color: string }[] = []; + let tableHeader: string[] = []; + let tableRows: string[][] = []; + + if (chart.type === "pie") { + const model = buildPieModel(chart); + if (model.segments.length === 0) return null; + + const ariaLabel = + `Pie chart${title ? `: ${title}` : ""}, with ${model.segments.length} ` + + `segment${model.segments.length === 1 ? "" : "s"}. Segments: ` + + `${joinList( + model.segments.map((s) => `${s.label} (${s.pct}%)`), + true, + )}.`; + + chartNode = ( +
+ +
+ ); + legendItems = model.segments.map((s) => ({ name: `${s.label} · ${s.pct}%`, color: s.color })); + tableHeader = ["Segment", "Value", "Percent"]; + tableRows = model.segments.map((s) => [s.label, String(s.value), `${s.pct}%`]); + } else { + const model = buildCartesianModel(chart); + if (model.series.length === 0 || model.categories.length === 0) return null; + + const typeName = chart.type === "line" ? "Line" : chart.type === "bar" ? "Bar" : "Area"; + const names = model.series.map((s) => s.name); + const axisSentence = + model.xLabel && model.yLabel + ? ` The X axis is ${model.xLabel}, the Y axis is ${model.yLabel}.` + : ""; + const ariaLabel = + `${typeName} chart${title ? `: ${title}` : ""}.${axisSentence} ` + + `There ${names.length === 1 ? "is" : "are"} ${names.length} series: ${joinList(names, false)}.`; + + chartNode = ; + legendItems = model.series.map((s) => ({ name: s.name, color: s.color })); + tableHeader = [model.xLabel || "", ...model.series.map((s) => s.name)]; + tableRows = model.categories.map((category, i) => [ + category, + ...model.series.map((s) => { + const v = s.values[i]; + return v == null ? "" : String(v); + }), + ]); + } + + return ( +
+
+ {title && ( +

+ {title} +

+ )} +
+ + +
+
+ +
+ {showTable ? ( + + ) : ( + <> + {chartNode} + + + )} +
+
+ ); +}; diff --git a/src/components/blocks/index.ts b/src/components/blocks/index.ts index 346ccfa..a1159f2 100644 --- a/src/components/blocks/index.ts +++ b/src/components/blocks/index.ts @@ -4,6 +4,7 @@ export * from "./card"; export * from "./carousel"; export * from "./context"; export * from "./context_actions"; +export * from "./data_visualization"; export * from "./divider"; export * from "./file"; export * from "./header"; diff --git a/src/components/index.tsx b/src/components/index.tsx index 9b33512..44fc33f 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -8,6 +8,7 @@ import { Carousel, Context, ContextActions, + DataVisualization, Divider, File, Header, @@ -71,6 +72,7 @@ export const getBlockComponent = (block: Block): ReactNode => { if (block.type === "alert") return ; if (block.type === "card") return ; if (block.type === "carousel") return ; + if (block.type === "data_visualization") return ; return null; }; diff --git a/src/style.css b/src/style.css index 0408820..f0260a7 100644 --- a/src/style.css +++ b/src/style.css @@ -566,6 +566,19 @@ @apply bg-black-primary/[0.2] dark:bg-dark-border rounded-full; } + /* Data Visualization Block */ + & .slack_blocks_to_jsx__data_visualization_chart { + user-select: none; + } + + & .slack_blocks_to_jsx__data_visualization_title { + @apply text-header; + } + + & .slack_blocks_to_jsx__data_visualization_table td { + font-variant-numeric: tabular-nums; + } + /* #endregion */ } diff --git a/src/types/layout.ts b/src/types/layout.ts index 181611d..0e07c72 100644 --- a/src/types/layout.ts +++ b/src/types/layout.ts @@ -36,6 +36,7 @@ export type Block = | CarouselBlock | ContextBlock | ContextActionsBlock + | DataVisualizationBlock | DividerBlock | FileBlock | HeaderBlock @@ -577,3 +578,131 @@ export type CarouselBlock = { */ block_id?: string; }; + +/** + * A single ⟨label, value⟩ pair plotted on a cartesian chart (line, bar, or area). + */ +export type DataVizDataPoint = { + /** + * The category this point belongs to. Matches an entry in + * {@link DataVizAxisConfig.categories}. + */ + label: string; + /** + * The numeric value for this point. May be negative. + */ + value: number; +}; + +/** + * A named collection of {@link DataVizDataPoint data points} rendered as one line, + * one set of bars, or one filled area. The ***name*** is shown in the chart legend. + */ +export type DataVizSeries = { + /** + * The series name, shown in the chart legend. + */ + name: string; + /** + * The data points for this series — typically one per category. + */ + data: DataVizDataPoint[]; +}; + +/** + * Axis configuration for cartesian charts (***line***, ***bar***, ***area***). Pie charts + * do not use this object. + */ +export type DataVizAxisConfig = { + /** + * The ordered category labels rendered along the X axis. + */ + categories?: string[]; + /** + * A label for the X axis. + */ + x_label?: string; + /** + * A label for the Y axis. + */ + y_label?: string; +}; + +/** + * A single slice of a pie chart. + */ +export type DataVizSegment = { + /** + * The segment name, shown in the legend and used to compute its percentage. + */ + label: string; + /** + * The segment value. Percentages are computed from the sum of all segment values. + */ + value: number; +}; + +/** + * A ***line***, ***bar***, or ***area*** chart. These plot one or more {@link DataVizSeries series} + * against a shared set of categories described by {@link DataVizAxisConfig axis_config}. + */ +export type DataVizCartesianChart = { + type: "line" | "bar" | "area"; + /** + * The series to plot. Each series is drawn in the next color from the palette and listed + * in the legend. + */ + series: DataVizSeries[]; + /** + * Axis configuration — categories and X / Y axis labels. + */ + axis_config?: DataVizAxisConfig; +}; + +/** + * A ***pie*** chart. Pie charts render a set of {@link DataVizSegment segments} and do not use + * ***series*** or ***axis_config***. + */ +export type DataVizPieChart = { + type: "pie"; + /** + * The pie segments. Each segment's percentage is computed from the sum of all values. + */ + segments: DataVizSegment[]; +}; + +/** + * The chart definition for a {@link DataVisualizationBlock}. The shape depends on the chart + * ***type***: ***line*** / ***bar*** / ***area*** use {@link DataVizCartesianChart}, while ***pie*** + * uses {@link DataVizPieChart}. + */ +export type DataVizChart = DataVizCartesianChart | DataVizPieChart; + +export type DataVisualizationBlock = { + /** + * Available in surfaces: **Messages** + * + * * Docs: {@link https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block View here} + * + * A ***data_visualization*** block displays data visually as a ***pie***, ***bar***, ***area***, + * or ***line*** chart. Line, bar, and area charts plot one or more {@link DataVizSeries series} + * against a shared set of categories; pie charts render a set of {@link DataVizSegment segments}. + * + * Added to Block Kit in Slack's 2026-06-16 release + * ({@link https://docs.slack.dev/changelog/2026/06/16/block-kit-data-visualization-block changelog}). + */ + type: "data_visualization"; + /** + * A plain-text title rendered above the chart (as an ***h3***). + */ + title?: string; + /** + * The chart definition. The shape depends on ***chart.type***. + */ + chart: DataVizChart; + /** + * A string acting as a unique identifier for a block. If not specified, one will be generated. + * Maximum length for this field is 255 characters. + */ + block_id?: string; +}; diff --git a/test/data_visualization.test.mjs b/test/data_visualization.test.mjs new file mode 100644 index 0000000..cb8d9f9 --- /dev/null +++ b/test/data_visualization.test.mjs @@ -0,0 +1,179 @@ +// The `data_visualization` block renders Slack's pie / bar / area / line charts. +// Slack draws these with ECharts → SVG; this library hand-rolls lightweight inline +// SVG instead (no charting dependency). These tests render the exact JSON fixtures +// from the Block Kit examples and lock in the title (

), the accessible +// aria-label patterns, the legend, axis labels, negative-value handling, the pie +// percentages, and the chart action buttons. Run against the built dist/ via Node's +// built-in runner (CI builds before testing). + +import test from "node:test"; +import assert from "node:assert/strict"; +import React from "react"; +import ReactDOMServer from "react-dom/server"; +import { Message } from "../dist/index.mjs"; + +const render = (blocks) => + ReactDOMServer.renderToStaticMarkup( + React.createElement(Message, { + logo: "logo.png", + name: "Tester", + theme: "light", + blocks, + }), + ); + +const lineMulti = { + type: "data_visualization", + block_id: "viz-line-multi", + title: "Weekly active users by platform", + chart: { + type: "line", + series: [ + { + name: "Desktop", + data: [ + { label: "Mon", value: 800 }, + { label: "Tue", value: 920 }, + { label: "Wed", value: 880 }, + { label: "Thu", value: 1010 }, + { label: "Fri", value: 1120 }, + ], + }, + { + name: "Mobile", + data: [ + { label: "Mon", value: 400 }, + { label: "Tue", value: 530 }, + { label: "Wed", value: 500 }, + { label: "Thu", value: 590 }, + { label: "Fri", value: 600 }, + ], + }, + ], + axis_config: { categories: ["Mon", "Tue", "Wed", "Thu", "Fri"], x_label: "Day", y_label: "Users" }, + }, +}; + +const barNegative = { + type: "data_visualization", + block_id: "viz-bar-negative", + title: "Net headcount change", + chart: { + type: "bar", + series: [ + { + name: "Delta", + data: [ + { label: "Mon", value: 4 }, + { label: "Tue", value: -2 }, + { label: "Wed", value: 6 }, + { label: "Thu", value: -1 }, + { label: "Fri", value: 3 }, + ], + }, + ], + axis_config: { categories: ["Mon", "Tue", "Wed", "Thu", "Fri"], x_label: "Day", y_label: "People (delta)" }, + }, +}; + +const areaMulti = { + type: "data_visualization", + block_id: "viz-area-multi", + title: "Concurrent users by platform", + chart: { + type: "area", + series: [ + { name: "Desktop", data: [{ label: "Mon", value: 2800 }, { label: "Tue", value: 3000 }] }, + { name: "Mobile", data: [{ label: "Mon", value: 1400 }, { label: "Tue", value: 1500 }] }, + ], + axis_config: { categories: ["Mon", "Tue"], x_label: "Day", y_label: "Users" }, + }, +}; + +const pieMulti = { + type: "data_visualization", + block_id: "viz-pie-multi", + title: "Plan distribution by tier", + chart: { + type: "pie", + segments: [ + { label: "Free", value: 4200 }, + { label: "Pro", value: 2300 }, + { label: "Business+", value: 1100 }, + { label: "Enterprise", value: 480 }, + ], + }, +}; + +test("line chart: title is an

, with an accessible aria-label and an SVG", () => { + const out = render([lineMulti]); + assert.match(out, /]*>Weekly active users by platform<\/h3>/); + assert.match(out, /]*role="img"/); + assert.match( + out, + /aria-label="Line chart: Weekly active users by platform\. The X axis is Day, the Y axis is Users\. There are 2 series: Desktop and Mobile\."/, + ); +}); + +test("line chart: axis labels and category ticks render as SVG text", () => { + const out = render([lineMulti]); + // axis titles + assert.match(out, />DayUsersMonFriDesktopMobile { + const out = render([barNegative]); + assert.match( + out, + /aria-label="Bar chart: Net headcount change\. The X axis is Day, the Y axis is People \(delta\)\. There is 1 series: Delta\."/, + ); + // bars are s; a negative-aware domain means a zero baseline tick (0) is present + assert.match(out, /0 { + const out = render([areaMulti]); + assert.match( + out, + /aria-label="Area chart: Concurrent users by platform\. The X axis is Day, the Y axis is Users\. There are 2 series: Desktop and Mobile\."/, + ); + assert.match(out, / { + const out = render([pieMulti]); + assert.match( + out, + /aria-label="Pie chart: Plan distribution by tier, with 4 segments\. Segments: Free \(52%\), Pro \(28%\), Business\+ \(14%\), and Enterprise \(6%\)\."/, + ); + // pie slices are s; legend shows the computed percentages + assert.match(out, /Free · 52%/); + assert.match(out, /Enterprise · 6%/); +}); + +test("every chart exposes the View-as-table and Download actions", () => { + const out = render([lineMulti]); + assert.match(out, /aria-label="View as table"/); + assert.match(out, /aria-label="Download chart data"/); +}); + +test("block_id is applied as the container id", () => { + const out = render([pieMulti]); + assert.match(out, /id="viz-pie-multi"/); +}); + +test("a chart with no usable data renders nothing rather than crashing", () => { + const empty = { type: "data_visualization", title: "Empty", chart: { type: "line", series: [] } }; + const out = render([empty]); + assert.doesNotMatch(out, / Date: Tue, 16 Jun 2026 18:26:29 +0000 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20=F0=9F=93=9D=20complete=20the=20Sup?= =?UTF-8?q?ported=20Blocks=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Alert, Card, and Carousel rows that were missing from the readme, note the Header block's `level` (H1–H4) support, and realign the table now that "Data Visualization" is the widest block name. https://claude.ai/code/session_018T7Lik8eFCV8DMtTckZC6a --- readme.md | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/readme.md b/readme.md index d020a53..b9bdfd4 100644 --- a/readme.md +++ b/readme.md @@ -125,24 +125,27 @@ const blocks: Block[] = [ ## Supported Blocks -| Block Type | Status | Notes | -| --------------- | ------- | ------------------------------------------------------------ | -| Section | ✅ Full | Text, fields, accessories, `expand` property | -| Divider | ✅ Full | Horizontal divider | -| Image | ✅ Full | Collapsible with alt text, `slack_file` support | -| Context | ✅ Full | Images and text elements | -| Header | ✅ Full | Large bold text | -| Rich Text | ✅ Full | Lists, quotes, preformatted, sections, color elements | -| Video | ✅ Full | Collapsible video embed | -| Table | ✅ Full | Rows, columns, alignment | -| Actions | ✅ Full | All interactive element types supported | -| Input | ✅ Full | All input element types supported | -| File | ✅ Full | Remote file display | -| Context Actions | ✅ Full | Feedback buttons and icon buttons | -| Markdown | ✅ Full | Standard markdown with GFM (tables, task lists, code blocks) | -| Plan | ✅ Full | Sequential task display with status indicators | -| Task Card | ✅ Full | Individual task with title, details, output, sources, status | -| Data Visualization | ✅ Full | Line, bar, area, and pie charts with legend, axes, and "view as table" / CSV export | +| Block Type | Status | Notes | +| ------------------ | ------- | ------------------------------------------------------------ | +| Section | ✅ Full | Text, fields, accessories, `expand` property | +| Divider | ✅ Full | Horizontal divider | +| Image | ✅ Full | Collapsible with alt text, `slack_file` support | +| Context | ✅ Full | Images and text elements | +| Header | ✅ Full | Large bold text with `level` (H1–H4) heading sizes | +| Rich Text | ✅ Full | Lists, quotes, preformatted, sections, color elements | +| Video | ✅ Full | Collapsible video embed | +| Table | ✅ Full | Rows, columns, alignment | +| Actions | ✅ Full | All interactive element types supported | +| Input | ✅ Full | All input element types supported | +| File | ✅ Full | Remote file display | +| Context Actions | ✅ Full | Feedback buttons and icon buttons | +| Markdown | ✅ Full | Standard markdown with GFM (tables, task lists, code blocks) | +| Alert | ✅ Full | Status banner with info / warning / error / success levels | +| Card | ✅ Full | Title, subtitle, body, image, icon, and action buttons | +| Carousel | ✅ Full | Horizontally-scrollable gallery of up to 10 cards | +| Plan | ✅ Full | Sequential task display with status indicators | +| Task Card | ✅ Full | Individual task with title, details, output, sources, status | +| Data Visualization | ✅ Full | Line, bar, area, and pie charts; legend, axes, table & CSV | ## Supported Elements From 4e2e2464c52143add70e6671b01f191cc021cc75 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 18:51:27 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E2=9C=A8=20smooth=20curves=20+=20i?= =?UTF-8?q?nteractive=20hover=20for=20data=5Fvisualization=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Line and area charts now use monotone-cubic (Fritsch–Carlson) interpolation for smooth curves that never overshoot the data (safe for negatives). - Hover behavior matching Slack: - line/bar show a tooltip with the category and every series value, plus a dashed vertical guide at the nearest category; line/area also mark each series' value at that point. - pie shows a tooltip for the hovered segment, and the slice expands outward from the pie center. - All interactivity is client-side and SSR-safe (default render is unchanged). https://claude.ai/code/session_018T7Lik8eFCV8DMtTckZC6a --- src/components/blocks/data_visualization.tsx | 539 +++++++++++++------ test/data_visualization.test.mjs | 30 +- 2 files changed, 412 insertions(+), 157 deletions(-) diff --git a/src/components/blocks/data_visualization.tsx b/src/components/blocks/data_visualization.tsx index 22acc29..32064b5 100644 --- a/src/components/blocks/data_visualization.tsx +++ b/src/components/blocks/data_visualization.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useState } from "react"; +import { PointerEvent as ReactPointerEvent, ReactNode, useRef, useState } from "react"; import { DataVisualizationBlock, DataVizCartesianChart, @@ -16,6 +16,9 @@ const colorAt = (i: number) => PALETTE[i % PALETTE.length]!; // #region helpers --------------------------------------------------------------- +const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); +const r2 = (n: number) => Math.round(n * 100) / 100; + const niceNum = (range: number, round: boolean) => { const exp = Math.floor(Math.log10(range || 1)); const fraction = range / Math.pow(10, exp); @@ -64,6 +67,9 @@ const formatTick = (n: number): string => { return n.toLocaleString("en-US"); }; +// Full, grouped number for tooltips (e.g. 1600 -> "1,600"). +const formatFull = (n: number) => n.toLocaleString("en-US"); + // "a", "a and b", "a, b, and c" (oxford) / "a, b and c". const joinList = (items: string[], oxford: boolean): string => { if (items.length === 0) return ""; @@ -73,6 +79,57 @@ const joinList = (items: string[], oxford: boolean): string => { return `${head}${oxford ? "," : ""} and ${items[items.length - 1]}`; }; +// Monotone cubic (Fritsch–Carlson) spline through x-ordered points. Produces a smooth +// curve that never overshoots the data — safe for negative values and flat runs. +const smoothLine = (pts: [number, number][]): string => { + const n = pts.length; + if (n === 0) return ""; + if (n === 1) return `M ${r2(pts[0]![0])} ${r2(pts[0]![1])}`; + if (n === 2) return `M ${r2(pts[0]![0])} ${r2(pts[0]![1])} L ${r2(pts[1]![0])} ${r2(pts[1]![1])}`; + + const xs = pts.map((p) => p[0]); + const ys = pts.map((p) => p[1]); + const dx: number[] = []; + const slope: number[] = []; + for (let i = 0; i < n - 1; i++) { + dx[i] = xs[i + 1]! - xs[i]!; + slope[i] = dx[i] === 0 ? 0 : (ys[i + 1]! - ys[i]!) / dx[i]!; + } + + const tangent: number[] = new Array(n); + tangent[0] = slope[0]!; + tangent[n - 1] = slope[n - 2]!; + for (let i = 1; i < n - 1; i++) { + if (slope[i - 1]! * slope[i]! <= 0) tangent[i] = 0; + else tangent[i] = (slope[i - 1]! + slope[i]!) / 2; + } + for (let i = 0; i < n - 1; i++) { + if (slope[i] === 0) { + tangent[i] = 0; + tangent[i + 1] = 0; + continue; + } + const a = tangent[i]! / slope[i]!; + const b = tangent[i + 1]! / slope[i]!; + const h = Math.hypot(a, b); + if (h > 3) { + const t = 3 / h; + tangent[i] = t * a * slope[i]!; + tangent[i + 1] = t * b * slope[i]!; + } + } + + const d = [`M ${r2(xs[0]!)} ${r2(ys[0]!)}`]; + for (let i = 0; i < n - 1; i++) { + const c1x = xs[i]! + dx[i]! / 3; + const c1y = ys[i]! + (tangent[i]! * dx[i]!) / 3; + const c2x = xs[i + 1]! - dx[i]! / 3; + const c2y = ys[i + 1]! - (tangent[i + 1]! * dx[i]!) / 3; + d.push(`C ${r2(c1x)} ${r2(c1y)} ${r2(c2x)} ${r2(c2y)} ${r2(xs[i + 1]!)} ${r2(ys[i + 1]!)}`); + } + return d.join(" "); +}; + const csvCell = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v); const downloadCsv = (filename: string, header: string[], rows: string[][]) => { @@ -147,6 +204,51 @@ const buildPieModel = (chart: DataVizPieChart): PieModel => { // #endregion +// #region tooltip --------------------------------------------------------------- + +type TooltipRow = { name: string; value: string; color: string }; + +const ChartTooltip = (props: { + left: string; + top: number; + flip: boolean; + header?: string; + rows: TooltipRow[]; +}) => ( +
+ {props.header && ( +
+ {props.header} +
+ )} +
+ {props.rows.map((row, i) => ( +
+ + + {row.name} + + + {row.value} + +
+ ))} +
+
+); + +// #endregion + // #region cartesian chart ------------------------------------------------------- const VIEW_W = 520; @@ -161,6 +263,9 @@ const CartesianChart = (props: { const { model, type, ariaLabel } = props; const { categories, series, xLabel, yLabel } = model; + const containerRef = useRef(null); + const [hover, setHover] = useState<{ index: number; x: number; y: number } | null>(null); + const plotLeft = MARGIN.left; const plotRight = VIEW_W - MARGIN.right; const plotTop = MARGIN.top; @@ -198,154 +303,229 @@ const CartesianChart = (props: { const groupWidth = bandWidth * 0.7; const barWidth = series.length > 0 ? groupWidth / series.length : groupWidth; + // Pixel positions a series occupies along the x-axis, used for smooth paths and markers. + const seriesPoints = (s: { values: (number | null)[] }): [number, number][] => { + const pts: [number, number][] = []; + s.values.forEach((v, i) => { + if (v != null) pts.push([xPoint(i), yOf(v)]); + }); + return pts; + }; + + const onPointerMove = (e: ReactPointerEvent) => { + const el = containerRef.current; + if (!el || count === 0) return; + const rect = el.getBoundingClientRect(); + if (rect.width === 0) return; + const x = e.clientX - rect.left; + const y = clamp(e.clientY - rect.top, 44, Math.max(44, rect.height - 44)); + const vbX = (x / rect.width) * VIEW_W; + let index = + type === "bar" + ? Math.floor((vbX - plotLeft) / bandWidth) + : count <= 1 + ? 0 + : Math.round((vbX - plotLeft) / (plotW / (count - 1))); + index = clamp(index, 0, count - 1); + setHover({ index, x, y }); + }; + + const guideX = hover ? (type === "bar" ? xBand(hover.index) : xPoint(hover.index)) : null; + const leftPct = guideX != null ? (guideX / VIEW_W) * 100 : 0; + const flip = leftPct > 58; + return ( - setHover(null)} > - {/* gridlines + y-axis ticks */} - {ticks.map((t, i) => { - const y = yOf(t); - return ( - - - - {formatTick(t)} - - - ); - })} - - {/* area fills (drawn under the lines) */} - {type === "area" && - series.map((s, si) => { - const pts = s.values - .map((v, i) => (v == null ? null : ([xPoint(i), yOf(v)] as [number, number]))) - .filter((p): p is [number, number] => p !== null); - if (pts.length === 0) return null; - const line = pts.map(([x, y], i) => `${i === 0 ? "M" : "L"} ${x} ${y}`).join(" "); - const area = `${line} L ${pts[pts.length - 1]![0]} ${baselineY} L ${pts[0]![0]} ${baselineY} Z`; + + {/* gridlines + y-axis ticks */} + {ticks.map((t, i) => { + const y = yOf(t); return ( - - - + + + {formatTick(t)} + ); })} - {/* lines */} - {type === "line" && - series.map((s, si) => { - const pts = s.values - .map((v, i) => (v == null ? null : ([xPoint(i), yOf(v)] as [number, number]))) - .filter((p): p is [number, number] => p !== null); - if (pts.length === 0) return null; - const line = pts.map(([x, y], i) => `${i === 0 ? "M" : "L"} ${x} ${y}`).join(" "); - return ( - + {/* area fills (drawn under the lines) */} + {type === "area" && + series.map((s, si) => { + const pts = seriesPoints(s); + if (pts.length === 0) return null; + const top = smoothLine(pts); + const area = `${top} L ${r2(pts[pts.length - 1]![0])} ${r2(baselineY)} L ${r2(pts[0]![0])} ${r2(baselineY)} Z`; + return ( + + + + + ); + })} + + {/* lines */} + {type === "line" && + series.map((s, si) => { + const pts = seriesPoints(s); + if (pts.length === 0) return null; + return ( - {pts.map(([x, y], i) => ( - - ))} + ); + })} + + {/* bars */} + {type === "bar" && + series.map((s, si) => ( + + {s.values.map((v, i) => { + if (v == null) return null; + const groupStart = xBand(i) - groupWidth / 2; + const x = groupStart + si * barWidth; + const valueY = yOf(v); + const y = Math.min(valueY, baselineY); + const h = Math.max(1, Math.abs(valueY - baselineY)); + return ( + + ); + })} - ); - })} + ))} - {/* bars */} - {type === "bar" && - series.map((s, si) => ( - - {s.values.map((v, i) => { - if (v == null) return null; - const groupStart = xBand(i) - groupWidth / 2; - const x = groupStart + si * barWidth; - const valueY = yOf(v); - const y = Math.min(valueY, baselineY); - const h = Math.max(1, Math.abs(valueY - baselineY)); - return ( - - ); - })} - + {/* x-axis category labels */} + {categories.map((c, i) => ( + + {c} + ))} - {/* x-axis category labels */} - {categories.map((c, i) => ( - - {c} - - ))} + {/* axis titles */} + {xLabel && ( + + {xLabel} + + )} + {yLabel && ( + + {yLabel} + + )} - {/* axis titles */} - {xLabel && ( - - {xLabel} - - )} - {yLabel && ( - - {yLabel} - + {/* hover guide line + value markers (drawn on top) */} + {guideX != null && ( + + )} + {hover && + type !== "bar" && + series.map((s, si) => { + const v = s.values[hover.index]; + if (v == null) return null; + return ( + + ); + })} + + + {hover && ( + ({ + name: s.name, + value: s.values[hover.index] == null ? "—" : formatFull(s.values[hover.index]!), + color: s.color, + }))} + /> )} - + ); }; @@ -362,50 +542,101 @@ const pieSlice = (cx: number, cy: number, r: number, start: number, end: number) const [sx, sy] = polar(cx, cy, r, start); const [ex, ey] = polar(cx, cy, r, end); const large = end - start > 180 ? 1 : 0; - return `M ${cx} ${cy} L ${sx} ${sy} A ${r} ${r} 0 ${large} 1 ${ex} ${ey} Z`; + return `M ${r2(cx)} ${r2(cy)} L ${r2(sx)} ${r2(sy)} A ${r} ${r} 0 ${large} 1 ${r2(ex)} ${r2(ey)} Z`; }; +const PIE_CX = 110; +const PIE_CY = 110; +const PIE_R = 92; + const PieChart = (props: { model: PieModel; ariaLabel: string }) => { const { model, ariaLabel } = props; - const cx = 110; - const cy = 110; - const r = 92; + const containerRef = useRef(null); + const [hover, setHover] = useState<{ seg: number; x: number; y: number } | null>(null); const segs = model.segments.filter((s) => Math.max(0, s.value) > 0); if (segs.length === 0 || model.total <= 0) return null; let angle = 0; - const arcs = segs.map((s) => { + const arcs = segs.map((s, i) => { const fraction = Math.max(0, s.value) / model.total; const start = angle; const end = angle + fraction * 360; angle = end; - return { color: s.color, start, end, full: fraction >= 0.999 }; + return { seg: s, index: i, start, end, full: fraction >= 0.999 }; }); + const pointToSeg = (i: number, e: ReactPointerEvent) => { + const el = containerRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = clamp(e.clientY - rect.top, 18, Math.max(18, rect.height - 18)); + setHover({ seg: i, x, y }); + }; + + // Hovered slice is rendered last (on top) and slightly larger, growing from the pie center. + const ordered = hover + ? [...arcs.filter((a) => a.index !== hover.seg), arcs.find((a) => a.index === hover.seg)!] + : arcs; + + const hovered = hover ? arcs.find((a) => a.index === hover.seg) : undefined; + const containerWidth = containerRef.current?.clientWidth ?? 200; + const flip = hover ? hover.x > containerWidth * 0.58 : false; + return ( - setHover(null)} > - {arcs.map((a, i) => - a.full ? ( - - ) : ( - - ), + + {ordered.map((a) => { + const r = hover && hover.seg === a.index ? PIE_R * 1.07 : PIE_R; + const handlers = { + onPointerEnter: (e: ReactPointerEvent) => pointToSeg(a.index, e), + onPointerMove: (e: ReactPointerEvent) => pointToSeg(a.index, e), + }; + return a.full ? ( + + ) : ( + + ); + })} + + + {hover && hovered && ( + )} - + ); }; diff --git a/test/data_visualization.test.mjs b/test/data_visualization.test.mjs index cb8d9f9..4325f1a 100644 --- a/test/data_visualization.test.mjs +++ b/test/data_visualization.test.mjs @@ -83,10 +83,28 @@ const areaMulti = { chart: { type: "area", series: [ - { name: "Desktop", data: [{ label: "Mon", value: 2800 }, { label: "Tue", value: 3000 }] }, - { name: "Mobile", data: [{ label: "Mon", value: 1400 }, { label: "Tue", value: 1500 }] }, + { + name: "Desktop", + data: [ + { label: "Mon", value: 2800 }, + { label: "Tue", value: 3000 }, + { label: "Wed", value: 3400 }, + { label: "Thu", value: 3200 }, + { label: "Fri", value: 3500 }, + ], + }, + { + name: "Mobile", + data: [ + { label: "Mon", value: 1400 }, + { label: "Tue", value: 1500 }, + { label: "Wed", value: 1700 }, + { label: "Thu", value: 1600 }, + { label: "Fri", value: 1800 }, + ], + }, ], - axis_config: { categories: ["Mon", "Tue"], x_label: "Day", y_label: "Users" }, + axis_config: { categories: ["Mon", "Tue", "Wed", "Thu", "Fri"], x_label: "Day", y_label: "Users" }, }, }; @@ -149,6 +167,12 @@ test("area chart: aria-label uses the Area phrasing", () => { assert.match(out, / { + // monotone-cubic interpolation emits cubic bézier (C) commands rather than straight L segments + assert.match(render([lineMulti]), /]*d="M[^"]* C /); + assert.match(render([areaMulti]), /]*d="M[^"]* C /); +}); + test("pie chart: aria-label lists segments with percentages summing to 100", () => { const out = render([pieMulti]); assert.match( From 9f2af7fc1f19449971de2f30b12a520fc98ebb6b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 22:41:00 +0000 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20=E2=9A=A1=EF=B8=8F=20memoize=20?= =?UTF-8?q?chart=20geometry,=20dedupe=20series=20stroke,=20complete=20read?= =?UTF-8?q?me=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups: - Memoize CartesianChart's hover-independent geometry (domain, ticks, scales, and the monotone-cubic path strings) with useMemo keyed on [model, type], so pointer-move re-renders no longer rebuild the spline paths. - Extract a shared SeriesStroke component; the line and area branches no longer duplicate the identical stroked-path markup. - readme: list the remaining exported block types (Table, Alert, Card, Carousel, Data Visualization) in the TypeScript example. https://claude.ai/code/session_018T7Lik8eFCV8DMtTckZC6a --- readme.md | 5 + src/components/blocks/data_visualization.tsx | 192 +++++++++++-------- 2 files changed, 120 insertions(+), 77 deletions(-) diff --git a/readme.md b/readme.md index b9bdfd4..5e9f142 100644 --- a/readme.md +++ b/readme.md @@ -412,11 +412,16 @@ import type { InputBlock, RichTextBlock, VideoBlock, + TableBlock, FileBlock, ContextActionsBlock, MarkdownBlock, PlanBlock, TaskCardBlock, + AlertBlock, + CardBlock, + CarouselBlock, + DataVisualizationBlock, // Element types ButtonElement, diff --git a/src/components/blocks/data_visualization.tsx b/src/components/blocks/data_visualization.tsx index 32064b5..e238e49 100644 --- a/src/components/blocks/data_visualization.tsx +++ b/src/components/blocks/data_visualization.tsx @@ -1,4 +1,4 @@ -import { PointerEvent as ReactPointerEvent, ReactNode, useRef, useState } from "react"; +import { PointerEvent as ReactPointerEvent, ReactNode, useMemo, useRef, useState } from "react"; import { DataVisualizationBlock, DataVizCartesianChart, @@ -255,6 +255,18 @@ const VIEW_W = 520; const VIEW_H = 300; const MARGIN = { top: 14, right: 16, bottom: 48, left: 60 }; +// Shared stroked path for line and area series (identical styling in both). +const SeriesStroke = (props: { d: string; color: string }) => ( + +); + const CartesianChart = (props: { model: CartesianModel; type: "line" | "bar" | "area"; @@ -266,51 +278,100 @@ const CartesianChart = (props: { const containerRef = useRef(null); const [hover, setHover] = useState<{ index: number; x: number; y: number } | null>(null); - const plotLeft = MARGIN.left; - const plotRight = VIEW_W - MARGIN.right; - const plotTop = MARGIN.top; - const plotBottom = VIEW_H - MARGIN.bottom; - const plotW = plotRight - plotLeft; - const plotH = plotBottom - plotTop; - - const values: number[] = []; - series.forEach((s) => s.values.forEach((v) => v != null && values.push(v))); - let dataMin = values.length ? Math.min(...values) : 0; - let dataMax = values.length ? Math.max(...values) : 1; - // Bars and areas are anchored at zero; lines reference zero only when they cross it. - if (type !== "line") { - dataMin = Math.min(0, dataMin); - dataMax = Math.max(0, dataMax); - } else if (dataMin < 0) { - dataMax = Math.max(0, dataMax); - } + // All hover-independent geometry — domain, ticks, scales, and the (costly) monotone-cubic + // path strings — is memoized so it is computed once per data change, NOT on every + // pointer-move. `model` keeps a stable identity across hover re-renders (hover state is + // local to this component), so the memo only recomputes when the data or chart type changes. + const geom = useMemo(() => { + const plotLeft = MARGIN.left; + const plotRight = VIEW_W - MARGIN.right; + const plotTop = MARGIN.top; + const plotBottom = VIEW_H - MARGIN.bottom; + const plotW = plotRight - plotLeft; + const plotH = plotBottom - plotTop; + + const values: number[] = []; + series.forEach((s) => s.values.forEach((v) => v != null && values.push(v))); + let dataMin = values.length ? Math.min(...values) : 0; + let dataMax = values.length ? Math.max(...values) : 1; + // Bars and areas are anchored at zero; lines reference zero only when they cross it. + if (type !== "line") { + dataMin = Math.min(0, dataMin); + dataMax = Math.max(0, dataMax); + } else if (dataMin < 0) { + dataMax = Math.max(0, dataMax); + } - const ticks = niceTicks(dataMin, dataMax, 5); - const domainMin = ticks[0]!; - const domainMax = ticks[ticks.length - 1]!; - const span = domainMax - domainMin || 1; - - const yOf = (v: number) => plotTop + (1 - (v - domainMin) / span) * plotH; - const count = categories.length; - const xPoint = (i: number) => - count <= 1 ? plotLeft + plotW / 2 : plotLeft + (i / (count - 1)) * plotW; - const bandWidth = count > 0 ? plotW / count : plotW; - const xBand = (i: number) => plotLeft + (i + 0.5) * bandWidth; - - const baselineY = yOf(Math.min(Math.max(0, domainMin), domainMax)); - const showZeroLine = domainMin < 0 && domainMax > 0; - - const groupWidth = bandWidth * 0.7; - const barWidth = series.length > 0 ? groupWidth / series.length : groupWidth; - - // Pixel positions a series occupies along the x-axis, used for smooth paths and markers. - const seriesPoints = (s: { values: (number | null)[] }): [number, number][] => { - const pts: [number, number][] = []; - s.values.forEach((v, i) => { - if (v != null) pts.push([xPoint(i), yOf(v)]); + const ticks = niceTicks(dataMin, dataMax, 5); + const domainMin = ticks[0]!; + const domainMax = ticks[ticks.length - 1]!; + const span = domainMax - domainMin || 1; + + const yOf = (v: number) => plotTop + (1 - (v - domainMin) / span) * plotH; + const count = categories.length; + const xPoint = (i: number) => + count <= 1 ? plotLeft + plotW / 2 : plotLeft + (i / (count - 1)) * plotW; + const bandWidth = count > 0 ? plotW / count : plotW; + const xBand = (i: number) => plotLeft + (i + 0.5) * bandWidth; + + const baselineY = yOf(Math.min(Math.max(0, domainMin), domainMax)); + const showZeroLine = domainMin < 0 && domainMax > 0; + + const groupWidth = bandWidth * 0.7; + const barWidth = series.length > 0 ? groupWidth / series.length : groupWidth; + + // Smooth (monotone-cubic) path per series + its area-fill variant — the expensive part. + const seriesPaths = series.map((s) => { + const pts: [number, number][] = []; + s.values.forEach((v, i) => { + if (v != null) pts.push([xPoint(i), yOf(v)]); + }); + const line = pts.length > 0 ? smoothLine(pts) : ""; + const area = + pts.length > 0 + ? `${line} L ${r2(pts[pts.length - 1]![0])} ${r2(baselineY)} L ${r2(pts[0]![0])} ${r2(baselineY)} Z` + : ""; + return { color: s.color, line, area }; }); - return pts; - }; + + return { + plotLeft, + plotRight, + plotTop, + plotBottom, + plotW, + ticks, + yOf, + count, + xPoint, + bandWidth, + xBand, + baselineY, + showZeroLine, + groupWidth, + barWidth, + seriesPaths, + }; + }, [model, type]); + + const { + plotLeft, + plotRight, + plotTop, + plotBottom, + plotW, + ticks, + yOf, + count, + xPoint, + bandWidth, + xBand, + baselineY, + showZeroLine, + groupWidth, + barWidth, + seriesPaths, + } = geom; const onPointerMove = (e: ReactPointerEvent) => { const el = containerRef.current; @@ -377,45 +438,22 @@ const CartesianChart = (props: { ); })} - {/* area fills (drawn under the lines) */} + {/* area fills (drawn under the smooth lines) */} {type === "area" && - series.map((s, si) => { - const pts = seriesPoints(s); - if (pts.length === 0) return null; - const top = smoothLine(pts); - const area = `${top} L ${r2(pts[pts.length - 1]![0])} ${r2(baselineY)} L ${r2(pts[0]![0])} ${r2(baselineY)} Z`; - return ( + seriesPaths.map((sp, si) => + sp.area ? ( - - + + - ); - })} + ) : null, + )} {/* lines */} {type === "line" && - series.map((s, si) => { - const pts = seriesPoints(s); - if (pts.length === 0) return null; - return ( - - ); - })} + seriesPaths.map((sp, si) => + sp.line ? : null, + )} {/* bars */} {type === "bar" &&