Skip to content

Commit 9f2af7f

Browse files
committed
refactor: ⚡️ memoize chart geometry, dedupe series stroke, complete readme types
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
1 parent 4e2e246 commit 9f2af7f

2 files changed

Lines changed: 120 additions & 77 deletions

File tree

readme.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,11 +412,16 @@ import type {
412412
InputBlock,
413413
RichTextBlock,
414414
VideoBlock,
415+
TableBlock,
415416
FileBlock,
416417
ContextActionsBlock,
417418
MarkdownBlock,
418419
PlanBlock,
419420
TaskCardBlock,
421+
AlertBlock,
422+
CardBlock,
423+
CarouselBlock,
424+
DataVisualizationBlock,
420425

421426
// Element types
422427
ButtonElement,

src/components/blocks/data_visualization.tsx

Lines changed: 115 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { PointerEvent as ReactPointerEvent, ReactNode, useRef, useState } from "react";
1+
import { PointerEvent as ReactPointerEvent, ReactNode, useMemo, useRef, useState } from "react";
22
import {
33
DataVisualizationBlock,
44
DataVizCartesianChart,
@@ -255,6 +255,18 @@ const VIEW_W = 520;
255255
const VIEW_H = 300;
256256
const MARGIN = { top: 14, right: 16, bottom: 48, left: 60 };
257257

258+
// Shared stroked path for line and area series (identical styling in both).
259+
const SeriesStroke = (props: { d: string; color: string }) => (
260+
<path
261+
d={props.d}
262+
fill="none"
263+
stroke={props.color}
264+
strokeWidth={2}
265+
strokeLinejoin="round"
266+
strokeLinecap="round"
267+
/>
268+
);
269+
258270
const CartesianChart = (props: {
259271
model: CartesianModel;
260272
type: "line" | "bar" | "area";
@@ -266,51 +278,100 @@ const CartesianChart = (props: {
266278
const containerRef = useRef<HTMLDivElement>(null);
267279
const [hover, setHover] = useState<{ index: number; x: number; y: number } | null>(null);
268280

269-
const plotLeft = MARGIN.left;
270-
const plotRight = VIEW_W - MARGIN.right;
271-
const plotTop = MARGIN.top;
272-
const plotBottom = VIEW_H - MARGIN.bottom;
273-
const plotW = plotRight - plotLeft;
274-
const plotH = plotBottom - plotTop;
275-
276-
const values: number[] = [];
277-
series.forEach((s) => s.values.forEach((v) => v != null && values.push(v)));
278-
let dataMin = values.length ? Math.min(...values) : 0;
279-
let dataMax = values.length ? Math.max(...values) : 1;
280-
// Bars and areas are anchored at zero; lines reference zero only when they cross it.
281-
if (type !== "line") {
282-
dataMin = Math.min(0, dataMin);
283-
dataMax = Math.max(0, dataMax);
284-
} else if (dataMin < 0) {
285-
dataMax = Math.max(0, dataMax);
286-
}
281+
// All hover-independent geometry — domain, ticks, scales, and the (costly) monotone-cubic
282+
// path strings — is memoized so it is computed once per data change, NOT on every
283+
// pointer-move. `model` keeps a stable identity across hover re-renders (hover state is
284+
// local to this component), so the memo only recomputes when the data or chart type changes.
285+
const geom = useMemo(() => {
286+
const plotLeft = MARGIN.left;
287+
const plotRight = VIEW_W - MARGIN.right;
288+
const plotTop = MARGIN.top;
289+
const plotBottom = VIEW_H - MARGIN.bottom;
290+
const plotW = plotRight - plotLeft;
291+
const plotH = plotBottom - plotTop;
292+
293+
const values: number[] = [];
294+
series.forEach((s) => s.values.forEach((v) => v != null && values.push(v)));
295+
let dataMin = values.length ? Math.min(...values) : 0;
296+
let dataMax = values.length ? Math.max(...values) : 1;
297+
// Bars and areas are anchored at zero; lines reference zero only when they cross it.
298+
if (type !== "line") {
299+
dataMin = Math.min(0, dataMin);
300+
dataMax = Math.max(0, dataMax);
301+
} else if (dataMin < 0) {
302+
dataMax = Math.max(0, dataMax);
303+
}
287304

288-
const ticks = niceTicks(dataMin, dataMax, 5);
289-
const domainMin = ticks[0]!;
290-
const domainMax = ticks[ticks.length - 1]!;
291-
const span = domainMax - domainMin || 1;
292-
293-
const yOf = (v: number) => plotTop + (1 - (v - domainMin) / span) * plotH;
294-
const count = categories.length;
295-
const xPoint = (i: number) =>
296-
count <= 1 ? plotLeft + plotW / 2 : plotLeft + (i / (count - 1)) * plotW;
297-
const bandWidth = count > 0 ? plotW / count : plotW;
298-
const xBand = (i: number) => plotLeft + (i + 0.5) * bandWidth;
299-
300-
const baselineY = yOf(Math.min(Math.max(0, domainMin), domainMax));
301-
const showZeroLine = domainMin < 0 && domainMax > 0;
302-
303-
const groupWidth = bandWidth * 0.7;
304-
const barWidth = series.length > 0 ? groupWidth / series.length : groupWidth;
305-
306-
// Pixel positions a series occupies along the x-axis, used for smooth paths and markers.
307-
const seriesPoints = (s: { values: (number | null)[] }): [number, number][] => {
308-
const pts: [number, number][] = [];
309-
s.values.forEach((v, i) => {
310-
if (v != null) pts.push([xPoint(i), yOf(v)]);
305+
const ticks = niceTicks(dataMin, dataMax, 5);
306+
const domainMin = ticks[0]!;
307+
const domainMax = ticks[ticks.length - 1]!;
308+
const span = domainMax - domainMin || 1;
309+
310+
const yOf = (v: number) => plotTop + (1 - (v - domainMin) / span) * plotH;
311+
const count = categories.length;
312+
const xPoint = (i: number) =>
313+
count <= 1 ? plotLeft + plotW / 2 : plotLeft + (i / (count - 1)) * plotW;
314+
const bandWidth = count > 0 ? plotW / count : plotW;
315+
const xBand = (i: number) => plotLeft + (i + 0.5) * bandWidth;
316+
317+
const baselineY = yOf(Math.min(Math.max(0, domainMin), domainMax));
318+
const showZeroLine = domainMin < 0 && domainMax > 0;
319+
320+
const groupWidth = bandWidth * 0.7;
321+
const barWidth = series.length > 0 ? groupWidth / series.length : groupWidth;
322+
323+
// Smooth (monotone-cubic) path per series + its area-fill variant — the expensive part.
324+
const seriesPaths = series.map((s) => {
325+
const pts: [number, number][] = [];
326+
s.values.forEach((v, i) => {
327+
if (v != null) pts.push([xPoint(i), yOf(v)]);
328+
});
329+
const line = pts.length > 0 ? smoothLine(pts) : "";
330+
const area =
331+
pts.length > 0
332+
? `${line} L ${r2(pts[pts.length - 1]![0])} ${r2(baselineY)} L ${r2(pts[0]![0])} ${r2(baselineY)} Z`
333+
: "";
334+
return { color: s.color, line, area };
311335
});
312-
return pts;
313-
};
336+
337+
return {
338+
plotLeft,
339+
plotRight,
340+
plotTop,
341+
plotBottom,
342+
plotW,
343+
ticks,
344+
yOf,
345+
count,
346+
xPoint,
347+
bandWidth,
348+
xBand,
349+
baselineY,
350+
showZeroLine,
351+
groupWidth,
352+
barWidth,
353+
seriesPaths,
354+
};
355+
}, [model, type]);
356+
357+
const {
358+
plotLeft,
359+
plotRight,
360+
plotTop,
361+
plotBottom,
362+
plotW,
363+
ticks,
364+
yOf,
365+
count,
366+
xPoint,
367+
bandWidth,
368+
xBand,
369+
baselineY,
370+
showZeroLine,
371+
groupWidth,
372+
barWidth,
373+
seriesPaths,
374+
} = geom;
314375

315376
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
316377
const el = containerRef.current;
@@ -377,45 +438,22 @@ const CartesianChart = (props: {
377438
);
378439
})}
379440

380-
{/* area fills (drawn under the lines) */}
441+
{/* area fills (drawn under the smooth lines) */}
381442
{type === "area" &&
382-
series.map((s, si) => {
383-
const pts = seriesPoints(s);
384-
if (pts.length === 0) return null;
385-
const top = smoothLine(pts);
386-
const area = `${top} L ${r2(pts[pts.length - 1]![0])} ${r2(baselineY)} L ${r2(pts[0]![0])} ${r2(baselineY)} Z`;
387-
return (
443+
seriesPaths.map((sp, si) =>
444+
sp.area ? (
388445
<g key={`area-${si}`}>
389-
<path d={area} fill={s.color} fillOpacity={0.22} />
390-
<path
391-
d={top}
392-
fill="none"
393-
stroke={s.color}
394-
strokeWidth={2}
395-
strokeLinejoin="round"
396-
strokeLinecap="round"
397-
/>
446+
<path d={sp.area} fill={sp.color} fillOpacity={0.22} />
447+
<SeriesStroke d={sp.line} color={sp.color} />
398448
</g>
399-
);
400-
})}
449+
) : null,
450+
)}
401451

402452
{/* lines */}
403453
{type === "line" &&
404-
series.map((s, si) => {
405-
const pts = seriesPoints(s);
406-
if (pts.length === 0) return null;
407-
return (
408-
<path
409-
key={`line-${si}`}
410-
d={smoothLine(pts)}
411-
fill="none"
412-
stroke={s.color}
413-
strokeWidth={2}
414-
strokeLinejoin="round"
415-
strokeLinecap="round"
416-
/>
417-
);
418-
})}
454+
seriesPaths.map((sp, si) =>
455+
sp.line ? <SeriesStroke key={`line-${si}`} d={sp.line} color={sp.color} /> : null,
456+
)}
419457

420458
{/* bars */}
421459
{type === "bar" &&

0 commit comments

Comments
 (0)