Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Charts: Generate extra colors from theme colors if needed
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ const createAnnotationTemplate =
{ ...( annotationArgs?.[ 0 ] || {} ) }
/>
<LineChart.Annotation
datum={ sampleData[ 1 ].data[ sampleData[ 1 ].data.length - 10 ] }
datum={ sampleData[ 1 ].data[ 1 ] }
title="Another notable event"
subtitle="This is another notable event"
{ ...( annotationArgs?.[ 1 ] || {} ) }
/>
<LineChart.Annotation
datum={ sampleData[ 2 ].data[ sampleData[ 2 ].data.length - 51 ] }
datum={ sampleData[ 2 ].data[ 7 ] }
Comment on lines -31 to +37
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the sample data was updated sometime recently these indexes broke. Fixing here as I'm touching the sample data again.

title="Concerning event"
subtitle="This is a concerning event"
{ ...( annotationArgs?.[ 2 ] || {} ) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const lineChartMetaArgs: Meta< StoryArgs > = {
};

export const lineChartStoryArgs = {
data: sampleData,
data: sampleData.slice( 0, 4 ),
withGradientFill: false,
withLegendGlyph: false,
smoothing: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ SingleSeries.args = {
data: [ sampleData[ 0 ] ], // Only London temperature data
};

export const ManySeries: StoryObj< typeof LineChart > = Template.bind( {} );
ManySeries.args = {
...lineChartStoryArgs,
data: sampleData,
showLegend: true,
};

export const WithLegend: StoryObj< typeof LineChart > = Template.bind( {} );
WithLegend.args = {
...lineChartStoryArgs,
Expand All @@ -42,7 +49,7 @@ WithLegend.args = {

export const CustomLegendPositioning: StoryObj< typeof LineChart > = Template.bind( {} );
CustomLegendPositioning.args = {
data: sampleData,
...lineChartStoryArgs,
showLegend: true,
height: 400,
legendAlignment: 'start',
Expand Down Expand Up @@ -93,17 +100,17 @@ export const WithCompositionLegend: StoryObj< typeof LineChart > = {
// Story with custom dimensions
export const CustomDimensions: StoryObj< typeof LineChart > = Template.bind( {} );
CustomDimensions.args = {
...lineChartStoryArgs,
width: 800,
height: 400,
data: sampleData,
};

// Add after existing stories
export const FixedDimensions: StoryObj< typeof LineChart > = Template.bind( {} );
FixedDimensions.args = {
...lineChartStoryArgs,
width: 800,
height: 400,
data: sampleData,
withTooltips: true,
};

Expand All @@ -118,7 +125,7 @@ FixedDimensions.parameters = {
// Story with gradient filled line chart
export const GradientFilled: StoryObj< typeof LineChart > = Template.bind( {} );
GradientFilled.args = {
...Default.args,
...lineChartStoryArgs,
margin: undefined,
data: webTrafficData,
withGradientFill: true,
Expand Down Expand Up @@ -207,13 +214,13 @@ export const ErrorStates: StoryObj< typeof LineChart > = {

export const WithoutSmoothing: StoryObj< typeof LineChart > = Template.bind( {} );
WithoutSmoothing.args = {
...Default.args,
...lineChartStoryArgs,
smoothing: false,
};

export const WithPointerEvents: StoryObj< typeof LineChart > = Template.bind( {} );
WithPointerEvents.args = {
...Default.args,
...lineChartStoryArgs,
// eslint-disable-next-line no-alert
onPointerDown: ( { datum } ) => alert( 'Pointer down:' + JSON.stringify( datum ) ),
};
Expand Down Expand Up @@ -318,7 +325,7 @@ const DASHED_LINE_OFFSET = 100;

export const BrokenLine: StoryObj< typeof LineChart > = Template.bind( {} );
BrokenLine.args = {
...Default.args,
...lineChartStoryArgs,
data: [
{
...webTrafficData[ 0 ],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createContext, useCallback, useMemo, useState, useEffect } from 'react';
import { getItemShapeStyles, getSeriesLineStyles, mergeThemes } from '../../utils';
import { getItemShapeStyles, getSeriesLineStyles, mergeThemes, hexToHsl } from '../../utils';
import { getChartColor, type ColorCache } from './private/get-chart-color';
import { defaultTheme } from './themes';
import type { GlobalChartsContextValue, ChartRegistration } from './types';
import type { ChartTheme, CompleteChartTheme } from '../../types';
Expand All @@ -19,6 +20,36 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
return theme ? mergeThemes( defaultTheme, theme ) : defaultTheme;
}, [ theme ] );

// Cache expensive color computations that only change when theme colors change
const colorCache: ColorCache = useMemo( () => {
const { colors } = providerTheme;
const hues: number[] = [];
const existingHslColors: Array< [ number, number, number ] > = [];
let minHue = 360;
let maxHue = 0;

// Process all colors once and cache the results
if ( Array.isArray( colors ) ) {
for ( const color of colors ) {
if ( color && typeof color === 'string' && color.startsWith( '#' ) ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens for named colors, like red, dodgerblue etc? if we don't not support them then we should probably be clear in the documentation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note added f9ea43a

const hslColor = hexToHsl( color );
hues.push( hslColor[ 0 ] );
existingHslColors.push( hslColor );
minHue = Math.min( minHue, hslColor[ 0 ] );
maxHue = Math.max( maxHue, hslColor[ 0 ] );
}
}
}

return {
colors: colors || [],
hues,
existingHslColors,
minHue,
maxHue,
};
}, [ providerTheme ] );

const [ groupToColorMap, setGroupToColorMap ] = useState< Map< string, string > >(
() => new Map()
);
Expand Down Expand Up @@ -63,26 +94,25 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
return overrideColor;
}

const { colors } = providerTheme;

// If group provided, maintain a stable assignment
if ( group ) {
const existing = groupToColorMap.get( group );

if ( existing ) {
return existing;
}
// Assign next color from palette in a deterministic cycling manner

const assignedCount = groupToColorMap.size;
const color = colors.length > 0 ? colors[ assignedCount % colors.length ] : '#000000';
const color =
colorCache.colors.length > 0 ? getChartColor( assignedCount, colorCache ) : '#000000';
groupToColorMap.set( group, color );

return color;
}

// Fallback: index-based color cycling
return colors.length > 0 ? colors[ ( index || 0 ) % colors.length ] : '#000000';
return colorCache.colors.length > 0 ? getChartColor( index, colorCache ) : '#000000';
},
[ providerTheme, groupToColorMap ]
[ colorCache, groupToColorMap ]
);

const getElementStyles = useCallback< GlobalChartsContextValue[ 'getElementStyles' ] >(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { getColorDistance } from '../../../utils';

export interface ColorCache {
colors: string[];
hues: number[];
existingHslColors: Array< [ number, number, number ] >;
minHue: number;
maxHue: number;
}

/**
* Golden ratio for mathematically pleasing color distribution
* Used to generate evenly spaced hues that are visually distinct
*/
const GOLDEN_RATIO = 0.618033988749;

/**
* Minimum perceptual distance between colors to ensure visual distinction
* Based on weighted HSL distance calculation optimized for chart readability
*/
const MIN_COLOR_DISTANCE = 25;

/**
* Maximum attempts to find a sufficiently different color
* Prevents infinite loops while allowing reasonable search space
*/
const MAX_COLOR_GENERATION_ATTEMPTS = 50;

/**
* Color variation attempt offset
* Small increment to explore slightly different color variations per attempt
*/
const VARIATION_ATTEMPT_OFFSET = 0.1;

// Saturation configuration for generated colors

/**
* Base saturation percentage for generated colors
* 60% provides good color vibrancy without being overwhelming
*/
const BASE_SATURATION = 60;

/**
* Number of saturation variation steps
* Creates 3 different saturation levels for variety
*/
const SATURATION_VARIATION_STEPS = 3;

/**
* Saturation increment per variation step
* 15% increments provide noticeable but not jarring differences
* Results in saturation levels: 60%, 75%, 90%
*/
const SATURATION_INCREMENT = 15;

// Lightness configuration for WCAG AA accessibility compliance

/**
* Base lightness percentage for generated colors
* 35% ensures sufficient contrast against white backgrounds for WCAG AA compliance
* WCAG AA requires 4.5:1 contrast ratio for normal text
*/
const BASE_LIGHTNESS = 35;

/**
* Number of lightness variation steps
* Creates 4 different lightness levels for variety
*/
const LIGHTNESS_VARIATION_STEPS = 4;

/**
* Lightness increment per variation step
* 8% increments provide subtle lightness variation while maintaining accessibility
* Results in lightness levels: 35%, 43%, 51%, 59%
* All levels maintain WCAG AA compliance against white backgrounds
*/
const LIGHTNESS_INCREMENT = 8;

// Hue range expansion and constraints

/**
* Minimum hue range in degrees to ensure sufficient color variety
* 60 degrees provides reasonable color spread even for narrow palettes
*/
const MIN_HUE_RANGE_DEGREES = 60;

/**
* Hue range expansion factor
* 1.3x expansion provides slightly more variety than the original palette
*/
const HUE_RANGE_EXPANSION_FACTOR = 1.3;

/**
* Threshold for detecting hue wrap-around (color wheel boundary crossing)
* 180 degrees indicates the colors span more than half the color wheel
*/
const HUE_WRAP_THRESHOLD_DEGREES = 180;

/**
* Full color wheel rotation in degrees
*/
const FULL_HUE_ROTATION_DEGREES = 360;

/**
* Get a color from the colors array or generate a new color using the golden ratio
*
* @param index - the index of the color to get
* @param colorCache - pre-computed color data for performance
* @return a color from the colors array or a new color using the golden ratio
*/
export const getChartColor = ( index: number, colorCache: ColorCache ) => {
const {
colors,
hues,
existingHslColors,
minHue: cachedMinHue,
maxHue: cachedMaxHue,
} = colorCache;

if ( index < colors.length ) {
return colors[ index ];
}

let minHue = cachedMinHue;
let maxHue = cachedMaxHue;

// Generate additional colors using golden ratio, avoiding similar colors
for ( let attempt = 0; attempt < MAX_COLOR_GENERATION_ATTEMPTS; attempt++ ) {
// Calculate hue using golden ratio distribution with variation per attempt
// Formula: ((base_index + attempt_variation) * golden_ratio * 360°) mod 360°
// This ensures mathematically pleasing spacing while allowing slight shifts per attempt
let hue =
( ( index - colors.length + attempt * VARIATION_ATTEMPT_OFFSET ) *
GOLDEN_RATIO *
FULL_HUE_ROTATION_DEGREES ) %
FULL_HUE_ROTATION_DEGREES;

// If we have existing colors, constrain new colors to their hue range
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hue range for Jetpack is wide (lots of different colors), whereas hue range is small for Woo (all blue/purple/pink). Consider this when generating.

if ( hues.length > 0 ) {
// Handle hue wrap-around (e.g., if colors span across 0 degrees)
let hueRange = maxHue - minHue;

// If the range is very large, it might be wrapping around the color wheel
// Check if a smaller range exists when considering wrap-around
if ( hueRange > HUE_WRAP_THRESHOLD_DEGREES ) {
// Try the alternative: wrap around the full rotation
const altMinHue = Math.min( ...hues.filter( h => h > HUE_WRAP_THRESHOLD_DEGREES ) );
const altMaxHue =
Math.max( ...hues.filter( h => h < HUE_WRAP_THRESHOLD_DEGREES ) ) +
FULL_HUE_ROTATION_DEGREES;
const altRange = altMaxHue - altMinHue;

if ( altRange < hueRange ) {
minHue = altMinHue;
maxHue = altMaxHue;
hueRange = altRange;
}
}

// Expand the range slightly to provide some variation
const expandedRange = Math.max(
hueRange * HUE_RANGE_EXPANSION_FACTOR,
MIN_HUE_RANGE_DEGREES
);
const rangeCenter = ( minHue + maxHue ) / 2;
const expandedMin = rangeCenter - expandedRange / 2;

// Map the generated hue to the expanded range
hue = expandedMin + ( hue / FULL_HUE_ROTATION_DEGREES ) * expandedRange;

// Normalize to 0-360 range
hue =
( ( hue % FULL_HUE_ROTATION_DEGREES ) + FULL_HUE_ROTATION_DEGREES ) %
FULL_HUE_ROTATION_DEGREES;
}

const saturation =
BASE_SATURATION + ( ( index + attempt ) % SATURATION_VARIATION_STEPS ) * SATURATION_INCREMENT;
const lightness =
BASE_LIGHTNESS + ( ( index + attempt ) % LIGHTNESS_VARIATION_STEPS ) * LIGHTNESS_INCREMENT;

const candidateHsl: [ number, number, number ] = [ hue, saturation, lightness ];

// Check if this color is sufficiently different from existing colors
let isSufficientlyDifferent = true;
for ( const existingHsl of existingHslColors ) {
if ( getColorDistance( candidateHsl, existingHsl ) < MIN_COLOR_DISTANCE ) {
isSufficientlyDifferent = false;
break;
}
}

if ( isSufficientlyDifferent ) {
return `hsl(${ Math.round( hue ) }, ${ saturation }%, ${ lightness }%)`;
}
}

// Fallback if we couldn't find a sufficiently different color
// Formula: ((base_index) * golden_ratio * 360°) mod 360°
// This ensures mathematically pleasing spacing while maintaining consistency
const fallbackHue =
( ( index - colors.length ) * GOLDEN_RATIO * FULL_HUE_ROTATION_DEGREES ) %
FULL_HUE_ROTATION_DEGREES;
const fallbackSaturation =
BASE_SATURATION + ( index % SATURATION_VARIATION_STEPS ) * SATURATION_INCREMENT;
const fallbackLightness =
BASE_LIGHTNESS + ( index % LIGHTNESS_VARIATION_STEPS ) * LIGHTNESS_INCREMENT;
return `hsl(${ Math.round( fallbackHue ) }, ${ fallbackSaturation }%, ${ fallbackLightness }%)`;
};
Loading
Loading