Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 8 additions & 30 deletions src/components/Charts/BarChart/BarChartContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 (
<>
<ChartXAxisLabels
Expand All @@ -231,8 +210,7 @@ function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left'
fontMgr={fontMgr}
labelColor={theme.textSupporting}
xScale={args.xScale}
chartBoundsBottom={args.chartBounds.bottom}
centerRotatedLabels
chartBoundsBottom={chartBoundsBottom}
/>
<ChartYAxisLabels
yTicks={args.yTicks}
Expand All @@ -258,7 +236,7 @@ function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left'
fontMgr,
variables.iconSizeExtraSmall,
);
const chartPadding = {...CHART_PADDING, bottom: labelSpace + CHART_PADDING.bottom + variables.iconSizeExtraSmall, left: yAxisLabelWidth + GLYPH_PADDING};
const chartPadding = {...CHART_PADDING, bottom: labelSpace + CHART_PADDING.bottom, left: yAxisLabelWidth + GLYPH_PADDING};

if (isLoading || !fontMgr) {
const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'BarChartContent', isLoading, isFontLoading: !fontMgr};
Expand Down
33 changes: 6 additions & 27 deletions src/components/Charts/LineChart/LineChartContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import ChartYAxisLabels from '@components/Charts/components/ChartYAxisLabels';
import LeftFrameLine from '@components/Charts/components/LeftFrameLine';
import ScatterPoints from '@components/Charts/components/ScatterPoints';
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,
Expand All @@ -24,7 +24,7 @@ import {
useYAxisLabelWidth,
} from '@components/Charts/hooks';
import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types';
import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getAdditionalOffset, rotatedLabelYOffset} from '@components/Charts/utils';
import {calculateMinDomainPadding, DEFAULT_CHART_COLOR} from '@components/Charts/utils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
Expand All @@ -42,26 +42,6 @@ const MIN_SAFE_PADDING = DOT_RADIUS + DOT_HOVER_EXTRA_RADIUS;
/** Base domain padding applied to all sides */
const BASE_DOMAIN_PADDING = {top: 16, bottom: 16, left: 0, right: 0};

/**
* Line chart geometry for label hit-testing.
* Labels are start-anchored 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.
*/
const computeLineLabelGeometry: ComputeGeometryFn = ({ascent, descent, sinA, angleRad, labelWidths, padding}) => {
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;
Expand Down Expand Up @@ -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,
});

Expand All @@ -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) => {
Expand Down Expand Up @@ -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 (
<>
<LeftFrameLine
Expand Down Expand Up @@ -224,7 +203,7 @@ function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left
fontMgr={fontMgr}
labelColor={theme.textSupporting}
xScale={args.xScale}
chartBoundsBottom={args.chartBounds.bottom}
chartBoundsBottom={chartBoundsBottom}
/>
)}
{!!fontMgr && (
Expand Down Expand Up @@ -253,7 +232,7 @@ function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left
fontMgr,
variables.iconSizeExtraSmall,
);
const chartPadding = {...CHART_PADDING, bottom: labelSpace + CHART_PADDING.bottom + variables.iconSizeExtraSmall, left: yAxisLabelWidth + GLYPH_PADDING};
const chartPadding = {...CHART_PADDING, bottom: labelSpace + CHART_PADDING.bottom, left: yAxisLabelWidth + GLYPH_PADDING};

if (isLoading || !fontMgr) {
const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'LineChartContent', isLoading, isFontLoading: !fontMgr};
Expand Down
14 changes: 3 additions & 11 deletions src/components/Charts/components/ChartXAxisLabels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {AXIS_LABEL_GAP, GLYPH_PADDING, MAX_X_AXIS_LABEL_WIDTH} from '@components
import useChartParagraphs from '@components/Charts/hooks/useChartParagraphs';
import type {LabelRotation} from '@components/Charts/types';
import {getFontLineMetrics, rotatedLabelCenterCorrection, rotatedLabelYOffset, truncateLabel} from '@components/Charts/utils';
import variables from '@styles/variables';

type ChartXAxisLabelsProps = {
/** Original (non-truncated) label strings from the data. */
Expand Down Expand Up @@ -46,9 +45,6 @@ type ChartXAxisLabelsProps = {

/** Y-pixel coordinate of the bottom edge of the chart plot area. */
chartBoundsBottom: number;

/** When true, rotated labels are centered on the tick. When false, they are right-aligned (end of text at tick). */
centerRotatedLabels?: boolean;
};

function ChartXAxisLabels({
Expand All @@ -65,7 +61,6 @@ function ChartXAxisLabels({
labelColor,
xScale,
chartBoundsBottom,
centerRotatedLabels = false,
}: ChartXAxisLabelsProps) {
const angleRad = (Math.abs(labelRotation) * Math.PI) / 180;
const truncatedLabels = (() => {
Expand All @@ -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) {
Expand All @@ -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 (
Expand Down
1 change: 0 additions & 1 deletion src/components/Charts/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
35 changes: 10 additions & 25 deletions src/components/Charts/hooks/useChartLabelLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useChartLabelMeasurements>;
};
Expand All @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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 {
Expand Down
Loading
Loading