@automattic/charts 4.3.0 → 4.5.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 (41) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/TOKENS.md +2 -2
  3. package/dist/index.cjs +584 -209
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +4 -0
  6. package/dist/index.d.cts +14 -3
  7. package/dist/index.d.ts +14 -3
  8. package/dist/index.js +585 -210
  9. package/dist/index.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/charts/bar-chart/bar-chart.tsx +57 -23
  12. package/src/charts/bar-chart/private/band-tooltip.tsx +18 -12
  13. package/src/charts/bar-chart/private/classified-bar-series.tsx +85 -0
  14. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +3 -6
  15. package/src/charts/bar-chart/private/index.ts +1 -0
  16. package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +2 -3
  17. package/src/charts/bar-chart/test/bar-chart.test.tsx +130 -1
  18. package/src/charts/bar-chart/types.ts +5 -1
  19. package/src/charts/geo-chart/geo-chart.tsx +4 -0
  20. package/src/charts/geo-chart/test/geo-chart.test.tsx +8 -0
  21. package/src/charts/geo-chart/types.ts +5 -0
  22. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +1 -1
  23. package/src/charts/pie-chart/pie-chart.module.scss +4 -0
  24. package/src/charts/pie-chart/pie-chart.tsx +103 -7
  25. package/src/charts/pie-chart/test/pie-chart.test.tsx +149 -0
  26. package/src/charts/private/chart-layout/chart-layout.tsx +5 -1
  27. package/src/components/tooltip/accessible-tooltip.tsx +40 -4
  28. package/src/components/tooltip/test/accessible-tooltip.test.tsx +82 -5
  29. package/src/providers/chart-context/global-charts-provider.tsx +57 -41
  30. package/src/providers/chart-context/private/catalog-pointers.ts +9 -1
  31. package/src/providers/chart-context/private/palette-generator.ts +305 -0
  32. package/src/providers/chart-context/private/perceptual-color.ts +219 -0
  33. package/src/providers/chart-context/private/series-palette.ts +1 -1
  34. package/src/providers/chart-context/test/chart-context.test.tsx +88 -8
  35. package/src/providers/chart-context/test/global-charts-provider-first-render.test.tsx +27 -0
  36. package/src/providers/chart-context/test/palette-generator.test.ts +181 -0
  37. package/src/providers/chart-context/test/perceptual-color.test.ts +75 -0
  38. package/src/test-utils/zoned-parsing-suite.ts +1 -1
  39. package/src/utils/color-utils.ts +5 -3
  40. package/src/utils/test/date-formatting.test.ts +1 -1
  41. package/src/providers/chart-context/private/get-chart-color.ts +0 -223
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { getStringWidth as getStringWidth$1 } from "./visx/text/index.js";
2
2
  import { formatNumber, formatNumberCompact } from "@automattic/number-formatters";
3
- import { AnimatedAreaSeries, AnimatedAreaStack, AreaSeries, Axis, BarGroup, BarSeries, DataContext, Grid, TooltipContext, XYChart, buildChartTheme, useEventEmitter } from "@visx/xychart";
3
+ import { AnimatedAreaSeries, AnimatedAreaStack, AreaSeries, Axis, BarGroup, BarSeries, DataContext, GlyphSeries, Grid, TooltipContext, XYChart, buildChartTheme, useEventEmitter } from "@visx/xychart";
4
4
  import { __, _x, sprintf } from "@wordpress/i18n";
5
5
  import clsx from "clsx";
6
6
  import * as React from "react";
@@ -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
- /**
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
805
  /**
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,
@@ -5659,9 +5920,8 @@ const createGroupScale = (keys, bandwidth, padding) => scaleBand({
5659
5920
  //#endregion
5660
5921
  //#region src/charts/bar-chart/private/comparison-bars-geometry.ts
5661
5922
  /**
5662
- * Output position of a value scale's baseline: zero if in-domain, else the
5663
- * nearest range edge. Mirrors visx's getScaleBaseline so comparison shadows
5664
- * sit on the same baseline as primary bars.
5923
+ * Mirrors visx's getScaleBaseline so comparison shadows and primary bars
5924
+ * share the same baseline, including out-of-domain zero values.
5665
5925
  *
5666
5926
  * @param {ValueScale} scale - The continuous value scale.
5667
5927
  * @return {number} The baseline output position in pixels.
@@ -5672,7 +5932,7 @@ function getValueScaleBaseline(scale) {
5672
5932
  const maybeZero = scale(0);
5673
5933
  const [minOutput, maxOutput] = isDescending ? [b, a] : [a, b];
5674
5934
  if (isDescending) return Number.isFinite(maybeZero) ? Math.min(Math.max(minOutput, maybeZero), maxOutput) : maxOutput;
5675
- return Number.isFinite(maybeZero) ? Math.min(Math.max(maybeZero, minOutput), maxOutput) : minOutput;
5935
+ return Number.isFinite(maybeZero) ? Math.max(maybeZero, minOutput) : minOutput;
5676
5936
  }
5677
5937
  /**
5678
5938
  * Compute the rect for a comparison "shadow" bar, centered on the paired
@@ -5708,6 +5968,51 @@ function computeComparisonRect(params) {
5708
5968
  height: valueLength
5709
5969
  };
5710
5970
  }
5971
+ //#endregion
5972
+ //#region src/charts/bar-chart/private/classified-bar-series.tsx
5973
+ const ClassifiedBarSeries = ({ barClassName, primaryKeys, groupPadding, ...props }) => {
5974
+ const { xScale, yScale, horizontal } = useContext(DataContext);
5975
+ const bandScale = horizontal ? yScale : xScale;
5976
+ const valueScale = horizontal ? xScale : yScale;
5977
+ const groupScale = useMemo(() => createGroupScale(primaryKeys, bandScale?.bandwidth?.() ?? 0, groupPadding), [
5978
+ primaryKeys,
5979
+ bandScale,
5980
+ groupPadding
5981
+ ]);
5982
+ const renderGlyph = useCallback(({ datum, x, y, color, onBlur, onFocus, onPointerMove, onPointerOut, onPointerUp }) => {
5983
+ if (!bandScale?.bandwidth || !valueScale) return null;
5984
+ const baseline = getValueScaleBaseline(valueScale);
5985
+ const position = Number(bandScale((horizontal ? props.yAccessor : props.xAccessor)(datum))) + (groupScale(props.dataKey) ?? 0);
5986
+ const value = horizontal ? x : y;
5987
+ return /* @__PURE__ */ jsx("rect", {
5988
+ className: clsx("visx-bar", barClassName(datum)),
5989
+ tabIndex: onFocus || onBlur ? 0 : void 0,
5990
+ onBlur,
5991
+ onFocus,
5992
+ onPointerMove,
5993
+ onPointerOut,
5994
+ onPointerUp,
5995
+ x: horizontal ? baseline + Math.min(0, value - baseline) : position,
5996
+ y: horizontal ? position : baseline + Math.min(0, value - baseline),
5997
+ width: horizontal ? Math.abs(value - baseline) : groupScale.bandwidth(),
5998
+ height: horizontal ? groupScale.bandwidth() : Math.abs(value - baseline),
5999
+ fill: color
6000
+ });
6001
+ }, [
6002
+ bandScale,
6003
+ valueScale,
6004
+ horizontal,
6005
+ groupScale,
6006
+ props.dataKey,
6007
+ props.xAccessor,
6008
+ props.yAccessor,
6009
+ barClassName
6010
+ ]);
6011
+ return /* @__PURE__ */ jsx(GlyphSeries, {
6012
+ ...props,
6013
+ renderGlyph
6014
+ });
6015
+ };
5711
6016
  /**
5712
6017
  * Upper clamp on the computed group padding, so bars can never collapse to zero width
5713
6018
  * even at very large `widthFactor` values.
@@ -5872,9 +6177,10 @@ function nearestBandIndex(data, accessor, scale, position) {
5872
6177
  * @param root0.withTooltips - Whether pointer events update the tooltip.
5873
6178
  * @param root0.onPointerDown - Receives the corrected pointer-down datum.
5874
6179
  * @param root0.onPointerUp - Receives the corrected pointer-up datum.
6180
+ * @param root0.onPointerMove - Receives the corrected datum under a moving pointer.
5875
6181
  * @return No visual content.
5876
6182
  */
5877
- function BandTooltip({ keys, groupPadding, withTooltips, onPointerDown, onPointerUp }) {
6183
+ function BandTooltip({ keys, groupPadding, withTooltips, onPointerDown, onPointerUp, onPointerMove }) {
5878
6184
  const { xScale, yScale, dataRegistry, horizontal } = useContext(DataContext);
5879
6185
  const { showTooltip } = useContext(TooltipContext);
5880
6186
  const scale = horizontal ? yScale : xScale;
@@ -5919,20 +6225,22 @@ function BandTooltip({ keys, groupPadding, withTooltips, onPointerDown, onPointe
5919
6225
  ]);
5920
6226
  const handlePointer = useCallback((params) => {
5921
6227
  const selections = getSelections(params);
5922
- if (withTooltips && params?.event.type !== "pointerup") selections.forEach(showTooltip);
5923
- const callback = params?.event.type === "pointerdown" ? onPointerDown : onPointerUp;
5924
- if (params?.event.type === "pointermove" || !callback) return;
5925
6228
  const nearest = selections.reduce((best, selection) => {
5926
6229
  const distance = (value) => Math.hypot(value.distanceX, value.distanceY);
5927
6230
  return !best || distance(selection) <= distance(best) ? selection : best;
5928
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;
5929
6236
  if (nearest) callback(nearest);
5930
6237
  }, [
5931
6238
  getSelections,
5932
6239
  withTooltips,
5933
6240
  showTooltip,
5934
6241
  onPointerDown,
5935
- onPointerUp
6242
+ onPointerUp,
6243
+ onPointerMove
5936
6244
  ]);
5937
6245
  useEventEmitter("pointermove", withTooltips ? handlePointer : void 0);
5938
6246
  useEventEmitter("pointerdown", handlePointer);
@@ -5951,7 +6259,7 @@ const renderTooltipRow = (label, value) => /* @__PURE__ */ jsx("div", {
5951
6259
  className: bar_chart_module_default["bar-chart__tooltip-row"],
5952
6260
  children: sprintf(__("%1$s: %2$s", "jetpack-charts"), label, value)
5953
6261
  });
5954
- const BarChartInternal = ({ data, chartId: providedChartId, width, height, className, margin, withTooltips = false, showLegend = false, legend = {}, gridVisibility: gridVisibilityProp, renderTooltip, tooltipPlacement, tooltipAnchorTop, options = {}, orientation = "vertical", withPatterns = false, showZeroValues = false, withBandHighlight = false, onBandHighlightChange, defaultHiddenSeries, animation, children, gap = "md", onPointerDown, onPointerUp, onDatumActivate }) => {
6262
+ const BarChartInternal = ({ data, chartId: providedChartId, width, height, className, margin, withTooltips = false, showLegend = false, legend = {}, gridVisibility: gridVisibilityProp, renderTooltip, tooltipPlacement, tooltipAnchorTop, tooltipStyle, barClassName, options = {}, orientation = "vertical", withPatterns = false, showZeroValues = false, withBandHighlight = false, onBandHighlightChange, defaultHiddenSeries, animation, children, gap = "md", onPointerDown, onPointerUp, onDatumActivate }) => {
5955
6263
  if (!withTooltips && (withBandHighlight || onBandHighlightChange)) warnOnce("bar-chart-band-highlight-without-tooltips", "BarChart: withBandHighlight and onBandHighlightChange require withTooltips.");
5956
6264
  const legendInteractive = legend.interactive ?? false;
5957
6265
  const legendCollapseGroups = legend.collapseGroups ?? false;
@@ -6015,7 +6323,7 @@ const BarChartInternal = ({ data, chartId: providedChartId, width, height, class
6015
6323
  });
6016
6324
  }, [primaryEntries, onDatumActivate]);
6017
6325
  const visibleSeriesKey = useMemo(() => JSON.stringify(primaryKeys), [primaryKeys]);
6018
- const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation({
6326
+ const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown, onChartPointerMove } = useKeyboardNavigation({
6019
6327
  selectedIndex,
6020
6328
  setSelectedIndex,
6021
6329
  isNavigating,
@@ -6025,6 +6333,10 @@ const BarChartInternal = ({ data, chartId: providedChartId, width, height, class
6025
6333
  onActivate: activateSelectedBar,
6026
6334
  visibleSeriesKey
6027
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]);
6028
6340
  const comparisonEntries = useMemo(() => {
6029
6341
  const primaryByGroup = new Map(primaryEntries.map(({ series, index }) => [series.group, {
6030
6342
  label: series.label,
@@ -6321,7 +6633,19 @@ const BarChartInternal = ({ data, chartId: providedChartId, width, height, class
6321
6633
  getElementStyles,
6322
6634
  resolveFill: resolveComparisonFill
6323
6635
  }),
6324
- /* @__PURE__ */ jsx(BarGroup, {
6636
+ barClassName ? /* @__PURE__ */ jsx("g", {
6637
+ className: "visx-bar-group",
6638
+ children: primaryEntries.map(({ series: seriesData, index }) => /* @__PURE__ */ jsx(ClassifiedBarSeries, {
6639
+ dataKey: seriesData.label,
6640
+ data: seriesData.data,
6641
+ xAccessor: chartOptions.accessors.xAccessor,
6642
+ yAccessor: chartOptions.accessors.yAccessor,
6643
+ colorAccessor: getBarBackground(index),
6644
+ barClassName,
6645
+ primaryKeys,
6646
+ groupPadding
6647
+ }, seriesData.label))
6648
+ }) : /* @__PURE__ */ jsx(BarGroup, {
6325
6649
  padding: groupPadding,
6326
6650
  children: primaryEntries.map(({ series: seriesData, index }) => /* @__PURE__ */ jsx(BarSeries, {
6327
6651
  dataKey: seriesData?.label,
@@ -6336,7 +6660,8 @@ const BarChartInternal = ({ data, chartId: providedChartId, width, height, class
6336
6660
  groupPadding,
6337
6661
  withTooltips,
6338
6662
  onPointerDown,
6339
- onPointerUp
6663
+ onPointerUp,
6664
+ onPointerMove: handlePointerMove
6340
6665
  }),
6341
6666
  !allSeriesHidden && /* @__PURE__ */ jsx(WholeNumberTicks, {
6342
6667
  ...wholeNumberTicksProps,
@@ -6351,6 +6676,7 @@ const BarChartInternal = ({ data, chartId: providedChartId, width, height, class
6351
6676
  withTooltips && /* @__PURE__ */ jsx(AccessibleTooltip, {
6352
6677
  tooltipPlacement,
6353
6678
  tooltipAnchorTop,
6679
+ style: tooltipStyle,
6354
6680
  detectBounds: true,
6355
6681
  snapTooltipToDatumX: true,
6356
6682
  snapTooltipToDatumY: true,
@@ -7017,12 +7343,13 @@ function normalizeGeoChartError(eventArgs) {
7017
7343
  * @param props.height - Height of the chart in pixels
7018
7344
  * @param props.region - Region to display ('world', 'US', or ISO 3166-1 alpha-2 code)
7019
7345
  * @param props.resolution - Resolution level ('countries', 'provinces', or 'metros')
7346
+ * @param props.domain - Country code whose viewpoint sets the disputed borders
7020
7347
  * @param props.onError - Optional callback for Google Charts errors
7021
7348
  * @param props.className - Additional CSS class name for the chart container
7022
7349
  * @param props.renderPlaceholder - Optional render function for the loading placeholder
7023
7350
  * @return A React component displaying an interactive map with data visualization
7024
7351
  */
7025
- const GeoChartInternal = ({ className, data, width, height, region = "world", resolution = "countries", onError, renderPlaceholder }) => {
7352
+ const GeoChartInternal = ({ className, data, width, height, region = "world", resolution = "countries", domain, onError, renderPlaceholder }) => {
7026
7353
  const { getElementStyles } = useGlobalChartsContext();
7027
7354
  const scopeElement = useChartScopeElement();
7028
7355
  const containerRef = useRef(null);
@@ -7086,6 +7413,7 @@ const GeoChartInternal = ({ className, data, width, height, region = "world", re
7086
7413
  const options = useMemo(() => ({
7087
7414
  ...region !== "world" && { region },
7088
7415
  ...resolution !== "countries" && { resolution },
7416
+ ...domain && { domain },
7089
7417
  colorAxis: { colors: [lightColorHex, fullColorHex] },
7090
7418
  backgroundColor: backgroundColorHex,
7091
7419
  datalessRegionColor: defaultFillColorHex,
@@ -7099,6 +7427,7 @@ const GeoChartInternal = ({ className, data, width, height, region = "world", re
7099
7427
  }), [
7100
7428
  region,
7101
7429
  resolution,
7430
+ domain,
7102
7431
  lightColorHex,
7103
7432
  fullColorHex,
7104
7433
  backgroundColorHex,
@@ -11027,6 +11356,7 @@ function RadialWipeAnimation({ id, radius, innerRadius = 0, durationMs = 1e3, wi
11027
11356
  var pie_chart_module_default = {
11028
11357
  "pie-chart__label-plate": "a8ccharts-gnszbG-pie-chart__label-plate",
11029
11358
  "pie-chart__label-text": "a8ccharts-gnszbG-pie-chart__label-text",
11359
+ "pie-chart__label-text--on-light": "a8ccharts-gnszbG-pie-chart__label-text--on-light",
11030
11360
  "pie-chart__plot": "a8ccharts-gnszbG-pie-chart__plot",
11031
11361
  "pie-chart--responsive": "a8ccharts-gnszbG-pie-chart--responsive"
11032
11362
  };
@@ -11071,6 +11401,25 @@ const validateData$1 = (data) => {
11071
11401
  };
11072
11402
  };
11073
11403
  /**
11404
+ * Whether a pie slice's label should use the dark `label` role instead of the default `label-inverse` role.
11405
+ *
11406
+ * A label plate always wins: the text sits on the plate, not the slice fill, so it never flips.
11407
+ * Otherwise the role that contrasts more with the resolved slice fill wins. `null` pointers (not
11408
+ * yet resolved, or labels off) keep the default inverse role.
11409
+ *
11410
+ * @param fill - The slice's resolved fill color.
11411
+ * @param pointers - The label pointers, resolved at the chart's own root element.
11412
+ * @return Whether the label needs dark text.
11413
+ */
11414
+ const labelNeedsDarkText = (fill, pointers) => {
11415
+ if (!pointers || pointers.hasPlate) return false;
11416
+ const fillHex = normalizeColorToHex(fill);
11417
+ if (!isValidHexColor(fillHex) || !isValidHexColor(pointers.labelHex)) return false;
11418
+ if (pointers.isInverseSeeThrough) return true;
11419
+ if (!isValidHexColor(pointers.labelInverseHex)) return false;
11420
+ return contrastRatio(fillHex, pointers.labelHex) > contrastRatio(fillHex, pointers.labelInverseHex);
11421
+ };
11422
+ /**
11074
11423
  * Renders a pie or donut chart using the provided data.
11075
11424
  *
11076
11425
  * @param {PieChartProps} props - Component props
@@ -11084,11 +11433,36 @@ const PieChartInternal = ({ data, chartId: providedChartId, withTooltips = false
11084
11433
  const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, hideTooltip, showTooltip } = useTooltip();
11085
11434
  const standaloneScopeClass = useStandaloneScopeClass();
11086
11435
  const containerRef = useRef(null);
11436
+ const rootRef = useRef(null);
11437
+ const [labelPointers, setLabelPointers] = useState(null);
11087
11438
  const onMouseLeave = useCallback(() => {
11088
11439
  if (!withTooltips) return;
11089
11440
  hideTooltip();
11090
11441
  }, [withTooltips, hideTooltip]);
11091
- const { getElementStyles, isSeriesVisible } = useGlobalChartsContext();
11442
+ const { getElementStyles, isSeriesVisible, isColorPaletteResolved } = useGlobalChartsContext();
11443
+ const { isValid, message } = validateData$1(data);
11444
+ useLayoutEffect(() => {
11445
+ if (!showLabels || !rootRef.current) return;
11446
+ const resolve = createCssVariableResolver(rootRef.current);
11447
+ const rawLabelBackground = resolve(CATALOG_POINTERS.labelBackground);
11448
+ const rawLabel = resolve(CATALOG_POINTERS.label);
11449
+ const isLabelOpaque = rawLabel ? color(rawLabel)?.opacity === 1 : false;
11450
+ const rawLabelInverse = resolve(CATALOG_POINTERS.labelInverse);
11451
+ const labelInverseColor = rawLabelInverse ? color(rawLabelInverse) : null;
11452
+ const plateColor = rawLabelBackground ? color(rawLabelBackground) : null;
11453
+ const next = {
11454
+ hasPlate: rawLabelBackground ? !plateColor || plateColor.opacity > 0 : false,
11455
+ labelHex: isLabelOpaque ? normalizeColorToHex(CATALOG_POINTERS.label, null, resolve) : "",
11456
+ labelInverseHex: normalizeColorToHex(CATALOG_POINTERS.labelInverse, null, resolve),
11457
+ isInverseSeeThrough: labelInverseColor ? labelInverseColor.opacity < 1 : false
11458
+ };
11459
+ setLabelPointers((previous) => previous && previous.hasPlate === next.hasPlate && previous.labelHex === next.labelHex && previous.labelInverseHex === next.labelInverseHex && previous.isInverseSeeThrough === next.isInverseSeeThrough ? previous : next);
11460
+ }, [
11461
+ showLabels,
11462
+ className,
11463
+ isColorPaletteResolved,
11464
+ isValid
11465
+ ]);
11092
11466
  const dataWithPercentages = useDataWithPercentages(data);
11093
11467
  const { visibleData, allSegmentsHidden, legendData } = useLegendVisibilityData({
11094
11468
  data: dataWithPercentages,
@@ -11099,7 +11473,6 @@ const PieChartInternal = ({ data, chartId: providedChartId, withTooltips = false
11099
11473
  showValues: true,
11100
11474
  legendValueDisplay
11101
11475
  }), [legendValueDisplay]));
11102
- const { isValid, message } = validateData$1(data);
11103
11476
  const { svgChildren, htmlChildren, legendChildren, otherChildren } = useChartChildren(children, "PieChart");
11104
11477
  const chartMetadata = useMemo(() => ({
11105
11478
  thickness,
@@ -11161,6 +11534,7 @@ const PieChartInternal = ({ data, chartId: providedChartId, withTooltips = false
11161
11534
  legendElement,
11162
11535
  legendChildren,
11163
11536
  gap,
11537
+ rootRef,
11164
11538
  className: clsx("pie-chart", pie_chart_module_default["pie-chart"], { [pie_chart_module_default["pie-chart--responsive"]]: !propWidth && !propHeight }, className),
11165
11539
  style: {
11166
11540
  width: propWidth || void 0,
@@ -11222,9 +11596,10 @@ const PieChartInternal = ({ data, chartId: providedChartId, withTooltips = false
11222
11596
  tooltipTop: event.clientY - bounds.top + tooltipOffsetY
11223
11597
  });
11224
11598
  };
11599
+ const fill = accessors.fill(arc.data);
11225
11600
  const pathProps = {
11226
11601
  d: pie.path(arc) || "",
11227
- fill: accessors.fill(arc.data),
11602
+ fill,
11228
11603
  "data-testid": "pie-segment"
11229
11604
  };
11230
11605
  const groupProps = {};
@@ -11252,7 +11627,7 @@ const PieChartInternal = ({ data, chartId: providedChartId, withTooltips = false
11252
11627
  ry: 4,
11253
11628
  pointerEvents: "none"
11254
11629
  }), /* @__PURE__ */ jsx("text", {
11255
- className: pie_chart_module_default["pie-chart__label-text"],
11630
+ className: clsx(pie_chart_module_default["pie-chart__label-text"], { [pie_chart_module_default["pie-chart__label-text--on-light"]]: labelNeedsDarkText(fill, labelPointers) }),
11256
11631
  x: centroidX,
11257
11632
  y: centroidY,
11258
11633
  dy: ".33em",