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 );
+ } );
+ } );
+} );