diff --git a/projects/js-packages/charts/changelog/charts-107-generate-extra-colors-for-charts-from-primary-color b/projects/js-packages/charts/changelog/charts-107-generate-extra-colors-for-charts-from-primary-color new file mode 100644 index 0000000000000..84781e2aa453c --- /dev/null +++ b/projects/js-packages/charts/changelog/charts-107-generate-extra-colors-for-charts-from-primary-color @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Charts: Generate extra colors from theme colors if needed diff --git a/projects/js-packages/charts/src/components/line-chart/stories/annotation.stories.tsx b/projects/js-packages/charts/src/components/line-chart/stories/annotation.stories.tsx index c822d4a734e8a..ff5ded1872953 100644 --- a/projects/js-packages/charts/src/components/line-chart/stories/annotation.stories.tsx +++ b/projects/js-packages/charts/src/components/line-chart/stories/annotation.stories.tsx @@ -28,13 +28,13 @@ const createAnnotationTemplate = { ...( annotationArgs?.[ 0 ] || {} ) } /> = { }; export const lineChartStoryArgs = { - data: sampleData, + data: sampleData.slice( 0, 4 ), withGradientFill: false, withLegendGlyph: false, smoothing: true, diff --git a/projects/js-packages/charts/src/components/line-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/components/line-chart/stories/index.stories.tsx index 02eec84cea160..1a26aa0fc6cc1 100644 --- a/projects/js-packages/charts/src/components/line-chart/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/components/line-chart/stories/index.stories.tsx @@ -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, @@ -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', @@ -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, }; @@ -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, @@ -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 ) ), }; @@ -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 ], 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 7bfe90def26a8..fd680382f0385 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,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( '#' ) ) { + 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' ] >( diff --git a/projects/js-packages/charts/src/providers/chart-context/private/get-chart-color.ts b/projects/js-packages/charts/src/providers/chart-context/private/get-chart-color.ts new file mode 100644 index 0000000000000..c32733977ba8c --- /dev/null +++ b/projects/js-packages/charts/src/providers/chart-context/private/get-chart-color.ts @@ -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 + 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 }%)`; +}; 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 13ed9a42724fc..39725cd43d68c 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 @@ -4,6 +4,7 @@ import { GlobalChartsProvider } from '../global-charts-provider'; import { useChartId } from '../hooks/use-chart-id'; import { useChartRegistration } from '../hooks/use-chart-registration'; import { useGlobalChartsContext } from '../hooks/use-global-charts-context'; +import { wooTheme } from '../themes'; import type { BaseLegendItem } from '../../../components/legend'; import type { ChartTheme, SeriesData } from '../../../types'; import type { GlobalChartsContextValue } from '../types'; @@ -410,7 +411,7 @@ describe( 'ChartContext', () => { expect( color3 ).toBe( mockTheme.colors[ 2 ] ); } ); - it( 'wraps around theme colors when index exceeds theme color array', () => { + it( 'generates new colors when index exceeds theme color array', () => { let contextValue: GlobalChartsContextValue; const TestComponent = () => { @@ -424,18 +425,84 @@ describe( 'ChartContext', () => { ); - // mockTheme has 3 colors, so index 3 should wrap to index 0 - const color1 = contextValue.getElementStyles( { + // mockTheme has 3 colors, so index 3 should generate a new color + const paletteColor = contextValue.getElementStyles( { + data: createMockDataWithGroup( undefined ), + index: 0, + } ).color; + const generatedColor = contextValue.getElementStyles( { data: createMockDataWithGroup( undefined ), index: 3, } ).color; + + // Generated color should be different from palette colors + expect( generatedColor ).not.toBe( paletteColor ); + expect( generatedColor ).not.toBe( mockTheme.colors[ 0 ] ); + expect( generatedColor ).not.toBe( mockTheme.colors[ 1 ] ); + expect( generatedColor ).not.toBe( mockTheme.colors[ 2 ] ); + + // Generated color should be in HSL format + expect( generatedColor ).toMatch( /^hsl\(\d+,\s*\d+%,\s*\d+%\)$/ ); + } ); + + it( 'generates consistent colors for same index beyond palette', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + render( + + + + ); + + // Generated colors should be consistent for the same index + const color1 = contextValue.getElementStyles( { + data: createMockDataWithGroup( undefined ), + index: 5, + } ).color; const color2 = contextValue.getElementStyles( { data: createMockDataWithGroup( undefined ), - index: 0, + index: 5, } ).color; expect( color1 ).toBe( color2 ); - expect( color1 ).toBe( mockTheme.colors[ 0 ] ); + } ); + + it( 'generates different colors for different indices beyond palette', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + render( + + + + ); + + // Different indices should generate different colors + const color1 = contextValue.getElementStyles( { + data: createMockDataWithGroup( undefined ), + index: 3, + } ).color; + const color2 = contextValue.getElementStyles( { + data: createMockDataWithGroup( undefined ), + index: 4, + } ).color; + const color3 = contextValue.getElementStyles( { + data: createMockDataWithGroup( undefined ), + index: 5, + } ).color; + + expect( color1 ).not.toBe( color2 ); + expect( color2 ).not.toBe( color3 ); + expect( color1 ).not.toBe( color3 ); } ); it( 'maintains color stability when same group accessed multiple times', () => { @@ -625,6 +692,118 @@ describe( 'ChartContext', () => { } ); } ); + describe( 'Color cache performance', () => { + it( 'maintains stable colors when theme remains unchanged', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + const { rerender } = render( + + + + ); + + // Get initial generated color + const initialColor = contextValue.getElementStyles( { + data: createMockDataWithGroup( undefined ), + index: 5, + } ).color; + + // Re-render with same theme + rerender( + + + + ); + + // Color should remain the same + const afterRerenderColor = contextValue.getElementStyles( { + data: createMockDataWithGroup( undefined ), + index: 5, + } ).color; + + expect( afterRerenderColor ).toBe( initialColor ); + } ); + + it( 'updates colors when theme changes', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + const newTheme: typeof mockTheme = { + colors: [ '#000000', '#111111', '#222222' ], + } as typeof mockTheme; + + const { rerender } = render( + + + + ); + + // Get initial generated color + const initialColor = contextValue.getElementStyles( { + data: createMockDataWithGroup( undefined ), + index: 5, + } ).color; + + // Re-render with different theme + rerender( + + + + ); + + // Color should change due to different theme + const afterThemeChangeColor = contextValue.getElementStyles( { + data: createMockDataWithGroup( undefined ), + index: 5, + } ).color; + + expect( afterThemeChangeColor ).not.toBe( initialColor ); + } ); + + it( 'generates colors with Woo theme characteristics', () => { + let contextValue: GlobalChartsContextValue; + + const TestComponent = () => { + contextValue = useGlobalChartsContext(); + return
Test
; + }; + + render( + + + + ); + + // Generate colors beyond the palette + const generatedColors = []; + for ( let i = 5; i < 8; i++ ) { + const color = contextValue.getElementStyles( { + data: createMockDataWithGroup( undefined ), + index: i, + } ).color; + generatedColors.push( color ); + } + + // All generated colors should be in HSL format + generatedColors.forEach( color => { + expect( color ).toMatch( /^hsl\(\d+,\s*\d+%,\s*\d+%\)$/ ); + } ); + + // All generated colors should be different + const uniqueColors = new Set( generatedColors ); + expect( uniqueColors.size ).toBe( generatedColors.length ); + } ); + } ); + describe( 'Context stability', () => { it( 'maintains stable function references when no theme changes', () => { const functionRefs: Array< { diff --git a/projects/js-packages/charts/src/stories/sample-data/index.ts b/projects/js-packages/charts/src/stories/sample-data/index.ts index 205ef52b557e6..24151bfdc92b3 100644 --- a/projects/js-packages/charts/src/stories/sample-data/index.ts +++ b/projects/js-packages/charts/src/stories/sample-data/index.ts @@ -212,6 +212,82 @@ export const temperatureData: SeriesData[] = [ ], options: {}, }, + { + group: 'sydney', + label: 'Sydney', + data: [ + { date: new Date( '2024-01-01' ), value: 24 }, + { date: new Date( '2024-02-01' ), value: 24 }, + { date: new Date( '2024-03-01' ), value: 22 }, + { date: new Date( '2024-04-01' ), value: 19 }, + { date: new Date( '2024-05-01' ), value: 16 }, + { date: new Date( '2024-06-01' ), value: 13 }, + { date: new Date( '2024-07-01' ), value: 12 }, + { date: new Date( '2024-08-01' ), value: 14 }, + { date: new Date( '2024-09-01' ), value: 17 }, + { date: new Date( '2024-10-01' ), value: 20 }, + { date: new Date( '2024-11-01' ), value: 22 }, + { date: new Date( '2024-12-01' ), value: 24 }, + ], + options: {}, + }, + { + group: 'moscow', + label: 'Moscow', + data: [ + { date: new Date( '2024-01-01' ), value: -8 }, + { date: new Date( '2024-02-01' ), value: -6 }, + { date: new Date( '2024-03-01' ), value: 0 }, + { date: new Date( '2024-04-01' ), value: 8 }, + { date: new Date( '2024-05-01' ), value: 16 }, + { date: new Date( '2024-06-01' ), value: 20 }, + { date: new Date( '2024-07-01' ), value: 23 }, + { date: new Date( '2024-08-01' ), value: 21 }, + { date: new Date( '2024-09-01' ), value: 15 }, + { date: new Date( '2024-10-01' ), value: 8 }, + { date: new Date( '2024-11-01' ), value: 2 }, + { date: new Date( '2024-12-01' ), value: -4 }, + ], + options: {}, + }, + { + group: 'cairo', + label: 'Cairo', + data: [ + { date: new Date( '2024-01-01' ), value: 15 }, + { date: new Date( '2024-02-01' ), value: 17 }, + { date: new Date( '2024-03-01' ), value: 21 }, + { date: new Date( '2024-04-01' ), value: 26 }, + { date: new Date( '2024-05-01' ), value: 30 }, + { date: new Date( '2024-06-01' ), value: 33 }, + { date: new Date( '2024-07-01' ), value: 35 }, + { date: new Date( '2024-08-01' ), value: 34 }, + { date: new Date( '2024-09-01' ), value: 31 }, + { date: new Date( '2024-10-01' ), value: 27 }, + { date: new Date( '2024-11-01' ), value: 22 }, + { date: new Date( '2024-12-01' ), value: 17 }, + ], + options: {}, + }, + { + group: 'vancouver', + label: 'Vancouver', + data: [ + { date: new Date( '2024-01-01' ), value: 4 }, + { date: new Date( '2024-02-01' ), value: 6 }, + { date: new Date( '2024-03-01' ), value: 8 }, + { date: new Date( '2024-04-01' ), value: 11 }, + { date: new Date( '2024-05-01' ), value: 15 }, + { date: new Date( '2024-06-01' ), value: 18 }, + { date: new Date( '2024-07-01' ), value: 21 }, + { date: new Date( '2024-08-01' ), value: 22 }, + { date: new Date( '2024-09-01' ), value: 18 }, + { date: new Date( '2024-10-01' ), value: 13 }, + { date: new Date( '2024-11-01' ), value: 8 }, + { date: new Date( '2024-12-01' ), value: 5 }, + ], + options: {}, + }, ]; /** diff --git a/projects/js-packages/charts/src/utils/color-utils.ts b/projects/js-packages/charts/src/utils/color-utils.ts index b80904c72eeca..9fa47a4fd21db 100644 --- a/projects/js-packages/charts/src/utils/color-utils.ts +++ b/projects/js-packages/charts/src/utils/color-utils.ts @@ -1,12 +1,9 @@ /** - * Convert hex color to rgba with specified opacity - * This is genuinely reusable across chart components - * @param hex - The hex color string (e.g., '#ff0000') - * @param alpha - The opacity value between 0 and 1 - * @return The rgba color string (e.g., 'rgba(255, 0, 0, 0.5)') + * Validate hex color format + * @param hex - The hex color string to validate * @throws Error if hex string is malformed */ -export const hexToRgba = ( hex: string, alpha: number ): string => { +const validateHexColor = ( hex: string ): void => { // Validate hex format if ( typeof hex !== 'string' ) { throw new Error( 'Hex color must be a string' ); @@ -27,6 +24,18 @@ export const hexToRgba = ( hex: string, alpha: number ): string => { if ( ! /^[0-9a-fA-F]{6}$/.test( hexDigits ) ) { throw new Error( 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' ); } +}; + +/** + * Convert hex color to rgba with specified opacity + * This is genuinely reusable across chart components + * @param hex - The hex color string (e.g., '#ff0000') + * @param alpha - The opacity value between 0 and 1 + * @return The rgba color string (e.g., 'rgba(255, 0, 0, 0.5)') + * @throws Error if hex string is malformed + */ +export const hexToRgba = ( hex: string, alpha: number ): string => { + validateHexColor( hex ); // Validate alpha if ( typeof alpha !== 'number' || isNaN( alpha ) ) { @@ -38,3 +47,72 @@ export const hexToRgba = ( hex: string, alpha: number ): string => { const b = parseInt( hex.slice( 5, 7 ), 16 ); return `rgba(${ r }, ${ g }, ${ b }, ${ alpha })`; }; + +/** + * Convert hex color to HSL + * @param hex - hex color string + * @return HSL values as [h, s, l] + * @throws Error if hex string is malformed + */ +export const hexToHsl = ( hex: string ): [ number, number, number ] => { + validateHexColor( hex ); + + const r = parseInt( hex.slice( 1, 3 ), 16 ) / 255; + const g = parseInt( hex.slice( 3, 5 ), 16 ) / 255; + const b = parseInt( hex.slice( 5, 7 ), 16 ) / 255; + + const max = Math.max( r, g, b ); + const min = Math.min( r, g, b ); + let h = 0; + let s = 0; + const l = ( max + min ) / 2; + + if ( max !== min ) { + const d = max - min; + s = l > 0.5 ? d / ( 2 - max - min ) : d / ( max + min ); + + switch ( max ) { + case r: + h = ( g - b ) / d + ( g < b ? 6 : 0 ); + break; + case g: + h = ( b - r ) / d + 2; + break; + case b: + h = ( r - g ) / d + 4; + break; + } + h /= 6; + } + + return [ h * 360, s * 100, l * 100 ]; +}; + +/** + * Calculate the perceptual distance between two HSL colors + * @param hsl1 - first color in HSL format [h, s, l] + * @param hsl2 - second color in HSL format [h, s, l] + * @return distance value (0-100+, lower means more similar) + */ +export const getColorDistance = ( + hsl1: [ number, number, number ], + hsl2: [ number, number, number ] +): number => { + const [ h1, s1, l1 ] = hsl1; + const [ h2, s2, l2 ] = hsl2; + + // Calculate hue difference, accounting for circular nature (0° = 360°) + let hueDiff = Math.abs( h1 - h2 ); + hueDiff = Math.min( hueDiff, 360 - hueDiff ); + + // Weight the differences: hue is most important, then lightness, then saturation + const hueWeight = 2; + const lightnessWeight = 1; + const saturationWeight = 0.5; + + return Math.sqrt( + Math.pow( hueDiff * hueWeight, 2 ) + + Math.pow( ( l1 - l2 ) * lightnessWeight, 2 ) + + Math.pow( ( s1 - s2 ) * saturationWeight, 2 ) + ); +}; diff --git a/projects/js-packages/charts/src/utils/index.ts b/projects/js-packages/charts/src/utils/index.ts index e2ad030eafa69..c64d28a458b7d 100644 --- a/projects/js-packages/charts/src/utils/index.ts +++ b/projects/js-packages/charts/src/utils/index.ts @@ -22,4 +22,4 @@ export { isSafari } from './is-safari'; export { mergeThemes } from './merge-themes'; // Color utilities -export { hexToRgba } from './color-utils'; +export * from './color-utils'; diff --git a/projects/js-packages/charts/src/utils/test/color-utils.test.ts b/projects/js-packages/charts/src/utils/test/color-utils.test.ts index da69b25e91432..9fe376f20e79c 100644 --- a/projects/js-packages/charts/src/utils/test/color-utils.test.ts +++ b/projects/js-packages/charts/src/utils/test/color-utils.test.ts @@ -1,4 +1,4 @@ -import { hexToRgba } from '../color-utils'; +import { hexToRgba, hexToHsl, getColorDistance } from '../color-utils'; describe( 'hexToRgba', () => { describe( 'Valid hex colors', () => { @@ -230,3 +230,373 @@ describe( 'hexToRgba', () => { } ); } ); } ); + +describe( 'hexToHsl', () => { + describe( 'Basic color conversions', () => { + it( 'converts pure red to HSL', () => { + const result = hexToHsl( '#ff0000' ); + expect( result ).toEqual( [ 0, 100, 50 ] ); + } ); + + it( 'converts pure green to HSL', () => { + const result = hexToHsl( '#00ff00' ); + expect( result ).toEqual( [ 120, 100, 50 ] ); + } ); + + it( 'converts pure blue to HSL', () => { + const result = hexToHsl( '#0000ff' ); + expect( result ).toEqual( [ 240, 100, 50 ] ); + } ); + + it( 'converts white to HSL', () => { + const result = hexToHsl( '#ffffff' ); + expect( result ).toEqual( [ 0, 0, 100 ] ); + } ); + + it( 'converts black to HSL', () => { + const result = hexToHsl( '#000000' ); + expect( result ).toEqual( [ 0, 0, 0 ] ); + } ); + + it( 'converts gray to HSL', () => { + const result = hexToHsl( '#808080' ); + // Gray should have no hue or saturation, lightness around 50% + expect( result[ 0 ] ).toBe( 0 ); // Hue + expect( result[ 1 ] ).toBe( 0 ); // Saturation + expect( result[ 2 ] ).toBeCloseTo( 50.2, 1 ); // Lightness + } ); + } ); + + describe( 'Complex color conversions', () => { + it( 'converts cyan to HSL', () => { + const result = hexToHsl( '#00ffff' ); + expect( result ).toEqual( [ 180, 100, 50 ] ); + } ); + + it( 'converts magenta to HSL', () => { + const result = hexToHsl( '#ff00ff' ); + expect( result ).toEqual( [ 300, 100, 50 ] ); + } ); + + it( 'converts yellow to HSL', () => { + const result = hexToHsl( '#ffff00' ); + expect( result ).toEqual( [ 60, 100, 50 ] ); + } ); + + it( 'converts orange to HSL', () => { + const result = hexToHsl( '#ffa500' ); + expect( result[ 0 ] ).toBeCloseTo( 38.8, 1 ); // Hue + expect( result[ 1 ] ).toBe( 100 ); // Saturation + expect( result[ 2 ] ).toBeCloseTo( 50, 1 ); // Lightness + } ); + + it( 'converts purple to HSL', () => { + const result = hexToHsl( '#800080' ); + expect( result[ 0 ] ).toBe( 300 ); // Hue + expect( result[ 1 ] ).toBe( 100 ); // Saturation + expect( result[ 2 ] ).toBeCloseTo( 25.1, 1 ); // Lightness + } ); + } ); + + describe( 'Real-world color examples', () => { + it( 'converts primary blue color', () => { + const result = hexToHsl( '#4f46e5' ); + expect( result[ 0 ] ).toBeCloseTo( 243.4, 1 ); // Hue + expect( result[ 1 ] ).toBeCloseTo( 75.4, 1 ); // Saturation + expect( result[ 2 ] ).toBeCloseTo( 58.6, 1 ); // Lightness + } ); + + it( 'converts success green color', () => { + const result = hexToHsl( '#10b981' ); + expect( result[ 0 ] ).toBeCloseTo( 160.1, 1 ); // Hue + expect( result[ 1 ] ).toBeCloseTo( 84.1, 1 ); // Saturation + expect( result[ 2 ] ).toBeCloseTo( 39.4, 1 ); // Lightness + } ); + + it( 'converts error red color', () => { + const result = hexToHsl( '#ef4444' ); + expect( result[ 0 ] ).toBe( 0 ); // Hue + expect( result[ 1 ] ).toBeCloseTo( 84.2, 1 ); // Saturation + expect( result[ 2 ] ).toBeCloseTo( 60.2, 1 ); // Lightness + } ); + } ); + + describe( 'Input validation', () => { + describe( 'Invalid hex format', () => { + it( 'throws error for non-string input', () => { + expect( () => hexToHsl( 123 as unknown as string ) ).toThrow( + 'Hex color must be a string' + ); + expect( () => hexToHsl( null as unknown as string ) ).toThrow( + 'Hex color must be a string' + ); + expect( () => hexToHsl( undefined as unknown as string ) ).toThrow( + 'Hex color must be a string' + ); + } ); + + it( 'throws error for hex without # prefix', () => { + expect( () => hexToHsl( 'ff0000' ) ).toThrow( 'Hex color must start with #' ); + expect( () => hexToHsl( '000000' ) ).toThrow( 'Hex color must start with #' ); + } ); + + it( 'throws error for wrong length hex strings', () => { + expect( () => hexToHsl( '#ff' ) ).toThrow( + 'Hex color must be 7 characters long (e.g., #ff0000)' + ); + expect( () => hexToHsl( '#fff' ) ).toThrow( + 'Hex color must be 7 characters long (e.g., #ff0000)' + ); + expect( () => hexToHsl( '#ffff' ) ).toThrow( + 'Hex color must be 7 characters long (e.g., #ff0000)' + ); + expect( () => hexToHsl( '#fffff' ) ).toThrow( + 'Hex color must be 7 characters long (e.g., #ff0000)' + ); + expect( () => hexToHsl( '#ff00000' ) ).toThrow( + 'Hex color must be 7 characters long (e.g., #ff0000)' + ); + expect( () => hexToHsl( '#' ) ).toThrow( + 'Hex color must be 7 characters long (e.g., #ff0000)' + ); + } ); + + it( 'throws error for invalid hex characters', () => { + expect( () => hexToHsl( '#gggggg' ) ).toThrow( + 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' + ); + expect( () => hexToHsl( '#ff00gg' ) ).toThrow( + 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' + ); + expect( () => hexToHsl( '#zz0000' ) ).toThrow( + 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' + ); + expect( () => hexToHsl( '#ff@000' ) ).toThrow( + 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' + ); + expect( () => hexToHsl( '#ff 000' ) ).toThrow( + 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' + ); + } ); + + it( 'throws error for empty string', () => { + expect( () => hexToHsl( '' ) ).toThrow( 'Hex color must start with #' ); + } ); + } ); + } ); + + describe( 'Edge cases and precision', () => { + it( 'handles colors with very low saturation', () => { + const result = hexToHsl( '#fefefe' ); + expect( result[ 0 ] ).toBe( 0 ); // Hue should be 0 for near-white + expect( result[ 1 ] ).toBe( 0 ); // Saturation should be 0 for near-white + expect( result[ 2 ] ).toBeCloseTo( 99.6, 1 ); // Very high lightness + } ); + + it( 'handles colors with very high saturation', () => { + const result = hexToHsl( '#ff0001' ); + expect( result[ 0 ] ).toBeCloseTo( 359.8, 1 ); // Hue close to red + expect( result[ 1 ] ).toBe( 100 ); // Full saturation + expect( result[ 2 ] ).toBeCloseTo( 50.0, 1 ); // Medium lightness + } ); + + it( 'returns array with exactly 3 elements', () => { + const result = hexToHsl( '#abcdef' ); + expect( result ).toHaveLength( 3 ); + } ); + + it( 'returns hue in 0-360 range', () => { + const colors = [ '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff' ]; + colors.forEach( color => { + const [ h ] = hexToHsl( color ); + expect( h ).toBeGreaterThanOrEqual( 0 ); + expect( h ).toBeLessThan( 360 ); + } ); + } ); + + it( 'returns saturation in 0-100 range', () => { + const colors = [ '#ff0000', '#808080', '#ffffff', '#000000', '#abcdef' ]; + colors.forEach( color => { + const [ , s ] = hexToHsl( color ); + expect( s ).toBeGreaterThanOrEqual( 0 ); + expect( s ).toBeLessThanOrEqual( 100 ); + } ); + } ); + + it( 'returns lightness in 0-100 range', () => { + const colors = [ '#ff0000', '#808080', '#ffffff', '#000000', '#abcdef' ]; + colors.forEach( color => { + const [ , , l ] = hexToHsl( color ); + expect( l ).toBeGreaterThanOrEqual( 0 ); + expect( l ).toBeLessThanOrEqual( 100 ); + } ); + } ); + } ); +} ); + +describe( 'getColorDistance', () => { + describe( 'Identical colors', () => { + it( 'returns 0 for identical colors', () => { + const color1: [ number, number, number ] = [ 120, 50, 50 ]; + const color2: [ number, number, number ] = [ 120, 50, 50 ]; + const distance = getColorDistance( color1, color2 ); + expect( distance ).toBe( 0 ); + } ); + + it( 'returns 0 for black with black', () => { + const black: [ number, number, number ] = [ 0, 0, 0 ]; + const distance = getColorDistance( black, black ); + expect( distance ).toBe( 0 ); + } ); + + it( 'returns 0 for white with white', () => { + const white: [ number, number, number ] = [ 0, 0, 100 ]; + const distance = getColorDistance( white, white ); + expect( distance ).toBe( 0 ); + } ); + } ); + + describe( 'Hue differences', () => { + it( 'calculates distance for colors with different hues', () => { + const red: [ number, number, number ] = [ 0, 100, 50 ]; + const green: [ number, number, number ] = [ 120, 100, 50 ]; + const distance = getColorDistance( red, green ); + expect( distance ).toBeCloseTo( 240, 0 ); // 120° hue difference * 2 weight + } ); + + it( 'handles circular hue differences correctly', () => { + const red1: [ number, number, number ] = [ 0, 100, 50 ]; + const red2: [ number, number, number ] = [ 350, 100, 50 ]; + const distance = getColorDistance( red1, red2 ); + // Should use the shorter path: 10° instead of 350° + expect( distance ).toBeCloseTo( 20, 0 ); // 10° * 2 weight + } ); + + it( 'calculates maximum hue difference correctly', () => { + const red: [ number, number, number ] = [ 0, 100, 50 ]; + const cyan: [ number, number, number ] = [ 180, 100, 50 ]; + const distance = getColorDistance( red, cyan ); + expect( distance ).toBeCloseTo( 360, 0 ); // 180° * 2 weight + } ); + } ); + + describe( 'Lightness differences', () => { + it( 'calculates distance for colors with different lightness', () => { + const dark: [ number, number, number ] = [ 120, 50, 20 ]; + const light: [ number, number, number ] = [ 120, 50, 80 ]; + const distance = getColorDistance( dark, light ); + expect( distance ).toBeCloseTo( 60, 0 ); // 60% lightness difference * 1 weight + } ); + + it( 'calculates maximum lightness difference', () => { + const black: [ number, number, number ] = [ 0, 0, 0 ]; + const white: [ number, number, number ] = [ 0, 0, 100 ]; + const distance = getColorDistance( black, white ); + expect( distance ).toBeCloseTo( 100, 0 ); // 100% lightness difference * 1 weight + } ); + } ); + + describe( 'Saturation differences', () => { + it( 'calculates distance for colors with different saturation', () => { + const dull: [ number, number, number ] = [ 120, 20, 50 ]; + const vivid: [ number, number, number ] = [ 120, 80, 50 ]; + const distance = getColorDistance( dull, vivid ); + expect( distance ).toBeCloseTo( 30, 0 ); // 60% saturation difference * 0.5 weight + } ); + + it( 'calculates maximum saturation difference', () => { + const gray: [ number, number, number ] = [ 120, 0, 50 ]; + const vivid: [ number, number, number ] = [ 120, 100, 50 ]; + const distance = getColorDistance( gray, vivid ); + expect( distance ).toBeCloseTo( 50, 0 ); // 100% saturation difference * 0.5 weight + } ); + } ); + + describe( 'Combined differences', () => { + it( 'calculates distance with all components different', () => { + const color1: [ number, number, number ] = [ 0, 100, 25 ]; // Dark red + const color2: [ number, number, number ] = [ 180, 50, 75 ]; // Light cyan + const distance = getColorDistance( color1, color2 ); + + // Expected calculation: + // Hue: 180° * 2 = 360 + // Lightness: 50% * 1 = 50 + // Saturation: 50% * 0.5 = 25 + // Distance = sqrt(360² + 50² + 25²) ≈ 364.3 + expect( distance ).toBeCloseTo( 364.3, 1 ); + } ); + + it( 'weights hue differences more heavily than others', () => { + const baseColor: [ number, number, number ] = [ 0, 50, 50 ]; + + // Same hue difference, different component changes + const hueChange: [ number, number, number ] = [ 30, 50, 50 ]; // +30° hue + const lightnessChange: [ number, number, number ] = [ 0, 50, 80 ]; // +30% lightness + const saturationChange: [ number, number, number ] = [ 0, 80, 50 ]; // +30% saturation + + const hueDistance = getColorDistance( baseColor, hueChange ); + const lightnessDistance = getColorDistance( baseColor, lightnessChange ); + const saturationDistance = getColorDistance( baseColor, saturationChange ); + + // Hue should have the largest impact + expect( hueDistance ).toBeGreaterThan( lightnessDistance ); + expect( hueDistance ).toBeGreaterThan( saturationDistance ); + expect( lightnessDistance ).toBeGreaterThan( saturationDistance ); + } ); + } ); + + describe( 'Real-world color comparisons', () => { + it( 'calculates distance between similar blues', () => { + const blue1 = hexToHsl( '#4f46e5' ); // Primary blue + const blue2 = hexToHsl( '#3b82f6' ); // Sky blue + const distance = getColorDistance( blue1, blue2 ); + + // Should be relatively small since both are blue + expect( distance ).toBeLessThan( 100 ); + expect( distance ).toBeGreaterThan( 0 ); + } ); + + it( 'calculates distance between complementary colors', () => { + const red = hexToHsl( '#ef4444' ); + const green = hexToHsl( '#10b981' ); + const distance = getColorDistance( red, green ); + + // Should be large since they're complementary + expect( distance ).toBeGreaterThan( 200 ); + } ); + + it( 'calculates distance between different shades of same hue', () => { + const lightBlue = hexToHsl( '#bfdbfe' ); + const darkBlue = hexToHsl( '#1e40af' ); + const distance = getColorDistance( lightBlue, darkBlue ); + + // Should be moderate - same hue but different lightness + expect( distance ).toBeGreaterThan( 50 ); + expect( distance ).toBeLessThan( 150 ); + } ); + } ); + + describe( 'Edge cases', () => { + it( 'handles extreme hue values correctly', () => { + const color1: [ number, number, number ] = [ 359, 50, 50 ]; + const color2: [ number, number, number ] = [ 1, 50, 50 ]; + const distance = getColorDistance( color1, color2 ); + // Should use shorter circular distance: 2° not 358° + expect( distance ).toBeCloseTo( 4, 0 ); // 2° * 2 weight + } ); + + it( 'handles zero values correctly', () => { + const color1: [ number, number, number ] = [ 0, 0, 0 ]; + const color2: [ number, number, number ] = [ 1, 1, 1 ]; + const distance = getColorDistance( color1, color2 ); + expect( distance ).toBeCloseTo( 2.29, 1 ); // sqrt(2² + 1² + 0.5²) + } ); + + it( 'returns positive distance values', () => { + const color1: [ number, number, number ] = [ 100, 80, 30 ]; + const color2: [ number, number, number ] = [ 200, 20, 70 ]; + const distance = getColorDistance( color1, color2 ); + expect( distance ).toBeGreaterThan( 0 ); + } ); + } ); +} );