-
Notifications
You must be signed in to change notification settings - Fork 834
Charts: generate extra colors from color palette #45276
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e2e313d
df53d54
771da03
e3392a4
0f0a402
dd36607
64c7ba0
8067b86
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
---|---|---|
@@ -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'; | ||
|
@@ -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( '#' ) ) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What happens for named colors, like There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
); | ||
|
@@ -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' ] >( | ||
|
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }%)`; | ||
}; |
There was a problem hiding this comment.
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.