Skip to content

Commit c4bf347

Browse files
committed
Use an overlay for positioning annotations above the interaction layer
1 parent 34ea492 commit c4bf347

File tree

3 files changed

+235
-41
lines changed

3 files changed

+235
-41
lines changed

projects/js-packages/charts/src/components/line-chart/line-chart-annotation.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ export type LineChartAnnotationProps = {
4343
styles?: AnnotationStyles;
4444
testId?: string;
4545
renderLabel?: React.FC< { title: string; subtitle: string } >;
46+
// External positioning for when rendering outside DataContext
47+
x?: number;
48+
y?: number;
49+
chartBounds?: {
50+
xMin: number;
51+
xMax: number;
52+
yMin: number;
53+
yMax: number;
54+
};
4655
};
4756

4857
export const getLabelPosition = ( {
@@ -183,6 +192,9 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
183192
styles: datumStyles,
184193
testId,
185194
renderLabel,
195+
x: externalX,
196+
y: externalY,
197+
chartBounds,
186198
} ) => {
187199
const providerTheme = useChartTheme();
188200
const { xScale, yScale } = useContext( DataContext ) || {};
@@ -201,6 +213,47 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
201213
}, [] );
202214

203215
const positionData = useMemo( () => {
216+
// Use external coordinates if provided
217+
if ( typeof externalX === 'number' && typeof externalY === 'number' ) {
218+
// Use provided chart bounds or defaults
219+
const yMin = chartBounds?.yMin ?? 0;
220+
const yMax = chartBounds?.yMax ?? 400;
221+
const xMin = chartBounds?.xMin ?? 0;
222+
const xMax = chartBounds?.xMax ?? 800;
223+
224+
// If a custom label is provided, use the provided position
225+
if ( renderLabel ) {
226+
return {
227+
x: externalX,
228+
y: externalY,
229+
yMin,
230+
yMax,
231+
xMin,
232+
xMax,
233+
dx: customDx,
234+
dy: customDy,
235+
isFlippedHorizontally: false,
236+
isFlippedVertically: false,
237+
};
238+
}
239+
240+
const position = getLabelPosition( {
241+
subjectType,
242+
x: externalX,
243+
dx: customDx,
244+
xMax,
245+
y: externalY,
246+
dy: customDy,
247+
yMin,
248+
yMax,
249+
maxWidth: styles?.label?.maxWidth,
250+
height,
251+
} );
252+
253+
return { x: externalX, y: externalY, yMin, yMax, xMin, xMax, ...position };
254+
}
255+
256+
// Fall back to DataContext coordinates
204257
if ( ! datum || ! datum.date || datum.value == null || ! xScale || ! yScale ) return null;
205258

206259
const x = xScale( datum.date );
@@ -251,6 +304,9 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
251304
customDx,
252305
customDy,
253306
renderLabel,
307+
externalX,
308+
externalY,
309+
chartBounds,
254310
] );
255311

256312
if ( ! positionData ) return null;
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { useEffect, useState, useCallback } from 'react';
2+
import LineChartAnnotation from './line-chart-annotation';
3+
import type { LineChartRef } from './line-chart';
4+
import type { LineChartAnnotationProps } from './line-chart-annotation';
5+
6+
interface LineChartAnnotationsProps {
7+
chartRef: React.RefObject< LineChartRef >;
8+
annotations: LineChartAnnotationProps[];
9+
chartWidth: number;
10+
chartHeight: number;
11+
}
12+
13+
const LineChartAnnotations: React.FC< LineChartAnnotationsProps > = ( {
14+
chartRef,
15+
annotations,
16+
chartWidth,
17+
chartHeight,
18+
} ) => {
19+
const [ scales, setScales ] = useState< { xScale: unknown; yScale: unknown } | null >( null );
20+
const [ isReady, setIsReady ] = useState( false );
21+
22+
// Get scales from chart ref
23+
const updateScales = useCallback( () => {
24+
if ( chartRef.current ) {
25+
const scaleData = chartRef.current.getScales();
26+
if ( scaleData ) {
27+
setScales( scaleData );
28+
setIsReady( true );
29+
}
30+
}
31+
}, [ chartRef ] );
32+
33+
// Update scales when component mounts and when chart updates
34+
useEffect( () => {
35+
updateScales();
36+
37+
// Set up a timer to retry getting scales if not immediately available
38+
const timer = setTimeout( updateScales, 100 );
39+
40+
return () => clearTimeout( timer );
41+
}, [ updateScales ] );
42+
43+
// Don't render anything if scales aren't ready
44+
if ( ! isReady || ! scales ) {
45+
return null;
46+
}
47+
48+
const { xScale, yScale } = scales;
49+
50+
// Type guard functions for scales
51+
const hasRangeMethod = (
52+
scale: unknown
53+
): scale is {
54+
( input: Date | number ): number;
55+
range: () => number[];
56+
} => {
57+
return typeof scale === 'function' && 'range' in scale && typeof scale.range === 'function';
58+
};
59+
60+
if ( ! hasRangeMethod( xScale ) || ! hasRangeMethod( yScale ) ) {
61+
return null;
62+
}
63+
64+
// Get chart bounds from scale ranges - these are the bounds for the positioning logic
65+
const chartBounds = {
66+
xMin: Math.min( ...xScale.range() ),
67+
xMax: Math.max( ...xScale.range() ),
68+
yMin: Math.min( ...yScale.range() ),
69+
yMax: Math.max( ...yScale.range() ),
70+
};
71+
72+
// Calculate positions for each annotation
73+
const positionedAnnotations = annotations
74+
.filter( annotation => annotation.datum )
75+
.map( ( annotation, index ) => {
76+
const { datum, ...rest } = annotation;
77+
if ( ! datum ) return null;
78+
79+
// Get scale coordinates - these are already positioned correctly for the chart
80+
const chartX = xScale( datum.date );
81+
const chartY = yScale( datum.value );
82+
83+
return {
84+
...rest,
85+
datum,
86+
index,
87+
chartX,
88+
chartY,
89+
};
90+
} )
91+
.filter( Boolean );
92+
93+
return (
94+
<svg
95+
width={ chartWidth }
96+
height={ chartHeight }
97+
style={ {
98+
position: 'absolute',
99+
left: 0,
100+
top: 0,
101+
overflow: 'visible',
102+
pointerEvents: 'none',
103+
} }
104+
>
105+
{ positionedAnnotations.map( annotation => {
106+
if ( ! annotation ) return null;
107+
108+
return (
109+
<g
110+
key={ `overlay-annotation-${ annotation.datum.date?.getTime() }-${
111+
annotation.datum.value
112+
}` }
113+
style={ { pointerEvents: 'auto' } }
114+
>
115+
<LineChartAnnotation
116+
testId={ `overlay-annotation-${ annotation.index }` }
117+
datum={ annotation.datum }
118+
// Use the full chart coordinates
119+
x={ annotation.chartX }
120+
y={ annotation.chartY }
121+
// Pass the scale ranges as chart bounds for boundary detection
122+
chartBounds={ chartBounds }
123+
{ ...annotation }
124+
/>
125+
</g>
126+
);
127+
} ) }
128+
</svg>
129+
);
130+
};
131+
132+
export default LineChartAnnotations;

projects/js-packages/charts/src/components/line-chart/line-chart.tsx

Lines changed: 47 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { curveCatmullRom, curveLinear, curveMonotoneX } from '@visx/curve';
33
import { LinearGradient } from '@visx/gradient';
44
import { XYChart, AreaSeries, Tooltip, Grid, Axis, DataContext } from '@visx/xychart';
55
import clsx from 'clsx';
6-
import { useId, useMemo, useContext, forwardRef, useImperativeHandle } from 'react';
6+
import { useId, useMemo, useContext, forwardRef, useImperativeHandle, useRef } from 'react';
77
import { useXYChartTheme, useChartTheme } from '../../providers/theme/theme-provider';
88
import { Legend } from '../legend';
99
import { DefaultGlyph } from '../shared/default-glyph';
@@ -12,6 +12,7 @@ import { useChartMargin } from '../shared/use-chart-margin';
1212
import { useElementHeight } from '../shared/use-element-height';
1313
import { withResponsive } from '../shared/with-responsive';
1414
import LineChartAnnotation from './line-chart-annotation';
15+
import LineChartAnnotations from './line-chart-annotations-overlay';
1516
import styles from './line-chart.module.scss';
1617
import type { LineChartAnnotationProps } from './line-chart-annotation';
1718
import type { BaseChartProps, DataPoint, DataPointDate, SeriesData } from '../../types';
@@ -185,10 +186,12 @@ const validateData = ( data: SeriesData[] ) => {
185186
};
186187

187188
// Inner component to access DataContext and provide scale data to ref
188-
const LineChartInternal: FC< LineChartProps & { chartRef?: React.Ref< LineChartRef > } > = ( {
189-
chartRef,
190-
...props
191-
} ) => {
189+
const LineChartInternal: FC< {
190+
chartRef?: React.Ref< LineChartRef >;
191+
width: number;
192+
height: number;
193+
margin?: { top?: number; right?: number; bottom?: number; left?: number };
194+
} > = ( { chartRef, width, height, margin } ) => {
192195
const context = useContext( DataContext );
193196

194197
useImperativeHandle(
@@ -204,12 +207,12 @@ const LineChartInternal: FC< LineChartProps & { chartRef?: React.Ref< LineChartR
204207
};
205208
},
206209
getChartDimensions: () => ( {
207-
width: props.width,
208-
height: props.height,
209-
margin: props.margin || {},
210+
width,
211+
height,
212+
margin: margin || {},
210213
} ),
211214
} ),
212-
[ context, props.width, props.height, props.margin ]
215+
[ context, width, height, margin ]
213216
);
214217

215218
return null; // This component only provides the ref interface
@@ -251,6 +254,18 @@ const LineChart = forwardRef< LineChartRef, LineChartProps >(
251254
const theme = useXYChartTheme( data );
252255
const chartId = useId(); // Ensure unique ids for gradient fill.
253256
const [ legendRef, legendHeight ] = useElementHeight< HTMLDivElement >();
257+
const internalChartRef = useRef< LineChartRef >( null );
258+
259+
// Forward the external ref to the internal ref
260+
useImperativeHandle(
261+
ref,
262+
() => ( {
263+
getScales: () => internalChartRef.current?.getScales() || null,
264+
getChartDimensions: () =>
265+
internalChartRef.current?.getChartDimensions() || { width: 0, height: 0, margin: {} },
266+
} ),
267+
[ internalChartRef ]
268+
);
254269

255270
const dataSorted = useChartDataTransform( data );
256271

@@ -418,54 +433,45 @@ const LineChart = forwardRef< LineChartRef, LineChartProps >(
418433
showHorizontalCrosshair={ withTooltipCrosshairs?.showHorizontal }
419434
/>
420435
) }
421-
{ /* Component to expose scale data via ref */ }
422-
<LineChartInternal
423-
chartRef={ ref }
424-
data={ data }
425-
width={ width }
426-
height={ height }
427-
className={ className }
428-
margin={ margin }
429-
withTooltips={ withTooltips }
430-
withTooltipCrosshairs={ withTooltipCrosshairs }
431-
showLegend={ showLegend }
432-
legendOrientation={ legendOrientation }
433-
legendAlignmentHorizontal={ legendAlignmentHorizontal }
434-
legendAlignmentVertical={ legendAlignmentVertical }
435-
renderGlyph={ renderGlyph }
436-
glyphStyle={ glyphStyle }
437-
legendShape={ legendShape }
438-
withLegendGlyph={ withLegendGlyph }
439-
withGradientFill={ withGradientFill }
440-
smoothing={ smoothing }
441-
curveType={ curveType }
442-
renderTooltip={ renderTooltip }
443-
withStartGlyphs={ withStartGlyphs }
444-
options={ options }
445-
annotations={ annotations }
446-
onPointerDown={ onPointerDown }
447-
onPointerUp={ onPointerUp }
448-
onPointerMove={ onPointerMove }
449-
onPointerOut={ onPointerOut }
450-
/>
451436

452-
{ /* Render annotations last so they appear on top of all chart elements */ }
453437
{ annotations?.length && (
454438
<g className="line-chart__annotations">
455-
{ annotations.map( ( { datum, ...rest }, index ) =>
439+
{ annotations.map( ( { datum, styles: datumStyles, ...rest }, index ) =>
456440
datum ? (
457441
<LineChartAnnotation
458442
key={ `annotation-${ datum.date?.getTime() }-${ datum.value }` }
459443
testId={ `annotation-${ index }` }
460444
datum={ datum }
445+
styles={ {
446+
...datumStyles,
447+
label: { ...datumStyles?.label, backgroundFill: 'gray' },
448+
} }
461449
{ ...rest }
462450
/>
463451
) : null
464452
) }
465453
</g>
466454
) }
455+
456+
{ /* Component to expose scale data via ref */ }
457+
<LineChartInternal
458+
chartRef={ internalChartRef }
459+
width={ width }
460+
height={ height }
461+
margin={ margin }
462+
/>
467463
</XYChart>
468464

465+
{ /* Render annotations as external overlay to avoid interaction blocking */ }
466+
{ annotations?.length && (
467+
<LineChartAnnotations
468+
chartRef={ internalChartRef }
469+
annotations={ annotations }
470+
chartWidth={ width }
471+
chartHeight={ height - ( showLegend ? legendHeight : 0 ) }
472+
/>
473+
) }
474+
469475
{ showLegend && (
470476
<Legend
471477
items={ legendItems }

0 commit comments

Comments
 (0)