1- import { PointerEvent as ReactPointerEvent , ReactNode , useRef , useState } from "react" ;
1+ import { PointerEvent as ReactPointerEvent , ReactNode , useMemo , useRef , useState } from "react" ;
22import {
33 DataVisualizationBlock ,
44 DataVizCartesianChart ,
@@ -255,6 +255,18 @@ const VIEW_W = 520;
255255const VIEW_H = 300 ;
256256const 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+
258270const 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