@automattic/charts 4.2.0 → 4.4.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 (48) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/index.cjs +533 -127
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +27 -2
  5. package/dist/index.d.ts +27 -2
  6. package/dist/index.js +535 -129
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/charts/area-chart/area-chart.tsx +42 -10
  10. package/src/charts/area-chart/private/validate-data.ts +2 -3
  11. package/src/charts/area-chart/test/area-chart.test.tsx +72 -1
  12. package/src/charts/bar-chart/bar-chart.tsx +135 -96
  13. package/src/charts/bar-chart/index.ts +1 -1
  14. package/src/charts/bar-chart/private/band-highlight.tsx +3 -1
  15. package/src/charts/bar-chart/private/band-tooltip.tsx +1 -1
  16. package/src/charts/bar-chart/private/classified-bar-series.tsx +85 -0
  17. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +3 -6
  18. package/src/charts/bar-chart/private/index.ts +2 -0
  19. package/src/charts/bar-chart/private/test/band-highlight.test.tsx +28 -2
  20. package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +2 -3
  21. package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
  22. package/src/charts/bar-chart/private/use-bar-chart-options.ts +12 -39
  23. package/src/charts/bar-chart/private/value-domain.ts +90 -0
  24. package/src/charts/bar-chart/test/bar-chart.test.tsx +614 -10
  25. package/src/charts/bar-chart/types.ts +43 -0
  26. package/src/charts/line-chart/line-chart.tsx +148 -35
  27. package/src/charts/line-chart/private/index.ts +1 -0
  28. package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
  29. package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
  30. package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
  31. package/src/charts/line-chart/types.ts +2 -1
  32. package/src/charts/private/readings.ts +34 -0
  33. package/src/charts/private/test/readings.test.ts +29 -0
  34. package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
  35. package/src/charts/private/whole-number-ticks.tsx +79 -0
  36. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +124 -0
  37. package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
  38. package/src/components/legend/private/base-legend.tsx +36 -21
  39. package/src/components/legend/test/legend.test.tsx +84 -0
  40. package/src/components/legend/types.ts +2 -0
  41. package/src/components/legend/utils/index.ts +0 -1
  42. package/src/components/tooltip/accessible-tooltip.tsx +1 -1
  43. package/src/hooks/test/use-chart-margin.test.tsx +35 -0
  44. package/src/hooks/use-chart-margin.tsx +7 -2
  45. package/src/providers/chart-context/global-charts-provider.tsx +1 -1
  46. package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
  47. package/src/types.ts +8 -0
  48. package/src/components/legend/utils/label-transform-factory.ts +0 -24
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";
@@ -12,7 +12,7 @@ import { Text, getStringWidth } from "@visx/text";
12
12
  import deepmerge from "deepmerge";
13
13
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
14
14
  import { Tooltip, defaultStyles, useTooltip } from "@visx/tooltip";
15
- import { createScale, getTicks, scaleBand, scaleOrdinal, scaleTime } from "@visx/scale";
15
+ import { createScale, getTicks, scaleBand, scaleCanBeZeroed, scaleOrdinal, scaleTime } from "@visx/scale";
16
16
  import { Group } from "@visx/group";
17
17
  import { LegendItem, LegendLabel, LegendOrdinal, LegendShape } from "@visx/legend";
18
18
  import { IconButton, Stack, Text as Text$1 } from "@wordpress/ui";
@@ -21,6 +21,7 @@ import { LinearGradient } from "@visx/gradient";
21
21
  import { curveCatmullRom, curveLinear, curveMonotoneX } from "@visx/curve";
22
22
  import { useParentSize } from "@visx/responsive";
23
23
  import { Annotation, CircleSubject, Connector, HtmlLabel, Label, LineSubject } from "@visx/annotation";
24
+ import { bisector } from "@visx/vendor/d3-array";
24
25
  import { PatternCircles, PatternHexagons, PatternLines, PatternWaves } from "@visx/pattern";
25
26
  import { Chart } from "react-google-charts";
26
27
  import DOMPurify from "dompurify";
@@ -1074,7 +1075,7 @@ const GlobalChartsProvider = ({ children, theme, locale, timeZone }) => {
1074
1075
  return getChartColor(index, colorCache);
1075
1076
  }, [colorCache, groupToColorMap]);
1076
1077
  const getElementStyles = useCallback(({ data, index, overrideColor, legendShape }) => {
1077
- const isSeriesData = data && typeof data === "object" && "data" in data && "options" in data;
1078
+ const isSeriesData = data && typeof data === "object" && "data" in data;
1078
1079
  const isPointPercentageData = data && typeof data === "object" && "value" in data && typeof data.value === "number" && !("data" in data);
1079
1080
  return {
1080
1081
  color: resolveColor({
@@ -1455,8 +1456,9 @@ const useChartMargin = (height, options, data, theme, horizontal = false) => {
1455
1456
  const allDataPoints = data.flatMap((series) => series.data);
1456
1457
  if (horizontal) return allDataPoints.map((d) => d.label || d.date?.getTime());
1457
1458
  if (options.axis?.y?.tickValues?.length) return options.axis.y.tickValues;
1458
- const minY = Math.min(...allDataPoints.map((d) => d.value));
1459
- const maxY = Math.max(...allDataPoints.map((d) => d.value));
1459
+ const values = allDataPoints.map((d) => d.value).filter((value) => typeof value === "number" && Number.isFinite(value));
1460
+ const minY = values.length ? Math.min(...values) : 0;
1461
+ const maxY = values.length ? Math.max(...values) : 1;
1460
1462
  const yScale = createScale({
1461
1463
  ...options.yScale,
1462
1464
  domain: options.yScale?.domain ?? [minY, maxY],
@@ -1791,23 +1793,6 @@ function valueOrIdentityString(_) {
1791
1793
  return String(valueOrIdentity(_));
1792
1794
  }
1793
1795
  //#endregion
1794
- //#region src/components/legend/utils/label-transform-factory.ts
1795
- /**
1796
- * Returns a function which takes a Datum and index as input, and returns a formatted label object.
1797
- * @param {object} root0 - The object to return the value of.
1798
- * @param {AnyD3Scale} root0.scale - The scale to use.
1799
- * @param {LabelFormatter<ScaleInput<Scale>>} root0.labelFormat - The label format to use.
1800
- * @return {ItemTransformer<ScaleInput<Scale>, ReturnType<Scale>>} The label transform factory.
1801
- */
1802
- function labelTransformFactory({ scale, labelFormat }) {
1803
- return (d, i) => ({
1804
- datum: d,
1805
- index: i,
1806
- text: `${labelFormat(d, i)}`,
1807
- value: scale(d)
1808
- });
1809
- }
1810
- //#endregion
1811
1796
  //#region src/components/legend/private/base-legend.module.scss
1812
1797
  var base_legend_module_default = {
1813
1798
  "legend": "a8ccharts-04TogW-legend",
@@ -1849,7 +1834,7 @@ const getLegendItemAriaLabel = (text, value, visible, interactive) => {
1849
1834
  if (visible) return;
1850
1835
  return sprintf(_x("%s: hidden", "hidden non-interactive legend item", "jetpack-charts"), accessibleText);
1851
1836
  };
1852
- const BaseLegend = forwardRef(({ items, className, orientation = "horizontal", alignment = "center", shape = "rect", fill = valueOrIdentityString, size = valueOrIdentityString, labelFormat = valueOrIdentity, labelTransform = labelTransformFactory, itemStyles, itemClassName, labelStyles, labelClassName, shapeStyles, render, interactive = false, chartId }, ref) => {
1837
+ const BaseLegend = forwardRef(({ items, className, orientation = "horizontal", alignment = "center", shape = "rect", fill = valueOrIdentityString, size = valueOrIdentityString, labelFormat = valueOrIdentity, labelTransform, itemStyles, itemClassName, labelStyles, labelClassName, shapeStyles, render, interactive = false, chartId }, ref) => {
1853
1838
  const { margin: itemMargin = "0", flexDirection: itemDirection = "row" } = itemStyles ?? {};
1854
1839
  const { justifyContent: labelJustifyContent = "flex-start", flex: labelFlex = "0 0 auto", margin: labelMargin = "0 4px", maxWidth, textOverflow = "wrap" } = labelStyles ?? {};
1855
1840
  const { width: shapeWidth = 16, height: shapeHeight = 16, margin: shapeMargin = "2px 4px 2px 0" } = shapeStyles ?? {};
@@ -1857,11 +1842,17 @@ const BaseLegend = forwardRef(({ items, className, orientation = "horizontal", a
1857
1842
  const context = useContext(GlobalChartsContext);
1858
1843
  const chartInstanceContext = useContext(ChartInstanceContext);
1859
1844
  const standaloneScopeClass = useStandaloneScopeClass();
1845
+ const domain = items.map((item) => item.label);
1860
1846
  const legendScale = scaleOrdinal({
1861
- domain: items.map((item) => item.label),
1847
+ domain,
1862
1848
  range: items.map((item) => item.color)
1863
1849
  });
1864
- const domain = legendScale.domain();
1850
+ const defaultLabelTransform = useCallback(({ labelFormat: format }) => (datum, index) => ({
1851
+ datum,
1852
+ index,
1853
+ text: String(format(datum, index)),
1854
+ value: items[index].color
1855
+ }), [items]);
1865
1856
  const getShapeStyle = useCallback(({ index }) => items[index]?.shapeStyle, [items]);
1866
1857
  const handleLegendClick = useCallback((seriesLabels) => {
1867
1858
  if (interactive && chartId && context) {
@@ -1882,24 +1873,26 @@ const BaseLegend = forwardRef(({ items, className, orientation = "horizontal", a
1882
1873
  chartInstanceContext,
1883
1874
  context
1884
1875
  ]);
1885
- const createClickHandler = useCallback((seriesLabels) => {
1886
- if (!interactive) return;
1876
+ const createClickHandler = useCallback((seriesLabels, itemInteractive) => {
1877
+ if (!itemInteractive) return;
1887
1878
  return () => handleLegendClick(seriesLabels);
1888
- }, [interactive, handleLegendClick]);
1889
- const createKeyDownHandler = useCallback((seriesLabels) => {
1890
- if (!interactive) return;
1879
+ }, [handleLegendClick]);
1880
+ const createKeyDownHandler = useCallback((seriesLabels, itemInteractive) => {
1881
+ if (!itemInteractive) return;
1891
1882
  return (event) => {
1892
1883
  if (event.key === "Enter" || event.key === " ") {
1893
1884
  event.preventDefault();
1894
1885
  handleLegendClick(seriesLabels);
1895
1886
  }
1896
1887
  };
1897
- }, [interactive, handleLegendClick]);
1888
+ }, [handleLegendClick]);
1898
1889
  const flexAlignment = ALIGNMENT_TO_FLEX[alignment] ?? "center";
1890
+ const staticItemRole = interactive ? void 0 : "listitem";
1899
1891
  return render ? render(items) : /* @__PURE__ */ jsx(LegendOrdinal, {
1900
1892
  scale: legendScale,
1893
+ domain,
1901
1894
  labelFormat,
1902
- labelTransform,
1895
+ labelTransform: labelTransform ?? defaultLabelTransform,
1903
1896
  children: (labels) => /* @__PURE__ */ jsx(Stack, {
1904
1897
  ref,
1905
1898
  direction: orientation === "vertical" ? "column" : "row",
@@ -1913,19 +1906,20 @@ const BaseLegend = forwardRef(({ items, className, orientation = "horizontal", a
1913
1906
  children: labels.map((label, i) => {
1914
1907
  const matchedItem = items[i];
1915
1908
  const seriesLabels = matchedItem?.seriesLabels?.length ? matchedItem.seriesLabels : [label.text];
1916
- const visible = isSeriesVisible(seriesLabels[0]);
1917
- const handleClick = createClickHandler(seriesLabels);
1918
- const handleKeyDown = createKeyDownHandler(seriesLabels);
1909
+ const visible = matchedItem?.interactive === false || isSeriesVisible(seriesLabels[0]);
1910
+ const itemInteractive = interactive && matchedItem?.interactive !== false;
1911
+ const handleClick = createClickHandler(seriesLabels, itemInteractive);
1912
+ const handleKeyDown = createKeyDownHandler(seriesLabels, itemInteractive);
1919
1913
  return /* @__PURE__ */ jsxs(LegendItem, {
1920
- className: clsx("visx-legend-item", base_legend_module_default["legend-item"], interactive && base_legend_module_default["legend-item--interactive"], !visible && base_legend_module_default["legend-item--inactive"], itemClassName),
1914
+ className: clsx("visx-legend-item", base_legend_module_default["legend-item"], itemInteractive && base_legend_module_default["legend-item--interactive"], !visible && base_legend_module_default["legend-item--inactive"], itemClassName),
1921
1915
  margin: itemMargin,
1922
1916
  flexDirection: orientation === "vertical" && alignment === "end" ? "row-reverse" : itemDirection,
1923
1917
  onClick: handleClick,
1924
1918
  onKeyDown: handleKeyDown,
1925
- role: interactive ? "button" : "listitem",
1926
- tabIndex: interactive ? 0 : void 0,
1927
- "aria-pressed": interactive ? visible : void 0,
1928
- "aria-label": getLegendItemAriaLabel(label.text, matchedItem?.value, visible, interactive),
1919
+ role: itemInteractive ? "button" : staticItemRole,
1920
+ tabIndex: itemInteractive ? 0 : void 0,
1921
+ "aria-pressed": itemInteractive ? visible : void 0,
1922
+ "aria-label": getLegendItemAriaLabel(label.text, matchedItem?.value, visible, itemInteractive),
1929
1923
  children: [items[i]?.renderGlyph ? /* @__PURE__ */ jsx("svg", {
1930
1924
  width: items[i]?.glyphSize * 2,
1931
1925
  height: items[i]?.glyphSize * 2,
@@ -2127,6 +2121,32 @@ function processSeriesData(seriesData, getElementStyles, showValues, withGlyph,
2127
2121
  });
2128
2122
  }
2129
2123
  /**
2124
+ * Builds the static item that names the comparison overlay, styled like its first series.
2125
+ * Skipped when a comparison series already has an item of its own, so nothing is listed twice.
2126
+ * @param seriesData - The series data to search for a comparison series
2127
+ * @param items - The legend items already built from the series
2128
+ * @param label - The item label
2129
+ * @param getElementStyles - Function to get element styles
2130
+ * @param legendShape - The shape type for legend items (string literal or React component)
2131
+ * @return The legend item, or null when it would add nothing
2132
+ */
2133
+ function buildComparisonLegendItem(seriesData, items, label, getElementStyles, legendShape) {
2134
+ const itemLabels = new Set(items.map((item) => item.label));
2135
+ const index = seriesData.findIndex((series) => series.options?.type === "comparison" && !itemLabels.has(series.label));
2136
+ if (index === -1) return null;
2137
+ const { color, shapeStyles } = getElementStyles({
2138
+ data: seriesData[index],
2139
+ index,
2140
+ legendShape
2141
+ });
2142
+ return {
2143
+ label,
2144
+ color,
2145
+ shapeStyle: shapeStyles,
2146
+ interactive: false
2147
+ };
2148
+ }
2149
+ /**
2130
2150
  * Processes point data into legend items
2131
2151
  * @param pointData - The point data to process
2132
2152
  * @param getElementStyles - Function to get element styles
@@ -2162,11 +2182,16 @@ function processPointData(pointData, getElementStyles, showValues, legendValueDi
2162
2182
  * @return Array of legend items ready for display
2163
2183
  */
2164
2184
  function useChartLegendItems(data, options = {}, legendShape) {
2165
- const { showValues = false, legendValueDisplay = "percentage", withGlyph = false, glyphSize = 8, collapseGroups = false, renderGlyph } = options;
2185
+ const { showValues = false, legendValueDisplay = "percentage", withGlyph = false, glyphSize = 8, collapseGroups = false, comparisonItem = false, renderGlyph } = options;
2166
2186
  const { getElementStyles } = useGlobalChartsContext();
2167
2187
  return useMemo(() => {
2168
2188
  if (!data || !Array.isArray(data) || data.length === 0) return [];
2169
- if ("data" in data[0]) return processSeriesData(data, getElementStyles, showValues, withGlyph, glyphSize, collapseGroups, renderGlyph, legendShape);
2189
+ if ("data" in data[0]) {
2190
+ const seriesData = data;
2191
+ const items = processSeriesData(seriesData, getElementStyles, showValues, withGlyph, glyphSize, collapseGroups, renderGlyph, legendShape);
2192
+ const comparison = comparisonItem ? buildComparisonLegendItem(seriesData, items, typeof comparisonItem === "string" ? comparisonItem : __("Comparison period", "jetpack-charts"), getElementStyles, legendShape) : null;
2193
+ return comparison ? [...items, comparison] : items;
2194
+ }
2170
2195
  return processPointData(data, getElementStyles, showValues, legendValueDisplay, withGlyph, glyphSize, renderGlyph, legendShape);
2171
2196
  }, [
2172
2197
  data,
@@ -2176,6 +2201,7 @@ function useChartLegendItems(data, options = {}, legendShape) {
2176
2201
  withGlyph,
2177
2202
  glyphSize,
2178
2203
  collapseGroups,
2204
+ comparisonItem,
2179
2205
  renderGlyph,
2180
2206
  legendShape
2181
2207
  ]);
@@ -2639,7 +2665,7 @@ const AccessibleTooltip = ({ renderTooltip, selectedIndex, tooltipRef, keyboardF
2639
2665
  tooltipContext?.showTooltip({
2640
2666
  datum: tooltipItem.datum,
2641
2667
  key: tooltipItem.seriesLabel,
2642
- index: tooltipItem.seriesIndex
2668
+ index: tooltipItem.dataPointIndex
2643
2669
  });
2644
2670
  }
2645
2671
  }
@@ -2962,6 +2988,31 @@ const DefaultGlyph = (props) => {
2962
2988
  });
2963
2989
  };
2964
2990
  //#endregion
2991
+ //#region src/charts/private/readings.ts
2992
+ /**
2993
+ * Whether a value is a reading a scale can place.
2994
+ *
2995
+ * @param value - The point's value.
2996
+ * @return True for a finite number.
2997
+ */
2998
+ const isReading = (value) => typeof value === "number" && Number.isFinite(value);
2999
+ /**
3000
+ * Whether a data point's value cannot be drawn.
3001
+ *
3002
+ * @param value - The point's value.
3003
+ * @param options - Validation options.
3004
+ * @param options.allowMissing - Whether `null` is a bucket with no reading rather than a fault.
3005
+ * @return True when the value is invalid.
3006
+ */
3007
+ const isInvalidReading = (value, { allowMissing }) => value === null ? !allowMissing : value === void 0 || isNaN(value);
3008
+ /**
3009
+ * Formats a reading for a tooltip.
3010
+ *
3011
+ * @param value - The reading, or null when the bucket has none.
3012
+ * @return The formatted value.
3013
+ */
3014
+ const formatReading = (value) => value == null ? __("No data", "jetpack-charts") : formatNumber(value);
3015
+ //#endregion
2965
3016
  //#region src/charts/private/center/center.module.scss
2966
3017
  var center_module_default = { "center": "a8ccharts-w3qxlG-center" };
2967
3018
  //#endregion
@@ -3334,6 +3385,54 @@ const buildTimeAxisOptions = ({ dataSorted, width, axisOptions, scaleDomain, zoo
3334
3385
  };
3335
3386
  };
3336
3387
  //#endregion
3388
+ //#region src/charts/private/whole-number-ticks.tsx
3389
+ /**
3390
+ * Whether every reading in the series is a whole number.
3391
+ *
3392
+ * @param series - The series visx renders.
3393
+ * @return True when at least one reading exists and none has a fraction.
3394
+ */
3395
+ const hasOnlyWholeNumbers = (series) => {
3396
+ let sawReading = false;
3397
+ for (const { data } of series) for (const point of data) {
3398
+ const value = point?.value;
3399
+ if (!isReading(value)) continue;
3400
+ if (!Number.isInteger(value)) return false;
3401
+ sawReading = true;
3402
+ }
3403
+ return sawReading;
3404
+ };
3405
+ /**
3406
+ * The scale's ticks with the fractional ones dropped.
3407
+ *
3408
+ * d3 steps by 1, 2 or 5 times a power of ten, so every whole number inside the domain is one of its ticks.
3409
+ *
3410
+ * @param scale - The value scale visx built.
3411
+ * @param count - Ticks to ask the scale for; undefined uses the scale's own default, as visx does.
3412
+ * @return Whole-number ticks, or undefined when visx's own ticks need no change.
3413
+ */
3414
+ const getWholeNumberTickValues = (scale, count) => {
3415
+ const ticks = scale?.ticks?.(count);
3416
+ if (!ticks?.every((tick) => typeof tick === "number")) return;
3417
+ const whole = ticks.filter((tick) => Number.isInteger(tick));
3418
+ return whole.length > 1 && whole.length < ticks.length ? whole : void 0;
3419
+ };
3420
+ /**
3421
+ * Hands its children the whole-number ticks of the chart's value scale.
3422
+ *
3423
+ * @param props - Component props.
3424
+ * @param props.axis - Which scale carries the values.
3425
+ * @param props.numTicks - Ticks to ask the scale for.
3426
+ * @param props.enabled - Whether the plotted values are all whole numbers and the caller pinned neither the ticks nor the value domain.
3427
+ * @param props.children - Renders the axis and grid with the tick values.
3428
+ * @return The rendered children.
3429
+ */
3430
+ const WholeNumberTicks = ({ axis, numTicks, enabled, children }) => {
3431
+ const { xScale, yScale } = useContext(DataContext);
3432
+ const tickValues = enabled ? getWholeNumberTickValues(axis === "x" ? xScale : yScale, numTicks) : void 0;
3433
+ return /* @__PURE__ */ jsx(Fragment$1, { children: children(tickValues) });
3434
+ };
3435
+ //#endregion
3337
3436
  //#region src/charts/private/with-responsive/with-responsive.module.scss
3338
3437
  var with_responsive_module_default = {
3339
3438
  "container": "a8ccharts-sP1gHa-container",
@@ -4000,11 +4099,15 @@ const toNumber$1 = (val) => {
4000
4099
  const LineChartGlyph = ({ data, index, color, glyphStyle, renderGlyph, accessors, position }) => {
4001
4100
  const { xScale, yScale } = useContext(DataContext) || {};
4002
4101
  if (!xScale || !yScale) return null;
4003
- if (data.data.length === 0) return null;
4004
- const point = position === "start" ? data.data[0] : data.data[data.data.length - 1];
4102
+ const hasFiniteY = (datum) => {
4103
+ const scaledY = yScale(accessors.yAccessor(datum));
4104
+ return typeof scaledY === "number" && Number.isFinite(scaledY);
4105
+ };
4106
+ const point = position === "start" ? data.data.find(hasFiniteY) : data.data.findLast(hasFiniteY);
4107
+ if (!point) return null;
4005
4108
  const x = xScale(accessors.xAccessor(point));
4006
4109
  const y = yScale(accessors.yAccessor(point));
4007
- if (typeof x !== "number" || typeof y !== "number") return null;
4110
+ if (typeof x !== "number" || typeof y !== "number" || !Number.isFinite(x) || !Number.isFinite(y)) return null;
4008
4111
  const size = Math.max(0, toNumber$1(glyphStyle?.radius) ?? 4);
4009
4112
  return renderGlyph({
4010
4113
  key: `${position}-glyph-${data.label}`,
@@ -4019,6 +4122,106 @@ const LineChartGlyph = ({ data, index, color, glyphStyle, renderGlyph, accessors
4019
4122
  });
4020
4123
  };
4021
4124
  //#endregion
4125
+ //#region src/charts/line-chart/private/nearest-pointer-events.tsx
4126
+ /**
4127
+ * Find the datum nearest a pointer along x, in one series sorted by x.
4128
+ *
4129
+ * @param data - The series' registered data, in ascending x order.
4130
+ * @param xAccessor - Reads a datum's x value.
4131
+ * @param xScale - The chart's x scale.
4132
+ * @param x - Pointer x in SVG coordinates.
4133
+ * @return Index of the nearest datum and its distance, or index -1 when none has a position.
4134
+ */
4135
+ function nearestByX(data, xAccessor, xScale, x) {
4136
+ return (typeof xScale.invert === "function" ? [bisector(xAccessor).left(data, xScale.invert(x))].flatMap((index) => [index - 1, index]) : data.map((_datum, index) => index)).filter((index) => index >= 0 && index < data.length).reduce((best, index) => {
4137
+ const distance = Math.abs(Number(xScale(xAccessor(data[index]))) - x);
4138
+ return distance < best.distance ? {
4139
+ index,
4140
+ distance
4141
+ } : best;
4142
+ }, {
4143
+ index: -1,
4144
+ distance: Infinity
4145
+ });
4146
+ }
4147
+ /**
4148
+ * Pick the candidate to report: the closest placed reading, else the closest bucket with no reading along x.
4149
+ *
4150
+ * @param candidates - One candidate per series.
4151
+ * @return The chosen candidate's params, or undefined when there are none.
4152
+ */
4153
+ function pickNearest(candidates) {
4154
+ const placed = candidates.map(({ params }) => params).filter((params) => Number.isFinite(params.distanceY));
4155
+ const missing = candidates.filter(({ isMissing }) => isMissing).map(({ params }) => params);
4156
+ const distance = (params) => placed.length ? Math.hypot(params.distanceX ?? 0, params.distanceY ?? 0) : params.distanceX ?? 0;
4157
+ return (placed.length ? placed : missing).reduce((best, params) => !best || distance(params) < distance(best) ? params : best, void 0);
4158
+ }
4159
+ /**
4160
+ * Report pointer events at the nearest datum, including a bucket with no reading.
4161
+ *
4162
+ * visx's own nearest search measures y distance too, which is NaN for a null value, so a pointer
4163
+ * over a bucket where no series has a reading fires no event at all.
4164
+ *
4165
+ * @param props - Handlers to call.
4166
+ * @param props.onPointerDown - Receives the nearest datum on pointer down.
4167
+ * @param props.onPointerMove - Receives the nearest datum on pointer move.
4168
+ * @param props.onPointerUp - Receives the nearest datum on pointer up.
4169
+ * @return No visual content.
4170
+ */
4171
+ function NearestPointerEvents({ onPointerDown, onPointerMove, onPointerUp }) {
4172
+ const { xScale, yScale, dataRegistry } = useContext(DataContext);
4173
+ const findNearest = useCallback((params) => {
4174
+ const point = params?.svgPoint;
4175
+ if (!point || !xScale || !yScale || !dataRegistry) return;
4176
+ const candidates = [];
4177
+ for (const key of dataRegistry.keys()) {
4178
+ const entry = dataRegistry.get(key);
4179
+ if (!entry) continue;
4180
+ const { index, distance } = nearestByX(entry.data, entry.xAccessor, xScale, point.x);
4181
+ if (index < 0) continue;
4182
+ const datum = entry.data[index];
4183
+ const value = entry.yAccessor(datum);
4184
+ candidates.push({
4185
+ params: {
4186
+ event: params.event,
4187
+ svgPoint: point,
4188
+ key,
4189
+ datum,
4190
+ index,
4191
+ distanceX: distance,
4192
+ distanceY: Math.abs(Number(yScale(value)) - point.y)
4193
+ },
4194
+ isMissing: value == null
4195
+ });
4196
+ }
4197
+ return pickNearest(candidates);
4198
+ }, [
4199
+ xScale,
4200
+ yScale,
4201
+ dataRegistry
4202
+ ]);
4203
+ const handlers = useMemo(() => {
4204
+ const report = (handler) => handler && ((params) => {
4205
+ const nearest = findNearest(params);
4206
+ if (nearest) handler(nearest);
4207
+ });
4208
+ return {
4209
+ pointerdown: report(onPointerDown),
4210
+ pointermove: report(onPointerMove),
4211
+ pointerup: report(onPointerUp)
4212
+ };
4213
+ }, [
4214
+ findNearest,
4215
+ onPointerDown,
4216
+ onPointerMove,
4217
+ onPointerUp
4218
+ ]);
4219
+ useEventEmitter("pointerdown", handlers.pointerdown);
4220
+ useEventEmitter("pointermove", handlers.pointermove);
4221
+ useEventEmitter("pointerup", handlers.pointerup);
4222
+ return null;
4223
+ }
4224
+ //#endregion
4022
4225
  //#region src/charts/line-chart/line-chart.tsx
4023
4226
  const defaultRenderGlyph = (props) => {
4024
4227
  return /* @__PURE__ */ createElement(DefaultGlyph, {
@@ -4062,8 +4265,13 @@ const renderDefaultTooltip = (params, contentStyle) => {
4062
4265
  if (!nearestDatum) return null;
4063
4266
  const tooltipPoints = Object.entries(tooltipData?.datumByKey || {}).map(([key, { datum }]) => ({
4064
4267
  key,
4065
- value: datum.value
4066
- })).sort((a, b) => b.value - a.value);
4268
+ value: datum.value ?? null
4269
+ })).sort((a, b) => {
4270
+ if (a.value === null && b.value === null) return 0;
4271
+ if (a.value === null) return 1;
4272
+ if (b.value === null) return -1;
4273
+ return b.value - a.value;
4274
+ });
4067
4275
  return /* @__PURE__ */ jsxs("div", {
4068
4276
  className: line_chart_module_default["line-chart__tooltip"],
4069
4277
  style: contentStyle,
@@ -4083,16 +4291,23 @@ const renderDefaultTooltip = (params, contentStyle) => {
4083
4291
  children: [point.key, ":"]
4084
4292
  }), /* @__PURE__ */ jsx("span", {
4085
4293
  className: line_chart_module_default["line-chart__tooltip-value"],
4086
- children: formatNumber(point.value)
4294
+ children: formatReading(point.value)
4087
4295
  })]
4088
4296
  }, point.key))]
4089
4297
  });
4090
4298
  };
4091
4299
  const validateData$4 = (data) => {
4092
- if (!data?.length) return "No data available";
4093
- if (data.some((series) => series.data.some((point) => isNaN(point.value) || point.value === null || point.value === void 0 || "date" in point && point.date && isNaN(point.date.getTime())))) return "Invalid data";
4300
+ if (!data?.length) return __("No data available", "jetpack-charts");
4301
+ if (data.some((series) => series.data.some((point) => isInvalidReading(point.value, { allowMissing: true }) || "date" in point && point.date && isNaN(point.date.getTime())))) return __("Invalid data", "jetpack-charts");
4094
4302
  return null;
4095
4303
  };
4304
+ const getFallbackYDomain = (readingExtent, isLogScale) => {
4305
+ const emptyDomain = isLogScale ? [1, 10] : [0, 1];
4306
+ if (!readingExtent) return emptyDomain;
4307
+ const [min, max] = readingExtent;
4308
+ if (min !== max || isLogScale) return;
4309
+ return min === 0 ? emptyDomain : [Math.min(0, min), Math.max(0, max)];
4310
+ };
4096
4311
  const LineChartScalesRef = ({ chartRef, width, height, margin }) => {
4097
4312
  const context = useContext(DataContext);
4098
4313
  useImperativeHandle(chartRef, () => ({
@@ -4119,6 +4334,7 @@ const LineChartScalesRef = ({ chartRef, width, height, margin }) => {
4119
4334
  const LineChartInternal = forwardRef(({ data, chartId: providedChartId, width, height, className, margin, withTooltips = true, withTooltipCrosshairs, showLegend = false, legend = {}, renderGlyph = defaultRenderGlyph, glyphStyle = {}, withLegendGlyph = false, withGradientFill = false, smoothing = true, curveType, renderTooltip = renderDefaultTooltip, tooltipPlacement, tooltipStyle, withStartGlyphs = false, withEndGlyphs = false, animation, options = {}, onPointerDown = void 0, onPointerUp = void 0, onPointerMove = void 0, onPointerOut = void 0, onDatumActivate = void 0, zoomable = false, rescaleYOnVisibilityChange = true, defaultHiddenSeries, children, gridVisibility, gap = "md" }, ref) => {
4120
4335
  const legendInteractive = legend.interactive ?? false;
4121
4336
  const legendCollapseGroups = legend.collapseGroups ?? false;
4337
+ const legendComparisonItem = legend.comparisonItem ?? false;
4122
4338
  const legendShape = legend.shape ?? "line";
4123
4339
  const legendPosition = legend.position ?? "bottom";
4124
4340
  const formatting = useChartFormatting();
@@ -4165,19 +4381,32 @@ const LineChartInternal = forwardRef(({ data, chartId: providedChartId, width, h
4165
4381
  const allSeriesHidden = useMemo(() => {
4166
4382
  return seriesWithVisibility.every(({ isVisible }) => !isVisible);
4167
4383
  }, [seriesWithVisibility]);
4168
- const stableYDomain = useMemo(() => {
4169
- if (rescaleYOnVisibilityChange) return;
4384
+ const yDomain = useMemo(() => {
4385
+ const includeZero = options?.yScale?.zero === true && scaleCanBeZeroed({ type: options.yScale.type ?? "linear" });
4386
+ if (rescaleYOnVisibilityChange && !includeZero) return;
4170
4387
  let min = Infinity;
4171
4388
  let max = -Infinity;
4172
- for (const series of dataSorted) for (const point of series.data ?? []) {
4173
- const value = point?.value;
4174
- if (typeof value === "number" && Number.isFinite(value)) {
4175
- min = Math.min(min, value);
4176
- max = Math.max(max, value);
4389
+ for (const series of dataSorted) {
4390
+ if (rescaleYOnVisibilityChange && !isSeriesVisible(series.label)) continue;
4391
+ for (const point of series.data ?? []) {
4392
+ const value = point?.value;
4393
+ if (isReading(value)) {
4394
+ min = Math.min(min, value);
4395
+ max = Math.max(max, value);
4396
+ }
4177
4397
  }
4178
4398
  }
4399
+ if (includeZero) {
4400
+ if (min === Infinity) return;
4401
+ return max === 0 && min === 0 ? [0, 1] : [Math.min(0, min), Math.max(0, max)];
4402
+ }
4179
4403
  return min < max ? [min, max] : void 0;
4180
- }, [rescaleYOnVisibilityChange, dataSorted]);
4404
+ }, [
4405
+ rescaleYOnVisibilityChange,
4406
+ dataSorted,
4407
+ isSeriesVisible,
4408
+ options?.yScale
4409
+ ]);
4181
4410
  const activateSelectedPoint = useCallback((index) => {
4182
4411
  const series = dataSorted[0];
4183
4412
  const datum = series?.data[index];
@@ -4197,7 +4426,24 @@ const LineChartInternal = forwardRef(({ data, chartId: providedChartId, width, h
4197
4426
  onActivate: activateSelectedPoint,
4198
4427
  preventTooltipScroll: tooltipPlacement === "below-axis"
4199
4428
  });
4429
+ const visibleReadingExtent = useMemo(() => {
4430
+ let min = Infinity;
4431
+ let max = -Infinity;
4432
+ for (const series of dataSorted) {
4433
+ if (!isSeriesVisible(series.label)) continue;
4434
+ for (const point of series.data) {
4435
+ const value = point?.value;
4436
+ if (isReading(value)) {
4437
+ min = Math.min(min, value);
4438
+ max = Math.max(max, value);
4439
+ }
4440
+ }
4441
+ }
4442
+ return min <= max ? [min, max] : void 0;
4443
+ }, [dataSorted, isSeriesVisible]);
4444
+ const hasWholeNumberValues = useMemo(() => hasOnlyWholeNumbers(dataSorted.filter((series) => isSeriesVisible(series.label))), [dataSorted, isSeriesVisible]);
4200
4445
  const chartOptions = useMemo(() => {
4446
+ const fallbackYDomain = getFallbackYDomain(visibleReadingExtent, options?.yScale?.type === "log");
4201
4447
  return {
4202
4448
  axis: {
4203
4449
  x: buildTimeAxisOptions({
@@ -4226,7 +4472,8 @@ const LineChartInternal = forwardRef(({ data, chartId: providedChartId, width, h
4226
4472
  type: "linear",
4227
4473
  nice: true,
4228
4474
  zero: false,
4229
- ...stableYDomain ? { domain: stableYDomain } : {},
4475
+ ...fallbackYDomain ? { domain: fallbackYDomain } : {},
4476
+ ...yDomain ? { domain: yDomain } : {},
4230
4477
  ...options?.yScale
4231
4478
  }
4232
4479
  };
@@ -4235,7 +4482,8 @@ const LineChartInternal = forwardRef(({ data, chartId: providedChartId, width, h
4235
4482
  dataSorted,
4236
4483
  width,
4237
4484
  zoom.domain,
4238
- stableYDomain,
4485
+ yDomain,
4486
+ visibleReadingExtent,
4239
4487
  formatting,
4240
4488
  isSeriesVisible
4241
4489
  ]);
@@ -4270,11 +4518,13 @@ const LineChartInternal = forwardRef(({ data, chartId: providedChartId, width, h
4270
4518
  withGlyph: withLegendGlyph,
4271
4519
  glyphSize: Math.max(0, toNumber(glyphStyle?.radius) ?? 4),
4272
4520
  collapseGroups: legendCollapseGroups,
4521
+ comparisonItem: legendComparisonItem,
4273
4522
  renderGlyph
4274
4523
  }), [
4275
4524
  withLegendGlyph,
4276
4525
  glyphStyle?.radius,
4277
4526
  legendCollapseGroups,
4527
+ legendComparisonItem,
4278
4528
  renderGlyph
4279
4529
  ]), legendShape);
4280
4530
  const chartMetadata = useMemo(() => ({
@@ -4386,18 +4636,26 @@ const LineChartInternal = forwardRef(({ data, chartId: providedChartId, width, h
4386
4636
  },
4387
4637
  xScale: chartOptions.xScale,
4388
4638
  yScale: chartOptions.yScale,
4389
- onPointerDown: zoom.handlers.onPointerDown,
4390
- onPointerUp: zoom.handlers.onPointerUp,
4391
- onPointerMove: zoom.handlers.onPointerMove,
4392
4639
  onPointerOut,
4393
- pointerEventsDataKey: "nearest",
4394
4640
  children: [
4395
- !allSeriesHidden && gridVisibility !== "none" && /* @__PURE__ */ jsx(Grid, {
4396
- columns: false,
4397
- numTicks: 4
4641
+ /* @__PURE__ */ jsx(NearestPointerEvents, { ...zoom.handlers }),
4642
+ /* @__PURE__ */ jsx(WholeNumberTicks, {
4643
+ axis: "y",
4644
+ numTicks: chartOptions.axis.y.numTicks,
4645
+ enabled: hasWholeNumberValues && !chartOptions.axis.y.tickValues && !options?.yScale?.domain,
4646
+ children: (tickValues) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
4647
+ !allSeriesHidden && gridVisibility !== "none" && /* @__PURE__ */ jsx(Grid, {
4648
+ columns: false,
4649
+ numTicks: chartOptions.axis.y.numTicks,
4650
+ tickValues: tickValues ?? chartOptions.axis.y.tickValues
4651
+ }),
4652
+ !allSeriesHidden && chartOptions.axis.x.display && /* @__PURE__ */ jsx(Axis, { ...chartOptions.axis.x }),
4653
+ !allSeriesHidden && chartOptions.axis.y.display && /* @__PURE__ */ jsx(Axis, {
4654
+ ...chartOptions.axis.y,
4655
+ ...tickValues ? { tickValues } : {}
4656
+ })
4657
+ ] })
4398
4658
  }),
4399
- !allSeriesHidden && chartOptions.axis.x.display && /* @__PURE__ */ jsx(Axis, { ...chartOptions.axis.x }),
4400
- !allSeriesHidden && chartOptions.axis.y.display && /* @__PURE__ */ jsx(Axis, { ...chartOptions.axis.y }),
4401
4659
  allSeriesHidden ? /* @__PURE__ */ jsx(SvgEmptyState, {
4402
4660
  x: width / 2,
4403
4661
  y: chartHeight / 2,
@@ -4538,7 +4796,7 @@ var area_chart_module_default = {
4538
4796
  const validateData$3 = (data) => {
4539
4797
  if (!data?.length) return __("No data available", "jetpack-charts");
4540
4798
  if (data.some((series) => !series.data?.length)) return __("No data available", "jetpack-charts");
4541
- if (data.some((series) => series.data.some((point) => isNaN(point.value) || point.value === null || point.value === void 0 || "date" in point && point.date && isNaN(point.date.getTime())))) return __("Invalid data", "jetpack-charts");
4799
+ if (data.some((series) => series.data.some((point) => isInvalidReading(point.value, { allowMissing: false }) || "date" in point && point.date && isNaN(point.date.getTime())))) return __("Invalid data", "jetpack-charts");
4542
4800
  return null;
4543
4801
  };
4544
4802
  //#endregion
@@ -4654,6 +4912,12 @@ const AreaChartInternal = forwardRef(({ data, chartId: providedChartId, width, h
4654
4912
  }));
4655
4913
  }, [dataSorted, hiddenSeries]);
4656
4914
  const allSeriesHidden = useMemo(() => seriesWithVisibility.every(({ isVisible }) => !isVisible), [seriesWithVisibility]);
4915
+ const hasWholeNumberValues = useMemo(() => (!stacked || stackOffset === "none") && hasOnlyWholeNumbers(dataSorted.filter((series) => isSeriesVisible(series.label))), [
4916
+ dataSorted,
4917
+ isSeriesVisible,
4918
+ stacked,
4919
+ stackOffset
4920
+ ]);
4657
4921
  const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation({
4658
4922
  selectedIndex,
4659
4923
  setSelectedIndex,
@@ -4746,8 +5010,9 @@ const AreaChartInternal = forwardRef(({ data, chartId: providedChartId, width, h
4746
5010
  const legendItems = useChartLegendItems(dataSorted, useMemo(() => ({
4747
5011
  withGlyph: false,
4748
5012
  glyphSize: 0,
4749
- collapseGroups: legend.collapseGroups ?? false
4750
- }), [legend.collapseGroups]), legendShape);
5013
+ collapseGroups: legend.collapseGroups ?? false,
5014
+ comparisonItem: legend.comparisonItem ?? false
5015
+ }), [legend.collapseGroups, legend.comparisonItem]), legendShape);
4751
5016
  const chartMetadata = useMemo(() => ({
4752
5017
  stacked,
4753
5018
  stackOffset,
@@ -4897,12 +5162,23 @@ const AreaChartInternal = forwardRef(({ data, chartId: providedChartId, width, h
4897
5162
  onPointerOut,
4898
5163
  pointerEventsDataKey: "nearest",
4899
5164
  children: [
4900
- !allSeriesHidden && gridVisibility !== "none" && /* @__PURE__ */ jsx(Grid, {
4901
- columns: false,
4902
- numTicks: 4
5165
+ /* @__PURE__ */ jsx(WholeNumberTicks, {
5166
+ axis: "y",
5167
+ numTicks: chartOptions.axis.y.numTicks,
5168
+ enabled: hasWholeNumberValues && !chartOptions.axis.y.tickValues && !options?.yScale?.domain,
5169
+ children: (tickValues) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
5170
+ !allSeriesHidden && gridVisibility !== "none" && /* @__PURE__ */ jsx(Grid, {
5171
+ columns: false,
5172
+ numTicks: chartOptions.axis.y.numTicks,
5173
+ tickValues: tickValues ?? chartOptions.axis.y.tickValues
5174
+ }),
5175
+ !allSeriesHidden && chartOptions.axis.x.display && /* @__PURE__ */ jsx(Axis, { ...chartOptions.axis.x }),
5176
+ !allSeriesHidden && chartOptions.axis.y.display && /* @__PURE__ */ jsx(Axis, {
5177
+ ...chartOptions.axis.y,
5178
+ ...tickValues ? { tickValues } : {}
5179
+ })
5180
+ ] })
4903
5181
  }),
4904
- !allSeriesHidden && chartOptions.axis.x.display && /* @__PURE__ */ jsx(Axis, { ...chartOptions.axis.x }),
4905
- !allSeriesHidden && chartOptions.axis.y.display && /* @__PURE__ */ jsx(Axis, { ...chartOptions.axis.y }),
4906
5182
  allSeriesHidden ? /* @__PURE__ */ jsx(SvgEmptyState, {
4907
5183
  x: width / 2,
4908
5184
  y: chartHeight / 2,
@@ -5091,6 +5367,65 @@ const createTruncatedTickComponent = (axis) => (props) => {
5091
5367
  const TruncatedXTickComponent = createTruncatedTickComponent("x");
5092
5368
  const TruncatedYTickComponent = createTruncatedTickComponent("y");
5093
5369
  //#endregion
5370
+ //#region src/charts/bar-chart/private/value-domain.ts
5371
+ /** Domain for a value axis that has nothing to scale, so its ticks still read 0 to 1. */
5372
+ const EMPTY_DOMAIN = [0, 1];
5373
+ /**
5374
+ * The value a bar is drawn at, preferring `visualValue` so a zero still shows a sliver.
5375
+ *
5376
+ * A missing reading is NaN, not null: `ComparisonBars` coerces the value with `Number()`,
5377
+ * which turns null into 0 and would draw a comparison shadow at the baseline.
5378
+ *
5379
+ * @param point - A data point handed to visx.
5380
+ * @return The bar value, or NaN when the bucket has no reading.
5381
+ */
5382
+ const getBarValue = (point) => {
5383
+ const enhanced = point;
5384
+ return (enhanced?.visualValue !== void 0 ? enhanced.visualValue : point?.value) ?? NaN;
5385
+ };
5386
+ /**
5387
+ * Whether visx renders a bar for a point.
5388
+ *
5389
+ * @param point - A data point handed to visx.
5390
+ * @return True when the point has a finite bar value.
5391
+ */
5392
+ const isBarRendered = (point) => Number.isFinite(getBarValue(point));
5393
+ /**
5394
+ * How many bars visx renders for a list of points.
5395
+ *
5396
+ * @param points - Data points handed to visx.
5397
+ * @return The number of points that get a bar.
5398
+ */
5399
+ const countRenderedBars = (points) => points.filter(isBarRendered).length;
5400
+ /**
5401
+ * An explicit domain for the value scale, or null to let visx fit one to the data.
5402
+ *
5403
+ * Zero goes into the domain here rather than through the scale's `zero` flag: visx applies
5404
+ * `nice` before `zero`, which would leave the axis topping out at a raw maximum.
5405
+ *
5406
+ * @param data - Every series handed to the chart.
5407
+ * @param includeZero - Whether the domain must span zero.
5408
+ * @param isSeriesRendered - Whether visx mounts a series, i.e. the legend shows it.
5409
+ * @return Value domain, or null when visx should fit its own.
5410
+ */
5411
+ const getValueScaleDomain = (data, includeZero, isSeriesRendered) => {
5412
+ let min = Infinity;
5413
+ let max = -Infinity;
5414
+ for (const series of data) {
5415
+ if (!isSeriesRendered(series)) continue;
5416
+ for (const point of series.data) {
5417
+ const value = getBarValue(point);
5418
+ if (!Number.isFinite(value)) continue;
5419
+ min = Math.min(min, value);
5420
+ max = Math.max(max, value);
5421
+ }
5422
+ }
5423
+ if (min === Infinity) return [...EMPTY_DOMAIN];
5424
+ if (min === max) return min === 0 ? [...EMPTY_DOMAIN] : [Math.min(0, min), Math.max(0, max)];
5425
+ if (includeZero) return [Math.min(0, min), Math.max(0, max)];
5426
+ return null;
5427
+ };
5428
+ //#endregion
5094
5429
  //#region src/charts/bar-chart/private/use-bar-chart-options.ts
5095
5430
  /** Outer padding of the category band scale (space at the chart edges). */
5096
5431
  const BASE_BAND_PADDING = .2;
@@ -5215,10 +5550,6 @@ function useBarChartOptions(data, horizontal, options = {}, isSeriesRendered = A
5215
5550
  const tooltipDatumFormatter = hasLabels ? labelFormatter : byBucket(getTooltipFormatter(data, tickResolution, formatting));
5216
5551
  const valueFormatter = formatNumberCompact;
5217
5552
  const bandDomain = timeTickFormatter ? getBandDomain(data, isSeriesRendered) : null;
5218
- const valueAccessor = (d) => {
5219
- const enhancedPoint = d;
5220
- return enhancedPoint?.visualValue !== void 0 ? enhancedPoint.visualValue : d?.value;
5221
- };
5222
5553
  return {
5223
5554
  timeAxis: bandDomain && timeTickFormatter && {
5224
5555
  domain: bandDomain,
@@ -5229,7 +5560,7 @@ function useBarChartOptions(data, horizontal, options = {}, isSeriesRendered = A
5229
5560
  yTickFormat: valueFormatter,
5230
5561
  tooltipLabelFormatter: tooltipDatumFormatter,
5231
5562
  xAccessor: bucketAccessor,
5232
- yAccessor: valueAccessor,
5563
+ yAccessor: getBarValue,
5233
5564
  gridVisibility: "x",
5234
5565
  xScale: bandScale,
5235
5566
  yScale: linearScale
@@ -5238,7 +5569,7 @@ function useBarChartOptions(data, horizontal, options = {}, isSeriesRendered = A
5238
5569
  xTickFormat: valueFormatter,
5239
5570
  yTickFormat: labelFormatter,
5240
5571
  tooltipLabelFormatter: tooltipDatumFormatter,
5241
- xAccessor: valueAccessor,
5572
+ xAccessor: getBarValue,
5242
5573
  yAccessor: bucketAccessor,
5243
5574
  gridVisibility: "y",
5244
5575
  xScale: linearScale,
@@ -5253,20 +5584,10 @@ function useBarChartOptions(data, horizontal, options = {}, isSeriesRendered = A
5253
5584
  ]);
5254
5585
  return useMemo(() => {
5255
5586
  const { xTickFormat: defaultXTickFormat, yTickFormat: defaultYTickFormat, tooltipLabelFormatter: defaultTooltipLabelFormatter, xAccessor, yAccessor, gridVisibility, xScale: baseXScale, yScale: baseYScale } = defaultOptions[horizontal ? "horizontal" : "vertical"];
5256
- let valueScaleDomainOverride = {};
5257
- if (data.some((s) => s.options?.type === "comparison")) {
5258
- if (!(!horizontal ? stableOptions.yScale?.domain : stableOptions.xScale?.domain)) {
5259
- const allValues = [];
5260
- data.forEach((series) => {
5261
- series.data.forEach((d) => {
5262
- const enhanced = d;
5263
- const v = enhanced.visualValue !== void 0 ? enhanced.visualValue : d.value;
5264
- if (typeof v === "number" && Number.isFinite(v)) allValues.push(v);
5265
- });
5266
- });
5267
- if (allValues.length > 0) valueScaleDomainOverride = { domain: [Math.min(0, ...allValues), Math.max(0, ...allValues)] };
5268
- }
5269
- }
5587
+ const valueScaleOptions = horizontal ? stableOptions.xScale : stableOptions.yScale;
5588
+ const includeZero = data.some((s) => s.options?.type === "comparison") || valueScaleOptions?.zero !== false;
5589
+ const domain = valueScaleOptions?.domain ? null : getValueScaleDomain(data, includeZero, isSeriesRendered);
5590
+ const valueScaleDomainOverride = domain ? { domain } : {};
5270
5591
  const xScale = {
5271
5592
  ...baseXScale,
5272
5593
  ...stableOptions.xScale || {},
@@ -5317,7 +5638,8 @@ function useBarChartOptions(data, horizontal, options = {}, isSeriesRendered = A
5317
5638
  axisConfig,
5318
5639
  stableOptions,
5319
5640
  horizontal,
5320
- data
5641
+ data,
5642
+ isSeriesRendered
5321
5643
  ]);
5322
5644
  }
5323
5645
  //#endregion
@@ -5337,9 +5659,8 @@ const createGroupScale = (keys, bandwidth, padding) => scaleBand({
5337
5659
  //#endregion
5338
5660
  //#region src/charts/bar-chart/private/comparison-bars-geometry.ts
5339
5661
  /**
5340
- * Output position of a value scale's baseline: zero if in-domain, else the
5341
- * nearest range edge. Mirrors visx's getScaleBaseline so comparison shadows
5342
- * sit on the same baseline as primary bars.
5662
+ * Mirrors visx's getScaleBaseline so comparison shadows and primary bars
5663
+ * share the same baseline, including out-of-domain zero values.
5343
5664
  *
5344
5665
  * @param {ValueScale} scale - The continuous value scale.
5345
5666
  * @return {number} The baseline output position in pixels.
@@ -5350,7 +5671,7 @@ function getValueScaleBaseline(scale) {
5350
5671
  const maybeZero = scale(0);
5351
5672
  const [minOutput, maxOutput] = isDescending ? [b, a] : [a, b];
5352
5673
  if (isDescending) return Number.isFinite(maybeZero) ? Math.min(Math.max(minOutput, maybeZero), maxOutput) : maxOutput;
5353
- return Number.isFinite(maybeZero) ? Math.min(Math.max(maybeZero, minOutput), maxOutput) : minOutput;
5674
+ return Number.isFinite(maybeZero) ? Math.max(maybeZero, minOutput) : minOutput;
5354
5675
  }
5355
5676
  /**
5356
5677
  * Compute the rect for a comparison "shadow" bar, centered on the paired
@@ -5386,6 +5707,51 @@ function computeComparisonRect(params) {
5386
5707
  height: valueLength
5387
5708
  };
5388
5709
  }
5710
+ //#endregion
5711
+ //#region src/charts/bar-chart/private/classified-bar-series.tsx
5712
+ const ClassifiedBarSeries = ({ barClassName, primaryKeys, groupPadding, ...props }) => {
5713
+ const { xScale, yScale, horizontal } = useContext(DataContext);
5714
+ const bandScale = horizontal ? yScale : xScale;
5715
+ const valueScale = horizontal ? xScale : yScale;
5716
+ const groupScale = useMemo(() => createGroupScale(primaryKeys, bandScale?.bandwidth?.() ?? 0, groupPadding), [
5717
+ primaryKeys,
5718
+ bandScale,
5719
+ groupPadding
5720
+ ]);
5721
+ const renderGlyph = useCallback(({ datum, x, y, color, onBlur, onFocus, onPointerMove, onPointerOut, onPointerUp }) => {
5722
+ if (!bandScale?.bandwidth || !valueScale) return null;
5723
+ const baseline = getValueScaleBaseline(valueScale);
5724
+ const position = Number(bandScale((horizontal ? props.yAccessor : props.xAccessor)(datum))) + (groupScale(props.dataKey) ?? 0);
5725
+ const value = horizontal ? x : y;
5726
+ return /* @__PURE__ */ jsx("rect", {
5727
+ className: clsx("visx-bar", barClassName(datum)),
5728
+ tabIndex: onFocus || onBlur ? 0 : void 0,
5729
+ onBlur,
5730
+ onFocus,
5731
+ onPointerMove,
5732
+ onPointerOut,
5733
+ onPointerUp,
5734
+ x: horizontal ? baseline + Math.min(0, value - baseline) : position,
5735
+ y: horizontal ? position : baseline + Math.min(0, value - baseline),
5736
+ width: horizontal ? Math.abs(value - baseline) : groupScale.bandwidth(),
5737
+ height: horizontal ? groupScale.bandwidth() : Math.abs(value - baseline),
5738
+ fill: color
5739
+ });
5740
+ }, [
5741
+ bandScale,
5742
+ valueScale,
5743
+ horizontal,
5744
+ groupScale,
5745
+ props.dataKey,
5746
+ props.xAccessor,
5747
+ props.yAccessor,
5748
+ barClassName
5749
+ ]);
5750
+ return /* @__PURE__ */ jsx(GlyphSeries, {
5751
+ ...props,
5752
+ renderGlyph
5753
+ });
5754
+ };
5389
5755
  /**
5390
5756
  * Upper clamp on the computed group padding, so bars can never collapse to zero width
5391
5757
  * even at very large `widthFactor` values.
@@ -5482,6 +5848,8 @@ function BandHighlight({ visible, horizontal, onChange }) {
5482
5848
  if (position === void 0 || !Number.isFinite(position)) return null;
5483
5849
  return {
5484
5850
  datum: nearest.datum,
5851
+ key: nearest.key,
5852
+ index: nearest.index,
5485
5853
  x: horizontal ? margin?.left ?? 0 : position,
5486
5854
  y: horizontal ? position : margin?.top ?? 0,
5487
5855
  width: horizontal ? innerWidth : scale.bandwidth(),
@@ -5618,8 +5986,8 @@ function BandTooltip({ keys, groupPadding, withTooltips, onPointerDown, onPointe
5618
5986
  //#endregion
5619
5987
  //#region src/charts/bar-chart/bar-chart.tsx
5620
5988
  const validateData$2 = (data) => {
5621
- if (!data?.length) return "No data available";
5622
- if (data.some((series) => series.data.some((point) => isNaN(point.value) || point.value === null || point.value === void 0 || !point.label && (!("date" in point && point.date) || isNaN(point.date.getTime()))))) return "Invalid data";
5989
+ if (!data?.length) return __("No data available", "jetpack-charts");
5990
+ if (data.some((series) => series.data.some((point) => isInvalidReading(point.value, { allowMissing: true }) || !point.label && (!("date" in point && point.date) || isNaN(point.date.getTime()))))) return __("Invalid data", "jetpack-charts");
5623
5991
  return null;
5624
5992
  };
5625
5993
  const getPatternId = (chartId, index) => `bar-pattern-${chartId}-${index}`;
@@ -5627,9 +5995,11 @@ const renderTooltipRow = (label, value) => /* @__PURE__ */ jsx("div", {
5627
5995
  className: bar_chart_module_default["bar-chart__tooltip-row"],
5628
5996
  children: sprintf(__("%1$s: %2$s", "jetpack-charts"), label, value)
5629
5997
  });
5630
- 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 }) => {
5998
+ 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 }) => {
5999
+ if (!withTooltips && (withBandHighlight || onBandHighlightChange)) warnOnce("bar-chart-band-highlight-without-tooltips", "BarChart: withBandHighlight and onBandHighlightChange require withTooltips.");
5631
6000
  const legendInteractive = legend.interactive ?? false;
5632
6001
  const legendCollapseGroups = legend.collapseGroups ?? false;
6002
+ const legendComparisonItem = legend.comparisonItem ?? false;
5633
6003
  const horizontal = orientation === "horizontal";
5634
6004
  const chartId = useChartId(providedChartId);
5635
6005
  const hiddenSeries = useDefaultHiddenSeries(chartId, defaultHiddenSeries);
@@ -5640,10 +6010,21 @@ const BarChartInternal = ({ data, chartId: providedChartId, width, height, class
5640
6010
  enabled: showZeroValues,
5641
6011
  valueAxisLength: horizontal ? width : height
5642
6012
  });
5643
- const legendItems = useChartLegendItems(dataSorted, useMemo(() => ({ collapseGroups: legendCollapseGroups }), [legendCollapseGroups]));
6013
+ const legendItems = useChartLegendItems(dataSorted, useMemo(() => ({
6014
+ collapseGroups: legendCollapseGroups,
6015
+ comparisonItem: legendComparisonItem
6016
+ }), [legendCollapseGroups, legendComparisonItem]));
5644
6017
  const { getElementStyles } = useGlobalChartsContext();
5645
6018
  const isSeriesRendered = useCallback((series) => isSeriesVisible(series.label), [isSeriesVisible]);
6019
+ const hasWholeNumberValues = useMemo(() => hasOnlyWholeNumbers(dataSorted.filter(isSeriesRendered)), [dataSorted, isSeriesRendered]);
5646
6020
  const chartOptions = useBarChartOptions(dataWithVisibleZeros, horizontal, options, isSeriesRendered);
6021
+ const valueAxis = horizontal ? chartOptions.axis.x : chartOptions.axis.y;
6022
+ const callerValueDomain = horizontal ? options.xScale?.domain : options.yScale?.domain;
6023
+ const wholeNumberTicksProps = {
6024
+ axis: horizontal ? "x" : "y",
6025
+ numTicks: valueAxis.numTicks,
6026
+ enabled: hasWholeNumberValues && !valueAxis.tickValues && !callerValueDomain
6027
+ };
5647
6028
  const defaultMargin = useChartMargin(height, chartOptions, dataSorted, theme, horizontal);
5648
6029
  const chartRef = useRef(null);
5649
6030
  const { legendChildren, nonLegendChildren } = useChartChildren(children, "BarChart");
@@ -5776,15 +6157,15 @@ const BarChartInternal = ({ data, chartId: providedChartId, width, height, class
5776
6157
  const p = point;
5777
6158
  return nearestDatum.label != null ? p.label === nearestDatum.label : !!nearestDatum.date && !!p.date && p.date.getTime() === nearestDatum.date.getTime();
5778
6159
  });
5779
- if (comparisonEntry && comparisonDatum && comparisonDatum.value != null) return /* @__PURE__ */ jsxs("div", {
6160
+ if (comparisonEntry && comparisonDatum) return /* @__PURE__ */ jsxs("div", {
5780
6161
  className: bar_chart_module_default["bar-chart__tooltip"],
5781
6162
  children: [
5782
6163
  /* @__PURE__ */ jsx("div", {
5783
6164
  className: bar_chart_module_default["bar-chart__tooltip-header"],
5784
6165
  children: categoryLabel
5785
6166
  }),
5786
- renderTooltipRow(primaryKey, formatNumber(nearestDatum.value)),
5787
- renderTooltipRow(comparisonEntry.series.label, formatNumber(comparisonDatum.value))
6167
+ renderTooltipRow(primaryKey, formatReading(nearestDatum.value)),
6168
+ renderTooltipRow(comparisonEntry.series.label, formatReading(comparisonDatum.value))
5788
6169
  ]
5789
6170
  });
5790
6171
  return /* @__PURE__ */ jsxs("div", {
@@ -5792,7 +6173,7 @@ const BarChartInternal = ({ data, chartId: providedChartId, width, height, class
5792
6173
  children: [/* @__PURE__ */ jsx("div", {
5793
6174
  className: bar_chart_module_default["bar-chart__tooltip-header"],
5794
6175
  children: primaryKey
5795
- }), renderTooltipRow(categoryLabel, formatNumber(nearestDatum.value))]
6176
+ }), renderTooltipRow(categoryLabel, formatReading(nearestDatum.value))]
5796
6177
  });
5797
6178
  }, [chartOptions.tooltip, comparisonEntries]);
5798
6179
  const renderPattern = useCallback((index, color) => {
@@ -5843,15 +6224,15 @@ const BarChartInternal = ({ data, chartId: providedChartId, width, height, class
5843
6224
  const createKeyboardHighlightStyle = useCallback(() => {
5844
6225
  if (selectedIndex === void 0) return "";
5845
6226
  const primaryCount = primaryEntries.length;
5846
- const maxDataPoints = Math.max(...primaryEntries.map((e) => e.series.data.length));
6227
+ if (!primaryCount) return "";
5847
6228
  const dataPointIndex = Math.floor(selectedIndex / primaryCount);
5848
6229
  const seriesIndex = selectedIndex % primaryCount;
5849
- if (dataPointIndex >= maxDataPoints || seriesIndex >= primaryCount) return "";
5850
6230
  const seriesData = primaryEntries[seriesIndex]?.series;
5851
- if (!seriesData || dataPointIndex >= seriesData.data.length) return "";
5852
- const actualBarIndex = seriesIndex * maxDataPoints + dataPointIndex;
6231
+ const datum = seriesData?.data[dataPointIndex];
6232
+ if (!seriesData || !datum || !isBarRendered(datum)) return "";
6233
+ const domBarIndex = primaryEntries.slice(0, seriesIndex).reduce((count, { series }) => count + countRenderedBars(series.data), 0) + countRenderedBars(seriesData.data.slice(0, dataPointIndex));
5853
6234
  return `
5854
- .bar-chart[data-chart-id="bar-chart-${chartId}"] .visx-bar-group .visx-bar:nth-child(${actualBarIndex + 1}) {
6235
+ .bar-chart[data-chart-id="bar-chart-${chartId}"] .visx-bar-group .visx-bar:nth-child(${domBarIndex + 1}) {
5855
6236
  stroke: #005fcc;
5856
6237
  stroke-width: 2px;
5857
6238
  }
@@ -5945,17 +6326,20 @@ const BarChartInternal = ({ data, chartId: providedChartId, width, height, class
5945
6326
  horizontal,
5946
6327
  onChange: onBandHighlightChange
5947
6328
  }),
5948
- !allSeriesHidden && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Grid, {
5949
- columns: gridVisibility.includes("y"),
5950
- rows: false,
5951
- numTicks: 4,
5952
- tickValues: chartOptions.axis.x.tickValues
5953
- }), /* @__PURE__ */ jsx(Grid, {
5954
- columns: false,
5955
- rows: gridVisibility.includes("x"),
5956
- numTicks: 4,
5957
- tickValues: chartOptions.axis.y.tickValues
5958
- })] }),
6329
+ !allSeriesHidden && /* @__PURE__ */ jsx(WholeNumberTicks, {
6330
+ ...wholeNumberTicksProps,
6331
+ children: (valueTicks) => /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Grid, {
6332
+ columns: gridVisibility.includes("y"),
6333
+ rows: false,
6334
+ numTicks: chartOptions.axis.x.numTicks,
6335
+ tickValues: (horizontal ? valueTicks : void 0) ?? chartOptions.axis.x.tickValues
6336
+ }), /* @__PURE__ */ jsx(Grid, {
6337
+ columns: false,
6338
+ rows: gridVisibility.includes("x"),
6339
+ numTicks: chartOptions.axis.y.numTicks,
6340
+ tickValues: (horizontal ? void 0 : valueTicks) ?? chartOptions.axis.y.tickValues
6341
+ })] })
6342
+ }),
5959
6343
  withPatterns && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("defs", { children: dataSorted.map((seriesData, index) => renderPattern(index, getElementStyles({
5960
6344
  data: seriesData,
5961
6345
  index
@@ -5981,7 +6365,19 @@ const BarChartInternal = ({ data, chartId: providedChartId, width, height, class
5981
6365
  getElementStyles,
5982
6366
  resolveFill: resolveComparisonFill
5983
6367
  }),
5984
- /* @__PURE__ */ jsx(BarGroup, {
6368
+ barClassName ? /* @__PURE__ */ jsx("g", {
6369
+ className: "visx-bar-group",
6370
+ children: primaryEntries.map(({ series: seriesData, index }) => /* @__PURE__ */ jsx(ClassifiedBarSeries, {
6371
+ dataKey: seriesData.label,
6372
+ data: seriesData.data,
6373
+ xAccessor: chartOptions.accessors.xAccessor,
6374
+ yAccessor: chartOptions.accessors.yAccessor,
6375
+ colorAccessor: getBarBackground(index),
6376
+ barClassName,
6377
+ primaryKeys,
6378
+ groupPadding
6379
+ }, seriesData.label))
6380
+ }) : /* @__PURE__ */ jsx(BarGroup, {
5985
6381
  padding: groupPadding,
5986
6382
  children: primaryEntries.map(({ series: seriesData, index }) => /* @__PURE__ */ jsx(BarSeries, {
5987
6383
  dataKey: seriesData?.label,
@@ -5998,10 +6394,20 @@ const BarChartInternal = ({ data, chartId: providedChartId, width, height, class
5998
6394
  onPointerDown,
5999
6395
  onPointerUp
6000
6396
  }),
6001
- !allSeriesHidden && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Axis, { ...chartOptions.axis.x }), /* @__PURE__ */ jsx(Axis, { ...chartOptions.axis.y })] }),
6397
+ !allSeriesHidden && /* @__PURE__ */ jsx(WholeNumberTicks, {
6398
+ ...wholeNumberTicksProps,
6399
+ children: (valueTicks) => /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Axis, {
6400
+ ...chartOptions.axis.x,
6401
+ ...horizontal && valueTicks ? { tickValues: valueTicks } : {}
6402
+ }), /* @__PURE__ */ jsx(Axis, {
6403
+ ...chartOptions.axis.y,
6404
+ ...!horizontal && valueTicks ? { tickValues: valueTicks } : {}
6405
+ })] })
6406
+ }),
6002
6407
  withTooltips && /* @__PURE__ */ jsx(AccessibleTooltip, {
6003
6408
  tooltipPlacement,
6004
6409
  tooltipAnchorTop,
6410
+ style: tooltipStyle,
6005
6411
  detectBounds: true,
6006
6412
  snapTooltipToDatumX: true,
6007
6413
  snapTooltipToDatumY: true,