diff --git a/packages/@godaddy/antares/components/chart/bar-chart/bar-chart.stories.tsx b/packages/@godaddy/antares/components/chart/bar-chart/bar-chart.stories.tsx index 04c0632a4..b881a52d2 100644 --- a/packages/@godaddy/antares/components/chart/bar-chart/bar-chart.stories.tsx +++ b/packages/@godaddy/antares/components/chart/bar-chart/bar-chart.stories.tsx @@ -69,7 +69,8 @@ export const Playground = { width: undefined, 'aria-label': 'Playground bar chart', desc: '', - className: '' + className: '', + rtl: false }, argTypes: { orientation: { @@ -77,6 +78,7 @@ export const Playground = { options: ['vertical', 'horizontal'], description: 'Orientation of the bars' }, + rtl: { control: 'boolean', description: 'Render in right-to-left layout' }, numSeries: { control: 'radio', options: [1, 2, 3], diff --git a/packages/@godaddy/antares/components/chart/bar-chart/examples/bar-chart-playground.tsx b/packages/@godaddy/antares/components/chart/bar-chart/examples/bar-chart-playground.tsx index 16e19b303..beb953385 100644 --- a/packages/@godaddy/antares/components/chart/bar-chart/examples/bar-chart-playground.tsx +++ b/packages/@godaddy/antares/components/chart/bar-chart/examples/bar-chart-playground.tsx @@ -1,10 +1,13 @@ import { BarChart, type BarChartProps } from '@godaddy/antares'; import { cityTemperature } from '@visx/mock-data'; +import { RTLProvider } from '../../../../utils/rtl-locale-provider.tsx'; export interface PlaygroundExampleProps extends Omit { /** Number of series to render (1 hides the legend by default). */ numSeries?: 1 | 2 | 3; + /** Render in right-to-left layout by wrapping the chart in {@link RTLProvider}. */ + rtl?: boolean; } const CITIES = ['New York', 'San Francisco', 'Austin'] as const; @@ -15,6 +18,7 @@ export function PlaygroundExample({ xAxisTitle = 'Date', yAxisTitle = 'Temperature (°F)', height = 500, + rtl = false, ...rest }: PlaygroundExampleProps) { const rows = cityTemperature.slice(0, 10); @@ -29,7 +33,7 @@ export function PlaygroundExample({ }; }); - return ( + const chart = ( ); + + return rtl ? {chart} : chart; } diff --git a/packages/@godaddy/antares/components/chart/bar-chart/examples/formatted-tick-marks.tsx b/packages/@godaddy/antares/components/chart/bar-chart/examples/formatted-tick-marks.tsx index 2612ad8cd..5346d3f23 100644 --- a/packages/@godaddy/antares/components/chart/bar-chart/examples/formatted-tick-marks.tsx +++ b/packages/@godaddy/antares/components/chart/bar-chart/examples/formatted-tick-marks.tsx @@ -37,7 +37,7 @@ export function BarChartFormattedTickMarksExample() { ]} xAccessor={(d: { category: Date; value: number }) => d.category} yAccessor={(d: { category: Date; value: number }) => d.value} - height={400} + height={600} width={600} xAxisTitle="Date" yAxisTitle="Sales Amount" diff --git a/packages/@godaddy/antares/components/chart/bar-chart/src/index.module.css b/packages/@godaddy/antares/components/chart/bar-chart/src/index.module.css index e0ab4b5a2..ec7d894b4 100644 --- a/packages/@godaddy/antares/components/chart/bar-chart/src/index.module.css +++ b/packages/@godaddy/antares/components/chart/bar-chart/src/index.module.css @@ -36,16 +36,6 @@ display: block; } -/* Vertical X labels */ -.chart[data-x-labels-vertical="true"] .axisX text { - writing-mode: sideways-lr; - text-anchor: end; -} - -.chart[data-x-labels-vertical="true"] .axisX text:dir(rtl) { - writing-mode: sideways-rl; -} - /* First gridline overlapping axis: hide when baseline shown */ .chart[data-x-baseline="true"] .area .columns line:first-of-type, .chart[data-y-baseline="true"] .area .rows line:first-of-type { diff --git a/packages/@godaddy/antares/components/chart/bar-chart/src/index.tsx b/packages/@godaddy/antares/components/chart/bar-chart/src/index.tsx index daf540dbb..0d7a1001b 100644 --- a/packages/@godaddy/antares/components/chart/bar-chart/src/index.tsx +++ b/packages/@godaddy/antares/components/chart/bar-chart/src/index.tsx @@ -6,9 +6,14 @@ import type { SeriesConfig, XLabelsOrientation } from '../../types.ts'; -import { resolveLegendPosition, xAccessor as defaultXAccessor, yAccessor as defaultYAccessor } from '../../utils.ts'; +import { + getXLabelVerticalProps, + resolveLegendPosition, + xAccessor as defaultXAccessor, + yAccessor as defaultYAccessor +} from '../../utils.ts'; import { useNormalizedSeries } from '#components/chart/use-normalized-series'; -import { useChartContainer } from '../../line-chart/src/use-chart-container.ts'; +import { useScrollableXYChart } from '#components/chart/use-scrollable-xy-chart'; import { ChartColorProvider, useChartColor } from '#components/chart/use-chart-color'; import { AxisBottom, AxisLeft, AxisRight } from '@visx/axis'; import { AxisTitle } from '#components/chart/axis-title'; @@ -332,7 +337,7 @@ export function BarChart(props: BarChartProps) { const tickLength = 8; const { parentRef, chartWidth, chartHeight, margin, scrollLeft, scrollTop, xAxisRef, yAxisRef, xLabelsVertical } = - useChartContainer({ xLabelsOrientation }); + useScrollableXYChart({ xLabelsOrientation }); const series = useNormalizedSeries(seriesProp); @@ -341,7 +346,6 @@ export function BarChart(props: BarChartProps) { isVertical, barWidth, barPadding, - effectiveMargin, categoryValues, numSeries, totalBarWidth, @@ -449,7 +453,7 @@ export function BarChart(props: BarChartProps) { > {desc && {desc}} - + {yGridlines && } {xGridlines && } @@ -465,6 +469,7 @@ export function BarChart(props: BarChartProps) { tickLength={tickLength} hideAxisLine={!xBaseline} tickFormat={formatXTick} + tickLabelProps={xLabelsVertical ? getXLabelVerticalProps(rtl) : undefined} /> )} @@ -526,11 +531,11 @@ export function BarChart(props: BarChartProps) { - + (props: BarChartProps) { {isVertical && (yBaseline || yTickMarks || yLabels) && rtl && ( <> - + (props: BarChartProps) { <> - + (props: BarChartProps) { numTicks={xNumTicks} tickLength={tickLength} tickFormat={formatXTick} + tickLabelProps={xLabelsVertical ? getXLabelVerticalProps(rtl) : undefined} /> diff --git a/packages/@godaddy/antares/components/chart/bar-chart/src/use-bar-chart.ts b/packages/@godaddy/antares/components/chart/bar-chart/src/use-bar-chart.ts index e465606c6..ff3301bad 100644 --- a/packages/@godaddy/antares/components/chart/bar-chart/src/use-bar-chart.ts +++ b/packages/@godaddy/antares/components/chart/bar-chart/src/use-bar-chart.ts @@ -3,7 +3,6 @@ import { scaleBand, scaleLinear } from '@visx/scale'; import { useTooltip } from '@visx/tooltip'; import type { Accessors, SeriesConfig } from '../../types.ts'; import { - getEffectiveMargin, getCategoryValues, computeChartDimensions, computeBarGroupSpacing, @@ -53,13 +52,6 @@ export function useBarChart({ const numSeries = series?.length || 0; const totalBarWidth = numSeries * BAR_WIDTH + (numSeries - 1) * BAR_PADDING; - const effectiveMargin = useMemo( - function getMargin() { - return getEffectiveMargin(margin, rtl); - }, - [margin, rtl] - ); - const categoryValues = useMemo( function getCategories() { return getCategoryValues(series, isVertical, xAccessor, yAccessor as any); @@ -75,14 +67,14 @@ export function useBarChart({ return computeChartDimensions({ chartWidth, chartHeight, - effectiveMargin, + margin, numGroups, totalBarWidth, minGapBetweenGroups: MIN_GAP_BETWEEN_GROUPS, isVertical }); }, - [chartWidth, chartHeight, effectiveMargin, numGroups, totalBarWidth, isVertical] + [chartWidth, chartHeight, margin, numGroups, totalBarWidth, isVertical] ); const { innerWidth, innerHeight } = dimensions; @@ -176,7 +168,7 @@ export function useBarChart({ innerHeight, innerWidth: dimensions.innerWidth, totalBarWidth, - effectiveMargin, + margin, svgWidth: dimensions.svgWidth, svgRect, tooltipArrowHeight: TOOLTIP_ARROW_HEIGHT, @@ -198,7 +190,7 @@ export function useBarChart({ isVertical, totalBarWidth, valueScale, - effectiveMargin, + margin, rtl, dimensions ] @@ -216,7 +208,7 @@ export function useBarChart({ isVertical, barWidth: BAR_WIDTH, barPadding: BAR_PADDING, - effectiveMargin, + margin, categoryValues, categoryDomain, numSeries, diff --git a/packages/@godaddy/antares/components/chart/bar-chart/src/utils.ts b/packages/@godaddy/antares/components/chart/bar-chart/src/utils.ts index 0ea1b7df7..dfa4328f8 100644 --- a/packages/@godaddy/antares/components/chart/bar-chart/src/utils.ts +++ b/packages/@godaddy/antares/components/chart/bar-chart/src/utils.ts @@ -8,17 +8,6 @@ export interface Margin { right: number; } -/** - * Swaps left and right margins when the chart is in RTL mode. - * - * @param margin - The original margin values - * @param rtl - Whether the chart is in right-to-left mode - * @returns The margin with left/right swapped when rtl is true - */ -export function getEffectiveMargin(margin: Margin, rtl: boolean): Margin { - return rtl ? { top: margin.top, right: margin.left, bottom: margin.bottom, left: margin.right } : margin; -} - /** * Extracts the ordered list of unique category values across all series. * In vertical orientation, categories come from xAccessor; in horizontal, from yAccessor. @@ -62,7 +51,7 @@ export function getCategoryValues( * * @param chartWidth - Available container width in pixels * @param chartHeight - Available container height in pixels - * @param effectiveMargin - Margin after applying RTL adjustments + * @param margin - Physical chart margin (already RTL-mapped by `useScrollableXYChart`) * @param numGroups - Number of category groups (sets of bars) * @param totalBarWidth - Combined pixel width of all bars in one group * @param minGapBetweenGroups - Minimum pixel gap required between groups @@ -72,7 +61,7 @@ export function getCategoryValues( export function computeChartDimensions({ chartWidth, chartHeight, - effectiveMargin, + margin, numGroups, totalBarWidth, minGapBetweenGroups, @@ -80,19 +69,19 @@ export function computeChartDimensions({ }: { chartWidth: number; chartHeight: number; - effectiveMargin: Margin; + margin: Margin; numGroups: number; totalBarWidth: number; minGapBetweenGroups: number; isVertical: boolean; }) { - const baseInnerWidth = Math.max(chartWidth - effectiveMargin.left - effectiveMargin.right, 0); - const baseInnerHeight = Math.max(chartHeight - effectiveMargin.top - effectiveMargin.bottom, 0); + const baseInnerWidth = Math.max(chartWidth - margin.left - margin.right, 0); + const baseInnerHeight = Math.max(chartHeight - margin.top - margin.bottom, 0); const minSpacePerGroup = totalBarWidth + minGapBetweenGroups; const innerWidth = isVertical ? Math.max(baseInnerWidth, numGroups * minSpacePerGroup) : baseInnerWidth; const innerHeight = !isVertical ? Math.max(baseInnerHeight, numGroups * minSpacePerGroup) : baseInnerHeight; - const svgWidth = innerWidth + effectiveMargin.left + effectiveMargin.right; - const svgHeight = innerHeight + effectiveMargin.top + effectiveMargin.bottom; + const svgWidth = innerWidth + margin.left + margin.right; + const svgHeight = innerHeight + margin.top + margin.bottom; return { innerWidth, innerHeight, svgWidth, svgHeight }; } @@ -160,7 +149,7 @@ interface TooltipPositionOptions { innerHeight: number; innerWidth: number; totalBarWidth: number; - effectiveMargin: Margin; + margin: Margin; svgWidth: number; svgRect: DOMRect; tooltipArrowHeight: number; @@ -190,7 +179,7 @@ export function computeTooltipPosition({ innerHeight, innerWidth, totalBarWidth, - effectiveMargin, + margin, svgWidth, svgRect, tooltipArrowHeight, @@ -214,11 +203,11 @@ export function computeTooltipPosition({ const groupOffset = (categoryScale.bandwidth() - totalBarWidth) / 2; const barGroupCenter = groupCenter + groupOffset + totalBarWidth / 2; const tooltipLeft = rtl - ? svgRect.left + window.scrollX + svgWidth - effectiveMargin.right - barGroupCenter - : svgRect.left + window.scrollX + effectiveMargin.left + barGroupCenter; + ? svgRect.left + window.scrollX + svgWidth - margin.right - barGroupCenter + : svgRect.left + window.scrollX + margin.left + barGroupCenter; return { tooltipLeft, - tooltipTop: svgRect.top + window.scrollY + effectiveMargin.top + minY - tooltipArrowHeight, + tooltipTop: svgRect.top + window.scrollY + margin.top + minY - tooltipArrowHeight, tooltipData: { x: groupCenter, y: minY, datumByKey } }; } @@ -237,8 +226,8 @@ export function computeTooltipPosition({ const barGroupTop = yPos + groupOffset; const tooltipXOffset = rtl ? (extremeX + innerWidth) / 2 : extremeX / 2; return { - tooltipLeft: svgRect.left + window.scrollX + effectiveMargin.left + tooltipXOffset, - tooltipTop: svgRect.top + window.scrollY + effectiveMargin.top + barGroupTop - tooltipArrowHeight, + tooltipLeft: svgRect.left + window.scrollX + margin.left + tooltipXOffset, + tooltipTop: svgRect.top + window.scrollY + margin.top + barGroupTop - tooltipArrowHeight, tooltipData: { x: catValue, datumByKey } }; } diff --git a/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/custom-domain-chromium-linux.png b/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/custom-domain-chromium-linux.png index 69a629f8b..5c9fe88f9 100644 Binary files a/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/custom-domain-chromium-linux.png and b/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/custom-domain-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/formatted-tick-marks-chromium-linux.png b/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/formatted-tick-marks-chromium-linux.png index a04562135..d52bc36f3 100644 Binary files a/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/formatted-tick-marks-chromium-linux.png and b/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/formatted-tick-marks-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/horizontal-multi-series-chromium-linux.png b/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/horizontal-multi-series-chromium-linux.png index 13d3ffd7d..798b72543 100644 Binary files a/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/horizontal-multi-series-chromium-linux.png and b/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/horizontal-multi-series-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/horizontal-single-series-chromium-linux.png b/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/horizontal-single-series-chromium-linux.png index c12548161..f34a87a4d 100644 Binary files a/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/horizontal-single-series-chromium-linux.png and b/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/horizontal-single-series-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/multi-series-chromium-linux.png b/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/multi-series-chromium-linux.png index cac83e529..b4e0ce216 100644 Binary files a/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/multi-series-chromium-linux.png and b/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/multi-series-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/rtl-horizontal-multi-series-chromium-linux.png b/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/rtl-horizontal-multi-series-chromium-linux.png index 2d64d7b29..57a65987c 100644 Binary files a/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/rtl-horizontal-multi-series-chromium-linux.png and b/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/rtl-horizontal-multi-series-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/rtl-multi-series-chromium-linux.png b/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/rtl-multi-series-chromium-linux.png index 0e8e4826c..e5cfb0e9b 100644 Binary files a/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/rtl-multi-series-chromium-linux.png and b/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/rtl-multi-series-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/single-series-chromium-linux.png b/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/single-series-chromium-linux.png index 15eace6fc..119d4b10c 100644 Binary files a/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/single-series-chromium-linux.png and b/packages/@godaddy/antares/components/chart/bar-chart/test/__screenshots__/bar-chart.visual.test.tsx/single-series-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/bar-chart/test/__snapshots__/bar-chart.node.test.tsx.snap b/packages/@godaddy/antares/components/chart/bar-chart/test/__snapshots__/bar-chart.node.test.tsx.snap index ae74b66f1..30cabfffe 100644 --- a/packages/@godaddy/antares/components/chart/bar-chart/test/__snapshots__/bar-chart.node.test.tsx.snap +++ b/packages/@godaddy/antares/components/chart/bar-chart/test/__snapshots__/bar-chart.node.test.tsx.snap @@ -2,7 +2,7 @@ exports[`@godaddy/antares > #BarChart > renders custom-domain example 1`] = `"
Value
Category
"`; -exports[`@godaddy/antares > #BarChart > renders formatted-tick-marks example 1`] = `"
Sales Amount
Date
"`; +exports[`@godaddy/antares > #BarChart > renders formatted-tick-marks example 1`] = `"
Sales Amount
Date
"`; exports[`@godaddy/antares > #BarChart > renders horizontal-multi-series example 1`] = `"
Exoplanet
Radius (Rj)
Survey A
Survey B
Survey C
Survey D
"`; diff --git a/packages/@godaddy/antares/components/chart/line-chart/src/index.tsx b/packages/@godaddy/antares/components/chart/line-chart/src/index.tsx index 107c96c75..1652ee8f4 100644 --- a/packages/@godaddy/antares/components/chart/line-chart/src/index.tsx +++ b/packages/@godaddy/antares/components/chart/line-chart/src/index.tsx @@ -25,10 +25,15 @@ import type { SeriesConfig, XLabelsOrientation } from '../../types.ts'; -import { resolveLegendPosition, xAccessor as defaultXAccessor, yAccessor as defaultYAccessor } from '../../utils.ts'; +import { + resolveLegendPosition, + xAccessor as defaultXAccessor, + yAccessor as defaultYAccessor, + getXLabelVerticalProps +} from '../../utils.ts'; import { useNormalizedSeries } from '#components/chart/use-normalized-series'; import { buildScaleConfig } from './scale-config.ts'; -import { useChartContainer } from './use-chart-container.ts'; +import { useScrollableXYChart } from '#components/chart/use-scrollable-xy-chart'; import styles from './index.module.css'; /** Scale types supported by LineChart (subset of @visx/scale ScaleType). */ @@ -290,7 +295,7 @@ export function LineChart(props: LineChartProps className } = props; const { parentRef, chartWidth, chartHeight, margin, scrollLeft, xAxisRef, yAxisRef, xLabelsVertical, yAxisRect } = - useChartContainer({ xLabelsOrientation }); + useScrollableXYChart({ xLabelsOrientation }); const series = useNormalizedSeries(seriesProp); const showInteractiveFeatures = showTooltip || showCrosshair || showDataPoints; const effectiveLegendPosition = resolveLegendPosition(legendPosition, series.length); @@ -442,14 +447,7 @@ export function LineChart(props: LineChartProps tickValues={xTickValues} tickFormat={xTickFormat} tickClassName={styles.tickMark} - tickLabelProps={ - xLabelsVertical - ? { - angle: -90, - textAnchor: 'end' - } - : undefined - } + tickLabelProps={xLabelsVertical ? getXLabelVerticalProps(false) : undefined} /> )} diff --git a/packages/@godaddy/antares/components/chart/line-chart/src/use-chart-container.ts b/packages/@godaddy/antares/components/chart/line-chart/src/use-chart-container.ts deleted file mode 100644 index 5aeb8cdda..000000000 --- a/packages/@godaddy/antares/components/chart/line-chart/src/use-chart-container.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { useParentSize } from '@visx/responsive'; -import { Margin } from '@visx/xychart'; -import { type XLabelsOrientation } from '../../types.ts'; - -/** Minimum vertical gap between Y-axis labels (px). Implementation detail for min-height calculation. */ -const MIN_Y_LABEL_GAP_PX = 16; -/** Minimum horizontal gap between X-axis labels (px). Implementation detail for min-width calculation. */ -const MIN_X_LABEL_GAP_PX = 8; -/** Default chart margin (px) when axis dimensions are unknown. */ -const DEFAULT_MARGIN_PX = 50; -/** Debounce time for parent size observer (ms). */ -const RESIZE_DEBOUNCE_MS = 150; - -/** - * Computes minimum chart height from Y-axis label dimensions so all labels fit. - * Uses the sum of actual label heights plus gaps so the result is accurate when labels vary in height. - * - * @param yAxisElement - Y-axis DOM element (visx group) containing label nodes - * @returns Minimum height in px (sum of label heights + gaps between labels) - */ -function getChartMinHeight(yAxisElement: Element): number { - let totalLabelHeight = 0; - let labelsCount = 0; - - Array.from(yAxisElement.querySelectorAll('g.visx-group')).forEach(function getDimensions(g) { - totalLabelHeight += g.getBBox().height; - labelsCount++; - }); - - const spaceBetweenLabels = labelsCount * MIN_Y_LABEL_GAP_PX; - - return totalLabelHeight + spaceBetweenLabels; -} - -/** - * Measures X-axis label dimensions for min-width calculation (horizontal vs vertical layout). - * - * @param xAxisElement - X-axis DOM element (visx group) containing label nodes - * @returns longestLabel (px), labelsCount, maxLabelHeight (px) - */ -function getXAxisLabelMetrics(xAxisElement: Element): { - longestLabel: number; - labelsCount: number; - maxLabelHeight: number; -} { - let longestLabel = 0; - let labelsCount = 0; - let maxLabelHeight = 0; - - Array.from(xAxisElement.querySelectorAll('g.visx-group')).forEach(function getDimensions(g) { - const width = g.getBBox().width; - const height = g.getBBox().height; - - if (height > maxLabelHeight) { - maxLabelHeight = height; - } - - if (width > longestLabel) { - longestLabel = width; - } - - labelsCount++; - }); - - return { longestLabel, labelsCount, maxLabelHeight }; -} - -/** - * Computes minimum X-axis width for horizontal and vertical label layouts. - * - * @param xAxisElement - X-axis DOM element (visx group) containing label nodes - * @returns minWidthVertical (px) and minWidthHorizontal (px) - */ -function getChartMinWidth(xAxisElement: Element) { - const { longestLabel, labelsCount, maxLabelHeight } = getXAxisLabelMetrics(xAxisElement); - const spaceBetweenLabels = labelsCount * MIN_X_LABEL_GAP_PX; - const minWidthHorizontal = longestLabel * labelsCount + spaceBetweenLabels; - const minWidthVertical = maxLabelHeight * labelsCount + spaceBetweenLabels; - - return { minWidthVertical, minWidthHorizontal }; -} - -/** - * Left margin width from Y-axis bbox plus padding, or default if axis not measured. - * - * @param yAxisElement - Y-axis SVG group element, or null - * @returns Left margin in px - */ -function getLeftMargin(yAxisElement: SVGGraphicsElement | null): number { - return yAxisElement?.getBBox().width ?? 0; -} - -/** - * Bottom margin height from X-axis bbox plus padding, or default if axis not measured. - * - * @param xAxisElement - X-axis SVG group element, or null - * @returns Bottom margin in px - */ -function getBottomMargin(xAxisElement: SVGGraphicsElement | null): number { - return xAxisElement?.getBBox().height ?? 0; -} - -/** Axis-derived layout state updated by MutationObserver and ResizeObserver per axis. */ -interface AxisState { - margin: Margin; - minHeight: number; - minXAxisWidthHorizontal: number; - minXAxisWidthVertical: number; - yAxisRect: SVGRect | null; -} - -const INITIAL_AXIS_STATE: AxisState = { - margin: { - top: DEFAULT_MARGIN_PX, - right: DEFAULT_MARGIN_PX, - bottom: 0, - left: 0 - }, - minHeight: 0, - minXAxisWidthHorizontal: 0, - minXAxisWidthVertical: 0, - yAxisRect: null -}; - -interface UseChartContainerOptions { - /** When 'auto', labels rotate vertical when container is narrow; 'horizontal' or 'vertical' force that orientation. */ - xLabelsOrientation?: XLabelsOrientation; -} - -/** - * Provides container dimensions, margins, and axis refs for a scrollable line chart. - * Depends on xAxisRef and yAxisRef being attached to the chart axis DOM nodes so it can measure them. - * Uses MutationObserver and ResizeObserver on both axes; layout or DOM changes can trigger several - * state updates in one frame and cause multiple re-renders. The hook is sensitive to layout - * thrashing when many things resize at once. - * - * @param options - Optional config (e.g. xLabelsOrientation) - * @returns parentRef - Ref for the scrollable chart container - * @returns chartWidth - Chart width in px (visible width or min from axis labels) - * @returns minHeight - Minimum height from Y-axis labels - * @returns chartHeight - Chart height in px (visible or minHeight) - * @returns margin - Chart margin (top, right, bottom, left) in px - * @returns scrollLeft - Current horizontal scroll offset of the container - * @returns scrollTop - Current vertical scroll offset of the container - * @returns xAxisRef - Ref to attach to the X-axis DOM node - * @returns yAxisRef - Ref to attach to the Y-axis DOM node - * @returns xLabelsVertical - True when X labels are rotated vertical (narrow container or vertical orientation) - * @returns yAxisRect - Bounding box of the Y-axis (for background/positioning) - */ -export function useChartContainer(options?: UseChartContainerOptions) { - const { xLabelsOrientation = 'auto' } = options ?? {}; - const { - parentRef, - width: visibleChartWidth, - height: visibleChartHeight - } = useParentSize({ - debounceTime: RESIZE_DEBOUNCE_MS - }); - const xAxisRef = useRef(null); - const yAxisRef = useRef(null); - const [axisState, setAxisState] = useState(INITIAL_AXIS_STATE); - const [scrollLeft, setScrollLeft] = useState(0); - const [scrollTop, setScrollTop] = useState(0); - const isVisibleChart = visibleChartWidth > 0 && visibleChartHeight > 0; - const { margin, minHeight, minXAxisWidthHorizontal, minXAxisWidthVertical, yAxisRect } = axisState; - const yAxisWidth = yAxisRect?.width ?? 0; - const chartHeight = Math.max(visibleChartHeight, minHeight); - const autoVertical = visibleChartWidth <= minXAxisWidthHorizontal + yAxisWidth; - const xLabelsVertical = - xLabelsOrientation === 'vertical' ? true : xLabelsOrientation === 'horizontal' ? false : autoVertical; - const minXAxisWidth = xLabelsVertical ? minXAxisWidthVertical : minXAxisWidthHorizontal; - const chartWidth = Math.max(visibleChartWidth, minXAxisWidth + yAxisWidth); - - useEffect(function onParentScroll() { - const el = parentRef.current; - if (!el) return; - - // Track the last pointer state so we can re-dispatch on scroll. - // Storing the actual event target avoids relying on visx's internal DOM structure. - let lastClientX = 0; - let lastClientY = 0; - let lastTarget: EventTarget | null = null; - - function onPointerMove(e: PointerEvent) { - lastClientX = e.clientX; - lastClientY = e.clientY; - lastTarget = e.target; - } - - function onPointerLeave() { - lastTarget = null; - } - - function onScroll() { - setScrollLeft(el?.scrollLeft ?? 0); - setScrollTop(el?.scrollTop ?? 0); - - // Re-dispatch a pointermove on the stored target so visx recomputes the nearest - // datum and updates the tooltip data, crosshair, and glyph positions. - // localPoint() inside visx re-derives svgPoint from clientX/clientY via - // SVGSVGElement.getScreenCTM(), which already reflects the new scroll position, - // so the same screen coordinates now resolve to the correct datum after scrolling. - if (lastTarget) { - lastTarget.dispatchEvent( - new PointerEvent('pointermove', { - bubbles: true, - cancelable: true, - clientX: lastClientX, - clientY: lastClientY - }) - ); - } - } - - el.addEventListener('pointermove', onPointerMove, { passive: true }); - el.addEventListener('pointerleave', onPointerLeave, { passive: true }); - el.addEventListener('scroll', onScroll, { passive: true }); - - return function cleanup() { - el.removeEventListener('pointermove', onPointerMove); - el.removeEventListener('pointerleave', onPointerLeave); - el.removeEventListener('scroll', onScroll); - }; - }, []); - - useEffect( - function syncYAxis() { - if (!isVisibleChart || !yAxisRef.current) { - return; - } - - const observer = new MutationObserver(function onMutation() { - const yAxisElement = yAxisRef.current as SVGGraphicsElement; - - setAxisState(function updateYAxis(prev) { - return { - ...prev, - margin: { ...prev.margin, left: getLeftMargin(yAxisElement) }, - minHeight: getChartMinHeight(yAxisElement) - }; - }); - }); - observer.observe(yAxisRef.current, { childList: true, subtree: true }); - - return function cleanup() { - observer.disconnect(); - }; - }, - [isVisibleChart] - ); - - useEffect( - function syncXAxis() { - if (!isVisibleChart || !xAxisRef.current) { - return; - } - - const xAxisElement = xAxisRef.current as SVGGraphicsElement; - - function measure() { - const { minWidthHorizontal, minWidthVertical } = getChartMinWidth(xAxisElement); - setAxisState(function updateXAxis(prev) { - return { - ...prev, - minXAxisWidthHorizontal: minWidthHorizontal, - minXAxisWidthVertical: minWidthVertical - }; - }); - } - - measure(); - - const observer = new MutationObserver(measure); - - observer.observe(xAxisRef.current, { childList: true, subtree: true }); - - return function cleanup() { - observer.disconnect(); - }; - }, - [isVisibleChart] - ); - - useEffect( - function observeXAxisResize() { - if (!isVisibleChart || !xAxisRef.current) { - return; - } - - const xAxisElement = xAxisRef.current; - - const resizeObserver = new ResizeObserver(function onResize() { - setAxisState(function updateXAxisResize(prev) { - return { - ...prev, - margin: { ...prev.margin, bottom: getBottomMargin(xAxisElement) } - }; - }); - }); - - resizeObserver.observe(xAxisElement); - - return function cleanup() { - resizeObserver.disconnect(); - }; - }, - [isVisibleChart] - ); - - useEffect( - function observeYAxisResize() { - if (!isVisibleChart || !yAxisRef.current) { - return; - } - - const yAxisElement = yAxisRef.current; - - const resizeObserver = new ResizeObserver(function onResize() { - setAxisState(function updateYAxisResize(prev) { - return { - ...prev, - yAxisRect: yAxisElement.getBBox(), - margin: { ...prev.margin, left: getLeftMargin(yAxisElement) } - }; - }); - }); - - resizeObserver.observe(yAxisElement); - - return function cleanup() { - resizeObserver.disconnect(); - }; - }, - [isVisibleChart] - ); - - return { - parentRef, - chartWidth, - minHeight, - chartHeight, - margin, - scrollLeft, - scrollTop, - xAxisRef, - yAxisRef, - xLabelsVertical, - yAxisRect - }; -} diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/band-padding-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/band-padding-chromium-linux.png index d00b61456..c58d683af 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/band-padding-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/band-padding-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/baselines-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/baselines-chromium-linux.png index 13b0e81a9..eadc98875 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/baselines-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/baselines-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/bitcoin-price-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/bitcoin-price-chromium-linux.png index 496d479f2..9b45d5bda 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/bitcoin-price-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/bitcoin-price-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/bitcoin-price-scroll-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/bitcoin-price-scroll-chromium-linux.png index 1690f7583..00752765f 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/bitcoin-price-scroll-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/bitcoin-price-scroll-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/browser-usage-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/browser-usage-chromium-linux.png index e6699abad..86567813d 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/browser-usage-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/browser-usage-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/city-temperature-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/city-temperature-chromium-linux.png index 127ba0fac..f22170e0f 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/city-temperature-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/city-temperature-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/crosshair-only-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/crosshair-only-chromium-linux.png index 0c098a683..a288aabb7 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/crosshair-only-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/crosshair-only-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/custom-accessors-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/custom-accessors-chromium-linux.png index 6df4020bb..5b860f5a4 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/custom-accessors-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/custom-accessors-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/custom-ticks-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/custom-ticks-chromium-linux.png index 2c1e47c31..046d70d15 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/custom-ticks-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/custom-ticks-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/custom-tooltip-formatting-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/custom-tooltip-formatting-chromium-linux.png index 95ccb0452..751999ed0 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/custom-tooltip-formatting-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/custom-tooltip-formatting-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/fixed-domain-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/fixed-domain-chromium-linux.png index 1234e5663..cfe18e95e 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/fixed-domain-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/fixed-domain-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/fixed-size-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/fixed-size-chromium-linux.png index 6df4020bb..5b860f5a4 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/fixed-size-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/fixed-size-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/formatting-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/formatting-chromium-linux.png index 578c054e3..95e1e0729 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/formatting-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/formatting-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/gridlines-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/gridlines-chromium-linux.png index 3ee61617e..2772355cf 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/gridlines-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/gridlines-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/labels-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/labels-chromium-linux.png index eddb422c7..1c9f6bd8e 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/labels-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/labels-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/legend-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/legend-chromium-linux.png index 88f0889ed..479d989ed 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/legend-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/legend-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/missing-values-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/missing-values-chromium-linux.png index 331e3bb0a..b7de1e018 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/missing-values-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/missing-values-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/multiple-series-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/multiple-series-chromium-linux.png index 998103374..e73d4d914 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/multiple-series-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/multiple-series-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/nice-values-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/nice-values-chromium-linux.png index b4a2df1c2..21356a80a 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/nice-values-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/nice-values-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/single-series-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/single-series-chromium-linux.png index 6aff8a759..03515e2de 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/single-series-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/single-series-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/ticks-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/ticks-chromium-linux.png index 3fe76dd31..141ddc42c 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/ticks-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/ticks-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/titles-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/titles-chromium-linux.png index 361b7d8f7..deccf4c83 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/titles-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/titles-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/tooltip-disabled-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/tooltip-disabled-chromium-linux.png index 6df4020bb..5b860f5a4 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/tooltip-disabled-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/tooltip-disabled-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/zero-included-chromium-linux.png b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/zero-included-chromium-linux.png index 70b58e42e..5d2c2d313 100644 Binary files a/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/zero-included-chromium-linux.png and b/packages/@godaddy/antares/components/chart/line-chart/test/__screenshots__/line-chart.visual.test.tsx/zero-included-chromium-linux.png differ diff --git a/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/README.mdx b/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/README.mdx new file mode 100644 index 000000000..faba52b18 --- /dev/null +++ b/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/README.mdx @@ -0,0 +1,35 @@ +--- +title: useScrollableXYChart +description: A hook for scrollable visx XY charts that measures the rendered axes and returns margins, content-sized width and height, scroll offsets, and a vertical-labels flag, so the chart sizes itself to whatever it actually contains. +--- +import { Meta, Source, Story } from '@storybook/addon-docs/blocks'; +import * as Stories from './use-scrollable-xy-chart.stories.tsx'; + +import SourceExample from './examples/auto-layout.tsx?raw'; + + + +## For use with chart components (not exported) + +**Inputs.** Wire `parentRef` to the scroll container and `xAxisRef` / `yAxisRef` to the axis groups. Optionally pass `xLabelsOrientation` (`'auto'` | `'horizontal'` | `'vertical'`, default `'auto'`) to force the category-label layout; `'auto'` rotates labels vertical when the visible width can't fit the horizontal-tick footprint plus the Y-axis width. + +**Outputs.** The hook returns `margin`, `chartWidth`, `chartHeight`, `xLabelsVertical`, `scrollLeft` / `scrollTop`, and `yAxisRect` for the chart to consume. Width and height are sized to whichever is larger — the visible slot or the room the rendered labels need — so the chart grows past the viewport and the parent scrolls when labels demand more space. + +**Behavior.** Measurement runs through `useParentSize`, a `MutationObserver`, and a `ResizeObserver` on the wired nodes. All axis measurements are batched in one `requestAnimationFrame` and bail out when the result hasn't changed, so a burst of observer fires costs at most one reflow and one render per frame. + +```tsx +const { + parentRef, xAxisRef, yAxisRef, + margin, chartWidth, chartHeight, + xLabelsVertical, scrollLeft, scrollTop, yAxisRect, +} = useScrollableXYChart(); +``` + +## Examples + +### Auto layout + +Hook wired to a scrolling parent; scroll or resize to watch the returned values update. + + + diff --git a/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/examples/auto-layout.tsx b/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/examples/auto-layout.tsx new file mode 100644 index 000000000..a3ba29dbd --- /dev/null +++ b/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/examples/auto-layout.tsx @@ -0,0 +1,61 @@ +import { Box, Text } from '@godaddy/antares'; +import { useScrollableXYChart, UseScrollableXYChartProps } from '../src/index.tsx'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +export type AutoLayoutExampleOrientation = 'auto' | 'horizontal' | 'vertical'; + +export function AutoLayoutExample({ xLabelsOrientation }: UseScrollableXYChartProps) { + const { + parentRef, + chartWidth, + chartHeight, + margin, + scrollLeft, + scrollTop, + xAxisRef, + yAxisRef, + xLabelsVertical, + minHeight + } = useScrollableXYChart({ xLabelsOrientation }); + + return ( + + + chartWidth: {chartWidth} + chartHeight: {chartHeight} + minHeight: {minHeight} + + margin: {margin.top}/{margin.right}/{margin.bottom}/{margin.left} + + scrollLeft: {scrollLeft} + scrollTop: {scrollTop} + xLabelsVertical: {String(xLabelsVertical)} + + + + + 0 + + + + + Jan + + + + + ); +} diff --git a/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/src/chart-container-margins.ts b/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/src/chart-container-margins.ts new file mode 100644 index 000000000..54fc2bea8 --- /dev/null +++ b/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/src/chart-container-margins.ts @@ -0,0 +1,215 @@ +/** + * DOM measurement helpers for scrollable visx XY chart margins (axis tick structure). + */ + +/** + * Returns whether an element is rendered (computed `display` is not `none`). + * + * @param element - Element to check, or null/undefined + * @returns True when the element exists and is displayed + */ +export function isElementDisplayed(element: Element | null | undefined): boolean { + if (!element) { + return false; + } + + return getComputedStyle(element).display !== 'none'; +} + +/** + * Returns the first or last `.visx-axis-tick` group inside a visx axis. + * + * @param axisElement - Axis SVG group element, or null + * @param end - Which end of the axis to read + * @returns The matching tick group, or null when there are no ticks + */ +export function getAxisTickAt( + axisElement: SVGGraphicsElement | null, + end: 'first' | 'last' +): SVGGraphicsElement | null { + if (!axisElement) { + return null; + } + + const tickNodes = axisElement.querySelectorAll('.visx-axis-tick'); + if (tickNodes.length === 0) { + return null; + } + const index = end === 'first' ? 0 : tickNodes.length - 1; + + return tickNodes[index] ?? null; +} + +/** + * Returns the `` label inside a tick group. + * + * @param tick - Tick group element, or null + * @returns The tick's `SVGTextElement`, or null when missing + */ +export function getTickLabelText(tick: SVGGraphicsElement | null): SVGTextElement | null { + const node = tick?.querySelector('text'); + + return node instanceof SVGTextElement ? node : null; +} + +/** + * Returns half the visual width of the first X-axis tick label. + * + * @param xAxisElement - X-axis SVG group element, or null + * @returns Half the first tick label width in pixels, or 0 when missing or hidden + */ +export function getHalfFirstXAxisTickLabelWidth(xAxisElement: SVGGraphicsElement | null): number { + const labelText = getTickLabelText(getAxisTickAt(xAxisElement, 'first')); + + if (!labelText || !isElementDisplayed(labelText)) { + return 0; + } + + return Math.ceil(labelText.getBoundingClientRect().width / 2); +} + +/** + * Returns half the visual height of the first (bottom-most) Y-axis tick label. + * + * @param yAxisElement - Y-axis SVG group element, or null + * @returns Half the first tick label height in pixels, or 0 when missing or hidden + */ +export function getHalfFirstYAxisTickLabelHeight(yAxisElement: SVGGraphicsElement | null): number { + const labelText = getTickLabelText(getAxisTickAt(yAxisElement, 'first')); + + if (!labelText || !isElementDisplayed(labelText)) { + return 0; + } + + return Math.ceil(labelText.getBoundingClientRect().height / 2); +} + +/** + * Computes the chart's inline-start margin: the greater of the Y-axis width and half the first + * X-axis tick label width. + * + * "Inline start" is the side where the Y-axis lives — visual left in LTR, visual right in RTL. + * + * @param yAxisElement - Y-axis SVG group element, or null + * @param xAxisElement - X-axis SVG group element, or null + * @returns Inline-start margin in pixels + */ +export function getInlineStartMargin( + yAxisElement: SVGGraphicsElement | null, + xAxisElement: SVGGraphicsElement | null = null +): number { + const yAxisWidth = yAxisElement?.getBBox().width ?? 0; + + return Math.max(yAxisWidth, getHalfFirstXAxisTickLabelWidth(xAxisElement)); +} + +/** + * Computes the chart's block-end margin: the greater of the X-axis height and half the first + * (bottom-most) Y-axis tick label height. + * + * "Block end" is the bottom of the chart in horizontal writing modes (the X-axis side). + * + * @param xAxisElement - X-axis SVG group element, or null + * @param yAxisElement - Y-axis SVG group element, or null + * @returns Block-end margin in pixels + */ +export function getBlockEndMargin( + xAxisElement: SVGGraphicsElement | null, + yAxisElement: SVGGraphicsElement | null = null +): number { + const xAxisHeight = xAxisElement?.getBBox().height ?? 0; + + return Math.max(xAxisHeight, getHalfFirstYAxisTickLabelHeight(yAxisElement)); +} + +/** + * Computes the chart's inline-end margin so the last X-axis tick label is not clipped. + * + * "Inline end" is the side opposite the Y-axis — visual right in LTR, visual left in RTL. + * In RTL the last (largest-domain) tick is positioned on the visual left of the chart, so the + * label can overflow the SVG's left edge instead of its right; pass `isRtl: true` so the + * overflow check uses the correct edge. + * + * @param xAxisElement - X-axis SVG group element + * @param prevInlineEndMargin - Inline-end margin currently applied (px); kept when removing it would re-introduce overflow + * @param isRtl - Whether the chart is in RTL writing direction + * @returns Inline-end margin in pixels + */ +export function getInlineEndMargin(xAxisElement: SVGGraphicsElement, prevInlineEndMargin = 0, isRtl = false): number { + const lastTickText = getTickLabelText(getAxisTickAt(xAxisElement, 'last')); + + if (!lastTickText || !isElementDisplayed(lastTickText)) { + return 0; + } + + const svgRect = xAxisElement.closest('svg')?.getBoundingClientRect(); + const lastTickTextRect = lastTickText.getBoundingClientRect(); + + if (isRtl) { + const svgLeft = svgRect?.left ?? 0; + + if (lastTickTextRect.left < svgLeft) { + return Math.ceil(lastTickTextRect.width / 2); + } + + // The label fits, but the existing margin may be exactly what's keeping it inside the SVG. + // Removing it would shift the last tick leftward by `prevInlineEndMargin` (mapped onto + // `margin.left` in RTL); if that shift would push the label past the edge, keep the + // current margin so the layout does not oscillate. + if (prevInlineEndMargin > 0 && lastTickTextRect.left - prevInlineEndMargin < svgLeft) { + return prevInlineEndMargin; + } + + return 0; + } + + const svgRight = svgRect?.right ?? 0; + + if (lastTickTextRect.right > svgRight) { + return Math.ceil(lastTickTextRect.width / 2); + } + + // The label fits, but the existing margin may be exactly what's keeping it inside the SVG. + // Removing it would shift the last tick rightward by `prevInlineEndMargin` (visx anchors the + // last tick at `chartWidth - margin.right` in LTR); if that shift would push the label past + // the edge, keep the current margin so the layout does not oscillate. + if (prevInlineEndMargin > 0 && lastTickTextRect.right + prevInlineEndMargin > svgRight) { + return prevInlineEndMargin; + } + + return 0; +} + +/** + * Computes the chart's block-start margin so the topmost Y-axis tick label is not clipped. + * + * "Block start" is the top of the chart in horizontal writing modes. + * + * @param yAxisElement - Y-axis SVG group element + * @param prevBlockStartMargin - Block-start margin currently applied (px); kept when removing it would re-introduce overflow + * @returns Block-start margin in pixels + */ +export function getBlockStartMargin(yAxisElement: SVGGraphicsElement, prevBlockStartMargin = 0): number { + const lastTickText = getTickLabelText(getAxisTickAt(yAxisElement, 'last')); + + if (!lastTickText || !isElementDisplayed(lastTickText)) { + return 0; + } + + const svgTop = yAxisElement.closest('svg')?.getBoundingClientRect().top ?? 0; + const lastTickTextRect = lastTickText.getBoundingClientRect(); + + if (lastTickTextRect.top < svgTop) { + return Math.ceil(lastTickTextRect.height / 2); + } + + // The label fits, but the existing margin may be exactly what's keeping it inside the SVG. + // Removing it would shift the topmost tick upward by `prevBlockStartMargin` (visx anchors the + // topmost tick at `margin.top`); if that shift would push the label past the edge, keep the + // current margin so the layout does not oscillate. + if (prevBlockStartMargin > 0 && lastTickTextRect.top - prevBlockStartMargin < svgTop) { + return prevBlockStartMargin; + } + + return 0; +} diff --git a/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/src/index.tsx b/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/src/index.tsx new file mode 100644 index 000000000..f8da684ed --- /dev/null +++ b/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/src/index.tsx @@ -0,0 +1,355 @@ +import { useEffect, useRef, useState, type RefObject } from 'react'; +import { useParentSize } from '@visx/responsive'; +import { Margin } from '@visx/xychart'; +import { useLocale } from 'react-aria-components'; +import { type XLabelsOrientation } from '../../types.ts'; +import { + getBlockEndMargin, + getBlockStartMargin, + getInlineEndMargin, + getInlineStartMargin +} from './chart-container-margins.ts'; + +/** Minimum vertical gap between Y-axis labels (px). */ +const MIN_Y_LABEL_GAP_PX = 16; +/** Minimum horizontal gap between X-axis labels (px). */ +const MIN_X_LABEL_GAP_PX = 8; +/** Debounce time for the parent size observer (ms). */ +const RESIZE_DEBOUNCE_MS = 150; + +/** + * Returns the minimum chart height needed to render every Y-axis label without overlap. + * + * @param yAxisElement - Y-axis SVG group element + * @returns Minimum chart height in pixels + */ +function getChartMinHeight(yAxisElement: Element): number { + let totalLabelHeight = 0; + let labelsCount = 0; + + yAxisElement.querySelectorAll('g.visx-group').forEach(function getDimensions(g) { + totalLabelHeight += g.getBBox().height; + labelsCount++; + }); + + const spaceBetweenLabels = labelsCount * MIN_Y_LABEL_GAP_PX; + + return totalLabelHeight + spaceBetweenLabels; +} + +/** + * Returns the minimum X-axis width for both horizontal and vertical label layouts. + * + * @param xAxisElement - X-axis SVG group element + * @returns `minWidthHorizontal` (px) for upright labels and `minWidthVertical` (px) for rotated labels + */ +function getChartMinWidth(xAxisElement: Element): { minWidthHorizontal: number; minWidthVertical: number } { + let longestLabel = 0; + let labelsCount = 0; + let maxLabelHeight = 0; + + xAxisElement.querySelectorAll('g.visx-group text').forEach(function getDimensions(g) { + const { width, height } = g.getBBox(); + + if (height > maxLabelHeight) { + maxLabelHeight = height; + } + + if (width > longestLabel) { + longestLabel = width; + } + + labelsCount++; + }); + + const spaceBetweenLabels = labelsCount * MIN_X_LABEL_GAP_PX; + const minWidthHorizontal = Math.max(longestLabel, maxLabelHeight) * labelsCount + spaceBetweenLabels; + const minWidthVertical = Math.min(longestLabel, maxLabelHeight) * labelsCount + spaceBetweenLabels; + + return { minWidthHorizontal, minWidthVertical }; +} + +/** + * Returns whether two `SVGRect`s describe the same rectangle (two nulls are equal). + * + * @param a - First rect, or null + * @param b - Second rect, or null + * @returns True when both are null or all four coordinates match + */ +function rectsEqual(a: SVGRect | null, b: SVGRect | null): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; +} + +/** + * Axis-derived layout state recomputed when either axis mutates or resizes. Margins are stored + * in CSS-logical terms (`inline-start` is the Y-axis side, `inline-end` is the opposite side, + * `block-start`/`block-end` are top/bottom in horizontal writing modes) and mapped to physical + * `Margin` keys at the hook boundary so consumers can pass the result straight to visx. + */ +interface AxisState { + inlineStart: number; + inlineEnd: number; + blockStart: number; + blockEnd: number; + minHeight: number; + minXAxisWidthHorizontal: number; + minXAxisWidthVertical: number; + yAxisRect: SVGRect | null; +} + +const INITIAL_AXIS_STATE: AxisState = { + inlineStart: 0, + inlineEnd: 0, + blockStart: 0, + blockEnd: 0, + minHeight: 0, + minXAxisWidthHorizontal: 0, + minXAxisWidthVertical: 0, + yAxisRect: null +}; + +/** Options passed to {@link useScrollableXYChart}. */ +export interface UseScrollableXYChartProps { + /** X-axis label orientation. `'auto'` rotates labels vertical when the container is too narrow. @default 'auto' */ + xLabelsOrientation?: XLabelsOrientation; +} + +/** Result returned by {@link useScrollableXYChart}. */ +interface UseScrollableXYChartResult { + /** Ref for the scrollable chart container. */ + parentRef: RefObject; + /** Chart width in pixels (visible width or minimum from axis labels). */ + chartWidth: number; + /** Minimum chart height (px) derived from Y-axis label dimensions. */ + minHeight: number; + /** Chart height in pixels (visible height or `minHeight`). */ + chartHeight: number; + /** Chart margin (`top`, `right`, `bottom`, `left`) in pixels. */ + margin: Margin; + /** Current horizontal scroll offset of the parent container. */ + scrollLeft: number; + /** Current vertical scroll offset of the parent container. */ + scrollTop: number; + /** Ref to attach to the X-axis SVG group via visx `Axis` `innerRef`. */ + xAxisRef: RefObject; + /** Ref to attach to the Y-axis SVG group via visx `Axis` `innerRef`. */ + yAxisRef: RefObject; + /** True when X-axis labels are rendered rotated to vertical. */ + xLabelsVertical: boolean; + /** Bounding box of the Y-axis SVG group. */ + yAxisRect: SVGRect | null; +} + +/** + * Hook that returns refs and layout state for a scrollable visx `XYChart` parent. + * + * @param props - {@link UseScrollableXYChartProps} + * @returns {@link UseScrollableXYChartResult} + */ +export function useScrollableXYChart(props?: UseScrollableXYChartProps): UseScrollableXYChartResult { + const { xLabelsOrientation = 'auto' } = props ?? {}; + const { + parentRef, + width: visibleChartWidth, + height: visibleChartHeight + } = useParentSize({ + debounceTime: RESIZE_DEBOUNCE_MS + }); + const { direction } = useLocale(); + const isRtl = direction === 'rtl'; + const xAxisRef = useRef(null); + const yAxisRef = useRef(null); + const [axisState, setAxisState] = useState(INITIAL_AXIS_STATE); + const [scrollLeft, setScrollLeft] = useState(0); + const [scrollTop, setScrollTop] = useState(0); + const isVisibleChart = visibleChartWidth > 0 && visibleChartHeight > 0; + const { + inlineStart, + inlineEnd, + blockStart, + blockEnd, + minHeight, + minXAxisWidthHorizontal, + minXAxisWidthVertical, + yAxisRect + } = axisState; + const yAxisWidth = yAxisRect?.width ?? 0; + const chartHeight = Math.max(visibleChartHeight, minHeight); + const autoVertical = visibleChartWidth <= minXAxisWidthHorizontal + yAxisWidth; + const xLabelsVertical = + xLabelsOrientation === 'vertical' ? true : xLabelsOrientation === 'horizontal' ? false : autoVertical; + const minXAxisWidth = xLabelsVertical ? minXAxisWidthVertical : minXAxisWidthHorizontal; + const chartWidth = Math.max(visibleChartWidth, minXAxisWidth + yAxisWidth); + // Logical → physical: inline-start lives on the visual right in RTL (where the Y-axis renders + // via `AxisRight`), and inline-end lives on the visual left. + const margin: Margin = isRtl + ? { top: blockStart, right: inlineStart, bottom: blockEnd, left: inlineEnd } + : { top: blockStart, right: inlineEnd, bottom: blockEnd, left: inlineStart }; + + // Track scroll offsets and re-dispatch the last pointermove on scroll so visx tooltips, + // crosshairs, and glyphs follow the cursor while the chart scrolls under it. + useEffect(function onParentScroll() { + const el = parentRef.current; + if (!el) return; + + // Track the last pointer state so we can re-dispatch on scroll. + // Storing the actual event target avoids relying on visx's internal DOM structure. + let lastClientX = 0; + let lastClientY = 0; + let lastTarget: EventTarget | null = null; + + function onPointerMove(e: PointerEvent) { + lastClientX = e.clientX; + lastClientY = e.clientY; + lastTarget = e.target; + } + + function onPointerLeave() { + lastTarget = null; + } + + /** + * Mirrors the container's scroll offsets into state and re-dispatches the last pointermove + * on the cached target. visx's `localPoint()` re-derives the SVG point from `clientX/clientY` + * via `SVGSVGElement.getScreenCTM()`, which already reflects the new scroll position, so the + * same screen coordinates now resolve to the correct datum after scrolling. + */ + function onScroll() { + setScrollLeft(el?.scrollLeft ?? 0); + setScrollTop(el?.scrollTop ?? 0); + + if (lastTarget) { + lastTarget.dispatchEvent( + new PointerEvent('pointermove', { + bubbles: true, + cancelable: true, + clientX: lastClientX, + clientY: lastClientY + }) + ); + } + } + + el.addEventListener('pointermove', onPointerMove, { passive: true }); + el.addEventListener('pointerleave', onPointerLeave, { passive: true }); + el.addEventListener('scroll', onScroll, { passive: true }); + + return function cleanup() { + el.removeEventListener('pointermove', onPointerMove); + el.removeEventListener('pointerleave', onPointerLeave); + el.removeEventListener('scroll', onScroll); + }; + }, []); + + // Observe both axes and recompute axis-derived layout state when their DOM mutates or they + // resize. Measurements are batched into a single rAF so concurrent observer callbacks + // produce at most one re-render. + useEffect( + function syncAxes() { + if (!isVisibleChart) { + return; + } + + const xAxis = xAxisRef.current; + const yAxis = yAxisRef.current; + + if (!xAxis && !yAxis) { + return; + } + + let rafId = 0; + + function measureAll(prev: AxisState): AxisState { + // Cluster all DOM reads in one pass so the browser pays a single forced reflow per + // batch instead of one per observer callback. + const inlineStart = getInlineStartMargin(yAxis, xAxis); + const blockEnd = getBlockEndMargin(xAxis, yAxis); + const blockStart = yAxis ? getBlockStartMargin(yAxis, prev.blockStart) : prev.blockStart; + const inlineEnd = xAxis ? getInlineEndMargin(xAxis, prev.inlineEnd, isRtl) : prev.inlineEnd; + const minHeight = yAxis ? getChartMinHeight(yAxis) : prev.minHeight; + const xWidths = xAxis + ? getChartMinWidth(xAxis) + : { minWidthHorizontal: prev.minXAxisWidthHorizontal, minWidthVertical: prev.minXAxisWidthVertical }; + const yAxisRect = yAxis ? yAxis.getBBox() : prev.yAxisRect; + + // Bail out on no-op so React skips the re-render. + if ( + prev.blockStart === blockStart && + prev.inlineEnd === inlineEnd && + prev.blockEnd === blockEnd && + prev.inlineStart === inlineStart && + prev.minHeight === minHeight && + prev.minXAxisWidthHorizontal === xWidths.minWidthHorizontal && + prev.minXAxisWidthVertical === xWidths.minWidthVertical && + rectsEqual(prev.yAxisRect, yAxisRect) + ) { + return prev; + } + + return { + inlineStart, + inlineEnd, + blockStart, + blockEnd, + minHeight, + minXAxisWidthHorizontal: xWidths.minWidthHorizontal, + minXAxisWidthVertical: xWidths.minWidthVertical, + yAxisRect + }; + } + + function schedule() { + if (rafId) { + return; + } + rafId = requestAnimationFrame(function flush() { + rafId = 0; + setAxisState(measureAll); + }); + } + + schedule(); + + const mutationObserver = new MutationObserver(schedule); + const resizeObserver = new ResizeObserver(schedule); + + if (xAxis) { + mutationObserver.observe(xAxis, { childList: true, subtree: true }); + resizeObserver.observe(xAxis); + } + if (yAxis) { + mutationObserver.observe(yAxis, { childList: true, subtree: true }); + resizeObserver.observe(yAxis); + } + + return function cleanup() { + if (rafId) { + cancelAnimationFrame(rafId); + } + mutationObserver.disconnect(); + resizeObserver.disconnect(); + }; + }, + [isVisibleChart, isRtl] + ); + + return { + parentRef, + chartWidth, + minHeight, + chartHeight, + margin, + scrollLeft, + scrollTop, + xAxisRef, + yAxisRef, + xLabelsVertical, + yAxisRect + }; +} diff --git a/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/test/chart-container-margins.browser.test.tsx b/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/test/chart-container-margins.browser.test.tsx new file mode 100644 index 000000000..756ad74c4 --- /dev/null +++ b/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/test/chart-container-margins.browser.test.tsx @@ -0,0 +1,425 @@ +/** + * Browser (Playwright) unit tests for `use-scrollable-xy-chart/src/chart-container-margins.ts`. + * Run with: `npm run test:browser -- chart-container-margins` or `use-scrollable-xy-chart` from `packages/@godaddy/antares` + * (requires Chromium for Vitest browser mode, e.g. `npx playwright install`). + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + getAxisTickAt, + getBlockEndMargin, + getBlockStartMargin, + getHalfFirstXAxisTickLabelWidth, + getHalfFirstYAxisTickLabelHeight, + getInlineEndMargin, + getInlineStartMargin, + getTickLabelText, + isElementDisplayed +} from '../src/chart-container-margins.ts'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +// Tests stub element measurements rather than relying on real layout: real layout depends on font +// rendering, DPR, and Chromium version, which would make assertions like `expect(...).toBe(25)` flaky. +// Stubbing keeps the math exact while leaving the DOM traversal in `chart-container-margins.ts` honest. + +function mockRect(element: Element, rect: Partial): void { + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => '', + ...rect + } as DOMRect); +} + +function mockBBox(element: SVGGraphicsElement, bbox: Partial): void { + vi.spyOn(element, 'getBBox').mockReturnValue({ + x: 0, + y: 0, + width: 0, + height: 0, + ...bbox + } as DOMRect); +} + +function createAxisWithTwoTicks(): { + svg: SVGSVGElement; + xAxis: SVGGElement; + firstTick: SVGGElement; + firstText: SVGTextElement; + lastTick: SVGGElement; + lastText: SVGTextElement; +} { + const svg = document.createElementNS(SVG_NS, 'svg') as SVGSVGElement; + svg.setAttribute('width', '400'); + svg.setAttribute('height', '200'); + const xAxis = document.createElementNS(SVG_NS, 'g') as SVGGElement; + + const firstTick = document.createElementNS(SVG_NS, 'g') as SVGGElement; + firstTick.setAttribute('class', 'visx-axis-tick'); + const firstText = document.createElementNS(SVG_NS, 'text') as SVGTextElement; + firstTick.appendChild(firstText); + + const lastTick = document.createElementNS(SVG_NS, 'g') as SVGGElement; + lastTick.setAttribute('class', 'visx-axis-tick'); + const lastText = document.createElementNS(SVG_NS, 'text') as SVGTextElement; + lastTick.appendChild(lastText); + + xAxis.append(firstTick, lastTick); + svg.appendChild(xAxis); + document.body.append(svg); + + return { svg, xAxis, firstTick, firstText, lastTick, lastText }; +} + +function appendYAxisWithTick( + svg: SVGSVGElement, + before?: Node +): { + yAxis: SVGGElement; + yTick: SVGGElement; + yText: SVGTextElement; +} { + const yAxis = document.createElementNS(SVG_NS, 'g') as SVGGElement; + const yTick = document.createElementNS(SVG_NS, 'g') as SVGGElement; + yTick.setAttribute('class', 'visx-axis-tick'); + const yText = document.createElementNS(SVG_NS, 'text') as SVGTextElement; + yTick.appendChild(yText); + yAxis.appendChild(yTick); + if (before) { + svg.insertBefore(yAxis, before); + } else { + svg.appendChild(yAxis); + } + return { yAxis, yTick, yText }; +} + +describe('@godaddy/antares', function antares() { + describe('#chart-container-margins', function chartContainerMargins() { + afterEach(function cleanupDom() { + vi.restoreAllMocks(); + document.body.querySelectorAll('svg').forEach(function removeSvg(svg) { + svg.remove(); + }); + }); + + describe('#getAxisTickAt', function getAxisTickAtTests() { + it('returns null when axis element is null', function nullAxis() { + expect(getAxisTickAt(null, 'first')).toBe(null); + expect(getAxisTickAt(null, 'last')).toBe(null); + }); + + it('returns null when there are no tick nodes', function noTicks() { + const { xAxis } = createAxisWithTwoTicks(); + // Strip the ticks added by the fixture to simulate an axis that has not rendered any. + xAxis.replaceChildren(); + + expect(getAxisTickAt(xAxis, 'first')).toBe(null); + }); + + it('returns first and last tick groups', function firstLast() { + const { xAxis, firstTick, lastTick } = createAxisWithTwoTicks(); + + expect(getAxisTickAt(xAxis, 'first')).toBe(firstTick); + expect(getAxisTickAt(xAxis, 'last')).toBe(lastTick); + }); + }); + + describe('#getTickLabelText', function getTickLabelTextTests() { + it('returns null when tick is null', function nullTick() { + expect(getTickLabelText(null)).toBe(null); + }); + + it('returns SVGTextElement child when present', function textChild() { + const { firstTick, firstText } = createAxisWithTwoTicks(); + + expect(getTickLabelText(firstTick)).toBe(firstText); + }); + + it('returns null when tick has no text child', function noTextChild() { + const { firstTick, firstText } = createAxisWithTwoTicks(); + firstText.remove(); + + expect(getTickLabelText(firstTick)).toBe(null); + }); + }); + + describe('#isElementDisplayed', function isElementDisplayedTests() { + it('returns false for null', function nullEl() { + expect(isElementDisplayed(null)).toBe(false); + }); + + it('returns false when display is none', function displayNone() { + const el = document.createElement('div'); + el.style.display = 'none'; + document.body.append(el); + + expect(isElementDisplayed(el)).toBe(false); + el.remove(); + }); + + it('returns true when display is not none', function displayBlock() { + const el = document.createElement('div'); + el.style.display = 'block'; + document.body.append(el); + + expect(isElementDisplayed(el)).toBe(true); + el.remove(); + }); + }); + + describe('#getHalfFirstXAxisTickLabelWidth', function halfFirstTests() { + it('returns 0 when axis is null', function nullAxis() { + expect(getHalfFirstXAxisTickLabelWidth(null)).toBe(0); + }); + + it('returns 0 when label is display none', function hiddenLabel() { + const { xAxis, firstText } = createAxisWithTwoTicks(); + firstText.style.display = 'none'; + + expect(getHalfFirstXAxisTickLabelWidth(xAxis)).toBe(0); + }); + + it('returns ceil(half width) when first label is visible', function visibleLabel() { + const { xAxis, firstText } = createAxisWithTwoTicks(); + mockRect(firstText, { width: 47, height: 12 }); + + expect(getHalfFirstXAxisTickLabelWidth(xAxis)).toBe(24); + }); + + it('uses the post-transform rect width so vertical labels report their rotated footprint', function rotated() { + const { xAxis, firstText } = createAxisWithTwoTicks(); + firstText.setAttribute('transform', 'rotate(-90)'); + // Real browser-reported rect for a 50x10 text rotated -90° would be 10x50. + mockRect(firstText, { width: 10, height: 50 }); + + expect(getHalfFirstXAxisTickLabelWidth(xAxis)).toBe(5); + }); + }); + + describe('#getInlineStartMargin', function getInlineStartMarginTests() { + it('returns 0 when y-axis is null and x-axis defaults to null', function bothMissing() { + expect(getInlineStartMargin(null)).toBe(0); + }); + + it('returns y-axis width when no x-axis is provided', function yAxisOnly() { + const { yAxis } = appendYAxisWithTick(document.createElementNS(SVG_NS, 'svg') as SVGSVGElement); + mockBBox(yAxis, { width: 22, height: 40 }); + + expect(getInlineStartMargin(yAxis)).toBe(22); + }); + + it('uses half first x tick label width when greater than y-axis width', function xLabelWins() { + const { svg, xAxis, firstText } = createAxisWithTwoTicks(); + const { yAxis } = appendYAxisWithTick(svg, xAxis); + mockBBox(yAxis, { width: 5, height: 40 }); + mockRect(firstText, { width: 30, height: 10 }); + + expect(getInlineStartMargin(yAxis, xAxis)).toBe(15); + }); + + it('uses y-axis width when greater than half first x tick label width', function yAxisWins() { + const { svg, xAxis, firstText } = createAxisWithTwoTicks(); + const { yAxis } = appendYAxisWithTick(svg, xAxis); + mockBBox(yAxis, { width: 40, height: 40 }); + mockRect(firstText, { width: 10, height: 10 }); + + expect(getInlineStartMargin(yAxis, xAxis)).toBe(40); + }); + }); + + describe('#getHalfFirstYAxisTickLabelHeight', function halfFirstYTests() { + it('returns 0 when axis is null', function nullAxis() { + expect(getHalfFirstYAxisTickLabelHeight(null)).toBe(0); + }); + + it('returns 0 when label is display none', function hiddenLabel() { + const { xAxis, firstText } = createAxisWithTwoTicks(); + firstText.style.display = 'none'; + + expect(getHalfFirstYAxisTickLabelHeight(xAxis)).toBe(0); + }); + + it('returns ceil(half height) when first label is visible', function visibleLabel() { + const { xAxis, firstText } = createAxisWithTwoTicks(); + mockRect(firstText, { width: 12, height: 13 }); + + expect(getHalfFirstYAxisTickLabelHeight(xAxis)).toBe(7); + }); + }); + + describe('#getBlockEndMargin', function getBlockEndMarginTests() { + it('returns 0 when both axes are null', function nullAxes() { + expect(getBlockEndMargin(null)).toBe(0); + }); + + it('returns x-axis bbox height when greater than half first y-tick label height', function xAxisWins() { + const { xAxis } = createAxisWithTwoTicks(); + mockBBox(xAxis, { height: 33 }); + + expect(getBlockEndMargin(xAxis)).toBe(33); + }); + + it('uses half first y-tick label height when greater than x-axis height', function yAxisWins() { + const { svg, xAxis } = createAxisWithTwoTicks(); + const { yAxis, yText } = appendYAxisWithTick(svg, xAxis); + mockBBox(xAxis, { height: 0 }); + mockRect(yText, { height: 31 }); + + expect(getBlockEndMargin(xAxis, yAxis)).toBe(16); + }); + }); + + describe('#getInlineEndMargin', function getInlineEndMarginTests() { + it('returns 0 when last tick label is hidden', function hidden() { + const { xAxis, lastText } = createAxisWithTwoTicks(); + lastText.style.display = 'none'; + + expect(getInlineEndMargin(xAxis)).toBe(0); + }); + + it('returns half label width when last tick overflows svg right edge', function overflow() { + const { svg, xAxis, lastText } = createAxisWithTwoTicks(); + mockRect(svg, { right: 100, width: 100 }); + mockRect(lastText, { left: 70, right: 120, width: 50 }); + + expect(getInlineEndMargin(xAxis)).toBe(25); + }); + + it('uses the post-transform rect width when the last tick has a rotate transform', function rotated() { + const { svg, xAxis, lastText } = createAxisWithTwoTicks(); + lastText.setAttribute('transform', 'rotate(-90)'); + // Real browser-reported rect for a 50x10 text rotated -90° would be 10x50. + mockRect(svg, { right: 100, width: 100 }); + mockRect(lastText, { left: 100, right: 110, width: 10, height: 50 }); + + expect(getInlineEndMargin(xAxis)).toBe(5); + }); + + it('returns 0 when there is no horizontal overflow', function noOverflow() { + const { svg, xAxis, lastText } = createAxisWithTwoTicks(); + mockRect(svg, { right: 400, width: 400 }); + mockRect(lastText, { right: 200, width: 200 }); + + expect(getInlineEndMargin(xAxis)).toBe(0); + }); + + it('keeps the previous margin when the label fits only because of it', function sticky() { + const { svg, xAxis, lastText } = createAxisWithTwoTicks(); + // SVG right edge at 400. Last label sits with its right edge exactly at 400 thanks + // to the existing 25px inline-end margin. Removing the margin would shift it to 425. + mockRect(svg, { right: 400, width: 400 }); + mockRect(lastText, { left: 350, right: 400, width: 50 }); + + expect(getInlineEndMargin(xAxis, 25)).toBe(25); + }); + + it('returns 0 when the label fits with comfortable slack regardless of prev', function slack() { + const { svg, xAxis, lastText } = createAxisWithTwoTicks(); + // Label right edge at 200, far inside the SVG (right=400). Even removing the prev + // margin (25) would leave it at 225, still well inside. + mockRect(svg, { right: 400, width: 400 }); + mockRect(lastText, { left: 150, right: 200, width: 50 }); + + expect(getInlineEndMargin(xAxis, 25)).toBe(0); + }); + + // In RTL the X-scale's range is reversed (`[innerWidth, 0]`), so the last DOM tick + // (largest domain value) is rendered at the visual left of the chart and can overflow + // the SVG's left edge instead of its right. + describe('RTL', function rtlTests() { + it('returns half label width when last tick overflows svg left edge', function overflowLeft() { + const { svg, xAxis, lastText } = createAxisWithTwoTicks(); + // SVG left at 0; last label sits at left=-30 (overflow) with width=50. + mockRect(svg, { left: 0, right: 400, width: 400 }); + mockRect(lastText, { left: -30, right: 20, width: 50 }); + + expect(getInlineEndMargin(xAxis, 0, true)).toBe(25); + }); + + it('returns 0 when there is no horizontal overflow on the left', function noOverflowLeft() { + const { svg, xAxis, lastText } = createAxisWithTwoTicks(); + mockRect(svg, { left: 0, right: 400, width: 400 }); + mockRect(lastText, { left: 100, right: 150, width: 50 }); + + expect(getInlineEndMargin(xAxis, 0, true)).toBe(0); + }); + + it('keeps the previous margin when the label fits only because of it', function stickyLeft() { + const { svg, xAxis, lastText } = createAxisWithTwoTicks(); + // SVG left edge at 0. Last label sits with its left edge exactly at 0 thanks to the + // existing 25px margin. Removing the margin would shift it to -25 (overflow). + mockRect(svg, { left: 0, right: 400, width: 400 }); + mockRect(lastText, { left: 0, right: 50, width: 50 }); + + expect(getInlineEndMargin(xAxis, 25, true)).toBe(25); + }); + + it('returns 0 when the label fits with comfortable slack regardless of prev', function slackLeft() { + const { svg, xAxis, lastText } = createAxisWithTwoTicks(); + // Label left edge at 200, far inside the SVG (left=0). Even removing the prev + // margin (25) would leave it at 175, still well inside. + mockRect(svg, { left: 0, right: 400, width: 400 }); + mockRect(lastText, { left: 200, right: 250, width: 50 }); + + expect(getInlineEndMargin(xAxis, 25, true)).toBe(0); + }); + }); + }); + + describe('#getBlockStartMargin', function getBlockStartMarginTests() { + it('returns 0 when last tick label is hidden', function hidden() { + const { svg, xAxis } = createAxisWithTwoTicks(); + const { yAxis, yText } = appendYAxisWithTick(svg, xAxis); + yText.style.display = 'none'; + + expect(getBlockStartMargin(yAxis)).toBe(0); + }); + + it('returns half label height when last tick is above svg top', function overflowUp() { + const { svg, xAxis } = createAxisWithTwoTicks(); + const { yAxis, yText } = appendYAxisWithTick(svg, xAxis); + mockRect(svg, { top: 40, right: 400, bottom: 200, width: 400, height: 160 }); + mockRect(yText, { top: 20, right: 10, bottom: 51, width: 10, height: 31 }); + + expect(getBlockStartMargin(yAxis)).toBe(16); + }); + + it('returns 0 when last tick is not above svg top', function noOverflow() { + const { svg, xAxis } = createAxisWithTwoTicks(); + const { yAxis, yText } = appendYAxisWithTick(svg, xAxis); + mockRect(svg, { right: 400, bottom: 200, width: 400, height: 200 }); + mockRect(yText, { top: 50, right: 10, bottom: 60, width: 10, height: 10 }); + + expect(getBlockStartMargin(yAxis)).toBe(0); + }); + + it('keeps the previous margin when the label fits only because of it', function sticky() { + const { svg, xAxis } = createAxisWithTwoTicks(); + const { yAxis, yText } = appendYAxisWithTick(svg, xAxis); + // SVG top at 0. Topmost label is at top=8 thanks to the existing 16px margin. + // Removing the margin would shift it to top=-8 (overflow). + mockRect(svg, { right: 400, bottom: 200, width: 400, height: 200 }); + mockRect(yText, { top: 8, right: 10, bottom: 18, width: 10, height: 10 }); + + expect(getBlockStartMargin(yAxis, 16)).toBe(16); + }); + + it('returns 0 when the label fits with comfortable slack regardless of prev', function slack() { + const { svg, xAxis } = createAxisWithTwoTicks(); + const { yAxis, yText } = appendYAxisWithTick(svg, xAxis); + // Topmost label far inside the SVG (top=80). Even removing the 16px prev margin + // would leave it at 64, still well inside. + mockRect(svg, { right: 400, bottom: 200, width: 400, height: 200 }); + mockRect(yText, { top: 80, right: 10, bottom: 90, width: 10, height: 10 }); + + expect(getBlockStartMargin(yAxis, 16)).toBe(0); + }); + }); + }); +}); diff --git a/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/test/use-scrollable-xy-chart.browser.test.tsx b/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/test/use-scrollable-xy-chart.browser.test.tsx new file mode 100644 index 000000000..a8935fdc5 --- /dev/null +++ b/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/test/use-scrollable-xy-chart.browser.test.tsx @@ -0,0 +1,199 @@ +/** + * Browser (Playwright) tests for `useScrollableXYChart`. + * Run: `npm run test:browser -- use-scrollable-xy-chart.browser` from `packages/@godaddy/antares`. + */ +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-react'; +import { page } from 'vitest/browser'; +import { AutoLayoutExample } from '../examples/auto-layout.tsx'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +describe('@godaddy/antares', function antares() { + describe('#useScrollableXYChart', function useScrollableXYChartTests() { + it('forces xLabelsVertical when xLabelsOrientation is vertical', async function verticalOrientation() { + await page.viewport(800, 600); + const { container } = await render(); + const parent = container.querySelector('[data-testid="scroll-parent"]'); + + expect(parent?.getAttribute('data-x-labels-vertical')).toBe('true'); + }); + + it('forces horizontal X labels when xLabelsOrientation is horizontal', async function horizontalOrientation() { + await page.viewport(800, 600); + const { container } = await render(); + const parent = container.querySelector('[data-testid="scroll-parent"]'); + + expect(parent?.getAttribute('data-x-labels-vertical')).toBe('false'); + }); + + it('exposes non-zero chart dimensions after parent size is measured', async function chartDimensions() { + await page.viewport(800, 600); + const { container } = await render(); + const parent = container.querySelector('[data-testid="scroll-parent"]') as HTMLElement; + + expect(parent.getAttribute('data-chart-width')).toBe('0'); + + await vi.waitFor( + function waitForSize() { + const w = Number(parent.getAttribute('data-chart-width') ?? '0'); + expect(w).toBeGreaterThan(0); + }, + { timeout: 3000, interval: 50 } + ); + const h = Number(parent.getAttribute('data-chart-height') ?? '0'); + + expect(h).toBeGreaterThan(0); + }); + + it('updates scrollLeft when the scroll container is scrolled', async function scrollOffset() { + await page.viewport(800, 600); + const { container } = await render(); + const parent = container.querySelector('[data-testid="scroll-parent"]') as HTMLElement; + + await vi.waitFor( + function waitForSize() { + expect(Number(parent.getAttribute('data-chart-width') ?? 0)).toBeGreaterThan(0); + }, + { timeout: 3000, interval: 50 } + ); + + parent.scrollLeft = 41; + parent!.dispatchEvent(new Event('scroll', { bubbles: false })); + + await vi.waitFor( + function waitForScroll() { + expect(parent.getAttribute('data-scroll-left')).toBe('41'); + }, + { timeout: 2000, interval: 20 } + ); + }); + + it('updates scrollTop when the scroll container is scrolled', async function scrollOffset() { + await page.viewport(800, 600); + const { container } = await render(); + const parent = container.querySelector('[data-testid="scroll-parent"]') as HTMLElement; + + await vi.waitFor( + function waitForSize() { + expect(Number(parent.getAttribute('data-chart-height') ?? 0)).toBeGreaterThan(0); + }, + { timeout: 3000, interval: 50 } + ); + + parent.scrollTop = 41; + parent.dispatchEvent(new Event('scroll', { bubbles: false })); + + await vi.waitFor( + function waitForScroll() { + expect(parent.getAttribute('data-scroll-top')).toBe('41'); + }, + { timeout: 2000, interval: 20 } + ); + }); + + it('updates minHeight when the Y-axis subtree mutates', async function yAxisSubtreeMutation() { + await page.viewport(800, 600); + const { container } = await render(); + const parent = container.querySelector('[data-testid="scroll-parent"]') as HTMLElement; + const yAxis = container.querySelector('svg > g:first-of-type') as SVGGElement; + + await vi.waitFor( + function waitForSize() { + expect(Number(parent.getAttribute('data-chart-width') ?? 0)).toBeGreaterThan(0); + }, + { timeout: 3000, interval: 50 } + ); + + const beforeMinHeight = Number(parent.getAttribute('data-min-height') ?? '0'); + + const extraGroup = document.createElementNS(SVG_NS, 'g'); + extraGroup.setAttribute('class', 'visx-group'); + const extraText = document.createElementNS(SVG_NS, 'text'); + extraText.textContent = 'Extra tick'; + extraGroup.appendChild(extraText); + yAxis.appendChild(extraGroup); + + await vi.waitFor( + function waitForMinHeight() { + expect(Number(parent.getAttribute('data-min-height') ?? '0')).toBeGreaterThan(beforeMinHeight); + }, + { timeout: 2000, interval: 20 } + ); + }); + + it('re-dispatches pointermove on the last pointer target when the scroll container scrolls', async function scrollRedispatchesPointerMove() { + await page.viewport(800, 600); + const { container } = await render(); + const parent = container.querySelector('[data-testid="scroll-parent"]') as HTMLElement; + const svg = parent.querySelector('svg') as SVGGraphicsElement; + + await vi.waitFor( + function waitForSize() { + expect(Number(parent.getAttribute('data-chart-width') ?? 0)).toBeGreaterThan(0); + }, + { timeout: 3000, interval: 50 } + ); + + const coords: Array<{ clientX: number; clientY: number }> = []; + let pointerMovesOnSvg = 0; + svg.addEventListener('pointermove', function countPointerMove(e: PointerEvent) { + pointerMovesOnSvg++; + coords.push({ clientX: e.clientX, clientY: e.clientY }); + }); + + svg.dispatchEvent( + new PointerEvent('pointermove', { + bubbles: true, + cancelable: true, + clientX: 100, + clientY: 200 + }) + ); + + expect(pointerMovesOnSvg).toBe(1); + + parent.scrollLeft = 30; + parent.dispatchEvent(new Event('scroll', { bubbles: false })); + + expect(pointerMovesOnSvg).toBe(2); + expect(coords[1].clientX).toBe(100); + expect(coords[1].clientY).toBe(200); + }); + + it('clears stored pointer target on pointerleave so scroll does not re-dispatch pointermove', async function pointerLeaveClearsTarget() { + await page.viewport(800, 600); + const { container } = await render(); + const parent = container.querySelector('[data-testid="scroll-parent"]') as HTMLElement; + const svg = parent?.querySelector('svg') as SVGGraphicsElement; + + await vi.waitFor( + function waitForSize() { + expect(Number(parent?.getAttribute('data-chart-width') ?? 0)).toBeGreaterThan(0); + }, + { timeout: 3000, interval: 50 } + ); + + let pointerMovesOnSvg = 0; + svg.addEventListener('pointermove', function countPointerMove() { + pointerMovesOnSvg++; + }); + svg.dispatchEvent( + new PointerEvent('pointermove', { + bubbles: true, + cancelable: true, + clientX: 12, + clientY: 34 + }) + ); + + expect(pointerMovesOnSvg).toBe(1); + + parent.dispatchEvent(new PointerEvent('pointerleave', { bubbles: false })); + parent.scrollLeft = 55; + parent.dispatchEvent(new Event('scroll', { bubbles: false })); + + expect(pointerMovesOnSvg).toBe(1); + }); + }); +}); diff --git a/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/use-scrollable-xy-chart.stories.tsx b/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/use-scrollable-xy-chart.stories.tsx new file mode 100644 index 000000000..e632d0920 --- /dev/null +++ b/packages/@godaddy/antares/components/chart/use-scrollable-xy-chart/use-scrollable-xy-chart.stories.tsx @@ -0,0 +1,9 @@ +'use client'; +import { getMeta, getStory } from '@bento/storybook-addon-helpers'; +import { AutoLayoutExample } from './examples/auto-layout.tsx'; + +export default getMeta({ + title: 'Antares/Components/Chart/useScrollableXYChart' +}); + +export const AutoLayout = getStory(AutoLayoutExample); diff --git a/packages/@godaddy/antares/components/chart/utils.ts b/packages/@godaddy/antares/components/chart/utils.ts index ae29ccd03..616b45a96 100644 --- a/packages/@godaddy/antares/components/chart/utils.ts +++ b/packages/@godaddy/antares/components/chart/utils.ts @@ -65,3 +65,13 @@ export function chartSegmentGapPadAngle(outerRadiusPx: number): number { const r = Math.max(outerRadiusPx, 1); return CHART_ARC_GAP_PX / r; } + +/** + * Returns `tickLabelProps` for vertical X-axis labels, rotated 90° counterclockwise in LTR and + * clockwise in RTL so the rotation mirrors the layout direction. + * + * @param rtl - Whether the chart is in right-to-left mode + */ +export function getXLabelVerticalProps(rtl: boolean) { + return { angle: rtl ? 90 : -90, textAnchor: 'end', dominantBaseline: 'central' } as const; +}