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..5e9f142 100644 --- a/readme.md +++ b/readme.md @@ -125,23 +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 | +| 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 @@ -408,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 new file mode 100644 index 0000000..e238e49 --- /dev/null +++ b/src/components/blocks/data_visualization.tsx @@ -0,0 +1,871 @@ +import { PointerEvent as ReactPointerEvent, ReactNode, useMemo, useRef, 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 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); + 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"); +}; + +// 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 ""; + 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]}`; +}; + +// 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[][]) => { + 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 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; +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"; + ariaLabel: string; +}) => { + 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); + + // 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; + + // 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 { + 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; + 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 smooth lines) */} + {type === "area" && + seriesPaths.map((sp, si) => + sp.area ? ( + + + + + ) : null, + )} + + {/* lines */} + {type === "line" && + seriesPaths.map((sp, si) => + sp.line ? : null, + )} + + {/* 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} + + )} + + {/* 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, + }))} + /> + )} +
+ ); +}; + +// #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 ${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 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, i) => { + const fraction = Math.max(0, s.value) / model.total; + const start = angle; + const end = angle + fraction * 360; + angle = end; + 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)} + > + + {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 && ( + + )} +
+ ); +}; + +// #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..4325f1a --- /dev/null +++ b/test/data_visualization.test.mjs @@ -0,0 +1,203 @@ +// 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 }, + { 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" }, + }, +}; + +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, / { + // 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( + 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, /