diff --git a/projects/js-packages/charts/changelog/charts-111-charts-add-hook-to-get-the-styles-for-any-chart-component b/projects/js-packages/charts/changelog/charts-111-charts-add-hook-to-get-the-styles-for-any-chart-component new file mode 100644 index 0000000000000..b1f6d850e80be --- /dev/null +++ b/projects/js-packages/charts/changelog/charts-111-charts-add-hook-to-get-the-styles-for-any-chart-component @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Charts: Add get element styles utility to global context diff --git a/projects/js-packages/charts/src/components/bar-chart/bar-chart.tsx b/projects/js-packages/charts/src/components/bar-chart/bar-chart.tsx index f042875744e3a..282e2bdc14989 100644 --- a/projects/js-packages/charts/src/components/bar-chart/bar-chart.tsx +++ b/projects/js-packages/charts/src/components/bar-chart/bar-chart.tsx @@ -126,24 +126,14 @@ const BarChartInternal: FC< BarChartProps > = ( { totalPoints, } ); - const { resolveGroupColor } = useGlobalChartsContext(); - - const getColor = useCallback( - ( seriesData: SeriesData, index: number ) => - resolveGroupColor( { - group: seriesData.group, - index, - overrideColor: seriesData.options?.stroke, - } ), - [ resolveGroupColor ] - ); + const { getElementStyles } = useGlobalChartsContext(); const getBarBackground = useCallback( ( index: number ) => () => withPatterns ? `url(#${ getPatternId( chartId, index ) })` - : getColor( dataSorted[ index ], index ), - [ withPatterns, getColor, dataSorted, chartId ] + : getElementStyles( { data: dataSorted[ index ], index } ).color, + [ withPatterns, getElementStyles, dataSorted, chartId ] ); const renderDefaultTooltip = useCallback( @@ -341,12 +331,15 @@ const BarChartInternal: FC< BarChartProps > = ( { <> { dataSorted.map( ( seriesData, index ) => - renderPattern( index, getColor( seriesData, index ) ) + renderPattern( index, getElementStyles( { data: seriesData, index } ).color ) ) } diff --git a/projects/js-packages/charts/src/components/leaderboard-chart/hooks/use-leaderboard-legend-items.ts b/projects/js-packages/charts/src/components/leaderboard-chart/hooks/use-leaderboard-legend-items.ts index 6f5a7fb74cae2..9a876b3cd6cd8 100644 --- a/projects/js-packages/charts/src/components/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +++ b/projects/js-packages/charts/src/components/leaderboard-chart/hooks/use-leaderboard-legend-items.ts @@ -36,7 +36,7 @@ export function useLeaderboardLegendItems( { }; } ): BaseLegendItem[] { const { leaderboardChart: leaderboardChartSettings } = useGlobalChartsTheme(); - const { resolveGroupColor } = useGlobalChartsContext(); + const { getElementStyles } = useGlobalChartsContext(); return useMemo( () => { if ( ! data || data.length === 0 ) { @@ -46,7 +46,7 @@ export function useLeaderboardLegendItems( { const items: BaseLegendItem[] = []; // Add current period legend item - const resolvedPrimaryColor = resolveGroupColor( { + const { color: resolvedPrimaryColor } = getElementStyles( { index: 0, overrideColor: primaryColor || leaderboardChartSettings.primaryColor, } ); @@ -55,13 +55,11 @@ export function useLeaderboardLegendItems( { label: legendLabels?.primary || __( 'Current period', 'jetpack-charts' ), value: '', color: resolvedPrimaryColor, - index: 0, - overrideColor: primaryColor, } ); // Add comparison period legend item if comparison is enabled and overlay label is not enabled if ( withComparison && ! withOverlayLabel ) { - const resolvedSecondaryColor = resolveGroupColor( { + const { color: resolvedSecondaryColor } = getElementStyles( { index: 1, overrideColor: secondaryColor || leaderboardChartSettings.secondaryColor, } ); @@ -70,8 +68,6 @@ export function useLeaderboardLegendItems( { label: legendLabels?.comparison || __( 'Previous period', 'jetpack-charts' ), value: '', color: resolvedSecondaryColor, - index: 1, - overrideColor: secondaryColor, } ); } @@ -83,7 +79,7 @@ export function useLeaderboardLegendItems( { withComparison, legendLabels, leaderboardChartSettings, - resolveGroupColor, + getElementStyles, withOverlayLabel, ] ); } diff --git a/projects/js-packages/charts/src/components/leaderboard-chart/leaderboard-chart.tsx b/projects/js-packages/charts/src/components/leaderboard-chart/leaderboard-chart.tsx index 75da420cdd9b9..831a31bfe678b 100644 --- a/projects/js-packages/charts/src/components/leaderboard-chart/leaderboard-chart.tsx +++ b/projects/js-packages/charts/src/components/leaderboard-chart/leaderboard-chart.tsx @@ -158,12 +158,12 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( { secondaryColor: settingsSecondaryColor, deltaColors, } = leaderboardChartSettings; - const { resolveGroupColor } = useGlobalChartsContext(); - const resolvedPrimaryColor = resolveGroupColor( { + const { getElementStyles } = useGlobalChartsContext(); + const { color: resolvedPrimaryColor } = getElementStyles( { index: 0, overrideColor: primaryColor || settingsPrimaryColor, } ); - const resolvedSecondaryColor = resolveGroupColor( { + const { color: resolvedSecondaryColor } = getElementStyles( { index: 1, overrideColor: secondaryColor || settingsSecondaryColor, } ); diff --git a/projects/js-packages/charts/src/components/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx b/projects/js-packages/charts/src/components/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx index ea494f768824a..f8bf6471a7ac4 100644 --- a/projects/js-packages/charts/src/components/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +++ b/projects/js-packages/charts/src/components/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx @@ -82,8 +82,6 @@ describe( 'useLeaderboardLegendItems', () => { label: 'Current period', value: '', color: expect.any( String ), - index: 0, - overrideColor: undefined, } ); } ); @@ -106,8 +104,6 @@ describe( 'useLeaderboardLegendItems', () => { label: 'Current period', value: '', color: expect.any( String ), - index: 0, - overrideColor: undefined, } ); // Previous period item @@ -115,8 +111,6 @@ describe( 'useLeaderboardLegendItems', () => { label: 'Previous period', value: '', color: expect.any( String ), - index: 1, - overrideColor: undefined, } ); } ); } ); @@ -143,8 +137,6 @@ describe( 'useLeaderboardLegendItems', () => { // Note: The actual color will be resolved by the context, but we can check structure expect( result.current[ 0 ].color ).toBeTruthy(); expect( result.current[ 1 ].color ).toBeTruthy(); - expect( result.current[ 0 ].overrideColor ).toBeUndefined(); - expect( result.current[ 1 ].overrideColor ).toBeUndefined(); } ); it( 'should use custom primary color override', () => { @@ -161,7 +153,7 @@ describe( 'useLeaderboardLegendItems', () => { { wrapper } ); - expect( result.current[ 0 ].overrideColor ).toBe( customPrimary ); + expect( result.current[ 0 ].color ).toBe( customPrimary ); } ); it( 'should use custom secondary color override', () => { @@ -180,8 +172,8 @@ describe( 'useLeaderboardLegendItems', () => { { wrapper } ); - expect( result.current[ 0 ].overrideColor ).toBe( customPrimary ); - expect( result.current[ 1 ].overrideColor ).toBe( customSecondary ); + expect( result.current[ 0 ].color ).toBe( customPrimary ); + expect( result.current[ 1 ].color ).toBe( customSecondary ); } ); it( 'should use both custom colors with comparison', () => { @@ -201,8 +193,8 @@ describe( 'useLeaderboardLegendItems', () => { ); expect( result.current ).toHaveLength( 2 ); - expect( result.current[ 0 ].overrideColor ).toBe( customPrimary ); - expect( result.current[ 1 ].overrideColor ).toBe( customSecondary ); + expect( result.current[ 0 ].color ).toBe( customPrimary ); + expect( result.current[ 1 ].color ).toBe( customSecondary ); } ); } ); @@ -576,7 +568,7 @@ describe( 'useLeaderboardLegendItems', () => { } ); describe( 'Index and structure validation', () => { - it( 'should have correct indices for items', () => { + it( 'should have correct number of items with comparison', () => { const wrapper = createWrapper(); const { result } = renderHook( () => @@ -588,8 +580,9 @@ describe( 'useLeaderboardLegendItems', () => { { wrapper } ); - expect( result.current[ 0 ].index ).toBe( 0 ); - expect( result.current[ 1 ].index ).toBe( 1 ); + expect( result.current ).toHaveLength( 2 ); + expect( result.current[ 0 ].label ).toBe( 'Current period' ); + expect( result.current[ 1 ].label ).toBe( 'Previous period' ); } ); it( 'should have empty value strings for all items', () => { diff --git a/projects/js-packages/charts/src/components/legend/hooks/use-chart-legend-items.ts b/projects/js-packages/charts/src/components/legend/hooks/use-chart-legend-items.ts index dc950e1b08a92..22d1f85554c05 100644 --- a/projects/js-packages/charts/src/components/legend/hooks/use-chart-legend-items.ts +++ b/projects/js-packages/charts/src/components/legend/hooks/use-chart-legend-items.ts @@ -1,7 +1,11 @@ import { useMemo } from 'react'; -import { useGlobalChartsTheme } from '../../../providers'; -import { getItemShapeStyles, getSeriesStroke, formatPercentage } from '../../../utils'; -import type { ChartTheme, SeriesData, DataPointDate, DataPointPercentage } from '../../../types'; +import { + useGlobalChartsContext, + type GetElementStylesParams, + type ElementStyles, +} from '../../../providers'; +import { formatPercentage } from '../../../utils'; +import type { SeriesData, DataPointDate, DataPointPercentage } from '../../../types'; import type { BaseLegendItem } from '../types'; import type { LegendShape } from '@visx/legend/lib/types'; import type { GlyphProps } from '@visx/xychart'; @@ -15,6 +19,7 @@ export interface ChartLegendOptions { renderGlyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode; showValues?: boolean; legendValueDisplay?: LegendValueDisplay; + legendShape?: LegendShape< SeriesData[], number >; } /** @@ -57,19 +62,49 @@ function formatPointValue( } /** - * Processes SeriesData into legend items - * @param seriesData - The series data to process - * @param theme - The chart theme for colors - * @param showValues - Whether to show values in legend + * Applies glyph configuration to a legend item if needed + * @param baseItem - The base legend item * @param withGlyph - Whether to include glyph rendering + * @param glyph - Glyph component from theme + * @param renderGlyph - Custom glyph render function * @param glyphSize - Size of the glyph - * @param renderGlyph - Component to render the glyph - * @param legendShape - The shape to use for the legend + * @return The legend item with glyph configuration applied if applicable + */ +function applyGlyphToLegendItem( + baseItem: BaseLegendItem, + withGlyph: boolean, + glyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode, + renderGlyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode, + glyphSize?: number +): BaseLegendItem { + if ( withGlyph ) { + const glyphToUse = glyph || renderGlyph; + if ( glyphToUse ) { + return { + ...baseItem, + glyphSize, + renderGlyph: glyphToUse, + }; + } + } + + return baseItem; +} + +/** + * Processes SeriesData into legend items + * @param seriesData - The series data to process + * @param getElementStyles - Function to get element styles + * @param showValues - Whether to show values in legend + * @param withGlyph - Whether to include glyph rendering + * @param glyphSize - Size of the glyph + * @param renderGlyph - Component to render the glyph + * @param legendShape - The shape type for legend items (string literal or React component) * @return Array of processed legend items */ function processSeriesData( seriesData: SeriesData[], - theme: ChartTheme, + getElementStyles: ( params: GetElementStylesParams ) => ElementStyles, showValues: boolean, withGlyph: boolean, glyphSize: number, @@ -77,26 +112,20 @@ function processSeriesData( legendShape?: LegendShape< SeriesData[], number > ): BaseLegendItem[] { const mapper = ( series: SeriesData, index: number ) => { - const { shapeStyles } = getItemShapeStyles( series, index, theme, legendShape ); - const baseItem = { + const { color, glyph, shapeStyles } = getElementStyles( { + data: series, + index, + legendShape, + } ); + + const baseItem: BaseLegendItem = { label: series.label, value: showValues ? series.data?.length?.toString() || '0' : '', - color: getSeriesStroke( series, index, theme.colors ), + color, shapeStyle: shapeStyles, - group: series.group, - index, - overrideColor: series.options?.stroke, }; - if ( withGlyph && renderGlyph ) { - return { - ...baseItem, - glyphSize, - renderGlyph, - }; - } - - return baseItem; + return applyGlyphToLegendItem( baseItem, withGlyph, glyph, renderGlyph, glyphSize ); }; return seriesData.map( mapper ); @@ -105,44 +134,40 @@ function processSeriesData( /** * Processes point data into legend items * @param pointData - The point data to process - * @param theme - The chart theme for colors + * @param getElementStyles - Function to get element styles * @param showValues - Whether to show values in legend * @param legendValueDisplay - What type of value to display * @param withGlyph - Whether to include glyph rendering * @param glyphSize - Size of the glyph * @param renderGlyph - Component to render the glyph + * @param legendShape - The shape type for legend items (string literal or React component) * @return Array of processed legend items */ function processPointData( pointData: ( DataPointDate | DataPointPercentage )[], - theme: ChartTheme, + getElementStyles: ( params: GetElementStylesParams ) => ElementStyles, showValues: boolean, legendValueDisplay: LegendValueDisplay, withGlyph: boolean, glyphSize: number, - renderGlyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode + renderGlyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode, + legendShape?: LegendShape< SeriesData[], number > ): BaseLegendItem[] { const mapper = ( point: DataPointDate | DataPointPercentage, index: number ) => { - const baseItem = { + const { color, glyph, shapeStyles } = getElementStyles( { + data: point as DataPointPercentage, + index, + legendShape, + } ); + + const baseItem: BaseLegendItem = { label: point.label, value: formatPointValue( point, showValues, legendValueDisplay ), - color: ( point as DataPointPercentage ).color ?? theme.colors[ index % theme.colors.length ], - group: ( point as DataPointPercentage ).group, - index, - overrideColor: ( point as DataPointPercentage ).color, + color, + shapeStyle: shapeStyles, }; - if ( withGlyph && renderGlyph ) { - const itemWithGlyph = { - ...baseItem, - glyphSize, - renderGlyph, - }; - - return itemWithGlyph; - } - - return baseItem; + return applyGlyphToLegendItem( baseItem, withGlyph, glyph, renderGlyph, glyphSize ); }; return pointData.map( mapper ); @@ -169,7 +194,7 @@ export function useChartLegendItems< glyphSize = 8, renderGlyph, } = options; - const theme = useGlobalChartsTheme(); + const { getElementStyles } = useGlobalChartsContext(); return useMemo( () => { if ( ! data || ! Array.isArray( data ) || data.length === 0 ) { @@ -180,7 +205,7 @@ export function useChartLegendItems< if ( 'data' in data[ 0 ] ) { return processSeriesData( data as SeriesData[], - theme, + getElementStyles, showValues, withGlyph, glyphSize, @@ -192,16 +217,17 @@ export function useChartLegendItems< // Handle DataPointDate or DataPointPercentage (single data points) return processPointData( data as ( DataPointDate | DataPointPercentage )[], - theme, + getElementStyles, showValues, legendValueDisplay, withGlyph, glyphSize, - renderGlyph + renderGlyph, + legendShape ); }, [ data, - theme, + getElementStyles, showValues, legendValueDisplay, withGlyph, diff --git a/projects/js-packages/charts/src/components/legend/private/base-legend.tsx b/projects/js-packages/charts/src/components/legend/private/base-legend.tsx index b9c8aceffb73a..97d6055585676 100644 --- a/projects/js-packages/charts/src/components/legend/private/base-legend.tsx +++ b/projects/js-packages/charts/src/components/legend/private/base-legend.tsx @@ -2,16 +2,9 @@ import { Group } from '@visx/group'; import { LegendItem, LegendLabel, LegendOrdinal, LegendShape } from '@visx/legend'; import { scaleOrdinal } from '@visx/scale'; import clsx from 'clsx'; -import { - type RefAttributes, - type ForwardRefExoticComponent, - forwardRef, - useCallback, - useMemo, - useContext, -} from 'react'; +import { type RefAttributes, type ForwardRefExoticComponent, forwardRef, useCallback } from 'react'; import { useTextTruncation } from '../../../hooks'; -import { useGlobalChartsTheme, GlobalChartsContext } from '../../../providers'; +import { useGlobalChartsTheme } from '../../../providers'; import { valueOrIdentity, valueOrIdentityString, labelTransformFactory } from '../utils'; import styles from './base-legend.module.scss'; import type { BaseLegendProps } from '../types'; @@ -90,37 +83,16 @@ export const BaseLegend: ForwardRefExoticComponent< ref ) => { const theme = useGlobalChartsTheme(); - const context = useContext( GlobalChartsContext ); - const resolveGroupColor = context?.resolveGroupColor; - - // Resolve colors dynamically for items that have group info - const itemsWithResolvedColors = useMemo( () => { - return items.map( item => { - // If item has group info and we have a context, resolve color dynamically - if ( item.group !== undefined && item.index !== undefined && resolveGroupColor ) { - const resolvedColor = resolveGroupColor( { - group: item.group, - index: item.index, - overrideColor: item.overrideColor, - } ); - return { ...item, color: resolvedColor }; - } - // Otherwise use the static color - return item; - } ); - }, [ items, resolveGroupColor ] ); const legendScale = scaleOrdinal( { - domain: itemsWithResolvedColors.map( item => item.label ), - range: itemsWithResolvedColors.map( item => item.color ), + domain: items.map( item => item.label ), + range: items.map( item => item.color ), } ); const domain = legendScale.domain(); - // For right-aligned vertical legends, use row-reverse to align text consistently - const getShapeStyle = useCallback( - ( { index }: { index: number } ) => itemsWithResolvedColors[ index ]?.shapeStyle, - [ itemsWithResolvedColors ] + ( { index }: { index: number } ) => items[ index ]?.shapeStyle, + [ items ] ); return ( diff --git a/projects/js-packages/charts/src/components/legend/stories/index.stories.tsx b/projects/js-packages/charts/src/components/legend/stories/index.stories.tsx index d69864d2b050d..0d767d5e2e1c3 100644 --- a/projects/js-packages/charts/src/components/legend/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/components/legend/stories/index.stories.tsx @@ -177,7 +177,7 @@ const WithLineChartData = () => { withGradientFill={ false } withLegendGlyph={ false } /> - + ); }; @@ -231,7 +231,7 @@ const StandaloneLegendWithChartIdComponent = () => { withLegendGlyph={ false } /> { /* Standalone legend that automatically gets data from chart context */ } - + ); }; @@ -365,7 +365,7 @@ const DashboardWithCentralizedLegend = () => { > Revenue Trends - +
@@ -391,7 +391,7 @@ const DashboardWithCentralizedLegend = () => { > Device Distribution - +
@@ -551,7 +551,7 @@ This interactive story demonstrates all the text overflow and wrapping features - **Text Overflow Modes**: - **Wrap** (default): Text wraps naturally to multiple lines when it exceeds maxWidth - **Ellipsis**: Truncates text with ellipsis (...) and shows tooltip on hover - + - **Orientation**: Switch between horizontal and vertical layouts - **Max Width**: Adjust the maximum width constraint with the slider (50-300px) - **Position & Alignment**: Control legend placement diff --git a/projects/js-packages/charts/src/components/legend/types.ts b/projects/js-packages/charts/src/components/legend/types.ts index 603ccef4f8c05..651a55a0d0984 100644 --- a/projects/js-packages/charts/src/components/legend/types.ts +++ b/projects/js-packages/charts/src/components/legend/types.ts @@ -38,8 +38,4 @@ export type BaseLegendItem = { glyphSize?: number; renderGlyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode; shapeStyle?: CSSProperties & LineStyles; - // Optional group info for dynamic color resolution - group?: string; - index?: number; - overrideColor?: string; }; diff --git a/projects/js-packages/charts/src/components/line-chart/line-chart.tsx b/projects/js-packages/charts/src/components/line-chart/line-chart.tsx index 289c167cf8b8a..ef97b87d3ba12 100644 --- a/projects/js-packages/charts/src/components/line-chart/line-chart.tsx +++ b/projects/js-packages/charts/src/components/line-chart/line-chart.tsx @@ -19,7 +19,7 @@ import { useGlobalChartsContext, useGlobalChartsTheme, } from '../../providers'; -import { attachSubComponents, getSeriesLineStyles } from '../../utils'; +import { attachSubComponents } from '../../utils'; import { Legend, useChartLegendItems } from '../legend'; import { DefaultGlyph } from '../private/default-glyph'; import { SingleChartContext, type SingleChartRef } from '../private/single-chart-context'; @@ -252,7 +252,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >( ); const dataSorted = useChartDataTransform( data ); - const { resolveGroupColor } = useGlobalChartsContext(); + const { getElementStyles } = useGlobalChartsContext(); // Use the keyboard navigation hook const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( { @@ -301,23 +301,20 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >( series.label === props.key || series.data.includes( props.datum as DataPointDate ) ); - // Resolve group color for tooltip glyph const seriesData = dataSorted[ seriesIndex ]; - const resolvedColor = seriesData - ? resolveGroupColor( { - group: seriesData.group, - index: seriesIndex, - overrideColor: seriesData.options?.stroke, - } ) - : props.color; - - const propsWithResolvedColor = { ...props, color: resolvedColor }; - const themeGlyph = providerTheme.glyphs?.[ seriesIndex ]; + + const { color, glyph: themeGlyph } = getElementStyles( { + data: seriesData, + index: seriesIndex, + } ); + + const propsWithResolvedColor = { ...props, color }; + return themeGlyph ? themeGlyph( propsWithResolvedColor ) : renderGlyph( propsWithResolvedColor ); }; - }, [ dataSorted, providerTheme.glyphs, renderGlyph, resolveGroupColor ] ); + }, [ dataSorted, renderGlyph, getElementStyles ] ); const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme ); @@ -422,15 +419,13 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >( { dataSorted.map( ( seriesData, index ) => { - const stroke = resolveGroupColor( { - group: seriesData.group, + const { color, lineStyles, glyph } = getElementStyles( { + data: seriesData, index, - overrideColor: seriesData.options?.stroke, } ); - const lineStyles = getSeriesLineStyles( seriesData, index, providerTheme ); const lineProps = { - stroke, + stroke: color, ...lineStyles, }; @@ -440,8 +435,8 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >( @@ -450,7 +445,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >( { withGradientFill && ( d.value, - // Use the color property from the data object as a last resort. The theme provides colours by default. - fill: ( { group, index, color: overrideColor }: DataPointPercentage & { index: number } ) => - resolveGroupColor( { group, index, overrideColor } ), + fill: ( d: DataPointPercentage & { index: number } ) => { + return getElementStyles( { data: d, index: d.index } ).color; + }, }; return ( diff --git a/projects/js-packages/charts/src/components/pie-semi-circle-chart/pie-semi-circle-chart.tsx b/projects/js-packages/charts/src/components/pie-semi-circle-chart/pie-semi-circle-chart.tsx index cae6c9e88e4cb..423e20b738660 100644 --- a/projects/js-packages/charts/src/components/pie-semi-circle-chart/pie-semi-circle-chart.tsx +++ b/projects/js-packages/charts/src/components/pie-semi-circle-chart/pie-semi-circle-chart.tsx @@ -160,7 +160,7 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( { // Validate data first to get validation result const { isValid, message } = validateData( data ); - const { resolveGroupColor } = useGlobalChartsContext(); + const { getElementStyles } = useGlobalChartsContext(); // Define accessors with useMemo to avoid changing dependencies const accessors = useMemo( @@ -170,10 +170,10 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( { a: DataPointPercentage & { index: number }, b: DataPointPercentage & { index: number } ) => b.value - a.value, - fill: ( { group, index, color: overrideColor }: DataPointPercentage & { index: number } ) => - resolveGroupColor( { group, index, overrideColor } ), + fill: ( d: DataPointPercentage & { index: number } ) => + getElementStyles( { data: d, index: d.index } ).color, } ), - [ resolveGroupColor ] + [ getElementStyles ] ); // Memoize legend options to prevent unnecessary re-calculations diff --git a/projects/js-packages/charts/src/providers/chart-context/global-charts-provider.tsx b/projects/js-packages/charts/src/providers/chart-context/global-charts-provider.tsx index 49ddf88eea930..7bfe90def26a8 100644 --- a/projects/js-packages/charts/src/providers/chart-context/global-charts-provider.tsx +++ b/projects/js-packages/charts/src/providers/chart-context/global-charts-provider.tsx @@ -1,5 +1,5 @@ -import { createContext, useCallback, useMemo, useState, useEffect, useRef } from 'react'; -import { mergeThemes } from '../../utils'; +import { createContext, useCallback, useMemo, useState, useEffect } from 'react'; +import { getItemShapeStyles, getSeriesLineStyles, mergeThemes } from '../../utils'; import { defaultTheme } from './themes'; import type { GlobalChartsContextValue, ChartRegistration } from './types'; import type { ChartTheme, CompleteChartTheme } from '../../types'; @@ -9,27 +9,24 @@ export const GlobalChartsContext = createContext< GlobalChartsContextValue | nul export interface GlobalChartsProviderProps { children: ReactNode; - /** Optional theme override. Considered static for provider lifecycle. */ theme?: Partial< ChartTheme >; } -export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { - children, - theme = {}, -} ) => { +export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { children, theme } ) => { const [ charts, setCharts ] = useState< Map< string, ChartRegistration > >( () => new Map() ); - const providerTheme: CompleteChartTheme = useMemo( - () => mergeThemes( defaultTheme, theme ), - [ theme ] - ); + const providerTheme: CompleteChartTheme = useMemo( () => { + return theme ? mergeThemes( defaultTheme, theme ) : defaultTheme; + }, [ theme ] ); - // Stable group -> color mapping for this provider lifecycle - const groupToColorMapRef = useRef< Map< string, string > >( new Map() ); + const [ groupToColorMap, setGroupToColorMap ] = useState< Map< string, string > >( + () => new Map() + ); - // Reset group color mappings when theme changes + // Reset group color mappings when theme colors change useEffect( () => { - groupToColorMapRef.current = new Map(); + // Create a completely new Map instance to trigger dependencies, e.g. useChartLegendItems + setGroupToColorMap( new Map() ); }, [ providerTheme.colors ] ); const registerChart = useCallback( ( id: string, data: ChartRegistration ) => { @@ -51,33 +48,65 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { [ charts ] ); - const resolveGroupColor = useCallback< GlobalChartsContextValue[ 'resolveGroupColor' ] >( - ( { group, index, overrideColor } ) => { - // Highest precedence: explicit series stroke + const resolveColor = useCallback( + ( { + group, + index, + overrideColor, + }: { + group?: string; + index: number; + overrideColor?: string; + } ): string => { + // Highest precedence: eg. explicit series stroke or chart color prop if ( overrideColor ) { return overrideColor; } - const palette = providerTheme.colors ?? []; + const { colors } = providerTheme; // If group provided, maintain a stable assignment if ( group ) { - const existing = groupToColorMapRef.current.get( group ); + const existing = groupToColorMap.get( group ); if ( existing ) { return existing; } // Assign next color from palette in a deterministic cycling manner - const assignedCount = groupToColorMapRef.current.size; - const color = palette.length > 0 ? palette[ assignedCount % palette.length ] : '#000000'; - groupToColorMapRef.current.set( group, color ); + const assignedCount = groupToColorMap.size; + const color = colors.length > 0 ? colors[ assignedCount % colors.length ] : '#000000'; + groupToColorMap.set( group, color ); return color; } // Fallback: index-based color cycling - return palette.length > 0 ? palette[ ( index || 0 ) % palette.length ] : '#000000'; + return colors.length > 0 ? colors[ ( index || 0 ) % colors.length ] : '#000000'; + }, + [ providerTheme, groupToColorMap ] + ); + + const getElementStyles = useCallback< GlobalChartsContextValue[ 'getElementStyles' ] >( + ( { data, index, overrideColor, legendShape } ) => { + const isSeriesData = data && typeof data === 'object' && 'data' in data && 'options' in data; + const isPointPercentageData = data && typeof data === 'object' && 'percentage' in data; + + return { + color: resolveColor( { + group: data?.group, + index, + overrideColor: + overrideColor || + ( isSeriesData && data?.options?.stroke ) || + ( isPointPercentageData && data?.color ), + } ), + lineStyles: isSeriesData ? getSeriesLineStyles( data, index, providerTheme ) : {}, + glyph: providerTheme.glyphs?.[ index ], + shapeStyles: isSeriesData + ? getItemShapeStyles( data, index, providerTheme, legendShape ) + : {}, + }; }, - [ providerTheme.colors ] + [ providerTheme, resolveColor ] ); const value: GlobalChartsContextValue = useMemo( @@ -87,9 +116,9 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { unregisterChart, getChartData, theme: providerTheme, - resolveGroupColor, + getElementStyles, } ), - [ charts, registerChart, unregisterChart, getChartData, providerTheme, resolveGroupColor ] + [ charts, registerChart, unregisterChart, getChartData, providerTheme, getElementStyles ] ); return { children }; diff --git a/projects/js-packages/charts/src/providers/chart-context/index.ts b/projects/js-packages/charts/src/providers/chart-context/index.ts index bd89ef2f46737..83018ad412e73 100644 --- a/projects/js-packages/charts/src/providers/chart-context/index.ts +++ b/projects/js-packages/charts/src/providers/chart-context/index.ts @@ -3,5 +3,10 @@ export { useGlobalChartsContext } from './hooks/use-global-charts-context'; export { useChartId } from './hooks/use-chart-id'; export { useChartRegistration } from './hooks/use-chart-registration'; export { useGlobalChartsTheme } from './hooks/use-global-charts-theme'; -export type { GlobalChartsContextValue, ChartRegistration } from './types'; +export type { + GlobalChartsContextValue, + ChartRegistration, + GetElementStylesParams, + ElementStyles, +} from './types'; export * from './themes'; diff --git a/projects/js-packages/charts/src/providers/chart-context/test/chart-context.test.tsx b/projects/js-packages/charts/src/providers/chart-context/test/chart-context.test.tsx index 6936aa2ca3e7a..13ed9a42724fc 100644 --- a/projects/js-packages/charts/src/providers/chart-context/test/chart-context.test.tsx +++ b/projects/js-packages/charts/src/providers/chart-context/test/chart-context.test.tsx @@ -5,7 +5,7 @@ import { useChartId } from '../hooks/use-chart-id'; import { useChartRegistration } from '../hooks/use-chart-registration'; import { useGlobalChartsContext } from '../hooks/use-global-charts-context'; import type { BaseLegendItem } from '../../../components/legend'; -import type { ChartTheme } from '../../../types'; +import type { ChartTheme, SeriesData } from '../../../types'; import type { GlobalChartsContextValue } from '../types'; describe( 'ChartContext', () => { @@ -18,6 +18,13 @@ describe( 'ChartContext', () => { { label: 'Series 2', value: '200', color: '#00ff00' }, ]; + // Helper function to create mock data for color resolution tests + const createMockDataWithGroup = ( group: string | undefined ): SeriesData => ( { + label: 'Test', + data: [ { value: 100 } ], + group, + } ); + describe( 'GlobalChartsProvider', () => { it( 'provides context to child components', () => { let contextValue: GlobalChartsContextValue; @@ -217,8 +224,8 @@ describe( 'ChartContext', () => { } ); } ); - describe( 'Group Color Resolver', () => { - it( 'provides resolveGroupColor function', () => { + describe( 'Color resolution', () => { + it( 'provides getElementStyles function for color resolution', () => { let contextValue: GlobalChartsContextValue; const TestComponent = () => { @@ -232,7 +239,7 @@ describe( 'ChartContext', () => { ); - expect( contextValue.resolveGroupColor ).toBeInstanceOf( Function ); + expect( contextValue.getElementStyles ).toBeInstanceOf( Function ); } ); it( 'returns consistent colors for same group across different indices', () => { @@ -249,9 +256,18 @@ describe( 'ChartContext', () => { ); - const color1 = contextValue.resolveGroupColor( { group: 'united-states', index: 0 } ); - const color2 = contextValue.resolveGroupColor( { group: 'united-states', index: 5 } ); - const color3 = contextValue.resolveGroupColor( { group: 'united-states', index: 10 } ); + const color1 = contextValue.getElementStyles( { + data: createMockDataWithGroup( 'united-states' ), + index: 0, + } ).color; + const color2 = contextValue.getElementStyles( { + data: createMockDataWithGroup( 'united-states' ), + index: 5, + } ).color; + const color3 = contextValue.getElementStyles( { + data: createMockDataWithGroup( 'united-states' ), + index: 10, + } ).color; expect( color1 ).toBe( color2 ); expect( color2 ).toBe( color3 ); @@ -271,9 +287,18 @@ describe( 'ChartContext', () => { ); - const usColor = contextValue.resolveGroupColor( { group: 'united-states', index: 0 } ); - const gbColor = contextValue.resolveGroupColor( { group: 'great-britain', index: 0 } ); - const jpColor = contextValue.resolveGroupColor( { group: 'japan', index: 0 } ); + const usColor = contextValue.getElementStyles( { + data: createMockDataWithGroup( 'united-states' ), + index: 0, + } ).color; + const gbColor = contextValue.getElementStyles( { + data: createMockDataWithGroup( 'great-britain' ), + index: 0, + } ).color; + const jpColor = contextValue.getElementStyles( { + data: createMockDataWithGroup( 'japan' ), + index: 0, + } ).color; expect( usColor ).not.toBe( gbColor ); expect( gbColor ).not.toBe( jpColor ); @@ -295,15 +320,15 @@ describe( 'ChartContext', () => { ); const overrideColor = '#ff6600'; - const colorWithOverride = contextValue.resolveGroupColor( { - group: 'united-states', + const colorWithOverride = contextValue.getElementStyles( { + data: createMockDataWithGroup( 'united-states' ), index: 0, overrideColor, - } ); - const colorWithoutOverride = contextValue.resolveGroupColor( { - group: 'united-states', + } ).color; + const colorWithoutOverride = contextValue.getElementStyles( { + data: createMockDataWithGroup( 'united-states' ), index: 0, - } ); + } ).color; expect( colorWithOverride ).toBe( overrideColor ); expect( colorWithoutOverride ).not.toBe( overrideColor ); @@ -323,7 +348,10 @@ describe( 'ChartContext', () => { ); - const color = contextValue.resolveGroupColor( { group: undefined, index: 0 } ); + const color = contextValue.getElementStyles( { + data: createMockDataWithGroup( undefined ), + index: 0, + } ).color; expect( color ).toBe( mockTheme.colors[ 0 ] ); } ); @@ -342,7 +370,10 @@ describe( 'ChartContext', () => { ); - const color = contextValue.resolveGroupColor( { group: '', index: 0 } ); + const color = contextValue.getElementStyles( { + data: createMockDataWithGroup( '' ), + index: 0, + } ).color; expect( color ).toBe( mockTheme.colors[ 0 ] ); } ); @@ -361,12 +392,18 @@ describe( 'ChartContext', () => { ); - const color1 = contextValue.resolveGroupColor( { group: undefined, index: 0 } ); - const color2 = contextValue.resolveGroupColor( { group: '', index: 1 } ); - const color3 = contextValue.resolveGroupColor( { - group: null as string | undefined, + const color1 = contextValue.getElementStyles( { + data: createMockDataWithGroup( undefined ), + index: 0, + } ).color; + const color2 = contextValue.getElementStyles( { + data: createMockDataWithGroup( '' ), + index: 1, + } ).color; + const color3 = contextValue.getElementStyles( { + data: createMockDataWithGroup( null as string | undefined ), index: 2, - } ); + } ).color; expect( color1 ).toBe( mockTheme.colors[ 0 ] ); expect( color2 ).toBe( mockTheme.colors[ 1 ] ); @@ -388,8 +425,14 @@ describe( 'ChartContext', () => { ); // mockTheme has 3 colors, so index 3 should wrap to index 0 - const color1 = contextValue.resolveGroupColor( { group: undefined, index: 3 } ); - const color2 = contextValue.resolveGroupColor( { group: undefined, index: 0 } ); + const color1 = contextValue.getElementStyles( { + data: createMockDataWithGroup( undefined ), + index: 3, + } ).color; + const color2 = contextValue.getElementStyles( { + data: createMockDataWithGroup( undefined ), + index: 0, + } ).color; expect( color1 ).toBe( color2 ); expect( color1 ).toBe( mockTheme.colors[ 0 ] ); @@ -412,9 +455,14 @@ describe( 'ChartContext', () => { const groupName = 'consistent-group'; const colors = []; - // Call resolveGroupColor multiple times for the same group + // Call getElementStyles multiple times for the same group for ( let i = 0; i < 10; i++ ) { - colors.push( contextValue.resolveGroupColor( { group: groupName, index: i } ) ); + colors.push( + contextValue.getElementStyles( { + data: createMockDataWithGroup( groupName ), + index: i, + } ).color + ); } // All colors should be the same @@ -441,12 +489,15 @@ describe( 'ChartContext', () => { const groupName = 'test-group'; const overrideColor = '#purple'; - const groupColor = contextValue.resolveGroupColor( { group: groupName, index: 0 } ); - const overriddenColor = contextValue.resolveGroupColor( { - group: groupName, + const groupColor = contextValue.getElementStyles( { + data: createMockDataWithGroup( groupName ), + index: 0, + } ).color; + const overriddenColor = contextValue.getElementStyles( { + data: createMockDataWithGroup( groupName ), index: 0, overrideColor, - } ); + } ).color; expect( groupColor ).not.toBe( overrideColor ); expect( overriddenColor ).toBe( overrideColor ); @@ -474,8 +525,12 @@ describe( 'ChartContext', () => { ]; // Get initial colors for all groups - const initialColors = initialGroups.map( ( { group, index } ) => - contextValue.resolveGroupColor( { group, index } ) + const initialColors = initialGroups.map( + ( { group, index } ) => + contextValue.getElementStyles( { + data: createMockDataWithGroup( group ), + index, + } ).color ); // Simulate removing the middle group (great-britain) @@ -486,8 +541,12 @@ describe( 'ChartContext', () => { ]; // Get colors after "filtering" - const filteredColors = filteredGroups.map( ( { group, index } ) => - contextValue.resolveGroupColor( { group, index } ) + const filteredColors = filteredGroups.map( + ( { group, index } ) => + contextValue.getElementStyles( { + data: createMockDataWithGroup( group ), + index, + } ).color ); // Colors should remain the same despite index changes @@ -513,18 +572,42 @@ describe( 'ChartContext', () => { ); // Get initial colors for all groups - const usColor1 = contextValue.resolveGroupColor( { group: 'united-states', index: 0 } ); - const gbColor1 = contextValue.resolveGroupColor( { group: 'great-britain', index: 1 } ); - const jpColor1 = contextValue.resolveGroupColor( { group: 'japan', index: 2 } ); + const usColor1 = contextValue.getElementStyles( { + data: createMockDataWithGroup( 'united-states' ), + index: 0, + } ).color; + const gbColor1 = contextValue.getElementStyles( { + data: createMockDataWithGroup( 'great-britain' ), + index: 1, + } ).color; + const jpColor1 = contextValue.getElementStyles( { + data: createMockDataWithGroup( 'japan' ), + index: 2, + } ).color; // Simulate removing great-britain (only US and Japan visible) - const usColor2 = contextValue.resolveGroupColor( { group: 'united-states', index: 0 } ); - const jpColor2 = contextValue.resolveGroupColor( { group: 'japan', index: 1 } ); + const usColor2 = contextValue.getElementStyles( { + data: createMockDataWithGroup( 'united-states' ), + index: 0, + } ).color; + const jpColor2 = contextValue.getElementStyles( { + data: createMockDataWithGroup( 'japan' ), + index: 1, + } ).color; // Simulate re-adding great-britain back (all groups visible again) - const usColor3 = contextValue.resolveGroupColor( { group: 'united-states', index: 0 } ); - const gbColor3 = contextValue.resolveGroupColor( { group: 'great-britain', index: 1 } ); - const jpColor3 = contextValue.resolveGroupColor( { group: 'japan', index: 2 } ); + const usColor3 = contextValue.getElementStyles( { + data: createMockDataWithGroup( 'united-states' ), + index: 0, + } ).color; + const gbColor3 = contextValue.getElementStyles( { + data: createMockDataWithGroup( 'great-britain' ), + index: 1, + } ).color; + const jpColor3 = contextValue.getElementStyles( { + data: createMockDataWithGroup( 'japan' ), + index: 2, + } ).color; // All colors should remain stable throughout the process expect( usColor1 ).toBe( usColor2 ); @@ -543,12 +626,12 @@ describe( 'ChartContext', () => { } ); describe( 'Context stability', () => { - it( 'maintains stable function references', () => { + it( 'maintains stable function references when no theme changes', () => { const functionRefs: Array< { registerChart: GlobalChartsContextValue[ 'registerChart' ]; unregisterChart: GlobalChartsContextValue[ 'unregisterChart' ]; getChartData: GlobalChartsContextValue[ 'getChartData' ]; - resolveGroupColor: GlobalChartsContextValue[ 'resolveGroupColor' ]; + getElementStyles: GlobalChartsContextValue[ 'getElementStyles' ]; } > = []; const TestComponent = () => { @@ -557,28 +640,621 @@ describe( 'ChartContext', () => { registerChart: context.registerChart, unregisterChart: context.unregisterChart, getChartData: context.getChartData, - resolveGroupColor: context.resolveGroupColor, + getElementStyles: context.getElementStyles, } ); return
Test
; }; const { rerender } = render( - + ); rerender( - + ); - expect( functionRefs ).toHaveLength( 2 ); - expect( functionRefs[ 0 ].registerChart ).toBe( functionRefs[ 1 ].registerChart ); - expect( functionRefs[ 0 ].unregisterChart ).toBe( functionRefs[ 1 ].unregisterChart ); - expect( functionRefs[ 0 ].getChartData ).toBe( functionRefs[ 1 ].getChartData ); - expect( functionRefs[ 0 ].resolveGroupColor ).toBe( functionRefs[ 1 ].resolveGroupColor ); + // After initial mount and theme effect, function refs should be stable across re-renders + const lastTwoRefs = functionRefs.slice( -2 ); + expect( lastTwoRefs ).toHaveLength( 2 ); + expect( lastTwoRefs[ 0 ].registerChart ).toBe( lastTwoRefs[ 1 ].registerChart ); + expect( lastTwoRefs[ 0 ].unregisterChart ).toBe( lastTwoRefs[ 1 ].unregisterChart ); + expect( lastTwoRefs[ 0 ].getChartData ).toBe( lastTwoRefs[ 1 ].getChartData ); + expect( lastTwoRefs[ 0 ].getElementStyles ).toBe( lastTwoRefs[ 1 ].getElementStyles ); + } ); + } ); + + describe( 'getElementStyles - DataPointPercentage handling', () => { + it( 'handles DataPointPercentage data with color override', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + render( + + + + ); + + const percentageDataWithColor = { + label: 'Custom Color Point', + value: 100, + percentage: 50, + color: '#ff9900', + }; + + const styles = contextValue.getElementStyles( { + data: percentageDataWithColor, + index: 0, + } ); + + expect( styles.color ).toBe( '#ff9900' ); + expect( styles.lineStyles ).toEqual( {} ); + expect( styles.shapeStyles ).toEqual( {} ); + } ); + + it( 'handles DataPointPercentage data without color override', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + render( + + + + ); + + const percentageDataWithoutColor = { + label: 'No Color Point', + value: 100, + percentage: 50, + }; + + const styles = contextValue.getElementStyles( { + data: percentageDataWithoutColor, + index: 1, + } ); + + expect( styles.color ).toBe( mockTheme.colors[ 1 ] ); + expect( styles.lineStyles ).toEqual( {} ); + expect( styles.shapeStyles ).toEqual( {} ); + } ); + + it( 'handles DataPointPercentage data with group', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + render( + + + + ); + + const percentageDataWithGroup = { + label: 'Grouped Point', + value: 100, + percentage: 50, + group: 'pie-segment-group', + }; + + const styles1 = contextValue.getElementStyles( { + data: percentageDataWithGroup, + index: 0, + } ); + const styles2 = contextValue.getElementStyles( { + data: percentageDataWithGroup, + index: 5, + } ); + + // Should have same color due to group consistency + expect( styles1.color ).toBe( styles2.color ); + } ); + + it( 'prioritizes DataPointPercentage color over group color', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + render( + + + + ); + + const percentageDataWithBoth = { + label: 'Both Color and Group', + value: 100, + percentage: 50, + color: '#priority-color', + group: 'test-group', + }; + + const styles = contextValue.getElementStyles( { + data: percentageDataWithBoth, + index: 0, + } ); + + expect( styles.color ).toBe( '#priority-color' ); + } ); + } ); + + describe( 'getElementStyles - SeriesData line and shape styles', () => { + it( 'returns line styles for SeriesData', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + const extendedTheme = { + ...mockTheme, + seriesLineStyles: [ { strokeWidth: 2 }, { strokeWidth: 3, strokeDasharray: '2 2' } ], + }; + + render( + + + + ); + + const seriesData = { + label: 'Test Series', + data: [ { value: 100 } ], + options: { + seriesLineStyle: { strokeWidth: 5, strokeDasharray: '10 5' }, + }, + }; + + const styles = contextValue.getElementStyles( { + data: seriesData, + index: 0, + } ); + + expect( styles.lineStyles ).toEqual( { + strokeWidth: 5, + strokeDasharray: '10 5', + } ); + } ); + + it( 'returns shape styles for SeriesData', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + const extendedTheme = { + ...mockTheme, + legendShapeStyles: [ + { fill: '#LEGEND1', stroke: '#BORDER1' }, + { fill: '#LEGEND2', strokeWidth: 3 }, + ], + }; + + render( + + + + ); + + const seriesDataWithShapeStyle = { + label: 'Test Series', + data: [ { value: 100 } ], + options: { + legendShapeStyle: { fill: '#CUSTOM', strokeWidth: 5 }, + }, + }; + + const styles = contextValue.getElementStyles( { + data: seriesDataWithShapeStyle, + index: 0, + } ); + + expect( styles.shapeStyles ).toEqual( { + fill: '#CUSTOM', + strokeWidth: 5, + } ); + } ); + + it( 'combines line styles with shape styles when legendShape is line', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + const extendedTheme = { + ...mockTheme, + lineChart: { + lineStyles: { + comparison: { + strokeDasharray: '4 4', + strokeLinecap: 'square' as const, + strokeWidth: 1.5, + }, + }, + }, + }; + + render( + + + + ); + + const comparisonSeries = { + label: 'Comparison Series', + data: [ { value: 100 } ], + options: { + type: 'comparison' as const, + legendShapeStyle: { fill: '#CUSTOM' }, + }, + }; + + const styles = contextValue.getElementStyles( { + data: comparisonSeries, + index: 0, + legendShape: 'line', + } ); + + expect( styles.shapeStyles ).toEqual( { + fill: '#CUSTOM', + strokeDasharray: '4 4', + strokeLinecap: 'square', + strokeWidth: 1.5, + } ); + } ); + + it( 'does not include line styles in shapeStyles when legendShape is not line', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + const extendedTheme = { + ...mockTheme, + lineChart: { + lineStyles: { + comparison: { + strokeDasharray: '4 4', + strokeLinecap: 'square' as const, + strokeWidth: 1.5, + }, + }, + }, + legendShapeStyles: [ { fill: '#LEGEND1', stroke: '#BORDER1' } ], + }; + + render( + + + + ); + + const comparisonSeries = { + label: 'Comparison Series', + data: [ { value: 100 } ], + options: { + type: 'comparison' as const, + }, + }; + + const styles = contextValue.getElementStyles( { + data: comparisonSeries, + index: 0, + legendShape: 'rect', + } ); + + // Should get theme legend shape styles, not line styles + expect( styles.shapeStyles ).toEqual( { + fill: '#LEGEND1', + stroke: '#BORDER1', + } ); + } ); + } ); + + describe( 'getElementStyles - glyph assignment', () => { + it( 'assigns glyph from theme based on index', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + const mockGlyph1 = jest.fn(); + const mockGlyph2 = jest.fn(); + + const themeWithGlyphs = { + ...mockTheme, + glyphs: [ mockGlyph1, mockGlyph2 ], + }; + + render( + + + + ); + + const seriesData = { + label: 'Test Series', + data: [ { value: 100 } ], + }; + + const styles1 = contextValue.getElementStyles( { + data: seriesData, + index: 0, + } ); + const styles2 = contextValue.getElementStyles( { + data: seriesData, + index: 1, + } ); + + expect( styles1.glyph ).toBe( mockGlyph1 ); + expect( styles2.glyph ).toBe( mockGlyph2 ); + } ); + + it( 'handles undefined glyph when index exceeds glyph array', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + const mockGlyph = jest.fn(); + const themeWithGlyphs = { + ...mockTheme, + glyphs: [ mockGlyph ], + }; + + render( + + + + ); + + const seriesData = { + label: 'Test Series', + data: [ { value: 100 } ], + }; + + const styles = contextValue.getElementStyles( { + data: seriesData, + index: 5, // Beyond array length + } ); + + expect( styles.glyph ).toBeUndefined(); + } ); + + it( 'handles missing glyphs in theme', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + render( + + + + ); + + const seriesData = { + label: 'Test Series', + data: [ { value: 100 } ], + }; + + const styles = contextValue.getElementStyles( { + data: seriesData, + index: 0, + } ); + + expect( styles.glyph ).toBeUndefined(); + } ); + } ); + + describe( 'getElementStyles - complete object structure', () => { + it( 'returns complete ElementStyles object for SeriesData', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + const mockGlyph = jest.fn(); + const completeTheme = { + ...mockTheme, + glyphs: [ mockGlyph ], + seriesLineStyles: [ { strokeWidth: 2 } ], + legendShapeStyles: [ { fill: '#SHAPE1' } ], + }; + + render( + + + + ); + + const seriesData = { + label: 'Test Series', + data: [ { value: 100 } ], + group: 'test-group', + }; + + const styles = contextValue.getElementStyles( { + data: seriesData, + index: 0, + } ); + + // Verify all properties are present + expect( styles ).toHaveProperty( 'color' ); + expect( styles ).toHaveProperty( 'lineStyles' ); + expect( styles ).toHaveProperty( 'glyph' ); + expect( styles ).toHaveProperty( 'shapeStyles' ); + + // Verify types + expect( typeof styles.color ).toBe( 'string' ); + expect( typeof styles.lineStyles ).toBe( 'object' ); + expect( typeof styles.glyph ).toBe( 'function' ); + expect( typeof styles.shapeStyles ).toBe( 'object' ); + } ); + + it( 'returns complete ElementStyles object for DataPointPercentage', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + const mockGlyph = jest.fn(); + const completeTheme = { + ...mockTheme, + glyphs: [ mockGlyph ], + }; + + render( + + + + ); + + const percentageData = { + label: 'Test Point', + value: 100, + percentage: 50, + color: '#custom', + }; + + const styles = contextValue.getElementStyles( { + data: percentageData, + index: 0, + } ); + + // Verify all properties are present + expect( styles ).toHaveProperty( 'color' ); + expect( styles ).toHaveProperty( 'lineStyles' ); + expect( styles ).toHaveProperty( 'glyph' ); + expect( styles ).toHaveProperty( 'shapeStyles' ); + + // Verify values for percentage data + expect( styles.color ).toBe( '#custom' ); + expect( styles.lineStyles ).toEqual( {} ); + expect( styles.glyph ).toBe( mockGlyph ); + expect( styles.shapeStyles ).toEqual( {} ); + } ); + + it( 'handles undefined data gracefully', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + render( + + + + ); + + const styles = contextValue.getElementStyles( { + data: undefined, + index: 0, + } ); + + expect( styles ).toHaveProperty( 'color' ); + expect( styles ).toHaveProperty( 'lineStyles' ); + expect( styles ).toHaveProperty( 'glyph' ); + expect( styles ).toHaveProperty( 'shapeStyles' ); + + expect( styles.color ).toBe( mockTheme.colors[ 0 ] ); + expect( styles.lineStyles ).toEqual( {} ); + expect( styles.glyph ).toBeUndefined(); + expect( styles.shapeStyles ).toEqual( {} ); + } ); + } ); + + describe( 'getElementStyles - overrideColor precedence with SeriesData', () => { + it( 'prioritizes explicit overrideColor over series stroke', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + render( + + + + ); + + const seriesWithStroke = { + label: 'Series with stroke', + data: [ { value: 100 } ], + options: { stroke: '#series-stroke' }, + }; + + const styles = contextValue.getElementStyles( { + data: seriesWithStroke, + index: 0, + overrideColor: '#explicit-override', + } ); + + expect( styles.color ).toBe( '#explicit-override' ); + } ); + + it( 'uses series stroke when no explicit overrideColor', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + render( + + + + ); + + const seriesWithStroke = { + label: 'Series with stroke', + data: [ { value: 100 } ], + options: { stroke: '#series-stroke' }, + }; + + const styles = contextValue.getElementStyles( { + data: seriesWithStroke, + index: 0, + } ); + + expect( styles.color ).toBe( '#series-stroke' ); } ); } ); } ); diff --git a/projects/js-packages/charts/src/providers/chart-context/types.ts b/projects/js-packages/charts/src/providers/chart-context/types.ts index 574576c45b48d..0daeb1449d7a8 100644 --- a/projects/js-packages/charts/src/providers/chart-context/types.ts +++ b/projects/js-packages/charts/src/providers/chart-context/types.ts @@ -1,5 +1,8 @@ +import { CSSProperties, ReactNode } from 'react'; import type { BaseLegendItem } from '../../components/legend'; -import type { CompleteChartTheme } from '../../types'; +import type { CompleteChartTheme, DataPointPercentage, SeriesData } from '../../types'; +import type { LegendShape } from '@visx/legend/lib/types'; +import type { GlyphProps, LineStyles } from '@visx/xychart'; export interface ChartRegistration { legendItems: BaseLegendItem[]; @@ -7,22 +10,25 @@ export interface ChartRegistration { metadata?: Record< string, unknown >; } +export type GetElementStylesParams = { + index: number; + data?: SeriesData | DataPointPercentage; + overrideColor?: string; + legendShape?: LegendShape< SeriesData[], number >; +}; + +export type ElementStyles = { + color: string; + lineStyles: LineStyles; + glyph: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode; + shapeStyles: CSSProperties & LineStyles; +}; + export interface GlobalChartsContextValue { charts: Map< string, ChartRegistration >; registerChart: ( id: string, data: ChartRegistration ) => void; unregisterChart: ( id: string ) => void; getChartData: ( id: string ) => ChartRegistration | undefined; - /** Theme provided by the GlobalChartsProvider (merged with defaults) */ theme: CompleteChartTheme; - /** - * Resolve a stable color for a series. - * - If an override color is passed, it wins. - * - If a group is provided, returns a stable color per group across charts. - * - If no group, falls back to index-based color from the theme palette. - */ - resolveGroupColor: ( params: { - group?: string; - index: number; - overrideColor?: string; - } ) => string; + getElementStyles: ( params: GetElementStylesParams ) => ElementStyles; } diff --git a/projects/js-packages/charts/src/types.ts b/projects/js-packages/charts/src/types.ts index 6c4c455d0c2c9..98a0fa5d2952f 100644 --- a/projects/js-packages/charts/src/types.ts +++ b/projects/js-packages/charts/src/types.ts @@ -163,7 +163,7 @@ export type ChartTheme = { /** Styles for series lines */ seriesLineStyles?: LineStyles[]; /** Styles for legend shapes */ - legendShapeStyles?: ( CSSProperties & LineStyles )[]; + legendShapeStyles?: Record< string, unknown >[]; /** Array of render functions for glyphs */ glyphs?: Array< < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode >; /** Styles for legend labels */ diff --git a/projects/js-packages/charts/src/utils/get-styles.ts b/projects/js-packages/charts/src/utils/get-styles.ts index 57e3fc5ba5cd9..1c0dc4191df61 100644 --- a/projects/js-packages/charts/src/utils/get-styles.ts +++ b/projects/js-packages/charts/src/utils/get-styles.ts @@ -1,7 +1,6 @@ import type { ChartTheme, SeriesData } from '../types'; import type { LegendShape } from '@visx/legend/lib/types'; import type { LineStyles } from '@visx/xychart'; -import type { CSSProperties } from 'react'; /** * Utility function to get consolidated line styles for a series @@ -53,14 +52,14 @@ export function getSeriesStroke( * @param {number} index - The index of the series in the data array * @param {ChartTheme} theme - The chart theme configuration * @param {LegendShape} legendShape - The shape to use for the item (optional) - * @return {CSSProperties} The shape styles for the item + * @return {Record< string, unknown >} The shape styles for the item */ export function getItemShapeStyles( series: SeriesData, index: number, theme: ChartTheme, legendShape?: LegendShape< SeriesData[], number > -): { shapeStyles: CSSProperties & LineStyles } { +): Record< string, unknown > { const seriesShapeStyles = series.options?.legendShapeStyle ?? {}; const lineStyles = legendShape === 'line' ? getSeriesLineStyles( series, index, theme ) : {}; const themeShapeStyles = theme.legendShapeStyles?.[ index ]; @@ -76,9 +75,9 @@ export function getItemShapeStyles( value => value !== undefined && value !== null && value !== '' ) ) { - return { shapeStyles: itemShapeStyles as CSSProperties & LineStyles }; + return itemShapeStyles; } // Fallback to theme shape styles if defined - return { shapeStyles: themeShapeStyles ?? {} }; + return themeShapeStyles ?? {}; } diff --git a/projects/js-packages/charts/src/utils/test/get-styles.test.ts b/projects/js-packages/charts/src/utils/test/get-styles.test.ts index d1f62d89455b3..dd4b1669ee71f 100644 --- a/projects/js-packages/charts/src/utils/test/get-styles.test.ts +++ b/projects/js-packages/charts/src/utils/test/get-styles.test.ts @@ -131,7 +131,7 @@ describe( 'Series styling utility functions', () => { }; const result = getItemShapeStyles( seriesWithShapeStyle, 0, mockTheme as ChartTheme, 'rect' ); - expect( result.shapeStyles ).toEqual( customShapeStyle ); + expect( result ).toEqual( customShapeStyle ); } ); it( 'combines line styles when legendShape is "line"', () => { @@ -141,7 +141,7 @@ describe( 'Series styling utility functions', () => { }; const result = getItemShapeStyles( comparisonSeries, 0, mockTheme as ChartTheme, 'line' ); - expect( result.shapeStyles ).toEqual( { + expect( result ).toEqual( { strokeDasharray: '4 4', strokeLinecap: 'square', strokeWidth: 1.5, @@ -155,7 +155,7 @@ describe( 'Series styling utility functions', () => { }; const result = getItemShapeStyles( comparisonSeries, 0, mockTheme as ChartTheme, 'rect' ); - expect( result.shapeStyles ).toEqual( mockTheme.legendShapeStyles[ 0 ] ); + expect( result ).toEqual( mockTheme.legendShapeStyles[ 0 ] ); } ); it( 'merges custom shape styles with line styles for line shape', () => { @@ -169,7 +169,7 @@ describe( 'Series styling utility functions', () => { }; const result = getItemShapeStyles( comparisonSeries, 0, mockTheme as ChartTheme, 'line' ); - expect( result.shapeStyles ).toEqual( { + expect( result ).toEqual( { fill: '#CUSTOM', strokeDasharray: '4 4', strokeLinecap: 'square', @@ -189,7 +189,7 @@ describe( 'Series styling utility functions', () => { mockTheme as ChartTheme, 'rect' ); - expect( result.shapeStyles ).toEqual( mockTheme.legendShapeStyles[ 1 ] ); + expect( result ).toEqual( mockTheme.legendShapeStyles[ 1 ] ); } ); it( 'returns empty object when no theme shape styles and no meaningful custom styles', () => { @@ -199,7 +199,7 @@ describe( 'Series styling utility functions', () => { } as ChartTheme; const result = getItemShapeStyles( mockSeriesData, 0, themeWithoutShapeStyles, 'rect' ); - expect( result.shapeStyles ).toEqual( {} ); + expect( result ).toEqual( {} ); } ); it( 'returns custom styles even with undefined/null values when meaningful values exist', () => { @@ -221,7 +221,7 @@ describe( 'Series styling utility functions', () => { mockTheme as ChartTheme, 'rect' ); - expect( result.shapeStyles ).toEqual( { + expect( result ).toEqual( { fill: '#VALID', stroke: '', strokeWidth: undefined, @@ -242,7 +242,7 @@ describe( 'Series styling utility functions', () => { }; const result = getItemShapeStyles( complexSeries, 0, mockTheme as ChartTheme, 'line' ); - expect( result.shapeStyles ).toEqual( { + expect( result ).toEqual( { fill: '#OVERRIDE', strokeWidth: 1.5, // Semantic style wins over custom style strokeDasharray: '4 4', @@ -252,12 +252,12 @@ describe( 'Series styling utility functions', () => { it( 'returns empty object when index exceeds theme shape styles array length', () => { const result = getItemShapeStyles( mockSeriesData, 3, mockTheme as ChartTheme, 'rect' ); - expect( result.shapeStyles ).toEqual( {} ); // index 3 doesn't exist in array of length 2 + expect( result ).toEqual( {} ); // index 3 doesn't exist in array of length 2 } ); it( 'works without legendShape parameter', () => { const result = getItemShapeStyles( mockSeriesData, 0, mockTheme as ChartTheme ); - expect( result.shapeStyles ).toEqual( mockTheme.legendShapeStyles[ 0 ] ); + expect( result ).toEqual( mockTheme.legendShapeStyles[ 0 ] ); } ); it( 'handles other legendShape string values like "circle"', () => { @@ -268,7 +268,7 @@ describe( 'Series styling utility functions', () => { const result = getItemShapeStyles( comparisonSeries, 0, mockTheme as ChartTheme, 'circle' ); // Should not include line styles for circle shape - expect( result.shapeStyles ).toEqual( mockTheme.legendShapeStyles[ 0 ] ); + expect( result ).toEqual( mockTheme.legendShapeStyles[ 0 ] ); } ); it( 'handles React component as legendShape parameter', () => { @@ -285,7 +285,7 @@ describe( 'Series styling utility functions', () => { CustomShape ); // Should not include line styles for component shape - expect( result.shapeStyles ).toEqual( mockTheme.legendShapeStyles[ 0 ] ); + expect( result ).toEqual( mockTheme.legendShapeStyles[ 0 ] ); } ); it( 'prioritizes series line styles over theme line styles when legendShape is line', () => { @@ -302,7 +302,7 @@ describe( 'Series styling utility functions', () => { mockTheme as ChartTheme, 'line' ); - expect( result.shapeStyles ).toEqual( { + expect( result ).toEqual( { strokeWidth: 99, strokeDasharray: '10 10', } ); @@ -324,7 +324,7 @@ describe( 'Series styling utility functions', () => { 'line' ); // Should return line styles even though legendShapeStyle is empty - expect( result.shapeStyles ).toEqual( { + expect( result ).toEqual( { strokeDasharray: '4 4', strokeLinecap: 'square', strokeWidth: 1.5,