@automattic/charts 0.44.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/{chunk-2HB55BRH.js → chunk-4H3J2HCD.js} +102 -47
  3. package/dist/chunk-4H3J2HCD.js.map +1 -0
  4. package/dist/{chunk-G4FX5I3V.cjs → chunk-7AH76DXF.cjs} +119 -64
  5. package/dist/chunk-7AH76DXF.cjs.map +1 -0
  6. package/dist/{chunk-3O6FHD2T.js → chunk-A3PGOCJO.js} +46 -4
  7. package/dist/chunk-A3PGOCJO.js.map +1 -0
  8. package/dist/{chunk-G66WE3ON.js → chunk-BWEMZ72V.js} +41 -15
  9. package/dist/chunk-BWEMZ72V.js.map +1 -0
  10. package/dist/{chunk-BZ6UDD37.cjs → chunk-CNAKHZMW.cjs} +69 -31
  11. package/dist/chunk-CNAKHZMW.cjs.map +1 -0
  12. package/dist/{chunk-MAV6SE6L.cjs → chunk-GEB4GELE.cjs} +24 -24
  13. package/dist/{chunk-MAV6SE6L.cjs.map → chunk-GEB4GELE.cjs.map} +1 -1
  14. package/dist/{chunk-KM62I6SD.js → chunk-HVWETEEV.js} +53 -15
  15. package/dist/chunk-HVWETEEV.js.map +1 -0
  16. package/dist/{chunk-HYHBAHIU.js → chunk-JGX3ZNK5.js} +3 -3
  17. package/dist/{chunk-W5RFMC3A.js → chunk-JI6OGGGF.js} +3 -3
  18. package/dist/{chunk-SHADFB3T.js → chunk-KEBKTDOQ.js} +2 -2
  19. package/dist/{chunk-2HUX2CAT.cjs → chunk-LSGYIUQX.cjs} +44 -2
  20. package/dist/chunk-LSGYIUQX.cjs.map +1 -0
  21. package/dist/{chunk-UHESRL2F.cjs → chunk-N36WJKYM.cjs} +6 -6
  22. package/dist/{chunk-UHESRL2F.cjs.map → chunk-N36WJKYM.cjs.map} +1 -1
  23. package/dist/{chunk-Q2LDRQN7.js → chunk-PFT2X4OW.js} +2 -2
  24. package/dist/{chunk-GK3XEXVI.cjs → chunk-PNSMPZ3E.cjs} +8 -8
  25. package/dist/{chunk-GK3XEXVI.cjs.map → chunk-PNSMPZ3E.cjs.map} +1 -1
  26. package/dist/{chunk-SC462VDM.cjs → chunk-QPHNEQCK.cjs} +11 -11
  27. package/dist/{chunk-SC462VDM.cjs.map → chunk-QPHNEQCK.cjs.map} +1 -1
  28. package/dist/{chunk-ZA7OWPY7.cjs → chunk-VOMSG7KV.cjs} +50 -24
  29. package/dist/chunk-VOMSG7KV.cjs.map +1 -0
  30. package/dist/{chunk-QLLKOSJ6.cjs → chunk-YKVKFUV7.cjs} +50 -24
  31. package/dist/chunk-YKVKFUV7.cjs.map +1 -0
  32. package/dist/{chunk-XDIWMJZD.js → chunk-ZSNO2BYX.js} +39 -13
  33. package/dist/chunk-ZSNO2BYX.js.map +1 -0
  34. package/dist/components/bar-chart/index.cjs +4 -4
  35. package/dist/components/bar-chart/index.d.cts +2 -1
  36. package/dist/components/bar-chart/index.d.ts +2 -1
  37. package/dist/components/bar-chart/index.js +3 -3
  38. package/dist/components/bar-list-chart/index.cjs +5 -5
  39. package/dist/components/bar-list-chart/index.d.cts +1 -1
  40. package/dist/components/bar-list-chart/index.d.ts +1 -1
  41. package/dist/components/bar-list-chart/index.js +4 -4
  42. package/dist/components/conversion-funnel-chart/index.cjs +3 -3
  43. package/dist/components/conversion-funnel-chart/index.d.cts +1 -1
  44. package/dist/components/conversion-funnel-chart/index.d.ts +1 -1
  45. package/dist/components/conversion-funnel-chart/index.js +2 -2
  46. package/dist/components/leaderboard-chart/index.cjs +4 -4
  47. package/dist/components/leaderboard-chart/index.d.cts +2 -2
  48. package/dist/components/leaderboard-chart/index.d.ts +2 -2
  49. package/dist/components/leaderboard-chart/index.js +3 -3
  50. package/dist/components/legend/index.cjs +3 -3
  51. package/dist/components/legend/index.d.cts +1 -1
  52. package/dist/components/legend/index.d.ts +1 -1
  53. package/dist/components/legend/index.js +2 -2
  54. package/dist/components/line-chart/index.cjs +4 -4
  55. package/dist/components/line-chart/index.d.cts +1 -1
  56. package/dist/components/line-chart/index.d.ts +1 -1
  57. package/dist/components/line-chart/index.js +3 -3
  58. package/dist/components/pie-chart/index.cjs +4 -4
  59. package/dist/components/pie-chart/index.d.cts +7 -1
  60. package/dist/components/pie-chart/index.d.ts +7 -1
  61. package/dist/components/pie-chart/index.js +3 -3
  62. package/dist/components/pie-semi-circle-chart/index.cjs +4 -4
  63. package/dist/components/pie-semi-circle-chart/index.d.cts +7 -1
  64. package/dist/components/pie-semi-circle-chart/index.d.ts +7 -1
  65. package/dist/components/pie-semi-circle-chart/index.js +3 -3
  66. package/dist/components/tooltip/index.d.cts +1 -1
  67. package/dist/components/tooltip/index.d.ts +1 -1
  68. package/dist/hooks/index.cjs +4 -2
  69. package/dist/hooks/index.cjs.map +1 -1
  70. package/dist/hooks/index.d.cts +79 -2
  71. package/dist/hooks/index.d.ts +79 -2
  72. package/dist/hooks/index.js +3 -1
  73. package/dist/index.cjs +10 -10
  74. package/dist/index.d.cts +3 -3
  75. package/dist/index.d.ts +3 -3
  76. package/dist/index.js +9 -9
  77. package/dist/{leaderboard-chart-BWEheWCd.d.cts → leaderboard-chart-B5JRimc9.d.cts} +2 -2
  78. package/dist/{leaderboard-chart-rqyTz1m6.d.ts → leaderboard-chart-DQ8i8GMA.d.ts} +2 -2
  79. package/dist/providers/index.cjs +2 -2
  80. package/dist/providers/index.d.cts +2 -2
  81. package/dist/providers/index.d.ts +2 -2
  82. package/dist/providers/index.js +1 -1
  83. package/dist/{themes-CGUHFZ5g.d.ts → themes-CN85BQM1.d.ts} +1 -1
  84. package/dist/{themes-B4swlmql.d.cts → themes-TIJq1lG_.d.cts} +1 -1
  85. package/dist/{types-cEbX_Q2K.d.ts → types-73KOEWs9.d.cts} +3 -1
  86. package/dist/{types-cEbX_Q2K.d.cts → types-73KOEWs9.d.ts} +3 -1
  87. package/package.json +3 -3
  88. package/src/components/bar-chart/bar-chart.tsx +57 -11
  89. package/src/components/bar-chart/test/bar-chart.test.tsx +114 -0
  90. package/src/components/leaderboard-chart/leaderboard-chart.tsx +85 -38
  91. package/src/components/leaderboard-chart/test/leaderboard-chart.test.tsx +48 -0
  92. package/src/components/leaderboard-chart/types.ts +1 -0
  93. package/src/components/pie-chart/pie-chart.tsx +130 -93
  94. package/src/components/pie-chart/test/pie-chart.test.tsx +174 -0
  95. package/src/components/pie-semi-circle-chart/pie-semi-circle-chart.tsx +96 -57
  96. package/src/components/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +84 -0
  97. package/src/hooks/index.ts +1 -0
  98. package/src/hooks/use-interactive-legend-data.ts +138 -0
  99. package/src/types.ts +3 -1
  100. package/dist/chunk-2HB55BRH.js.map +0 -1
  101. package/dist/chunk-2HUX2CAT.cjs.map +0 -1
  102. package/dist/chunk-3O6FHD2T.js.map +0 -1
  103. package/dist/chunk-BZ6UDD37.cjs.map +0 -1
  104. package/dist/chunk-G4FX5I3V.cjs.map +0 -1
  105. package/dist/chunk-G66WE3ON.js.map +0 -1
  106. package/dist/chunk-KM62I6SD.js.map +0 -1
  107. package/dist/chunk-QLLKOSJ6.cjs.map +0 -1
  108. package/dist/chunk-XDIWMJZD.js.map +0 -1
  109. package/dist/chunk-ZA7OWPY7.cjs.map +0 -1
  110. /package/dist/{chunk-HYHBAHIU.js.map → chunk-JGX3ZNK5.js.map} +0 -0
  111. /package/dist/{chunk-W5RFMC3A.js.map → chunk-JI6OGGGF.js.map} +0 -0
  112. /package/dist/{chunk-SHADFB3T.js.map → chunk-KEBKTDOQ.js.map} +0 -0
  113. /package/dist/{chunk-Q2LDRQN7.js.map → chunk-PFT2X4OW.js.map} +0 -0
@@ -2,9 +2,10 @@ import { localPoint } from '@visx/event';
2
2
  import { Group } from '@visx/group';
3
3
  import { Pie } from '@visx/shape';
4
4
  import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
5
+ import { __ } from '@wordpress/i18n';
5
6
  import clsx from 'clsx';
6
7
  import { useCallback, useContext, useMemo } from 'react';
7
- import { useElementHeight } from '../../hooks';
8
+ import { useElementHeight, useInteractiveLegendData } from '../../hooks';
8
9
  import {
9
10
  GlobalChartsProvider,
10
11
  useChartId,
@@ -70,6 +71,13 @@ export interface PieChartProps extends BaseChartProps< DataPointPercentage[] > {
70
71
  */
71
72
  legendValueDisplay?: LegendValueDisplay;
72
73
 
74
+ /**
75
+ * Enable interactive legend items that can toggle segment visibility.
76
+ * Requires chartId and GlobalChartsProvider.
77
+ * When segments are hidden, percentages are recalculated so visible segments total 100%.
78
+ */
79
+ legendInteractive?: boolean;
80
+
73
81
  /**
74
82
  * Use the children prop to render additional elements on the chart.
75
83
  */
@@ -147,6 +155,7 @@ const PieChartInternal = ( {
147
155
  cornerScale = 0,
148
156
  showLabels = true,
149
157
  legendValueDisplay = 'percentage',
158
+ legendInteractive = false,
150
159
  children = null,
151
160
  tooltipOffsetX = 0,
152
161
  tooltipOffsetY = -15,
@@ -171,14 +180,24 @@ const PieChartInternal = ( {
171
180
  hideTooltip();
172
181
  }, [ withTooltips, hideTooltip ] );
173
182
 
183
+ const { getElementStyles, isSeriesVisible } = useGlobalChartsContext();
184
+
185
+ // Filter and recalculate data for interactive legends
186
+ const { visibleData, allSegmentsHidden, legendData } = useInteractiveLegendData( {
187
+ data,
188
+ chartId,
189
+ legendInteractive,
190
+ isSeriesVisible,
191
+ } );
192
+
174
193
  // Memoize legend options to prevent unnecessary re-calculations
175
194
  const legendOptions = useMemo(
176
195
  () => ( { showValues: true, legendValueDisplay } ),
177
196
  [ legendValueDisplay ]
178
197
  );
179
198
 
180
- // Create legend items using the reusable hook
181
- const legendItems = useChartLegendItems( data, legendOptions );
199
+ // Create legend items using legendData (has recalculated percentages for visible items)
200
+ const legendItems = useChartLegendItems( legendData, legendOptions );
182
201
 
183
202
  const { isValid, message } = validateData( data );
184
203
 
@@ -204,8 +223,6 @@ const PieChartInternal = ( {
204
223
  metadata: chartMetadata,
205
224
  } );
206
225
 
207
- const { getElementStyles } = useGlobalChartsContext();
208
-
209
226
  if ( ! isValid ) {
210
227
  return (
211
228
  <div className={ clsx( 'pie-chart', styles[ 'pie-chart' ], className ) }>
@@ -225,7 +242,7 @@ const PieChartInternal = ( {
225
242
  const centerX = width / 2;
226
243
  const centerY = adjustedHeight / 2;
227
244
 
228
- // Calculate the angle between each
245
+ // Calculate the angle between each (use original data length for consistent spacing)
229
246
  const padAngle = gapScale * ( ( 2 * Math.PI ) / data.length );
230
247
 
231
248
  const outerRadius = radius - padding;
@@ -235,10 +252,14 @@ const PieChartInternal = ( {
235
252
  const cornerRadius = cornerScale ? Math.min( cornerScale * outerRadius, maxCornerRadius ) : 0;
236
253
 
237
254
  // Map the data to include index for color assignment
238
- const dataWithIndex = data.map( ( d, index ) => ( {
239
- ...d,
240
- index,
241
- } ) );
255
+ // When interactive, we need to find the original index to maintain consistent colors
256
+ const dataWithIndex = visibleData.map( d => {
257
+ const originalIndex = data.findIndex( item => item.label === d.label );
258
+ return {
259
+ ...d,
260
+ index: originalIndex >= 0 ? originalIndex : 0,
261
+ };
262
+ } );
242
263
 
243
264
  const accessors = {
244
265
  value: ( d: DataPointPercentage ) => d.value,
@@ -270,94 +291,109 @@ const PieChartInternal = ( {
270
291
  height={ adjustedHeight }
271
292
  >
272
293
  <Group top={ centerY } left={ centerX }>
273
- <Pie< DataPointPercentage & { index: number } >
274
- data={ dataWithIndex }
275
- pieValue={ accessors.value }
276
- outerRadius={ outerRadius }
277
- innerRadius={ innerRadius }
278
- padAngle={ padAngle }
279
- cornerRadius={ cornerRadius }
280
- >
281
- { pie => {
282
- return pie.arcs.map( ( arc, index ) => {
283
- const [ centroidX, centroidY ] = pie.path.centroid( arc );
284
- const hasSpaceForLabel = arc.endAngle - arc.startAngle >= 0.25;
285
- const handleMouseMove = ( event: MouseEvent< SVGElement > ) => {
286
- if ( ! withTooltips ) {
287
- return;
294
+ { allSegmentsHidden ? (
295
+ <text
296
+ textAnchor="middle"
297
+ dy=".33em"
298
+ fill={ providerTheme.gridColor || '#ccc' }
299
+ fontSize="14"
300
+ fontFamily="-apple-system,BlinkMacSystemFont,Roboto,Helvetica Neue,sans-serif"
301
+ >
302
+ { __(
303
+ 'All segments are hidden. Click legend items to show data.',
304
+ 'jetpack-charts'
305
+ ) }
306
+ </text>
307
+ ) : (
308
+ <Pie< DataPointPercentage & { index: number } >
309
+ data={ dataWithIndex }
310
+ pieValue={ accessors.value }
311
+ outerRadius={ outerRadius }
312
+ innerRadius={ innerRadius }
313
+ padAngle={ padAngle }
314
+ cornerRadius={ cornerRadius }
315
+ >
316
+ { pie => {
317
+ return pie.arcs.map( ( arc, index ) => {
318
+ const [ centroidX, centroidY ] = pie.path.centroid( arc );
319
+ const hasSpaceForLabel = arc.endAngle - arc.startAngle >= 0.25;
320
+ const handleMouseMove = ( event: MouseEvent< SVGElement > ) => {
321
+ if ( ! withTooltips ) {
322
+ return;
323
+ }
324
+
325
+ // Get coordinates relative to the current target element
326
+ const coords = localPoint( event );
327
+ if ( coords ) {
328
+ // Account for legend offset when legend is on top
329
+ const legendOffset =
330
+ showLegend && legendPosition === 'top' ? legendHeight : 0;
331
+ showTooltip( {
332
+ tooltipData: arc.data,
333
+ tooltipLeft: coords.x + tooltipOffsetX,
334
+ tooltipTop: coords.y + legendOffset + tooltipOffsetY,
335
+ } );
336
+ }
337
+ };
338
+
339
+ const pathProps: SVGProps< SVGPathElement > & { 'data-testid'?: string } = {
340
+ d: pie.path( arc ) || '',
341
+ fill: accessors.fill( arc.data ),
342
+ 'data-testid': 'pie-segment',
343
+ };
344
+
345
+ const groupProps: SVGProps< SVGGElement > = {};
346
+ if ( withTooltips ) {
347
+ groupProps.onMouseMove = handleMouseMove;
348
+ groupProps.onMouseLeave = onMouseLeave;
288
349
  }
289
350
 
290
- // Get coordinates relative to the current target element
291
- const coords = localPoint( event );
292
- if ( coords ) {
293
- // Account for legend offset when legend is on top
294
- const legendOffset =
295
- showLegend && legendPosition === 'top' ? legendHeight : 0;
296
- showTooltip( {
297
- tooltipData: arc.data,
298
- tooltipLeft: coords.x + tooltipOffsetX,
299
- tooltipTop: coords.y + legendOffset + tooltipOffsetY,
300
- } );
301
- }
302
- };
303
-
304
- const pathProps: SVGProps< SVGPathElement > & { 'data-testid'?: string } = {
305
- d: pie.path( arc ) || '',
306
- fill: accessors.fill( arc.data ),
307
- 'data-testid': 'pie-segment',
308
- };
309
-
310
- const groupProps: SVGProps< SVGGElement > = {};
311
- if ( withTooltips ) {
312
- groupProps.onMouseMove = handleMouseMove;
313
- groupProps.onMouseLeave = onMouseLeave;
314
- }
315
-
316
- // Estimate text width more accurately for background sizing
317
- const fontSize = 12;
318
- const estimatedTextWidth = getStringWidth( arc.data.label, { fontSize } );
319
- const labelPadding = 6;
320
- const backgroundWidth = estimatedTextWidth + labelPadding * 2;
321
- const backgroundHeight = fontSize + labelPadding * 2;
322
-
323
- return (
324
- <g key={ `arc-${ index }` } { ...groupProps }>
325
- <path { ...pathProps } />
326
- { showLabels && hasSpaceForLabel && (
327
- <g>
328
- { providerTheme.labelBackgroundColor && (
329
- <rect
330
- x={ centroidX - backgroundWidth / 2 }
331
- y={ centroidY - backgroundHeight / 2 }
332
- width={ backgroundWidth }
333
- height={ backgroundHeight }
334
- fill={ providerTheme.labelBackgroundColor }
335
- rx={ 4 }
336
- ry={ 4 }
351
+ // Estimate text width more accurately for background sizing
352
+ const fontSize = 12;
353
+ const estimatedTextWidth = getStringWidth( arc.data.label, { fontSize } );
354
+ const labelPadding = 6;
355
+ const backgroundWidth = estimatedTextWidth + labelPadding * 2;
356
+ const backgroundHeight = fontSize + labelPadding * 2;
357
+
358
+ return (
359
+ <g key={ `arc-${ index }` } { ...groupProps }>
360
+ <path { ...pathProps } />
361
+ { showLabels && hasSpaceForLabel && (
362
+ <g>
363
+ { providerTheme.labelBackgroundColor && (
364
+ <rect
365
+ x={ centroidX - backgroundWidth / 2 }
366
+ y={ centroidY - backgroundHeight / 2 }
367
+ width={ backgroundWidth }
368
+ height={ backgroundHeight }
369
+ fill={ providerTheme.labelBackgroundColor }
370
+ rx={ 4 }
371
+ ry={ 4 }
372
+ pointerEvents="none"
373
+ />
374
+ ) }
375
+ <text
376
+ x={ centroidX }
377
+ y={ centroidY }
378
+ dy=".33em"
379
+ fill={ providerTheme.labelTextColor || '#333' }
380
+ fontSize={ fontSize }
381
+ textAnchor="middle"
337
382
  pointerEvents="none"
338
- />
339
- ) }
340
- <text
341
- x={ centroidX }
342
- y={ centroidY }
343
- dy=".33em"
344
- fill={ providerTheme.labelTextColor || '#333' }
345
- fontSize={ fontSize }
346
- textAnchor="middle"
347
- pointerEvents="none"
348
- >
349
- { arc.data.label }
350
- </text>
351
- </g>
352
- ) }
353
- </g>
354
- );
355
- } );
356
- } }
357
- </Pie>
383
+ >
384
+ { arc.data.label }
385
+ </text>
386
+ </g>
387
+ ) }
388
+ </g>
389
+ );
390
+ } );
391
+ } }
392
+ </Pie>
393
+ ) }
358
394
 
359
395
  { /* Render SVG children (like Group, Text) inside the SVG */ }
360
- { svgChildren }
396
+ { ! allSegmentsHidden && svgChildren }
361
397
  </Group>
362
398
  </svg>
363
399
 
@@ -373,6 +409,7 @@ const PieChartInternal = ( {
373
409
  shape={ legendShape }
374
410
  ref={ legendRef }
375
411
  chartId={ chartId }
412
+ interactive={ legendInteractive }
376
413
  />
377
414
  ) }
378
415
 
@@ -334,4 +334,178 @@ describe( 'PieChart', () => {
334
334
  expect( screen.getByRole( 'tooltip' ) ).toHaveTextContent( 'Test: 42' );
335
335
  } );
336
336
  } );
337
+
338
+ describe( 'Interactive Legend', () => {
339
+ test( 'filters segments when interactive legend is enabled and segment is toggled', async () => {
340
+ const user = userEvent.setup();
341
+ const testData = [
342
+ { label: 'Segment A', value: 50, percentage: 50 },
343
+ { label: 'Segment B', value: 50, percentage: 50 },
344
+ ];
345
+
346
+ renderWithTheme( {
347
+ data: testData,
348
+ showLegend: true,
349
+ legendInteractive: true,
350
+ chartId: 'test-interactive-pie-chart',
351
+ } );
352
+
353
+ // Initially both segments should be visible
354
+ let segments = screen.getAllByTestId( 'pie-segment' );
355
+ expect( segments ).toHaveLength( 2 );
356
+
357
+ // Click first legend item to hide segment A
358
+ const legendItem = screen.getByRole( 'button', { name: /Segment A/i } );
359
+ await user.click( legendItem );
360
+
361
+ // Only one segment should remain
362
+ await waitFor( () => {
363
+ segments = screen.getAllByTestId( 'pie-segment' );
364
+ expect( segments ).toHaveLength( 1 );
365
+ } );
366
+
367
+ // Legend item should be marked as hidden
368
+ expect( legendItem ).toHaveAttribute( 'aria-pressed', 'false' );
369
+ } );
370
+
371
+ test( 'shows empty state when all segments are hidden', async () => {
372
+ const user = userEvent.setup();
373
+ const testData = [
374
+ { label: 'Segment A', value: 50, percentage: 50 },
375
+ { label: 'Segment B', value: 50, percentage: 50 },
376
+ ];
377
+
378
+ renderWithTheme( {
379
+ data: testData,
380
+ showLegend: true,
381
+ legendInteractive: true,
382
+ chartId: 'test-all-hidden-pie-chart',
383
+ } );
384
+
385
+ // Initially should have 2 segments
386
+ expect( screen.getAllByTestId( 'pie-segment' ) ).toHaveLength( 2 );
387
+
388
+ // Hide both segments by clicking legend items
389
+ const legendItems = screen.getAllByRole( 'button' );
390
+ await user.click( legendItems[ 0 ] );
391
+
392
+ // Wait for first segment to be hidden
393
+ await waitFor( () => {
394
+ expect( screen.getAllByTestId( 'pie-segment' ) ).toHaveLength( 1 );
395
+ } );
396
+
397
+ await user.click( legendItems[ 1 ] );
398
+
399
+ // Wait for all segments to be hidden
400
+ await waitFor( () => {
401
+ expect( screen.queryAllByTestId( 'pie-segment' ) ).toHaveLength( 0 );
402
+ } );
403
+
404
+ // Empty state should appear
405
+ expect( screen.getByText( /all segments are hidden/i ) ).toBeInTheDocument();
406
+
407
+ // Legend items should still be present (just marked inactive)
408
+ expect( screen.getAllByRole( 'button' ) ).toHaveLength( 2 );
409
+ } );
410
+
411
+ test( 'does not filter segments when legendInteractive is false', () => {
412
+ const testData = [
413
+ { label: 'Segment A', value: 50, percentage: 50 },
414
+ { label: 'Segment B', value: 50, percentage: 50 },
415
+ ];
416
+
417
+ renderWithTheme( {
418
+ data: testData,
419
+ showLegend: true,
420
+ legendInteractive: false,
421
+ chartId: 'test-non-interactive-pie-chart',
422
+ } );
423
+
424
+ // Legend items should not be buttons
425
+ const buttons = screen.queryAllByRole( 'button' );
426
+ expect( buttons ).toHaveLength( 0 );
427
+
428
+ // All segments should be visible
429
+ const segments = screen.getAllByTestId( 'pie-segment' );
430
+ expect( segments ).toHaveLength( 2 );
431
+ } );
432
+
433
+ test( 'maintains consistent colors when segments are hidden', async () => {
434
+ const user = userEvent.setup();
435
+ const testData = [
436
+ { label: 'Segment A', value: 30, percentage: 30 },
437
+ { label: 'Segment B', value: 40, percentage: 40 },
438
+ { label: 'Segment C', value: 30, percentage: 30 },
439
+ ];
440
+
441
+ renderWithTheme( {
442
+ data: testData,
443
+ showLegend: true,
444
+ legendInteractive: true,
445
+ chartId: 'test-color-consistency-pie-chart',
446
+ } );
447
+
448
+ // Get initial segment colors
449
+ const initialSegments = screen.getAllByTestId( 'pie-segment' );
450
+ const segmentBColor = initialSegments[ 1 ].getAttribute( 'fill' );
451
+
452
+ // Hide Segment A
453
+ const legendItemA = screen.getByRole( 'button', { name: /Segment A/i } );
454
+ await user.click( legendItemA );
455
+
456
+ // Segment B should maintain its color (now it's the first visible segment)
457
+ await waitFor( () => {
458
+ expect( screen.getAllByTestId( 'pie-segment' ) ).toHaveLength( 2 );
459
+ } );
460
+
461
+ const remainingSegments = screen.getAllByTestId( 'pie-segment' );
462
+ expect( remainingSegments[ 0 ] ).toHaveAttribute( 'fill', segmentBColor );
463
+ } );
464
+
465
+ test( 'recalculates legend percentages when segments are hidden', async () => {
466
+ const user = userEvent.setup();
467
+ const testData = [
468
+ { label: 'Segment A', value: 25, percentage: 25 },
469
+ { label: 'Segment B', value: 50, percentage: 50 },
470
+ { label: 'Segment C', value: 25, percentage: 25 },
471
+ ];
472
+
473
+ renderWithTheme( {
474
+ data: testData,
475
+ showLegend: true,
476
+ legendInteractive: true,
477
+ legendValueDisplay: 'percentage',
478
+ chartId: 'test-percentage-recalc-pie-chart',
479
+ } );
480
+
481
+ // Initially, legend should show original percentages
482
+ const legendItems = screen.getAllByTestId( 'legend-item' );
483
+ expect( legendItems ).toHaveLength( 3 );
484
+ expect( screen.getAllByText( '25%' ) ).toHaveLength( 2 ); // A and C both 25%
485
+ expect( screen.getByText( '50%' ) ).toBeInTheDocument();
486
+
487
+ // Hide Segment A (25%)
488
+ const legendItemA = screen.getByRole( 'button', { name: /Segment A/i } );
489
+ await user.click( legendItemA );
490
+
491
+ // Now B and C should recalculate: B = 50/75 = 66.67%, C = 25/75 = 33.33%
492
+ await waitFor( () => {
493
+ expect( screen.getByText( /66\.6/ ) ).toBeInTheDocument();
494
+ } );
495
+
496
+ // All 3 legend items should remain (hidden items stay in legend)
497
+ const remainingItems = screen.getAllByTestId( 'legend-item' );
498
+ expect( remainingItems ).toHaveLength( 3 );
499
+
500
+ // Segment A should still show original 25% (hidden items don't recalculate)
501
+ expect( legendItemA ).toHaveAttribute( 'aria-pressed', 'false' );
502
+ expect( screen.getAllByText( '25%' ) ).toHaveLength( 1 ); // Only A shows 25%
503
+
504
+ // Segment B should now show ~67% (50 out of remaining 75)
505
+ expect( screen.getByText( /66\.6/ ) ).toBeInTheDocument();
506
+
507
+ // Segment C should now show ~33% (25 out of remaining 75)
508
+ expect( screen.getByText( /33\.3/ ) ).toBeInTheDocument();
509
+ } );
510
+ } );
337
511
  } );
@@ -3,9 +3,10 @@ import { Group } from '@visx/group';
3
3
  import { Pie } from '@visx/shape';
4
4
  import { Text } from '@visx/text';
5
5
  import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
6
+ import { __ } from '@wordpress/i18n';
6
7
  import clsx from 'clsx';
7
8
  import { useCallback, useContext, useMemo } from 'react';
8
- import { useElementHeight } from '../../hooks';
9
+ import { useElementHeight, useInteractiveLegendData } from '../../hooks';
9
10
  import {
10
11
  GlobalChartsProvider,
11
12
  useChartId,
@@ -70,6 +71,13 @@ export interface PieSemiCircleChartProps extends BaseChartProps< DataPointPercen
70
71
  */
71
72
  legendValueDisplay?: LegendValueDisplay;
72
73
 
74
+ /**
75
+ * Enable interactive legend items that can toggle segment visibility.
76
+ * Requires chartId and GlobalChartsProvider.
77
+ * When segments are hidden, percentages are recalculated so visible segments total 100%.
78
+ */
79
+ legendInteractive?: boolean;
80
+
73
81
  /**
74
82
  * Horizontal offset for tooltip positioning in pixels (default: 0)
75
83
  */
@@ -133,6 +141,7 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
133
141
  legendItemClassName,
134
142
  legendShape = 'circle',
135
143
  legendValueDisplay = 'percentage',
144
+ legendInteractive = false,
136
145
  label,
137
146
  note,
138
147
  className,
@@ -183,7 +192,15 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
183
192
  // Validate data first to get validation result
184
193
  const { isValid, message } = validateData( data );
185
194
 
186
- const { getElementStyles } = useGlobalChartsContext();
195
+ const { getElementStyles, isSeriesVisible } = useGlobalChartsContext();
196
+
197
+ // Filter and recalculate data for interactive legends
198
+ const { visibleData, allSegmentsHidden, legendData } = useInteractiveLegendData( {
199
+ data,
200
+ chartId,
201
+ legendInteractive,
202
+ isSeriesVisible,
203
+ } );
187
204
 
188
205
  // Define accessors with useMemo to avoid changing dependencies
189
206
  const accessors = useMemo(
@@ -205,8 +222,8 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
205
222
  [ legendValueDisplay ]
206
223
  );
207
224
 
208
- // Create legend items using the reusable hook
209
- const legendItems = useChartLegendItems( data, legendOptions );
225
+ // Create legend items using legendData (has recalculated percentages for visible items)
226
+ const legendItems = useChartLegendItems( legendData, legendOptions );
210
227
 
211
228
  // Process children to extract compound components
212
229
  const { svgChildren, htmlChildren, otherChildren } = useChartChildren(
@@ -253,10 +270,14 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
253
270
  const innerRadius = radius * ( 1 - thickness );
254
271
 
255
272
  // Map data with index for color assignment
256
- const dataWithIndex = data.map( ( d, index ) => ( {
257
- ...d,
258
- index,
259
- } ) );
273
+ // When interactive, we need to find the original index to maintain consistent colors
274
+ const dataWithIndex = visibleData.map( d => {
275
+ const originalIndex = data.findIndex( item => item.label === d.label );
276
+ return {
277
+ ...d,
278
+ index: originalIndex >= 0 ? originalIndex : 0,
279
+ };
280
+ } );
260
281
 
261
282
  // Configure pie angles based on clockwise direction
262
283
  const startAngle = clockwise ? -Math.PI / 2 : Math.PI / 2;
@@ -287,57 +308,74 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
287
308
  >
288
309
  { /* Main chart group centered horizontally and positioned at bottom */ }
289
310
  <Group top={ chartHeight } left={ width / 2 }>
290
- { /* Pie chart */ }
291
- <Pie< DataPointPercentage & { index: number } >
292
- data={ dataWithIndex }
293
- pieValue={ accessors.value }
294
- outerRadius={ radius }
295
- innerRadius={ innerRadius }
296
- cornerRadius={ 3 }
297
- padAngle={ PAD_ANGLE }
298
- startAngle={ startAngle }
299
- endAngle={ endAngle }
300
- pieSort={ accessors.sort }
301
- >
302
- { pie => {
303
- return pie.arcs.map( arc => (
304
- <g
305
- key={ arc.data.label }
306
- onMouseMove={ withTooltips ? handleArcMouseMove( arc ) : undefined }
307
- onMouseLeave={ withTooltips ? handleMouseLeave : undefined }
308
- >
309
- <path
310
- d={ pie.path( arc ) || '' }
311
- fill={ accessors.fill( arc.data ) }
312
- data-testid="pie-segment"
313
- />
314
- </g>
315
- ) );
316
- } }
317
- </Pie>
318
-
319
- { /* Label and note text */ }
320
- <Group>
321
- <Text
322
- textAnchor="middle"
323
- verticalAnchor="start"
324
- y={ -40 } // Position above the chart with space for note
325
- className={ styles.label }
326
- >
327
- { label }
328
- </Text>
329
- <Text
311
+ { allSegmentsHidden ? (
312
+ <text
330
313
  textAnchor="middle"
331
- verticalAnchor="start"
332
- y={ -20 } // Position between label and chart
333
- className={ styles.note }
314
+ y={ -radius / 2 }
315
+ fill="#ccc"
316
+ fontSize="14"
317
+ fontFamily="-apple-system,BlinkMacSystemFont,Roboto,Helvetica Neue,sans-serif"
334
318
  >
335
- { note }
336
- </Text>
337
- </Group>
338
-
339
- { /* Render SVG children from composition API */ }
340
- { svgChildren }
319
+ { __(
320
+ 'All segments are hidden. Click legend items to show data.',
321
+ 'jetpack-charts'
322
+ ) }
323
+ </text>
324
+ ) : (
325
+ <>
326
+ { /* Pie chart */ }
327
+ <Pie< DataPointPercentage & { index: number } >
328
+ data={ dataWithIndex }
329
+ pieValue={ accessors.value }
330
+ outerRadius={ radius }
331
+ innerRadius={ innerRadius }
332
+ cornerRadius={ 3 }
333
+ padAngle={ PAD_ANGLE }
334
+ startAngle={ startAngle }
335
+ endAngle={ endAngle }
336
+ pieSort={ accessors.sort }
337
+ >
338
+ { pie => {
339
+ return pie.arcs.map( arc => (
340
+ <g
341
+ key={ arc.data.label }
342
+ onMouseMove={ withTooltips ? handleArcMouseMove( arc ) : undefined }
343
+ onMouseLeave={ withTooltips ? handleMouseLeave : undefined }
344
+ >
345
+ <path
346
+ d={ pie.path( arc ) || '' }
347
+ fill={ accessors.fill( arc.data ) }
348
+ data-testid="pie-segment"
349
+ />
350
+ </g>
351
+ ) );
352
+ } }
353
+ </Pie>
354
+
355
+ { /* Label and note text */ }
356
+ <Group>
357
+ <Text
358
+ textAnchor="middle"
359
+ verticalAnchor="start"
360
+ y={ -40 } // Position above the chart with space for note
361
+ className={ styles.label }
362
+ >
363
+ { label }
364
+ </Text>
365
+ <Text
366
+ textAnchor="middle"
367
+ verticalAnchor="start"
368
+ y={ -20 } // Position between label and chart
369
+ className={ styles.note }
370
+ >
371
+ { note }
372
+ </Text>
373
+ </Group>
374
+
375
+ { /* Render SVG children from composition API */ }
376
+ { ! allSegmentsHidden && svgChildren }
377
+ </>
378
+ ) }
341
379
  </Group>
342
380
  </svg>
343
381
 
@@ -360,6 +398,7 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
360
398
  shape={ legendShape }
361
399
  ref={ legendRef }
362
400
  chartId={ chartId }
401
+ interactive={ legendInteractive }
363
402
  />
364
403
  ) }
365
404