@automattic/charts 4.4.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/TOKENS.md +2 -2
  3. package/dist/index.cjs +524 -203
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +4 -0
  6. package/dist/index.d.cts +22 -4
  7. package/dist/index.d.ts +22 -4
  8. package/dist/index.js +524 -203
  9. package/dist/index.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/charts/bar-chart/bar-chart.tsx +22 -10
  12. package/src/charts/bar-chart/private/band-tooltip.tsx +18 -12
  13. package/src/charts/geo-chart/geo-chart.tsx +8 -0
  14. package/src/charts/geo-chart/index.ts +7 -1
  15. package/src/charts/geo-chart/test/geo-chart.test.tsx +16 -0
  16. package/src/charts/geo-chart/types.ts +17 -0
  17. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +1 -1
  18. package/src/charts/pie-chart/pie-chart.module.scss +4 -0
  19. package/src/charts/pie-chart/pie-chart.tsx +103 -7
  20. package/src/charts/pie-chart/test/pie-chart.test.tsx +149 -0
  21. package/src/charts/private/chart-layout/chart-layout.tsx +5 -1
  22. package/src/components/tooltip/accessible-tooltip.tsx +40 -4
  23. package/src/components/tooltip/test/accessible-tooltip.test.tsx +82 -5
  24. package/src/index.ts +7 -1
  25. package/src/providers/chart-context/global-charts-provider.tsx +57 -41
  26. package/src/providers/chart-context/private/catalog-pointers.ts +9 -1
  27. package/src/providers/chart-context/private/palette-generator.ts +305 -0
  28. package/src/providers/chart-context/private/perceptual-color.ts +219 -0
  29. package/src/providers/chart-context/private/series-palette.ts +1 -1
  30. package/src/providers/chart-context/test/chart-context.test.tsx +88 -8
  31. package/src/providers/chart-context/test/global-charts-provider-first-render.test.tsx +27 -0
  32. package/src/providers/chart-context/test/palette-generator.test.ts +181 -0
  33. package/src/providers/chart-context/test/perceptual-color.test.ts +75 -0
  34. package/src/test-utils/zoned-parsing-suite.ts +1 -1
  35. package/src/utils/color-utils.ts +5 -3
  36. package/src/utils/test/date-formatting.test.ts +1 -1
  37. package/src/providers/chart-context/private/get-chart-color.ts +0 -223
package/dist/index.js CHANGED
@@ -436,9 +436,11 @@ const hexToRgba = (hex, alpha) => {
436
436
  return color(hex).copy({ opacity: alpha }).formatRgb();
437
437
  };
438
438
  /**
439
- * Calculate the perceptual distance between two HSL colors
440
- * @param hsl1 - first color in HSL format [h, s, l]
441
- * @param hsl2 - second color in HSL format [h, s, l]
439
+ * Weighted Euclidean distance between two HSL colors.
440
+ *
441
+ * @deprecated Not perceptual and blind to color vision deficiency, so charts no longer use it; it will be removed in a future major version.
442
+ * @param hsl1 - first color in HSL format [h, s, l]
443
+ * @param hsl2 - second color in HSL format [h, s, l]
442
444
  * @return distance value (0-100+, lower means more similar)
443
445
  */
444
446
  const getColorDistance = (hsl1, hsl2) => {
@@ -800,145 +802,421 @@ const createZonedClock = (timeZone) => {
800
802
  * All JS token resolution reads this element rather than `document.documentElement`, so a `getComputedStyle` call sees the same `--a8c-charts-*` values — including any override set inside the provider tree — that a CSS-painted sibling element does.
801
803
  */
802
804
  const ChartScopeContext = createContext(null);
803
- //#endregion
804
- //#region src/providers/chart-context/private/get-chart-color.ts
805
- /**
806
- * Golden ratio for mathematically pleasing color distribution
807
- * Used to generate evenly spaced hues that are visually distinct
808
- */
809
- const GOLDEN_RATIO = .618033988749;
810
805
  /**
811
- * Minimum perceptual distance between colors to ensure visual distinction
812
- * Based on weighted HSL distance calculation optimized for chart readability
813
- */
814
- const MIN_COLOR_DISTANCE = 25;
815
- /**
816
- * Maximum attempts to find a sufficiently different color
817
- * Prevents infinite loops while allowing reasonable search space
818
- */
819
- const MAX_COLOR_GENERATION_ATTEMPTS = 50;
820
- /**
821
- * Color variation attempt offset
822
- * Small increment to explore slightly different color variations per attempt
823
- */
824
- const VARIATION_ATTEMPT_OFFSET = .1;
825
- /**
826
- * Base saturation percentage for generated colors
827
- * 45% provides muted, professional colors without being washed out
828
- */
829
- const BASE_SATURATION = 45;
830
- /**
831
- * Number of saturation variation steps
832
- * Creates 3 different saturation levels for variety
833
- */
834
- const SATURATION_VARIATION_STEPS = 3;
835
- /**
836
- * Saturation increment per variation step
837
- * 10% increments provide subtle variation while keeping colors muted
838
- * Results in saturation levels: 45%, 55%, 65%
839
- */
840
- const SATURATION_INCREMENT = 10;
841
- /**
842
- * Base lightness percentage for generated colors
843
- * 35% ensures sufficient contrast against white backgrounds for WCAG AA compliance
844
- * WCAG AA requires 4.5:1 contrast ratio for normal text
806
+ * The catalog role holding one series-palette slot.
807
+ *
808
+ * @param slot - The one-based slot number.
809
+ * @return The role name.
845
810
  */
846
- const BASE_LIGHTNESS = 35;
811
+ const seriesRole = (slot) => `--a8c-charts-color-series-${slot}`;
812
+ const SERIES_SLOT_1_FALLBACK = "#3858e9";
813
+ /** The catalog pointer for every slot, in slot order. */
814
+ const SERIES_PALETTE_POINTERS = Array.from({ length: 5 }, (_, index) => index === 0 ? `var(${seriesRole(1)}, ${SERIES_SLOT_1_FALLBACK})` : `var(${seriesRole(index + 1)})`);
815
+ //#endregion
816
+ //#region src/providers/chart-context/private/catalog-pointers.ts
817
+ /** Terminal literals the provider also generates from before its first read, so SSR and the first client render agree. */
818
+ const BACKGROUND_FALLBACK = "#ffffff";
819
+ const LABEL_FALLBACK = "#1e1e1e";
820
+ const LABEL_INVERSE_FALLBACK = "#f0f0f0";
821
+ const CATALOG_POINTERS = {
822
+ background: `var(--a8c-charts-color-background, ${BACKGROUND_FALLBACK})`,
823
+ label: `var(--a8c-charts-color-label, ${LABEL_FALLBACK})`,
824
+ labelAxis: "var(--a8c-charts-color-label-axis, #1e1e1e)",
825
+ labelInverse: `var(--a8c-charts-color-label-inverse, ${LABEL_INVERSE_FALLBACK})`,
826
+ labelBackground: "var(--a8c-charts-color-label-background, transparent)",
827
+ grid: "var(--a8c-charts-color-grid, #dbdbdb)",
828
+ axisX: "var(--a8c-charts-color-axis-x, #dbdbdb)",
829
+ tickX: "var(--a8c-charts-color-tick-x, #dbdbdb)",
830
+ axisY: "var(--a8c-charts-color-axis-y, none)",
831
+ tickY: "var(--a8c-charts-color-tick-y, none)",
832
+ annotation: "var(--a8c-charts-color-annotation, #1e1e1e)",
833
+ surface: "var(--a8c-charts-color-surface, #fff)",
834
+ surfaceSecondary: "var(--a8c-charts-color-surface-secondary, #f4f4f4)",
835
+ trendUp: "var(--a8c-charts-color-trend-up, #008030)",
836
+ trendDown: "var(--a8c-charts-color-trend-down, #cc1818)",
837
+ series: SERIES_PALETTE_POINTERS
838
+ };
847
839
  /**
848
- * Number of lightness variation steps
849
- * Creates 4 different lightness levels for variety
840
+ * The annotation parts visx paints, in the shape `@visx/annotation` takes. `radius`
841
+ * rides along as the base the theme and the per-datum styles merge onto.
850
842
  */
851
- const LIGHTNESS_VARIATION_STEPS = 4;
843
+ const ANNOTATION_POINTERS = {
844
+ label: {
845
+ anchorLineStroke: CATALOG_POINTERS.annotation,
846
+ backgroundFill: CATALOG_POINTERS.surface
847
+ },
848
+ connector: { stroke: CATALOG_POINTERS.annotation },
849
+ circleSubject: {
850
+ stroke: "transparent",
851
+ fill: CATALOG_POINTERS.annotation,
852
+ radius: 5
853
+ }
854
+ };
855
+ //#endregion
856
+ //#region src/providers/chart-context/private/perceptual-color.ts
857
+ const DEUTERANOPIA = [
858
+ .367322,
859
+ .860646,
860
+ -.227968,
861
+ .280085,
862
+ .672501,
863
+ .047413,
864
+ -.01182,
865
+ .04294,
866
+ .968881
867
+ ];
868
+ const PROTANOPIA = [
869
+ .152286,
870
+ 1.052583,
871
+ -.204868,
872
+ .114503,
873
+ .786281,
874
+ .099216,
875
+ -.003882,
876
+ -.048116,
877
+ 1.051998
878
+ ];
879
+ const DEGREES = Math.PI / 180;
880
+ const toLinear = (channel) => channel <= .04045 ? channel / 12.92 : Math.pow((channel + .055) / 1.055, 2.4);
881
+ const fromLinear = (channel) => channel <= .0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - .055;
882
+ const hexToLinear = (hex) => {
883
+ validateHexColor(hex);
884
+ return [
885
+ 1,
886
+ 3,
887
+ 5
888
+ ].map((start) => toLinear(parseInt(hex.slice(start, start + 2), 16) / 255));
889
+ };
890
+ const linearToHex = (linear) => "#" + linear.map((channel) => Math.round(Math.min(1, Math.max(0, fromLinear(channel))) * 255).toString(16).padStart(2, "0")).join("");
891
+ const simulate = (matrix, [r, g, b]) => [
892
+ 0,
893
+ 3,
894
+ 6
895
+ ].map((row) => Math.min(1, Math.max(0, matrix[row] * r + matrix[row + 1] * g + matrix[row + 2] * b)));
896
+ const linearToLab = ([r, g, b]) => {
897
+ const x = (.4124564 * r + .3575761 * g + .1804375 * b) / .95047;
898
+ const y = .2126729 * r + .7151522 * g + .072175 * b;
899
+ const z = (.0193339 * r + .119192 * g + .9503041 * b) / 1.08883;
900
+ const f = (t) => t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116;
901
+ return [
902
+ 116 * f(y) - 16,
903
+ 500 * (f(x) - f(y)),
904
+ 200 * (f(y) - f(z))
905
+ ];
906
+ };
852
907
  /**
853
- * Lightness increment per variation step
854
- * 8% increments provide subtle lightness variation while maintaining accessibility
855
- * Results in lightness levels: 35%, 43%, 51%, 59%
856
- * All levels maintain WCAG AA compliance against white backgrounds
857
- */
858
- const LIGHTNESS_INCREMENT = 8;
908
+ * CIEDE2000 color difference between two CIELAB colors.
909
+ *
910
+ * @param first - First color in CIELAB.
911
+ * @param second - Second color in CIELAB.
912
+ * @return ΔE00; about 10 is where two chart categories stop being reliably distinguishable.
913
+ */
914
+ const deltaE2000 = (first, second) => {
915
+ const [l1, a1, b1] = first;
916
+ const [l2, a2, b2] = second;
917
+ const chromaMean = (Math.hypot(a1, b1) + Math.hypot(a2, b2)) / 2;
918
+ const g = .5 * (1 - Math.sqrt(chromaMean ** 7 / (chromaMean ** 7 + 25 ** 7)));
919
+ const a1p = a1 * (1 + g);
920
+ const a2p = a2 * (1 + g);
921
+ const c1p = Math.hypot(a1p, b1);
922
+ const c2p = Math.hypot(a2p, b2);
923
+ const hueOf = (a, b) => a === 0 && b === 0 ? 0 : (Math.atan2(b, a) / DEGREES + 360) % 360;
924
+ const h1p = hueOf(a1p, b1);
925
+ const h2p = hueOf(a2p, b2);
926
+ const chromatic = c1p * c2p !== 0;
927
+ let hueDelta = chromatic ? h2p - h1p : 0;
928
+ if (hueDelta > 180) hueDelta -= 360;
929
+ else if (hueDelta < -180) hueDelta += 360;
930
+ const lightnessDelta = l2 - l1;
931
+ const chromaDelta = c2p - c1p;
932
+ const hueDifference = 2 * Math.sqrt(c1p * c2p) * Math.sin(hueDelta * DEGREES / 2);
933
+ const lightnessMean = (l1 + l2) / 2;
934
+ const chromaMeanPrime = (c1p + c2p) / 2;
935
+ let hueMean = h1p + h2p;
936
+ if (chromatic) {
937
+ if (Math.abs(h1p - h2p) > 180) hueMean += hueMean < 360 ? 360 : -360;
938
+ hueMean /= 2;
939
+ }
940
+ const t = 1 - .17 * Math.cos((hueMean - 30) * DEGREES) + .24 * Math.cos(2 * hueMean * DEGREES) + .32 * Math.cos((3 * hueMean + 6) * DEGREES) - .2 * Math.cos((4 * hueMean - 63) * DEGREES);
941
+ const lightnessScale = 1 + .015 * (lightnessMean - 50) ** 2 / Math.sqrt(20 + (lightnessMean - 50) ** 2);
942
+ const chromaScale = 1 + .045 * chromaMeanPrime;
943
+ const hueScale = 1 + .015 * chromaMeanPrime * t;
944
+ const rotation = -2 * Math.sqrt(chromaMeanPrime ** 7 / (chromaMeanPrime ** 7 + 25 ** 7)) * Math.sin(60 * Math.exp(-(((hueMean - 275) / 25) ** 2)) * DEGREES);
945
+ const lightnessTerm = lightnessDelta / lightnessScale;
946
+ const chromaTerm = chromaDelta / chromaScale;
947
+ const hueTerm = hueDifference / hueScale;
948
+ return Math.sqrt(lightnessTerm ** 2 + chromaTerm ** 2 + hueTerm ** 2 + rotation * chromaTerm * hueTerm);
949
+ };
859
950
  /**
860
- * Minimum hue range in degrees to ensure sufficient color variety
861
- * 60 degrees provides reasonable color spread even for narrow palettes
951
+ * A hex color in CIELAB as seen in normal vision, under deuteranopia and under protanopia.
952
+ *
953
+ * @param hex - Six-digit hex color.
954
+ * @return The three views.
955
+ * @throws {Error} if hex string is malformed
862
956
  */
863
- const MIN_HUE_RANGE_DEGREES = 60;
957
+ const hexToViews = (hex) => {
958
+ const linear = hexToLinear(hex);
959
+ return [
960
+ linearToLab(linear),
961
+ linearToLab(simulate(DEUTERANOPIA, linear)),
962
+ linearToLab(simulate(PROTANOPIA, linear))
963
+ ];
964
+ };
864
965
  /**
865
- * Hue range expansion factor
866
- * 1.3x expansion provides slightly more variety than the original palette
966
+ * The smallest ΔE00 between two colors across the three views.
967
+ *
968
+ * @param first - First color's views.
969
+ * @param second - Second color's views.
970
+ * @return The distance in the view where the two are hardest to tell apart.
867
971
  */
868
- const HUE_RANGE_EXPANSION_FACTOR = 1.3;
972
+ const viewDistance = (first, second) => Math.min(deltaE2000(first[0], second[0]), deltaE2000(first[1], second[1]), deltaE2000(first[2], second[2]));
869
973
  /**
870
- * Threshold for detecting hue wrap-around (color wheel boundary crossing)
871
- * 180 degrees indicates the colors span more than half the color wheel
974
+ * Convert an OKLCH color to sRGB hex.
975
+ *
976
+ * @param lightness - OKLab L, 0 to 1.
977
+ * @param chroma - OKLCH C.
978
+ * @param hue - Hue in degrees.
979
+ * @return Hex color, or null when the color is outside the sRGB gamut.
872
980
  */
873
- const HUE_WRAP_THRESHOLD_DEGREES = 180;
981
+ const oklchToHex = (lightness, chroma, hue) => {
982
+ const a = chroma * Math.cos(hue * DEGREES);
983
+ const b = chroma * Math.sin(hue * DEGREES);
984
+ const l = (lightness + .3963377774 * a + .2158037573 * b) ** 3;
985
+ const m = (lightness - .1055613458 * a - .0638541728 * b) ** 3;
986
+ const s = (lightness - .0894841775 * a - 1.291485548 * b) ** 3;
987
+ const linear = [
988
+ 4.0767416621 * l - 3.3077633306 * m + .2309645873 * s,
989
+ -1.2684380046 * l + 2.6097574011 * m - .3413193965 * s,
990
+ -.0041960863 * l - .7034186147 * m + 1.707614701 * s
991
+ ];
992
+ if (linear.some((channel) => channel < -1e-4 || channel > 1.0001)) return null;
993
+ return linearToHex(linear);
994
+ };
874
995
  /**
875
- * Full color wheel rotation in degrees
996
+ * OKLCH chroma and hue of a hex color.
997
+ *
998
+ * @param hex - Six-digit hex color.
999
+ * @return Chroma, and hue in degrees from 0 to 360.
1000
+ * @throws {Error} if hex string is malformed
876
1001
  */
877
- const FULL_HUE_ROTATION_DEGREES = 360;
1002
+ const hexToOklch = (hex) => {
1003
+ const [r, g, b] = hexToLinear(hex);
1004
+ const l = Math.cbrt(.4122214708 * r + .5363325363 * g + .0514459929 * b);
1005
+ const m = Math.cbrt(.2119034982 * r + .6806995451 * g + .1073969566 * b);
1006
+ const s = Math.cbrt(.0883024619 * r + .2817188376 * g + .6299787005 * b);
1007
+ const a = 1.9779984951 * l - 2.428592205 * m + .4505937099 * s;
1008
+ const bAxis = .0259040371 * l + .7827717662 * m - .808675766 * s;
1009
+ return {
1010
+ chroma: Math.hypot(a, bAxis),
1011
+ hue: (Math.atan2(bAxis, a) / DEGREES + 360) % 360
1012
+ };
1013
+ };
878
1014
  /**
879
- * Factor for single color hue range
1015
+ * WCAG contrast ratio between two relative luminances.
1016
+ *
1017
+ * @param first - First relative luminance.
1018
+ * @param second - Second relative luminance.
1019
+ * @return Ratio from 1 to 21.
880
1020
  */
881
- const SINGLE_COLOR_HUE_RANGE_FACTOR = .33;
1021
+ const luminanceContrastRatio = (first, second) => (Math.max(first, second) + .05) / (Math.min(first, second) + .05);
882
1022
  /**
883
- * Get a color from the colors array or generate a new color using the golden ratio
1023
+ * WCAG contrast ratio between two hex colors.
884
1024
  *
885
- * @param index - the index of the color to get
886
- * @param colorCache - pre-computed color data for performance
887
- * @return a color from the colors array or a new color using the golden ratio
1025
+ * @param first - First hex color.
1026
+ * @param second - Second hex color.
1027
+ * @return Ratio from 1 to 21.
1028
+ * @throws {Error} if either hex string is malformed
888
1029
  */
889
- const getChartColor = (index, colorCache) => {
890
- const { colors, hues, existingHslColors, minHue: cachedMinHue, maxHue: cachedMaxHue } = colorCache;
891
- if (index < colors.length) return colors[index];
892
- let minHue = cachedMinHue;
893
- let maxHue = cachedMaxHue;
894
- for (let attempt = 0; attempt < MAX_COLOR_GENERATION_ATTEMPTS; attempt++) {
895
- let hue = (index - colors.length + attempt * VARIATION_ATTEMPT_OFFSET) * GOLDEN_RATIO * FULL_HUE_ROTATION_DEGREES % FULL_HUE_ROTATION_DEGREES;
896
- if (hues.length > 0) {
897
- let hueRange = maxHue - minHue;
898
- if (hues.length === 1) hueRange = FULL_HUE_ROTATION_DEGREES * SINGLE_COLOR_HUE_RANGE_FACTOR;
899
- else if (hueRange > HUE_WRAP_THRESHOLD_DEGREES) {
900
- const altMinHue = Math.min(...hues.filter((h) => h > HUE_WRAP_THRESHOLD_DEGREES));
901
- const altMaxHue = Math.max(...hues.filter((h) => h < HUE_WRAP_THRESHOLD_DEGREES)) + FULL_HUE_ROTATION_DEGREES;
902
- const altRange = altMaxHue - altMinHue;
903
- if (altRange < hueRange) {
904
- minHue = altMinHue;
905
- maxHue = altMaxHue;
906
- hueRange = altRange;
907
- }
1030
+ const contrastRatio = (first, second) => luminanceContrastRatio(relativeLuminance(first), relativeLuminance(second));
1031
+ /** WCAG 1.4.3 text contrast, which a label drawn on a fill needs from at least one label color. */
1032
+ const MIN_LABEL_CONTRAST = 4.5;
1033
+ /** Degrees of hue one unit of OKLCH chroma is worth when ranking candidates by hue. */
1034
+ const CHROMA_WEIGHT = 300;
1035
+ /** ΔE00 one unit of OKLCH chroma is worth when ranking candidates by distance. */
1036
+ const FAR_CHROMA_WEIGHT = 100;
1037
+ const toCandidate = (hex, oklch = hexToOklch(hex)) => ({
1038
+ hex,
1039
+ views: hexToViews(hex),
1040
+ luminance: relativeLuminance(hex),
1041
+ hue: oklch.hue,
1042
+ chroma: oklch.chroma
1043
+ });
1044
+ const rank = (candidate, distance, anchorHue) => anchorHue === null ? -(distance + candidate.chroma * FAR_CHROMA_WEIGHT) : (candidate.hue - anchorHue + 360) % 360 - candidate.chroma * CHROMA_WEIGHT;
1045
+ const LIGHTNESS_STEPS = Array.from({ length: 13 }, (_, step) => .45 + step * .025);
1046
+ const CHROMA_STEPS = [
1047
+ .12,
1048
+ .15,
1049
+ .18,
1050
+ .21
1051
+ ];
1052
+ const HUE_STEPS = Array.from({ length: 72 }, (_, step) => step * 5);
1053
+ let candidateGrid = null;
1054
+ const getCandidateGrid = () => {
1055
+ if (!candidateGrid) {
1056
+ const seen = /* @__PURE__ */ new Set();
1057
+ candidateGrid = [];
1058
+ for (const lightness of LIGHTNESS_STEPS) for (const chroma of CHROMA_STEPS) for (const hue of HUE_STEPS) {
1059
+ const hex = oklchToHex(lightness, chroma, hue);
1060
+ if (hex && !seen.has(hex)) {
1061
+ seen.add(hex);
1062
+ candidateGrid.push(toCandidate(hex, {
1063
+ hue,
1064
+ chroma
1065
+ }));
908
1066
  }
909
- const expandedRange = Math.max(hueRange * HUE_RANGE_EXPANSION_FACTOR, MIN_HUE_RANGE_DEGREES);
910
- hue = (minHue + maxHue) / 2 - expandedRange / 2 + hue / FULL_HUE_ROTATION_DEGREES * expandedRange;
911
- hue = (hue % FULL_HUE_ROTATION_DEGREES + FULL_HUE_ROTATION_DEGREES) % FULL_HUE_ROTATION_DEGREES;
912
1067
  }
913
- const saturation = BASE_SATURATION + (index + attempt) % SATURATION_VARIATION_STEPS * SATURATION_INCREMENT;
914
- const lightness = BASE_LIGHTNESS + (index + attempt) % LIGHTNESS_VARIATION_STEPS * LIGHTNESS_INCREMENT;
915
- const candidateHsl = [
916
- hue,
917
- saturation,
918
- lightness
919
- ];
920
- let isSufficientlyDifferent = true;
921
- for (const existingHsl of existingHslColors) if (getColorDistance(candidateHsl, existingHsl) < MIN_COLOR_DISTANCE) {
922
- isSufficientlyDifferent = false;
923
- break;
1068
+ }
1069
+ return candidateGrid;
1070
+ };
1071
+ const legiblePools = /* @__PURE__ */ new Map();
1072
+ const legibleCandidatesFor = (rawBackground, labelColors) => {
1073
+ const background = rawBackground.toLowerCase();
1074
+ const key = [background, ...labelColors.map((hex) => hex.toLowerCase())].join("|");
1075
+ let pool = legiblePools.get(key);
1076
+ if (!pool) {
1077
+ const backgroundLuminance = relativeLuminance(background);
1078
+ const labelLuminances = labelColors.map(relativeLuminance);
1079
+ const onBackground = getCandidateGrid().filter((candidate) => luminanceContrastRatio(candidate.luminance, backgroundLuminance) >= 3);
1080
+ const underLabels = onBackground.filter((candidate) => labelLuminances.some((labelLuminance) => luminanceContrastRatio(candidate.luminance, labelLuminance) >= MIN_LABEL_CONTRAST));
1081
+ pool = underLabels.length > 0 ? underLabels : onBackground;
1082
+ legiblePools.set(key, pool);
1083
+ }
1084
+ return pool;
1085
+ };
1086
+ /**
1087
+ * Build a tracker for a candidate pool, seeding each unused candidate's nearest distance against
1088
+ * every color already in the palette.
1089
+ *
1090
+ * @param pool - Candidates to track, in selection-priority order.
1091
+ * @param palette - Colors already placed.
1092
+ * @param usedHexes - Hex values already placed; a used candidate's distance is never read.
1093
+ * @return The tracker.
1094
+ */
1095
+ const buildTracker = (pool, palette, usedHexes) => {
1096
+ const nearest = new Float64Array(pool.length).fill(Infinity);
1097
+ for (let i = 0; i < pool.length; i++) {
1098
+ if (usedHexes.has(pool[i].hex)) continue;
1099
+ for (const color of palette) {
1100
+ const distance = viewDistance(pool[i].views, color.views);
1101
+ if (distance < nearest[i]) nearest[i] = distance;
924
1102
  }
925
- if (isSufficientlyDifferent) return hsl(Math.round(hue), saturation / 100, lightness / 100).formatHex();
926
1103
  }
927
- const fallbackHue = (index - colors.length) * GOLDEN_RATIO * FULL_HUE_ROTATION_DEGREES % FULL_HUE_ROTATION_DEGREES;
928
- const fallbackSaturation = BASE_SATURATION + index % SATURATION_VARIATION_STEPS * SATURATION_INCREMENT;
929
- const fallbackLightness = BASE_LIGHTNESS + index % LIGHTNESS_VARIATION_STEPS * LIGHTNESS_INCREMENT;
930
- return hsl(Math.round(fallbackHue), fallbackSaturation / 100, fallbackLightness / 100).formatHex();
1104
+ return {
1105
+ pool,
1106
+ nearest
1107
+ };
931
1108
  };
932
1109
  /**
933
- * The catalog role holding one series-palette slot.
1110
+ * Fold one newly placed color into a tracker's nearest distances.
934
1111
  *
935
- * @param slot - The one-based slot number.
936
- * @return The role name.
1112
+ * @param tracker - Tracker to update.
1113
+ * @param addedColor - Color just added to the palette.
1114
+ * @param usedHexes - Hex values already placed; a used candidate's distance is never read.
937
1115
  */
938
- const seriesRole = (slot) => `--a8c-charts-color-series-${slot}`;
939
- const SERIES_SLOT_1_FALLBACK = "#3858e9";
940
- /** The catalog pointer for every slot, in slot order. */
941
- const SERIES_PALETTE_POINTERS = Array.from({ length: 5 }, (_, index) => index === 0 ? `var(${seriesRole(1)}, ${SERIES_SLOT_1_FALLBACK})` : `var(${seriesRole(index + 1)})`);
1116
+ const updateTracker = (tracker, addedColor, usedHexes) => {
1117
+ for (let i = 0; i < tracker.pool.length; i++) {
1118
+ if (usedHexes.has(tracker.pool[i].hex)) continue;
1119
+ const distance = viewDistance(tracker.pool[i].views, addedColor.views);
1120
+ if (distance < tracker.nearest[i]) tracker.nearest[i] = distance;
1121
+ }
1122
+ };
1123
+ /**
1124
+ * Pick the next color: the best-ranked unused candidate that clears `PREFERRED_SEPARATION` from
1125
+ * every earlier color; otherwise the farthest candidate.
1126
+ *
1127
+ * @param tracker - Tracker to read.
1128
+ * @param usedHexes - Hex values already placed; a used candidate is never picked.
1129
+ * @param anchorHue - Hue to walk the wheel from, or null to rank by distance.
1130
+ * @return The picked candidate, or null if every candidate in the pool is used.
1131
+ */
1132
+ const pickNext = (tracker, usedHexes, anchorHue) => {
1133
+ let farthestIndex = -1;
1134
+ let farthestDistance = -Infinity;
1135
+ let preferredIndex = -1;
1136
+ let preferredRank = Infinity;
1137
+ for (let i = 0; i < tracker.pool.length; i++) {
1138
+ if (usedHexes.has(tracker.pool[i].hex)) continue;
1139
+ const distance = tracker.nearest[i];
1140
+ if (distance > farthestDistance) {
1141
+ farthestDistance = distance;
1142
+ farthestIndex = i;
1143
+ }
1144
+ if (distance >= 12) {
1145
+ const candidateRank = rank(tracker.pool[i], distance, anchorHue);
1146
+ if (candidateRank < preferredRank || candidateRank === preferredRank && distance > tracker.nearest[preferredIndex]) {
1147
+ preferredRank = candidateRank;
1148
+ preferredIndex = i;
1149
+ }
1150
+ }
1151
+ }
1152
+ const index = preferredIndex === -1 ? farthestIndex : preferredIndex;
1153
+ return index === -1 ? null : tracker.pool[index];
1154
+ };
1155
+ const generators = /* @__PURE__ */ new Map();
1156
+ const MAX_CACHED_GENERATORS = 32;
1157
+ /**
1158
+ * Build the series palette: the seeds, then colors that stay apart from every earlier color in
1159
+ * normal vision and under deuteranopia and protanopia.
1160
+ *
1161
+ * @param seeds - Resolved hex palette slots, in slot order.
1162
+ * @param background - Resolved hex chart background.
1163
+ * @param labelColors - Resolved hex label colors that may be drawn on a fill; each generated color reaches `MIN_LABEL_CONTRAST` with one of them.
1164
+ * @return The color at a palette index.
1165
+ */
1166
+ const createPaletteGenerator = (seeds, background, labelColors = []) => {
1167
+ const normalizedSeeds = seeds.map((hex) => hex.toLowerCase());
1168
+ const normalizedBackground = background.toLowerCase();
1169
+ const normalizedLabels = labelColors.map((hex) => hex.toLowerCase());
1170
+ const key = `${normalizedSeeds.join(",")}|${normalizedBackground}|${normalizedLabels.join(",")}`;
1171
+ let colorAt = generators.get(key);
1172
+ if (!colorAt) {
1173
+ if (generators.size >= MAX_CACHED_GENERATORS) generators.delete(generators.keys().next().value);
1174
+ colorAt = buildPaletteGenerator(normalizedSeeds, normalizedBackground, normalizedLabels);
1175
+ generators.set(key, colorAt);
1176
+ }
1177
+ return colorAt;
1178
+ };
1179
+ const buildPaletteGenerator = (normalizedSeeds, background, labelColors) => {
1180
+ const palette = normalizedSeeds.map((hex) => toCandidate(hex));
1181
+ const usedHexes = new Set(normalizedSeeds);
1182
+ const anchorHue = palette.length > 0 ? palette[0].hue : null;
1183
+ let legibleTracker = null;
1184
+ let legibleExhausted = false;
1185
+ let gridTracker = null;
1186
+ let gridExhausted = false;
1187
+ let placedBeforeExhaustion = 0;
1188
+ let repeatCount = 0;
1189
+ const nextColor = () => {
1190
+ const walkHue = (palette.length - normalizedSeeds.length) % 2 === 1 ? anchorHue : null;
1191
+ if (!legibleExhausted) {
1192
+ legibleTracker ??= buildTracker(legibleCandidatesFor(background, labelColors), palette, usedHexes);
1193
+ const picked = pickNext(legibleTracker, usedHexes, walkHue);
1194
+ if (picked) return picked;
1195
+ legibleExhausted = true;
1196
+ }
1197
+ if (!gridExhausted) {
1198
+ gridTracker ??= buildTracker(getCandidateGrid(), palette, usedHexes);
1199
+ const picked = pickNext(gridTracker, usedHexes, walkHue);
1200
+ if (picked) return picked;
1201
+ gridExhausted = true;
1202
+ placedBeforeExhaustion = palette.length;
1203
+ }
1204
+ const repeated = palette[repeatCount % placedBeforeExhaustion];
1205
+ repeatCount++;
1206
+ return repeated;
1207
+ };
1208
+ return (index) => {
1209
+ if (index < palette.length) return palette[index].hex;
1210
+ while (palette.length <= index) {
1211
+ const next = nextColor();
1212
+ usedHexes.add(next.hex);
1213
+ palette.push(next);
1214
+ if (legibleTracker && !legibleExhausted) updateTracker(legibleTracker, next, usedHexes);
1215
+ if (gridTracker && !gridExhausted) updateTracker(gridTracker, next, usedHexes);
1216
+ }
1217
+ return palette[index].hex;
1218
+ };
1219
+ };
942
1220
  //#endregion
943
1221
  //#region src/providers/chart-context/themes.ts
944
1222
  /**
@@ -988,6 +1266,13 @@ const defaultTheme = {
988
1266
  };
989
1267
  //#endregion
990
1268
  //#region src/providers/chart-context/global-charts-provider.tsx
1269
+ const resolveOpaqueHex = (pointer, element) => {
1270
+ const raw = resolveCssVariable(pointer, element);
1271
+ if (!raw || color(raw)?.opacity !== 1) return null;
1272
+ const hex = normalizeColorToHex(pointer, element, resolveCssVariable);
1273
+ return isValidHexColor(hex) ? hex : null;
1274
+ };
1275
+ const PLACEHOLDER_LABEL_COLORS = [LABEL_FALLBACK, LABEL_INVERSE_FALLBACK];
991
1276
  const GlobalChartsContext = createContext(null);
992
1277
  const GlobalChartsProvider = ({ children, theme, locale, timeZone }) => {
993
1278
  const [charts, setCharts] = useState(() => /* @__PURE__ */ new Map());
@@ -1002,50 +1287,32 @@ const GlobalChartsProvider = ({ children, theme, locale, timeZone }) => {
1002
1287
  const providerTheme = useMemo(() => theme ? mergeThemes(defaultTheme, theme) : defaultTheme, [theme]);
1003
1288
  const [colorCache, setColorCache] = useState(() => ({
1004
1289
  colors: [],
1005
- hues: [],
1006
- existingHslColors: [],
1007
- minHue: 360,
1008
- maxHue: 0
1290
+ background: BACKGROUND_FALLBACK,
1291
+ labelColors: PLACEHOLDER_LABEL_COLORS,
1292
+ colorAt: createPaletteGenerator([SERIES_SLOT_1_FALLBACK], BACKGROUND_FALLBACK, PLACEHOLDER_LABEL_COLORS)
1009
1293
  }));
1010
1294
  const [isColorPaletteResolved, setIsColorPaletteResolved] = useState(false);
1011
1295
  useLayoutEffect(() => {
1012
1296
  setIsColorPaletteResolved(false);
1013
1297
  const resolvedColors = [];
1014
- const hues = [];
1015
- const existingHslColors = [];
1016
- let minHue = 360;
1017
- let maxHue = 0;
1018
1298
  for (const color of SERIES_PALETTE_POINTERS) {
1019
1299
  const normalizedColor = normalizeColorToHex(color, wrapperRef.current, resolveCssVariable);
1020
- if (normalizedColor.startsWith("#")) {
1021
- resolvedColors.push(normalizedColor);
1022
- const hslColor = hsl(normalizedColor);
1023
- if (!isNaN(hslColor.h)) {
1024
- const hslTuple = [
1025
- hslColor.h,
1026
- hslColor.s * 100,
1027
- hslColor.l * 100
1028
- ];
1029
- hues.push(hslTuple[0]);
1030
- existingHslColors.push(hslTuple);
1031
- minHue = Math.min(minHue, hslTuple[0]);
1032
- maxHue = Math.max(maxHue, hslTuple[0]);
1033
- }
1034
- }
1300
+ if (isValidHexColor(normalizedColor)) resolvedColors.push(normalizedColor);
1035
1301
  }
1302
+ const backgroundHex = resolveOpaqueHex(CATALOG_POINTERS.background, wrapperRef.current) ?? "#ffffff";
1303
+ const labelColors = [CATALOG_POINTERS.label, CATALOG_POINTERS.labelInverse].map((pointer) => resolveOpaqueHex(pointer, wrapperRef.current)).filter((hex) => hex !== null);
1036
1304
  setColorCache({
1037
1305
  colors: resolvedColors,
1038
- hues,
1039
- existingHslColors,
1040
- minHue,
1041
- maxHue
1306
+ background: backgroundHex,
1307
+ labelColors,
1308
+ colorAt: createPaletteGenerator(resolvedColors, backgroundHex, labelColors)
1042
1309
  });
1043
1310
  }, []);
1044
1311
  useEffect(() => {
1045
1312
  if (colorCache.colors.length > 0) setIsColorPaletteResolved(true);
1046
1313
  }, [colorCache]);
1047
1314
  const [groupToColorMap, setGroupToColorMap] = useState(() => /* @__PURE__ */ new Map());
1048
- const paletteKey = colorCache.colors.join(",");
1315
+ const paletteKey = `${colorCache.colors.join(",")}|${colorCache.background}|${colorCache.labelColors.join(",")}`;
1049
1316
  useEffect(() => {
1050
1317
  setGroupToColorMap(/* @__PURE__ */ new Map());
1051
1318
  }, [paletteKey]);
@@ -1068,11 +1335,11 @@ const GlobalChartsProvider = ({ children, theme, locale, timeZone }) => {
1068
1335
  const existing = groupToColorMap.get(group);
1069
1336
  if (existing) return existing;
1070
1337
  const assignedCount = groupToColorMap.size;
1071
- const color = getChartColor(assignedCount, colorCache);
1338
+ const color = colorCache.colorAt(assignedCount);
1072
1339
  groupToColorMap.set(group, color);
1073
1340
  return color;
1074
1341
  }
1075
- return getChartColor(index, colorCache);
1342
+ return colorCache.colorAt(index);
1076
1343
  }, [colorCache, groupToColorMap]);
1077
1344
  const getElementStyles = useCallback(({ data, index, overrideColor, legendShape }) => {
1078
1345
  const isSeriesData = data && typeof data === "object" && "data" in data;
@@ -1238,39 +1505,6 @@ const useDeepMemo = (value) => {
1238
1505
  return ref.current;
1239
1506
  };
1240
1507
  //#endregion
1241
- //#region src/providers/chart-context/private/catalog-pointers.ts
1242
- const CATALOG_POINTERS = {
1243
- background: "var(--a8c-charts-color-background, #fff)",
1244
- labelAxis: "var(--a8c-charts-color-label-axis, #1e1e1e)",
1245
- grid: "var(--a8c-charts-color-grid, #dbdbdb)",
1246
- axisX: "var(--a8c-charts-color-axis-x, #dbdbdb)",
1247
- tickX: "var(--a8c-charts-color-tick-x, #dbdbdb)",
1248
- axisY: "var(--a8c-charts-color-axis-y, none)",
1249
- tickY: "var(--a8c-charts-color-tick-y, none)",
1250
- annotation: "var(--a8c-charts-color-annotation, #1e1e1e)",
1251
- surface: "var(--a8c-charts-color-surface, #fff)",
1252
- surfaceSecondary: "var(--a8c-charts-color-surface-secondary, #f4f4f4)",
1253
- trendUp: "var(--a8c-charts-color-trend-up, #008030)",
1254
- trendDown: "var(--a8c-charts-color-trend-down, #cc1818)",
1255
- series: SERIES_PALETTE_POINTERS
1256
- };
1257
- /**
1258
- * The annotation parts visx paints, in the shape `@visx/annotation` takes. `radius`
1259
- * rides along as the base the theme and the per-datum styles merge onto.
1260
- */
1261
- const ANNOTATION_POINTERS = {
1262
- label: {
1263
- anchorLineStroke: CATALOG_POINTERS.annotation,
1264
- backgroundFill: CATALOG_POINTERS.surface
1265
- },
1266
- connector: { stroke: CATALOG_POINTERS.annotation },
1267
- circleSubject: {
1268
- stroke: "transparent",
1269
- fill: CATALOG_POINTERS.annotation,
1270
- radius: 5
1271
- }
1272
- };
1273
- //#endregion
1274
1508
  //#region src/providers/chart-scope/use-chart-scope-element.ts
1275
1509
  /**
1276
1510
  * Returns the nearest chart scope element, or `null` when rendered outside any scope (and during SSR, before the ref is attached).
@@ -2748,18 +2982,41 @@ const useKeyboardNavigation = ({ selectedIndex, setSelectedIndex, isNavigating,
2748
2982
  getChartRoot,
2749
2983
  focusWithoutScrollIfNeeded
2750
2984
  ]);
2985
+ const onChartFocus = useCallback((event) => {
2986
+ if (event.currentTarget.contains(event.relatedTarget)) return;
2987
+ if (!isNavigating && selectedIndex !== void 0) setSelectedIndex(0);
2988
+ }, [
2989
+ isNavigating,
2990
+ selectedIndex,
2991
+ setSelectedIndex
2992
+ ]);
2993
+ const pointerIndex = useRef(void 0);
2994
+ const onChartPointerMove = useCallback((index) => {
2995
+ if (selectedIndex === void 0) {
2996
+ if (pointerIndex.current !== void 0) pointerIndex.current = index;
2997
+ return;
2998
+ }
2999
+ const root = getChartRoot();
3000
+ const activeElement = root?.ownerDocument.activeElement;
3001
+ if (activeElement && root.contains(activeElement)) {
3002
+ pointerIndex.current = index;
3003
+ focusWithoutScrollIfNeeded(root);
3004
+ }
3005
+ setSelectedIndex(void 0);
3006
+ setIsNavigating(false);
3007
+ }, [
3008
+ selectedIndex,
3009
+ setSelectedIndex,
3010
+ setIsNavigating,
3011
+ getChartRoot,
3012
+ focusWithoutScrollIfNeeded
3013
+ ]);
2751
3014
  return {
2752
3015
  tooltipRef,
2753
- onChartFocus: useCallback((event) => {
2754
- if (event.currentTarget.contains(event.relatedTarget)) return;
2755
- if (!isNavigating && selectedIndex !== void 0) setSelectedIndex(0);
2756
- }, [
2757
- isNavigating,
2758
- selectedIndex,
2759
- setSelectedIndex
2760
- ]),
2761
- onChartBlur: useCallback(() => {
3016
+ onChartFocus,
3017
+ onChartBlur: useCallback((event) => {
2762
3018
  setIsNavigating(false);
3019
+ if (!event.currentTarget.contains(event.relatedTarget)) pointerIndex.current = void 0;
2763
3020
  }, [setIsNavigating]),
2764
3021
  onChartKeyDown: useCallback((event) => {
2765
3022
  if (event.key === "Tab" || event.key === "Escape") {
@@ -2767,10 +3024,12 @@ const useKeyboardNavigation = ({ selectedIndex, setSelectedIndex, isNavigating,
2767
3024
  focusWithoutScrollIfNeeded(getChartRoot());
2768
3025
  setSelectedIndex(void 0);
2769
3026
  setIsNavigating(false);
3027
+ pointerIndex.current = void 0;
2770
3028
  return;
2771
3029
  }
2772
3030
  if (totalPoints === 0) return;
2773
- const currentSelectedIndex = selectedIndex === void 0 ? -1 : selectedIndex;
3031
+ const currentSelectedIndex = selectedIndex ?? pointerIndex.current ?? -1;
3032
+ if (event.key === "ArrowRight" || event.key === "ArrowLeft") pointerIndex.current = void 0;
2774
3033
  if (event.key === "ArrowRight") {
2775
3034
  event.preventDefault();
2776
3035
  setIsNavigating(true);
@@ -2791,7 +3050,8 @@ const useKeyboardNavigation = ({ selectedIndex, setSelectedIndex, isNavigating,
2791
3050
  getChartRoot,
2792
3051
  focusWithoutScrollIfNeeded,
2793
3052
  onActivate
2794
- ])
3053
+ ]),
3054
+ onChartPointerMove
2795
3055
  };
2796
3056
  };
2797
3057
  //#endregion
@@ -2930,7 +3190,7 @@ function useChartChildren(children, chartType) {
2930
3190
  var chart_layout_module_default = { "chart-layout__content": "a8ccharts-fpNVAq-chart-layout__content" };
2931
3191
  //#endregion
2932
3192
  //#region src/charts/private/chart-layout/chart-layout.tsx
2933
- const ChartLayout = ({ legendPosition, legendElement, legendChildren, children, trailingContent, onContentHeightChange, gap, className, style, "data-testid": dataTestId, "data-chart-id": dataChartId }) => {
3193
+ const ChartLayout = ({ legendPosition, legendElement, legendChildren, children, trailingContent, onContentHeightChange, gap, className, rootRef, style, "data-testid": dataTestId, "data-chart-id": dataChartId }) => {
2934
3194
  const [contentRef, contentWidth, contentHeight] = useElementSize();
2935
3195
  const isRenderProp = typeof children === "function";
2936
3196
  const isMeasured = contentHeight > 0;
@@ -2949,6 +3209,7 @@ const ChartLayout = ({ legendPosition, legendElement, legendChildren, children,
2949
3209
  isMeasured
2950
3210
  }) : children;
2951
3211
  return /* @__PURE__ */ jsxs(Stack, {
3212
+ ref: rootRef,
2952
3213
  direction: "column",
2953
3214
  gap,
2954
3215
  className,
@@ -5916,9 +6177,10 @@ function nearestBandIndex(data, accessor, scale, position) {
5916
6177
  * @param root0.withTooltips - Whether pointer events update the tooltip.
5917
6178
  * @param root0.onPointerDown - Receives the corrected pointer-down datum.
5918
6179
  * @param root0.onPointerUp - Receives the corrected pointer-up datum.
6180
+ * @param root0.onPointerMove - Receives the corrected datum under a moving pointer.
5919
6181
  * @return No visual content.
5920
6182
  */
5921
- function BandTooltip({ keys, groupPadding, withTooltips, onPointerDown, onPointerUp }) {
6183
+ function BandTooltip({ keys, groupPadding, withTooltips, onPointerDown, onPointerUp, onPointerMove }) {
5922
6184
  const { xScale, yScale, dataRegistry, horizontal } = useContext(DataContext);
5923
6185
  const { showTooltip } = useContext(TooltipContext);
5924
6186
  const scale = horizontal ? yScale : xScale;
@@ -5963,20 +6225,22 @@ function BandTooltip({ keys, groupPadding, withTooltips, onPointerDown, onPointe
5963
6225
  ]);
5964
6226
  const handlePointer = useCallback((params) => {
5965
6227
  const selections = getSelections(params);
5966
- if (withTooltips && params?.event.type !== "pointerup") selections.forEach(showTooltip);
5967
- const callback = params?.event.type === "pointerdown" ? onPointerDown : onPointerUp;
5968
- if (params?.event.type === "pointermove" || !callback) return;
5969
6228
  const nearest = selections.reduce((best, selection) => {
5970
6229
  const distance = (value) => Math.hypot(value.distanceX, value.distanceY);
5971
6230
  return !best || distance(selection) <= distance(best) ? selection : best;
5972
6231
  }, void 0);
6232
+ if (params?.event.type === "pointermove" && nearest) onPointerMove?.(nearest);
6233
+ if (withTooltips && params?.event.type !== "pointerup") selections.forEach(showTooltip);
6234
+ const callback = params?.event.type === "pointerdown" ? onPointerDown : onPointerUp;
6235
+ if (params?.event.type === "pointermove" || !callback) return;
5973
6236
  if (nearest) callback(nearest);
5974
6237
  }, [
5975
6238
  getSelections,
5976
6239
  withTooltips,
5977
6240
  showTooltip,
5978
6241
  onPointerDown,
5979
- onPointerUp
6242
+ onPointerUp,
6243
+ onPointerMove
5980
6244
  ]);
5981
6245
  useEventEmitter("pointermove", withTooltips ? handlePointer : void 0);
5982
6246
  useEventEmitter("pointerdown", handlePointer);
@@ -6059,7 +6323,7 @@ const BarChartInternal = ({ data, chartId: providedChartId, width, height, class
6059
6323
  });
6060
6324
  }, [primaryEntries, onDatumActivate]);
6061
6325
  const visibleSeriesKey = useMemo(() => JSON.stringify(primaryKeys), [primaryKeys]);
6062
- const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation({
6326
+ const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown, onChartPointerMove } = useKeyboardNavigation({
6063
6327
  selectedIndex,
6064
6328
  setSelectedIndex,
6065
6329
  isNavigating,
@@ -6069,6 +6333,10 @@ const BarChartInternal = ({ data, chartId: providedChartId, width, height, class
6069
6333
  onActivate: activateSelectedBar,
6070
6334
  visibleSeriesKey
6071
6335
  });
6336
+ const handlePointerMove = useCallback(({ key, index }) => {
6337
+ const seriesIndex = primaryKeys.indexOf(key);
6338
+ if (seriesIndex >= 0) onChartPointerMove(index * primaryKeys.length + seriesIndex);
6339
+ }, [primaryKeys, onChartPointerMove]);
6072
6340
  const comparisonEntries = useMemo(() => {
6073
6341
  const primaryByGroup = new Map(primaryEntries.map(({ series, index }) => [series.group, {
6074
6342
  label: series.label,
@@ -6392,7 +6660,8 @@ const BarChartInternal = ({ data, chartId: providedChartId, width, height, class
6392
6660
  groupPadding,
6393
6661
  withTooltips,
6394
6662
  onPointerDown,
6395
- onPointerUp
6663
+ onPointerUp,
6664
+ onPointerMove: handlePointerMove
6396
6665
  }),
6397
6666
  !allSeriesHidden && /* @__PURE__ */ jsx(WholeNumberTicks, {
6398
6667
  ...wholeNumberTicksProps,
@@ -7074,12 +7343,14 @@ function normalizeGeoChartError(eventArgs) {
7074
7343
  * @param props.height - Height of the chart in pixels
7075
7344
  * @param props.region - Region to display ('world', 'US', or ISO 3166-1 alpha-2 code)
7076
7345
  * @param props.resolution - Resolution level ('countries', 'provinces', or 'metros')
7346
+ * @param props.displayMode - Plot rows as shaded areas ('regions') or markers ('markers')
7347
+ * @param props.domain - Country code whose viewpoint sets the disputed borders
7077
7348
  * @param props.onError - Optional callback for Google Charts errors
7078
7349
  * @param props.className - Additional CSS class name for the chart container
7079
7350
  * @param props.renderPlaceholder - Optional render function for the loading placeholder
7080
7351
  * @return A React component displaying an interactive map with data visualization
7081
7352
  */
7082
- const GeoChartInternal = ({ className, data, width, height, region = "world", resolution = "countries", onError, renderPlaceholder }) => {
7353
+ const GeoChartInternal = ({ className, data, width, height, region = "world", resolution = "countries", displayMode = "regions", domain, onError, renderPlaceholder }) => {
7083
7354
  const { getElementStyles } = useGlobalChartsContext();
7084
7355
  const scopeElement = useChartScopeElement();
7085
7356
  const containerRef = useRef(null);
@@ -7143,6 +7414,8 @@ const GeoChartInternal = ({ className, data, width, height, region = "world", re
7143
7414
  const options = useMemo(() => ({
7144
7415
  ...region !== "world" && { region },
7145
7416
  ...resolution !== "countries" && { resolution },
7417
+ ...displayMode !== "regions" && { displayMode },
7418
+ ...domain && { domain },
7146
7419
  colorAxis: { colors: [lightColorHex, fullColorHex] },
7147
7420
  backgroundColor: backgroundColorHex,
7148
7421
  datalessRegionColor: defaultFillColorHex,
@@ -7156,6 +7429,8 @@ const GeoChartInternal = ({ className, data, width, height, region = "world", re
7156
7429
  }), [
7157
7430
  region,
7158
7431
  resolution,
7432
+ displayMode,
7433
+ domain,
7159
7434
  lightColorHex,
7160
7435
  fullColorHex,
7161
7436
  backgroundColorHex,
@@ -11084,6 +11359,7 @@ function RadialWipeAnimation({ id, radius, innerRadius = 0, durationMs = 1e3, wi
11084
11359
  var pie_chart_module_default = {
11085
11360
  "pie-chart__label-plate": "a8ccharts-gnszbG-pie-chart__label-plate",
11086
11361
  "pie-chart__label-text": "a8ccharts-gnszbG-pie-chart__label-text",
11362
+ "pie-chart__label-text--on-light": "a8ccharts-gnszbG-pie-chart__label-text--on-light",
11087
11363
  "pie-chart__plot": "a8ccharts-gnszbG-pie-chart__plot",
11088
11364
  "pie-chart--responsive": "a8ccharts-gnszbG-pie-chart--responsive"
11089
11365
  };
@@ -11128,6 +11404,25 @@ const validateData$1 = (data) => {
11128
11404
  };
11129
11405
  };
11130
11406
  /**
11407
+ * Whether a pie slice's label should use the dark `label` role instead of the default `label-inverse` role.
11408
+ *
11409
+ * A label plate always wins: the text sits on the plate, not the slice fill, so it never flips.
11410
+ * Otherwise the role that contrasts more with the resolved slice fill wins. `null` pointers (not
11411
+ * yet resolved, or labels off) keep the default inverse role.
11412
+ *
11413
+ * @param fill - The slice's resolved fill color.
11414
+ * @param pointers - The label pointers, resolved at the chart's own root element.
11415
+ * @return Whether the label needs dark text.
11416
+ */
11417
+ const labelNeedsDarkText = (fill, pointers) => {
11418
+ if (!pointers || pointers.hasPlate) return false;
11419
+ const fillHex = normalizeColorToHex(fill);
11420
+ if (!isValidHexColor(fillHex) || !isValidHexColor(pointers.labelHex)) return false;
11421
+ if (pointers.isInverseSeeThrough) return true;
11422
+ if (!isValidHexColor(pointers.labelInverseHex)) return false;
11423
+ return contrastRatio(fillHex, pointers.labelHex) > contrastRatio(fillHex, pointers.labelInverseHex);
11424
+ };
11425
+ /**
11131
11426
  * Renders a pie or donut chart using the provided data.
11132
11427
  *
11133
11428
  * @param {PieChartProps} props - Component props
@@ -11141,11 +11436,36 @@ const PieChartInternal = ({ data, chartId: providedChartId, withTooltips = false
11141
11436
  const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, hideTooltip, showTooltip } = useTooltip();
11142
11437
  const standaloneScopeClass = useStandaloneScopeClass();
11143
11438
  const containerRef = useRef(null);
11439
+ const rootRef = useRef(null);
11440
+ const [labelPointers, setLabelPointers] = useState(null);
11144
11441
  const onMouseLeave = useCallback(() => {
11145
11442
  if (!withTooltips) return;
11146
11443
  hideTooltip();
11147
11444
  }, [withTooltips, hideTooltip]);
11148
- const { getElementStyles, isSeriesVisible } = useGlobalChartsContext();
11445
+ const { getElementStyles, isSeriesVisible, isColorPaletteResolved } = useGlobalChartsContext();
11446
+ const { isValid, message } = validateData$1(data);
11447
+ useLayoutEffect(() => {
11448
+ if (!showLabels || !rootRef.current) return;
11449
+ const resolve = createCssVariableResolver(rootRef.current);
11450
+ const rawLabelBackground = resolve(CATALOG_POINTERS.labelBackground);
11451
+ const rawLabel = resolve(CATALOG_POINTERS.label);
11452
+ const isLabelOpaque = rawLabel ? color(rawLabel)?.opacity === 1 : false;
11453
+ const rawLabelInverse = resolve(CATALOG_POINTERS.labelInverse);
11454
+ const labelInverseColor = rawLabelInverse ? color(rawLabelInverse) : null;
11455
+ const plateColor = rawLabelBackground ? color(rawLabelBackground) : null;
11456
+ const next = {
11457
+ hasPlate: rawLabelBackground ? !plateColor || plateColor.opacity > 0 : false,
11458
+ labelHex: isLabelOpaque ? normalizeColorToHex(CATALOG_POINTERS.label, null, resolve) : "",
11459
+ labelInverseHex: normalizeColorToHex(CATALOG_POINTERS.labelInverse, null, resolve),
11460
+ isInverseSeeThrough: labelInverseColor ? labelInverseColor.opacity < 1 : false
11461
+ };
11462
+ setLabelPointers((previous) => previous && previous.hasPlate === next.hasPlate && previous.labelHex === next.labelHex && previous.labelInverseHex === next.labelInverseHex && previous.isInverseSeeThrough === next.isInverseSeeThrough ? previous : next);
11463
+ }, [
11464
+ showLabels,
11465
+ className,
11466
+ isColorPaletteResolved,
11467
+ isValid
11468
+ ]);
11149
11469
  const dataWithPercentages = useDataWithPercentages(data);
11150
11470
  const { visibleData, allSegmentsHidden, legendData } = useLegendVisibilityData({
11151
11471
  data: dataWithPercentages,
@@ -11156,7 +11476,6 @@ const PieChartInternal = ({ data, chartId: providedChartId, withTooltips = false
11156
11476
  showValues: true,
11157
11477
  legendValueDisplay
11158
11478
  }), [legendValueDisplay]));
11159
- const { isValid, message } = validateData$1(data);
11160
11479
  const { svgChildren, htmlChildren, legendChildren, otherChildren } = useChartChildren(children, "PieChart");
11161
11480
  const chartMetadata = useMemo(() => ({
11162
11481
  thickness,
@@ -11218,6 +11537,7 @@ const PieChartInternal = ({ data, chartId: providedChartId, withTooltips = false
11218
11537
  legendElement,
11219
11538
  legendChildren,
11220
11539
  gap,
11540
+ rootRef,
11221
11541
  className: clsx("pie-chart", pie_chart_module_default["pie-chart"], { [pie_chart_module_default["pie-chart--responsive"]]: !propWidth && !propHeight }, className),
11222
11542
  style: {
11223
11543
  width: propWidth || void 0,
@@ -11279,9 +11599,10 @@ const PieChartInternal = ({ data, chartId: providedChartId, withTooltips = false
11279
11599
  tooltipTop: event.clientY - bounds.top + tooltipOffsetY
11280
11600
  });
11281
11601
  };
11602
+ const fill = accessors.fill(arc.data);
11282
11603
  const pathProps = {
11283
11604
  d: pie.path(arc) || "",
11284
- fill: accessors.fill(arc.data),
11605
+ fill,
11285
11606
  "data-testid": "pie-segment"
11286
11607
  };
11287
11608
  const groupProps = {};
@@ -11309,7 +11630,7 @@ const PieChartInternal = ({ data, chartId: providedChartId, withTooltips = false
11309
11630
  ry: 4,
11310
11631
  pointerEvents: "none"
11311
11632
  }), /* @__PURE__ */ jsx("text", {
11312
- className: pie_chart_module_default["pie-chart__label-text"],
11633
+ className: clsx(pie_chart_module_default["pie-chart__label-text"], { [pie_chart_module_default["pie-chart__label-text--on-light"]]: labelNeedsDarkText(fill, labelPointers) }),
11313
11634
  x: centroidX,
11314
11635
  y: centroidY,
11315
11636
  dy: ".33em",