diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 030173ecb08d..6eab22c00f57 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -10,7 +10,7 @@ import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer'; import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels'; import ChartYAxisLabels from '@components/Charts/components/ChartYAxisLabels'; import {AXIS_LABEL_GAP, CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, GLYPH_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT} from '@components/Charts/constants'; -import type {ComputeGeometryFn, HitTestArgs} from '@components/Charts/hooks'; +import type {HitTestArgs} from '@components/Charts/hooks'; import { useChartFontManager, useChartInteractions, @@ -22,7 +22,7 @@ import { useYAxisLabelWidth, } from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; -import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getAdditionalOffset, getChartColor, rotatedLabelYOffset} from '@components/Charts/utils'; +import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; @@ -36,29 +36,6 @@ const BAR_INNER_PADDING = 0.3; */ const BASE_DOMAIN_PADDING = {top: 32, bottom: 1, left: 0, right: 0}; -/** - * Bar chart geometry for label hit-testing. - * Labels are center-anchored: the 45° parallelogram's upper-right corner is offset - * by (halfLabelWidth * sinA) right and up, so the box straddles the tick symmetrically. - */ -const computeBarLabelGeometry: ComputeGeometryFn = ({ascent, descent, sinA, angleRad, labelWidths, padding}) => { - const maxLabelWidth = labelWidths.length > 0 ? Math.max(...labelWidths) : 0; - const centeredUpwardOffset = angleRad > 0 ? (maxLabelWidth / 2) * sinA : 0; - const halfLabelSins = labelWidths.map((w) => (w / 2) * sinA - variables.iconSizeExtraSmall / 3); - const halfWidths = labelWidths.map((w) => w / 2); - const additionalOffset = getAdditionalOffset(angleRad); - return { - labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) + centeredUpwardOffset - additionalOffset, - iconSin: variables.iconSizeExtraSmall * sinA, - labelSins: labelWidths.map((w) => w * sinA), - halfWidths, - cornerAnchorDX: halfLabelSins, - cornerAnchorDY: halfLabelSins.map((v) => -v), - yMin90Offsets: halfWidths.map((hw) => -hw + padding), - yMax90Offsets: halfWidths.map((hw) => hw + padding), - }; -}; - type BarChartProps = CartesianChartProps & { /** Callback when a bar is pressed */ onBarPress?: (dataPoint: ChartDataPoint, index: number) => void; @@ -141,14 +118,12 @@ function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left' labelRotation, labelSkipInterval, chartBottom, - computeGeometry: computeBarLabelGeometry, }); const handleChartBoundsChange = (bounds: ChartBounds) => { const domainWidth = bounds.right - bounds.left; const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * domainWidth) / data.length; barWidth.set(calculatedBarWidth); - chartBottom.set(bounds.bottom); yZero.set(0); setBarAreaWidth(domainWidth); setBoundsLeft(bounds.left); @@ -216,6 +191,10 @@ function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left' if (!fontMgr || xAxisLabelHeight === undefined) { return null; } + + const chartBoundsBottom = args.yScale(Math.min(...args.yTicks)); + chartBottom.set(chartBoundsBottom); + return ( <> { - const iconThirdSin = (variables.iconSizeExtraSmall / 3) * sinA; - const additionalOffset = getAdditionalOffset(angleRad); - return { - labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) - additionalOffset, - iconSin: variables.iconSizeExtraSmall * sinA, - labelSins: labelWidths.map((w) => w * sinA), - halfWidths: labelWidths.map((w) => w / 2), - cornerAnchorDX: labelWidths.map(() => -iconThirdSin), - cornerAnchorDY: labelWidths.map(() => iconThirdSin), - yMin90Offsets: labelWidths.map(() => padding), - yMax90Offsets: labelWidths.map((w) => w + padding), - }; -}; - type LineChartProps = CartesianChartProps & { /** Callback when a data point is pressed */ onPointPress?: (dataPoint: ChartDataPoint, index: number) => void; @@ -139,7 +119,6 @@ function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left labelAreaWidth: plotAreaWidth, firstTickLeftSpace: boundsLeft + domainPadding.left * paddingScale, lastTickRightSpace: chartWidth > 0 ? chartWidth - boundsRight + domainPadding.right * paddingScale : 0, - allowTightDiagonalPacking: true, measurements, }); @@ -158,14 +137,12 @@ function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left labelRotation, labelSkipInterval, chartBottom, - computeGeometry: computeLineLabelGeometry, }); const handleChartBoundsChange = (bounds: ChartBounds) => { setPlotAreaWidth(bounds.right - bounds.left); setBoundsLeft(bounds.left); setBoundsRight(bounds.right); - chartBottom.set(bounds.bottom); }; const checkIsOverDot = (args: HitTestArgs) => { @@ -197,6 +174,8 @@ function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left })); const renderOutside = (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => { + const chartBoundsBottom = args.yScale(Math.min(...args.yTicks)); + chartBottom.set(chartBoundsBottom); return ( <> { @@ -83,14 +78,11 @@ function ChartXAxisLabels({ const paragraphs = useChartParagraphs(truncatedLabels, fontMgr, fontSize, labelColor, MAX_X_AXIS_LABEL_WIDTH); - const renderedWidths = truncatedLabels.map((_, i) => paragraphs?.at(i)?.width ?? 0); - // Derive ascent/descent from the first available paragraph's line metrics. const {ascent, descent} = getFontLineMetrics(fontMgr, fontSize); const correction = rotatedLabelCenterCorrection(ascent, descent, angleRad); - const centeredUpwardOffset = centerRotatedLabels && angleRad > 0 ? (Math.max(...renderedWidths) / 2) * Math.sin(angleRad) : 0; - const labelY = chartBoundsBottom + AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) + centeredUpwardOffset; + const labelY = chartBoundsBottom + AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad); return truncatedLabels.map((label, i) => { if (i % labelSkipInterval !== 0 || label.length === 0) { @@ -111,13 +103,13 @@ function ChartXAxisLabels({ key={`x-label-${label}-${tickX}`} paragraph={paraData.para} x={tickX - renderWidth / 2} - y={labelY - variables.iconSizeExtraSmall} + y={labelY - ascent} width={renderWidth + GLYPH_PADDING} /> ); } - const textX = centerRotatedLabels ? tickX - renderWidth / 2 : tickX - renderWidth; + const textX = tickX - renderWidth; const origin = vec(tickX, labelY); return ( diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index 16931744a3a7..13781a736967 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -9,4 +9,3 @@ export {default as useChartLabelFormats} from './useChartLabelFormats'; export {default as useDynamicYDomain} from './useDynamicYDomain'; export {useTooltipData} from './useTooltipData'; export {default as useLabelHitTesting} from './useLabelHitTesting'; -export type {ComputeGeometryFn, ComputeGeometryInput} from './useLabelHitTesting'; diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts index 727a1df3a086..4bdd06da9aa7 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -26,9 +26,6 @@ type LabelLayoutConfig = { /** Pixels from last tick to right edge of canvas. Defaults to Infinity (no constraint). */ lastTickRightSpace?: number; - /** When true, allows tighter label packing at 45° by accounting for vertical offset between right-aligned labels. */ - allowTightDiagonalPacking?: boolean; - /** Measurements of the label text. */ measurements: ReturnType; }; @@ -45,16 +42,7 @@ const EMPTY_LAYOUT = { ellipsisWidth: 0, }; -function useChartLabelLayout({ - data, - fontMgr, - tickSpacing, - labelAreaWidth, - firstTickLeftSpace = Infinity, - lastTickRightSpace = Infinity, - allowTightDiagonalPacking = false, - measurements, -}: LabelLayoutConfig) { +function useChartLabelLayout({data, fontMgr, tickSpacing, labelAreaWidth, firstTickLeftSpace = Infinity, lastTickRightSpace = Infinity, measurements}: LabelLayoutConfig) { // Phase 1: font/data measurements — stable across geometry-only changes (resize). // Phase 2: layout decisions + label truncation. @@ -83,18 +71,16 @@ function useChartLabelLayout({ rotation: LABEL_ROTATIONS.HORIZONTAL, firstTickLeftSpace: effectiveFirstTickLeftSpace, lastTickRightSpace: effectiveLastTickRightSpace, - rightAligned: false, }); if (hFitsInTicks && hEdgeFits) { rotation = LABEL_ROTATIONS.HORIZONTAL; } else { - const diagonalOverlap = allowTightDiagonalPacking ? lineHeight * SIN_45 : 0; - const minDiagWidth = minTruncatedWidth * SIN_45 - diagonalOverlap; + const minDiagWidth = minTruncatedWidth * SIN_45 - lineHeight * SIN_45; const dFitsInTicks = minDiagWidth + LABEL_PADDING <= tickSpacing; - const firstEdgeMax = edgeMaxLabelWidth(effectiveFirstTickLeftSpace, lineHeight, LABEL_ROTATIONS.DIAGONAL, allowTightDiagonalPacking, 'first'); - const lastEdgeMax = edgeMaxLabelWidth(effectiveLastTickRightSpace, lineHeight, LABEL_ROTATIONS.DIAGONAL, allowTightDiagonalPacking, 'last'); + const firstEdgeMax = edgeMaxLabelWidth(effectiveFirstTickLeftSpace, lineHeight, LABEL_ROTATIONS.DIAGONAL, 'first'); + const lastEdgeMax = edgeMaxLabelWidth(effectiveLastTickRightSpace, lineHeight, LABEL_ROTATIONS.DIAGONAL, 'last'); const dEdgeFits = firstEdgeMax >= firstMinTrunc && lastEdgeMax >= lastMinTrunc; if (dFitsInTicks && dEdgeFits) { @@ -103,17 +89,16 @@ function useChartLabelLayout({ } // Compute per-label max-width constraints (used by ChartXAxisLabels for truncation). - const truncDiagonalOverlap = allowTightDiagonalPacking ? lineHeight : 0; - const tickMaxWidth = rotation === LABEL_ROTATIONS.DIAGONAL ? (tickSpacing - LABEL_PADDING) / SIN_45 + truncDiagonalOverlap : Infinity; + const tickMaxWidth = rotation === LABEL_ROTATIONS.DIAGONAL ? (tickSpacing - LABEL_PADDING) / SIN_45 + lineHeight : Infinity; const labelMaxWidths = data.map((_, index) => { let maxWidth = tickMaxWidth; if (index === 0) { - const edgeMax = edgeMaxLabelWidth(effectiveFirstTickLeftSpace, lineHeight, rotation, allowTightDiagonalPacking, 'first'); + const edgeMax = edgeMaxLabelWidth(effectiveFirstTickLeftSpace, lineHeight, rotation, 'first'); maxWidth = Math.min(maxWidth, edgeMax); } if (index === data.length - 1) { - const edgeMax = edgeMaxLabelWidth(effectiveLastTickRightSpace, lineHeight, rotation, allowTightDiagonalPacking, 'last'); + const edgeMax = edgeMaxLabelWidth(effectiveLastTickRightSpace, lineHeight, rotation, 'last'); maxWidth = Math.min(maxWidth, edgeMax); } return maxWidth; @@ -122,15 +107,15 @@ function useChartLabelLayout({ // Approximate truncated widths for hit-testing: exact for non-truncated labels, // at most ellipsisWidth px over for truncated ones — acceptable for bounding boxes. const truncatedLabelWidths = labelMaxWidths.map((maxW, i) => Math.min(labelWidths.at(i) ?? 0, maxW)); - const finalMaxWidth = Math.max(...truncatedLabelWidths); let skipInterval = 1; if (rotation === LABEL_ROTATIONS.VERTICAL) { - const verticalWidth = effectiveWidth(finalMaxWidth, lineHeight, rotation); - const visibleCount = maxVisibleCount(labelAreaWidth, verticalWidth); + const visibleCount = maxVisibleCount(labelAreaWidth, lineHeight); skipInterval = visibleCount >= data.length ? 1 : Math.ceil(data.length / Math.max(1, visibleCount)); } + const finalMaxWidth = Math.max(...truncatedLabelWidths.filter((_, i) => i % skipInterval === 0)); + const lastIndex = data.length - 1; return { diff --git a/src/components/Charts/hooks/useLabelHitTesting.ts b/src/components/Charts/hooks/useLabelHitTesting.ts index ada0f9e47115..ccbd5f47ce0a 100644 --- a/src/components/Charts/hooks/useLabelHitTesting.ts +++ b/src/components/Charts/hooks/useLabelHitTesting.ts @@ -2,9 +2,9 @@ import type {SkTypefaceFontProvider} from '@shopify/react-native-skia'; import type {SharedValue} from 'react-native-reanimated'; import {useSharedValue} from 'react-native-reanimated'; import type {Scale} from 'victory-native'; -import {DIAGONAL_ANGLE_RADIAN_THRESHOLD} from '@components/Charts/constants'; +import {AXIS_LABEL_GAP, DIAGONAL_ANGLE_RADIAN_THRESHOLD} from '@components/Charts/constants'; import type {LabelRotation} from '@components/Charts/types'; -import {getFontLineMetrics, isCursorOverChartLabel} from '@components/Charts/utils'; +import {getAdditionalOffset, getFontLineMetrics, isCursorOverChartLabel, rotatedLabelYOffset} from '@components/Charts/utils'; import variables from '@styles/variables'; import type {HitTestArgs} from './useChartInteractions'; @@ -21,41 +21,19 @@ type LabelHitGeometry = { /** Per-label: labelWidth / 2 — half-extent for 0° and 90° hit bounds */ halfWidths: number[]; - /** Per-label: rightUpperCorner.x = targetX + cornerAnchorDX[i] */ - cornerAnchorDX: number[]; + /** rightUpperCorner.x = targetX + cornerAnchorDX */ + cornerAnchorDX: number; - /** Per-label: rightUpperCorner.y = labelY + cornerAnchorDY[i] */ - cornerAnchorDY: number[]; + /** rightUpperCorner.y = labelY + cornerAnchorDY */ + cornerAnchorDY: number; - /** Per-label: yMin90 = labelY + yMin90Offsets[i] */ - yMin90Offsets: number[]; + /** yMin90 = labelY + yMin90Offset */ + yMin90Offset: number; /** Per-label: yMax90 = labelY + yMax90Offsets[i] */ yMax90Offsets: number[]; }; -type ComputeGeometryInput = { - /** The ascent of the font */ - ascent: number; - - /** The descent of the font */ - descent: number; - - /** The sine of the angle */ - sinA: number; - - /** The angle in radians */ - angleRad: number; - - /** The widths of the labels */ - labelWidths: number[]; - - /** The padding of the labels */ - padding: number; -}; - -type ComputeGeometryFn = (input: ComputeGeometryInput) => LabelHitGeometry; - type UseLabelHitTestingParams = { fontMgr: SkTypefaceFontProvider | null | undefined; fontSize: number; @@ -64,13 +42,6 @@ type UseLabelHitTestingParams = { labelRotation: LabelRotation; labelSkipInterval: number; chartBottom: SharedValue; - - /** - * Chart-specific geometry factory. - * Receives font metrics, trig values, and per-label widths; returns the - * normalized geometry shape. Typically a module-level constant. - */ - computeGeometry: ComputeGeometryFn; }; /** @@ -78,31 +49,38 @@ type UseLabelHitTestingParams = { * * Encapsulates angle conversion, pre-computed hit geometry, and the * isCursorOverLabel / findLabelCursorX worklets — all of which are identical - * between bar and line chart except for how the hit geometry is computed. + * between bar and line charts. * * Label widths are accepted as a pre-computed array (from useChartLabelLayout) * so no Skia measurement happens here. * - * Chart-specific geometry (45° corner anchor offsets, 90° vertical bounds) is supplied - * via the `computeGeometry` callback, typically a module-level constant. + * Labels are right-aligned at the tick: the 45° parallelogram's upper-right corner is + * offset by (iconSize/3 * sinA) left and down, placing the box just below the axis line. */ -function useLabelHitTesting({fontMgr, fontSize, truncatedLabelWidths, labelRotation, labelSkipInterval, chartBottom, computeGeometry}: UseLabelHitTestingParams) { +function useLabelHitTesting({fontMgr, fontSize, truncatedLabelWidths, labelRotation, labelSkipInterval, chartBottom}: UseLabelHitTestingParams) { const tickXPositions = useSharedValue([]); const angleRad = (Math.abs(labelRotation) * Math.PI) / 180; const fontMetrics = fontMgr ? getFontLineMetrics(fontMgr, fontSize) : null; - /** - * Geometry for label hit-testing. The `computeGeometry` callback supplies the - * chart-specific differences (bar vs. line anchor offsets). - */ let labelHitGeometry: LabelHitGeometry | null = null; if (fontMetrics) { const {ascent, descent} = fontMetrics; const sinA = Math.sin(angleRad); const padding = variables.iconSizeExtraSmall / 2; - labelHitGeometry = computeGeometry({ascent, descent, sinA, angleRad, labelWidths: truncatedLabelWidths, padding}); + const iconThirdSin = (variables.iconSizeExtraSmall / 3) * sinA; + const additionalOffset = getAdditionalOffset(angleRad); + labelHitGeometry = { + labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) - additionalOffset, + iconSin: variables.iconSizeExtraSmall * sinA, + labelSins: truncatedLabelWidths.map((w) => w * sinA), + halfWidths: truncatedLabelWidths.map((w) => w / 2), + cornerAnchorDX: -iconThirdSin, + cornerAnchorDY: iconThirdSin, + yMin90Offset: padding, + yMax90Offsets: truncatedLabelWidths.map((w) => w + padding), + }; } /** @@ -116,7 +94,7 @@ function useLabelHitTesting({fontMgr, fontSize, truncatedLabelWidths, labelRotat return false; } - const {labelYOffset, iconSin, labelSins, halfWidths, cornerAnchorDX, cornerAnchorDY, yMin90Offsets, yMax90Offsets} = labelHitGeometry; + const {labelYOffset, iconSin, labelSins, halfWidths, cornerAnchorDX, cornerAnchorDY, yMin90Offset, yMax90Offsets} = labelHitGeometry; const padding = variables.iconSizeExtraSmall / 2; const halfWidth = halfWidths.at(activeIndex) ?? 0; const labelY = args.chartBottom + labelYOffset; @@ -124,9 +102,7 @@ function useLabelHitTesting({fontMgr, fontSize, truncatedLabelWidths, labelRotat let corners45: Array<{x: number; y: number}> | undefined; if (angleRad > 0 && angleRad < DIAGONAL_ANGLE_RADIAN_THRESHOLD) { const labelSin = labelSins.at(activeIndex) ?? 0; - const anchorDX = cornerAnchorDX.at(activeIndex) ?? 0; - const anchorDY = cornerAnchorDY.at(activeIndex) ?? 0; - const rightUpperCorner = {x: args.targetX + anchorDX, y: labelY + anchorDY}; + const rightUpperCorner = {x: args.targetX + cornerAnchorDX, y: labelY + cornerAnchorDY}; const rightLowerCorner = {x: rightUpperCorner.x + iconSin, y: rightUpperCorner.y + iconSin}; const leftUpperCorner = {x: rightUpperCorner.x - labelSin, y: rightUpperCorner.y + labelSin}; const leftLowerCorner = {x: rightLowerCorner.x - labelSin, y: rightLowerCorner.y + labelSin}; @@ -142,7 +118,7 @@ function useLabelHitTesting({fontMgr, fontSize, truncatedLabelWidths, labelRotat halfWidth, padding, corners45, - yMin90: labelY + (yMin90Offsets.at(activeIndex) ?? 0), + yMin90: labelY + yMin90Offset, yMax90: labelY + (yMax90Offsets.at(activeIndex) ?? 0), }); }; @@ -183,4 +159,3 @@ function useLabelHitTesting({fontMgr, fontSize, truncatedLabelWidths, labelRotat } export default useLabelHitTesting; -export type {ComputeGeometryFn, ComputeGeometryInput, LabelHitGeometry}; diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index 33e1919e522c..241d5be6bbc9 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils.ts @@ -332,20 +332,16 @@ function maxVisibleCount(areaWidth: number, itemWidth: number): number { * How far a label extends beyond its tick position after rotation. * Accounts for the rotatedLabelCenterCorrection translateX applied during rendering. */ -function labelOverhang(labelWidth: number, lineHeight: number, rotation: LabelRotation, rightAligned: boolean): {left: number; right: number} { +function labelOverhang(labelWidth: number, lineHeight: number, rotation: LabelRotation): {left: number; right: number} { if (rotation === LABEL_ROTATIONS.HORIZONTAL) { return {left: labelWidth / 2, right: labelWidth / 2}; } if (rotation === LABEL_ROTATIONS.DIAGONAL) { const halfLH = lineHeight / 2; - if (rightAligned) { - return { - left: (labelWidth + halfLH) * SIN_45, - right: halfLH * SIN_45, - }; - } - const overhang = (labelWidth / 2 + halfLH) * SIN_45; - return {left: overhang, right: overhang}; + return { + left: (labelWidth + halfLH) * SIN_45, + right: halfLH * SIN_45, + }; } return {left: lineHeight / 2, right: lineHeight / 2}; } @@ -358,7 +354,6 @@ function edgeLabelsFit({ rotation, firstTickLeftSpace, lastTickRightSpace, - rightAligned, }: { firstLabelWidth: number; lastLabelWidth: number; @@ -366,10 +361,9 @@ function edgeLabelsFit({ rotation: LabelRotation; firstTickLeftSpace: number; lastTickRightSpace: number; - rightAligned: boolean; }): boolean { - const first = labelOverhang(firstLabelWidth, lineHeight, rotation, rightAligned); - const last = labelOverhang(lastLabelWidth, lineHeight, rotation, rightAligned); + const first = labelOverhang(firstLabelWidth, lineHeight, rotation); + const last = labelOverhang(lastLabelWidth, lineHeight, rotation); return first.left <= firstTickLeftSpace && last.right <= lastTickRightSpace; } @@ -377,16 +371,12 @@ function edgeLabelsFit({ * Maximum label width that fits within the available edge space at a given rotation. * Returns Infinity when the overhang at that edge doesn't depend on label width. */ -function edgeMaxLabelWidth(edgeSpace: number, lineHeight: number, rotation: LabelRotation, rightAligned: boolean, edge: 'first' | 'last'): number { - const halfLH = lineHeight / 2; +function edgeMaxLabelWidth(edgeSpace: number, lineHeight: number, rotation: LabelRotation, edge: 'first' | 'last'): number { if (rotation === LABEL_ROTATIONS.HORIZONTAL) { return 2 * edgeSpace; } if (rotation === LABEL_ROTATIONS.DIAGONAL) { - if (rightAligned) { - return edge === 'first' ? Math.max(0, edgeSpace / SIN_45 - halfLH) : Infinity; - } - return Math.max(0, 2 * (edgeSpace / SIN_45 - halfLH)); + return edge === 'first' ? Math.max(0, edgeSpace / SIN_45 - lineHeight / 2) : Infinity; } return Infinity; } diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index a5763fc11184..479e921e8d6b 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -5,6 +5,7 @@ import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {formatToParts} from '@libs/NumberFormatUtils'; import {buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils'; +import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import CHART_GROUP_BY_CONFIG from './chartGroupByConfig'; @@ -78,7 +79,7 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading}: SearchChar return ( StringUtils.normalize(getLabel(item))} getFilterQuery={getFilterQuery} onItemPress={handleItemPress} isLoading={isLoading} diff --git a/src/styles/index.ts b/src/styles/index.ts index 9a33de715ddb..7dc7fe8e80b8 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6009,10 +6009,10 @@ const staticStyles = (theme: ThemeColors) => borderRadius: variables.componentBorderRadiusLarge, }, chartContent: { - minHeight: CHART_CONTENT_MIN_HEIGHT, + height: CHART_CONTENT_MIN_HEIGHT, }, chartActivityIndicator: { - minHeight: CHART_CONTENT_MIN_HEIGHT, + height: CHART_CONTENT_MIN_HEIGHT, justifyContent: 'center', alignItems: 'center', }, diff --git a/tests/unit/components/Charts/useChartLabelLayout.test.ts b/tests/unit/components/Charts/useChartLabelLayout.test.ts index 8dfe8443015e..801bac133e36 100644 --- a/tests/unit/components/Charts/useChartLabelLayout.test.ts +++ b/tests/unit/components/Charts/useChartLabelLayout.test.ts @@ -136,19 +136,22 @@ describe('useChartLabelLayout', () => { describe('edge-constrained rotation', () => { it('same data picks 0° without edge constraint but 45° with edge constraint', () => { - // "A".repeat(16) = 112px. At 0°: overhang = 56px. - const config = {data: makeData('A'.repeat(16), 'BB', 'CC'), fontMgr: mockFontMgr, fontSize: FONT_SIZE, tickSpacing: 120, labelAreaWidth: 360}; + // "A".repeat(22) = 154px. firstMinTrunc = (10+3)*7 = 91px. + // At 0°: centered overhang = 77px. firstTickLeftSpace=72 < 77 → 0° edge fails. + // At 45° right-aligned: edgeMax = 72/SIN_45−8 ≈ 93.8 ≥ 91 → 45° edge fits. + const config = {data: makeData('A'.repeat(22), 'BB', 'CC'), fontMgr: mockFontMgr, fontSize: FONT_SIZE, tickSpacing: 160, labelAreaWidth: 480}; const {result: noEdge} = renderLayout(config); expect(noEdge.current.labelRotation).toBe(0); - // firstTickLeftSpace=40 < 56 → 0° edge fails → escalates to 45° - const {result: withEdge} = renderLayout({...config, firstTickLeftSpace: 40, lastTickRightSpace: 200}); + // firstTickLeftSpace=72 < 77 → 0° edge fails → escalates to 45° + const {result: withEdge} = renderLayout({...config, firstTickLeftSpace: 72, lastTickRightSpace: 200}); expect(withEdge.current.labelRotation).toBe(45); }); it('escalates to 90° when edge space is too small for both 0° and 45°', () => { - // firstTickLeftSpace=5: at 45° centered edgeMax = max(0, 2*(5/SIN_45-8)) ≈ 0 → fails + // "AAAAAA" = 42px (6 chars <= MIN_TRUNCATED_CHARS=10), so firstMinTrunc = 42. + // firstTickLeftSpace=5: at 45° right-aligned edgeMax = max(0, 5/SIN_45−8) = 0 < 42 → fails const {result} = renderLayout({ data: makeData('AAAAAA', 'BBBBBB'), fontMgr: mockFontMgr, @@ -160,50 +163,9 @@ describe('useChartLabelLayout', () => { }); expect(result.current.labelRotation).toBe(90); }); - - it('allowTightDiagonalPacking enables 45° at tighter tick spacing', () => { - // "AAAAAA" = 42px. tickSpacing=30. - // Without packing: minDiagWidth = 42*SIN_45 ≈ 29.7, 29.7+4=33.7 > 30 → 45° fails - // With packing: diagonalOverlap = 16*SIN_45 ≈ 11.3, minDiagWidth = 29.7-11.3=18.4, 18.4+4=22.4 ≤ 30 ✓ - const base = { - data: makeData('AAAAAA', 'BBBBBB'), - fontMgr: mockFontMgr, - fontSize: FONT_SIZE, - tickSpacing: 30, - labelAreaWidth: 400, - firstTickLeftSpace: 100, - lastTickRightSpace: 100, - }; - - const {result: noPacking} = renderLayout({...base, allowTightDiagonalPacking: false}); - expect(noPacking.current.labelRotation).toBe(90); - - const {result: withPacking} = renderLayout({...base, allowTightDiagonalPacking: true}); - expect(withPacking.current.labelRotation).toBe(45); - }); }); describe('edge-aware max-width constraints', () => { - it('constrains first label below full width when centered and edge is tight', () => { - // First label: 16 chars = 112px. tickMaxWidth ≈ 164. edgeMax ≈ 97 (stricter). - // labelMaxWidths[0] should be < 112; middle/last labels unconstrained. - const {result} = renderLayout({ - data: makeData('A'.repeat(16), 'BB', 'CC'), - fontMgr: mockFontMgr, - fontSize: FONT_SIZE, - tickSpacing: 120, - labelAreaWidth: 360, - firstTickLeftSpace: 40, - lastTickRightSpace: 200, - }); - expect(result.current.labelRotation).toBe(45); - // Edge constraint tightens first label below its natural width - expect(result.current.labelMaxWidths.at(0)).toBeLessThan(16 * PX_PER_CHAR); - // Middle and last labels are only tick-constrained (much wider than 'BB'/'CC') - expect(result.current.labelMaxWidths.at(1)).toBeGreaterThanOrEqual(2 * PX_PER_CHAR); - expect(result.current.labelMaxWidths.at(2)).toBeGreaterThanOrEqual(2 * PX_PER_CHAR); - }); - it('constrains first label below full width when right-aligned and edge is tight', () => { // Right-aligned first label: edgeMax = 72/SIN_45 - 8 ≈ 93.8 < 112 → constrained. const {result} = renderLayout({ @@ -214,28 +176,12 @@ describe('useChartLabelLayout', () => { labelAreaWidth: 360, firstTickLeftSpace: 72, lastTickRightSpace: 200, - allowTightDiagonalPacking: true, }); expect(result.current.labelRotation).toBe(45); expect(result.current.labelMaxWidths.at(0)).toBeLessThan(16 * PX_PER_CHAR); expect(result.current.labelMaxWidths.at(1)).toBeGreaterThanOrEqual(2 * PX_PER_CHAR); }); - it('constrains last label below full width when centered and right edge is tight', () => { - // lastTickRightSpace=40: edgeMax = 2*(40/SIN_45-8) ≈ 97.1 < 112 → constrained. - const {result} = renderLayout({ - data: makeData('AA', 'BB', 'A'.repeat(16)), - fontMgr: mockFontMgr, - fontSize: FONT_SIZE, - tickSpacing: 200, - labelAreaWidth: 600, - firstTickLeftSpace: 200, - lastTickRightSpace: 40, - }); - expect(result.current.labelRotation).toBe(45); - expect(result.current.labelMaxWidths.at(2)).toBeLessThan(16 * PX_PER_CHAR); - }); - it('does NOT constrain last label when right-aligned despite tight right edge', () => { // Right-aligned: last label right overhang = halfLH*SIN_45 ≈ 5.6 (constant, tiny). // lastTickRightSpace=40 >> 5.6 → edgeMax = Infinity → no edge constraint. @@ -248,7 +194,6 @@ describe('useChartLabelLayout', () => { labelAreaWidth: 600, firstTickLeftSpace: 200, lastTickRightSpace: 40, - allowTightDiagonalPacking: true, }); expect(result.current.labelRotation).toBe(45); expect(result.current.labelMaxWidths.at(2)).toBeGreaterThanOrEqual(16 * PX_PER_CHAR); diff --git a/tests/unit/components/Charts/utils.test.ts b/tests/unit/components/Charts/utils.test.ts index ad36d30f80ae..8e89950a5f3d 100644 --- a/tests/unit/components/Charts/utils.test.ts +++ b/tests/unit/components/Charts/utils.test.ts @@ -98,23 +98,18 @@ describe('maxVisibleCount', () => { describe('labelOverhang', () => { it('returns symmetric halves at 0° (horizontal)', () => { - const result = labelOverhang(100, LINE_HEIGHT, LABEL_ROTATIONS.HORIZONTAL, false); + const result = labelOverhang(100, LINE_HEIGHT, LABEL_ROTATIONS.HORIZONTAL); expect(result.left).toBe(50); expect(result.right).toBe(50); }); - it('returns symmetric overhang at 45° when centered', () => { - const result = labelOverhang(100, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL, false); - expect(result.left).toBeCloseTo(result.right); - }); - - it('returns asymmetric overhang at 45° when right-aligned', () => { - const result = labelOverhang(100, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL, true); + it('returns asymmetric overhang at 45°', () => { + const result = labelOverhang(100, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL); expect(result.left).toBeGreaterThan(result.right); }); it('returns lineHeight/2 on both sides at 90°', () => { - const result = labelOverhang(100, LINE_HEIGHT, LABEL_ROTATIONS.VERTICAL, false); + const result = labelOverhang(100, LINE_HEIGHT, LABEL_ROTATIONS.VERTICAL); expect(result.left).toBe(LINE_HEIGHT / 2); expect(result.right).toBe(LINE_HEIGHT / 2); }); @@ -128,7 +123,6 @@ describe('edgeLabelsFit', () => { rotation: LABEL_ROTATIONS.HORIZONTAL, firstTickLeftSpace: 30, lastTickRightSpace: 30, - rightAligned: false, }; it('returns true when both edges have enough space', () => { @@ -146,32 +140,26 @@ describe('edgeLabelsFit', () => { describe('edgeMaxLabelWidth', () => { it('returns 2 * edgeSpace at 0°', () => { - expect(edgeMaxLabelWidth(50, LINE_HEIGHT, LABEL_ROTATIONS.HORIZONTAL, false, 'first')).toBe(100); + expect(edgeMaxLabelWidth(50, LINE_HEIGHT, LABEL_ROTATIONS.HORIZONTAL, 'first')).toBe(100); }); it('returns Infinity at 90° (overhang is constant)', () => { - expect(edgeMaxLabelWidth(50, LINE_HEIGHT, LABEL_ROTATIONS.VERTICAL, false, 'first')).toBe(Infinity); + expect(edgeMaxLabelWidth(50, LINE_HEIGHT, LABEL_ROTATIONS.VERTICAL, 'first')).toBe(Infinity); }); - it('returns finite value at 45° for first label when centered', () => { - const result = edgeMaxLabelWidth(50, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL, false, 'first'); + it('returns finite value at 45° for first label', () => { + const result = edgeMaxLabelWidth(50, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL, 'first'); expect(result).toBeGreaterThan(0); expect(result).not.toBe(Infinity); }); - it('returns Infinity at 45° for last label when right-aligned', () => { - expect(edgeMaxLabelWidth(50, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL, true, 'last')).toBe(Infinity); - }); - - it('returns finite value at 45° for first label when right-aligned', () => { - const result = edgeMaxLabelWidth(50, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL, true, 'first'); - expect(result).toBeGreaterThan(0); - expect(result).not.toBe(Infinity); + it('returns Infinity at 45° for last label (right-aligned labels never overhang right)', () => { + expect(edgeMaxLabelWidth(50, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL, 'last')).toBe(Infinity); }); - it('returns 0 when edgeSpace is too small at 45° centered', () => { + it('returns 0 when edgeSpace is too small at 45°', () => { // edgeSpace/SIN_45 - halfLH ≤ 0 → Math.max(0, ...) = 0 - expect(edgeMaxLabelWidth(1, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL, false, 'first')).toBe(0); + expect(edgeMaxLabelWidth(1, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL, 'first')).toBe(0); }); });