Skip to content

Commit 74d4fc8

Browse files
jamesxu-lightsparkclaude
authored andcommitted
fix(origin): thin x-axis labels by measured width (#28120)
## Summary - Use Origin's existing measured label width helper to choose x-axis label density - Omit edge x-axis labels after thinning to avoid boundary crowding - Preserve existing Origin chart styling and public APIs ## Checks - yarn workspace @lightsparkdev/origin types - yarn workspace @lightsparkdev/origin format - git diff --check -- js/packages/origin/src/components/Chart --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> GitOrigin-RevId: 76b97cdca1ffd1d681593c61dc43aaaef530a61a
1 parent 2b82edb commit 74d4fc8

9 files changed

Lines changed: 295 additions & 65 deletions

File tree

packages/origin/src/components/Chart/BarChart.tsx

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
niceTicks,
88
thinIndices,
99
dynamicTickTarget,
10+
xAxisTickTarget,
11+
applyEdgeLabels,
1012
measureLabelWidth,
1113
axisPadForLabels,
1214
formatChartDatumValue,
@@ -30,6 +32,7 @@ import {
3032
resolveSeries,
3133
resolveTooltipMode,
3234
axisTickTarget,
35+
type XAxisLabelProps,
3336
} from "./types";
3437
import { ChartWrapper } from "./ChartWrapper";
3538
import { useTrackedCallback } from "../Analytics/useTrackedCallback";
@@ -39,7 +42,9 @@ const EMPTY_TICKS = { min: 0, max: 1, ticks: [0, 1] } as const;
3942

4043
const clickIndexMeta = (index: number) => ({ index });
4144

42-
export interface BarChartProps extends React.ComponentPropsWithoutRef<"div"> {
45+
export interface BarChartProps
46+
extends React.ComponentPropsWithoutRef<"div">,
47+
XAxisLabelProps {
4348
data: ChartDatum[];
4449
/**
4550
* Pre-measurement width in pixels. Used as a fallback before
@@ -65,6 +70,22 @@ export interface BarChartProps extends React.ComponentPropsWithoutRef<"div"> {
6570
formatValue?: (value: number) => string;
6671
formatXLabel?: (value: ChartDatumValue) => string;
6772
formatYLabel?: (value: number) => string;
73+
/**
74+
* How vertical-bar x-axis (category) labels are thinned to avoid overlap.
75+
* Has no effect on horizontal bar charts.
76+
* - `"fixed"` (default): roughly one label per 60px, regardless of width.
77+
* - `"measured"`: spacing based on the measured pixel width of the labels,
78+
* so wide labels (dates, currency) get more room and short labels pack in.
79+
*/
80+
xAxisLabels?: "fixed" | "measured";
81+
/**
82+
* Whether the first and last x-axis (category) labels are shown on vertical
83+
* bar charts. Has no effect on horizontal bar charts.
84+
* - `"show"` (default): keep the edge labels.
85+
* - `"hide"`: drop the first and last labels (useful when they collide with
86+
* the y-axis or chart edges).
87+
*/
88+
xAxisEdgeLabels?: "show" | "hide";
6889
/** Fixed Y-axis domain. Overrides auto-computed domain from data. */
6990
yDomain?: [number, number];
7091
/** Show legend below chart. */
@@ -111,6 +132,8 @@ export const Bar = React.forwardRef<HTMLDivElement, BarChartProps>(function Bar(
111132
formatValue,
112133
formatXLabel,
113134
formatYLabel,
135+
xAxisLabels = "fixed",
136+
xAxisEdgeLabels = "show",
114137
yDomain,
115138
legend,
116139
loading,
@@ -234,6 +257,30 @@ export const Bar = React.forwardRef<HTMLDivElement, BarChartProps>(function Bar(
234257
const padRight = isHorizontal && showValueAxis ? 40 : PAD_RIGHT;
235258
const plotWidth = Math.max(0, width - padLeft - padRight);
236259

260+
const categoryAxisLabels = React.useMemo(() => {
261+
if (!xKey) return [];
262+
const labels = data.map((d) =>
263+
formatXLabel ? formatXLabel(d[xKey]) : formatChartDatumValue(d[xKey]),
264+
);
265+
const maxLabels = isHorizontal
266+
? Math.max(2, Math.floor(plotHeight / 24))
267+
: xAxisTickTarget(xAxisLabels, plotWidth, () => labels);
268+
const indices = thinIndices(data.length, maxLabels);
269+
const visibleIndices = isHorizontal
270+
? indices
271+
: applyEdgeLabels(xAxisEdgeLabels, indices);
272+
return visibleIndices.map((index) => ({ index, label: labels[index] }));
273+
}, [
274+
data,
275+
formatXLabel,
276+
isHorizontal,
277+
plotHeight,
278+
plotWidth,
279+
xKey,
280+
xAxisLabels,
281+
xAxisEdgeLabels,
282+
]);
283+
237284
const tickTarget = React.useMemo(() => {
238285
if (!isHorizontal) return verticalTickTarget;
239286
const fmt = formatYLabel ?? ((v: number) => String(v));
@@ -915,11 +962,7 @@ export const Bar = React.forwardRef<HTMLDivElement, BarChartProps>(function Bar(
915962
{/* Category axis labels (thinned to avoid overlap) */}
916963
{xKey &&
917964
(() => {
918-
const maxLabels = isHorizontal
919-
? Math.max(2, Math.floor(plotHeight / 24))
920-
: Math.max(2, Math.floor(plotWidth / 60));
921-
const indices = thinIndices(data.length, maxLabels);
922-
return indices.map((i) =>
965+
return categoryAxisLabels.map(({ index: i, label }) =>
923966
isHorizontal ? (
924967
<text
925968
key={i}
@@ -929,9 +972,7 @@ export const Bar = React.forwardRef<HTMLDivElement, BarChartProps>(function Bar(
929972
textAnchor="end"
930973
dominantBaseline="middle"
931974
>
932-
{formatXLabel
933-
? formatXLabel(data[i][xKey])
934-
: formatChartDatumValue(data[i][xKey])}
975+
{label}
935976
</text>
936977
) : (
937978
<text
@@ -942,9 +983,7 @@ export const Bar = React.forwardRef<HTMLDivElement, BarChartProps>(function Bar(
942983
textAnchor="middle"
943984
dominantBaseline="auto"
944985
>
945-
{formatXLabel
946-
? formatXLabel(data[i][xKey])
947-
: formatChartDatumValue(data[i][xKey])}
986+
{label}
948987
</text>
949988
),
950989
);

packages/origin/src/components/Chart/Chart.unit.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import {
2121
thinIndices,
2222
measureLabelWidth,
2323
dynamicTickTarget,
24+
xAxisTickTarget,
25+
omitEdgeLabels,
26+
applyEdgeLabels,
2427
axisPadForLabels,
2528
formatChartDatumValue,
2629
type Point,
@@ -736,6 +739,60 @@ describe("dynamicTickTarget", () => {
736739
});
737740
});
738741

742+
// ---------------------------------------------------------------------------
743+
// xAxisTickTarget
744+
// ---------------------------------------------------------------------------
745+
746+
describe("xAxisTickTarget", () => {
747+
it("uses fixed 60px spacing in 'fixed' mode, ignoring label width", () => {
748+
expect(xAxisTickTarget("fixed", 300, () => ["$1,234,567.00"])).toBe(5);
749+
expect(xAxisTickTarget("fixed", 600, () => ["0"])).toBe(10);
750+
});
751+
752+
it("does not evaluate the sample texts in 'fixed' mode", () => {
753+
let called = false;
754+
xAxisTickTarget("fixed", 400, () => {
755+
called = true;
756+
return ["whatever"];
757+
});
758+
expect(called).toBe(false);
759+
});
760+
761+
it("measures the sample texts in 'measured' mode", () => {
762+
const shortLabels = xAxisTickTarget("measured", 400, () => ["0", "100"]);
763+
const longLabels = xAxisTickTarget("measured", 400, () => [
764+
"$1,234,567.00",
765+
]);
766+
expect(shortLabels).toBeGreaterThan(longLabels);
767+
});
768+
});
769+
770+
// ---------------------------------------------------------------------------
771+
// omitEdgeLabels / applyEdgeLabels
772+
// ---------------------------------------------------------------------------
773+
774+
describe("omitEdgeLabels", () => {
775+
it("drops the first and last entry when there are more than two", () => {
776+
expect(omitEdgeLabels([0, 1, 2, 3])).toEqual([1, 2]);
777+
});
778+
779+
it("keeps the list unchanged at two or fewer entries", () => {
780+
expect(omitEdgeLabels([0, 1])).toEqual([0, 1]);
781+
expect(omitEdgeLabels([0])).toEqual([0]);
782+
expect(omitEdgeLabels([])).toEqual([]);
783+
});
784+
});
785+
786+
describe("applyEdgeLabels", () => {
787+
it("drops the edge entries in 'hide' mode", () => {
788+
expect(applyEdgeLabels("hide", [0, 1, 2, 3])).toEqual([1, 2]);
789+
});
790+
791+
it("returns the list unchanged in 'show' mode", () => {
792+
expect(applyEdgeLabels("show", [0, 1, 2, 3])).toEqual([0, 1, 2, 3]);
793+
});
794+
});
795+
739796
// ---------------------------------------------------------------------------
740797
// axisPadForLabels
741798
// ---------------------------------------------------------------------------

packages/origin/src/components/Chart/ComposedChart.tsx

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
monotoneInterpolator,
1313
linearInterpolator,
1414
thinIndices,
15+
xAxisTickTarget,
16+
applyEdgeLabels,
1517
axisPadForLabels,
1618
formatChartDatumValue,
1719
type Point,
@@ -36,6 +38,7 @@ import {
3638
BAR_ITEM_GAP,
3739
resolveTooltipMode,
3840
axisTickTarget,
41+
type XAxisLabelProps,
3942
} from "./types";
4043
import { ChartWrapper } from "./ChartWrapper";
4144
import styles from "./Chart.module.scss";
@@ -57,7 +60,8 @@ type ResolvedComposedSeries = {
5760
};
5861

5962
export interface ComposedChartProps
60-
extends React.ComponentPropsWithoutRef<"div"> {
63+
extends React.ComponentPropsWithoutRef<"div">,
64+
XAxisLabelProps {
6165
data: ChartDatum[];
6266
/**
6367
* Pre-measurement width in pixels. Used as a fallback before
@@ -131,6 +135,8 @@ export const Composed = React.forwardRef<HTMLDivElement, ComposedChartProps>(
131135
formatValue,
132136
formatXLabel,
133137
formatYLabel,
138+
xAxisLabels = "fixed",
139+
xAxisEdgeLabels = "show",
134140
formatYLabelRight,
135141
connectNulls = true,
136142
yDomain: yDomainProp,
@@ -373,6 +379,30 @@ export const Composed = React.forwardRef<HTMLDivElement, ComposedChartProps>(
373379
trackedClick(scrub.activeIndex, data[scrub.activeIndex]);
374380
}, [onClickDatum, trackedClick, scrub.activeIndex, data]);
375381

382+
// X axis labels (thinned to avoid overlap)
383+
const xLabels = React.useMemo(() => {
384+
if (!xKey) return [];
385+
const labels = data.map((d) =>
386+
formatXLabel ? formatXLabel(d[xKey]) : formatChartDatumValue(d[xKey]),
387+
);
388+
const maxLabels = xAxisTickTarget(xAxisLabels, plotWidth, () => labels);
389+
const indices = thinIndices(data.length, maxLabels);
390+
const visibleIndices = applyEdgeLabels(xAxisEdgeLabels, indices);
391+
return visibleIndices.map((i) => ({
392+
x: (i + 0.5) * slotWidth,
393+
text: labels[i],
394+
index: i,
395+
}));
396+
}, [
397+
xKey,
398+
data,
399+
formatXLabel,
400+
xAxisLabels,
401+
xAxisEdgeLabels,
402+
plotWidth,
403+
slotWidth,
404+
]);
405+
376406
const svgDesc = React.useMemo(() => {
377407
if (series.length === 0 || data.length === 0) return undefined;
378408
const names = series.map((s) => s.label).join(", ");
@@ -689,25 +719,18 @@ export const Composed = React.forwardRef<HTMLDivElement, ComposedChartProps>(
689719
))}
690720

691721
{/* X axis labels (thinned to avoid overlap) */}
692-
{xKey &&
693-
(() => {
694-
const maxLabels = Math.max(2, Math.floor(plotWidth / 60));
695-
const indices = thinIndices(data.length, maxLabels);
696-
return indices.map((i) => (
697-
<text
698-
key={i}
699-
x={(i + 0.5) * slotWidth}
700-
y={plotHeight + 20}
701-
className={styles.axisLabel}
702-
textAnchor="middle"
703-
dominantBaseline="auto"
704-
>
705-
{formatXLabel
706-
? formatXLabel(data[i][xKey])
707-
: formatChartDatumValue(data[i][xKey])}
708-
</text>
709-
));
710-
})()}
722+
{xLabels.map(({ x, text, index }) => (
723+
<text
724+
key={index}
725+
x={x}
726+
y={plotHeight + 20}
727+
className={styles.axisLabel}
728+
textAnchor="middle"
729+
dominantBaseline="auto"
730+
>
731+
{text}
732+
</text>
733+
))}
711734
</g>
712735
</svg>
713736

packages/origin/src/components/Chart/LineChart.tsx

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
monotoneInterpolator,
1313
linearInterpolator,
1414
thinIndices,
15+
xAxisTickTarget,
16+
applyEdgeLabels,
1517
axisPadForLabels,
1618
formatChartDatumValue,
1719
type Point,
@@ -34,6 +36,7 @@ import {
3436
resolveTooltipMode,
3537
resolveSeries,
3638
axisTickTarget,
39+
type XAxisLabelProps,
3740
} from "./types";
3841
import { ChartWrapper } from "./ChartWrapper";
3942
import styles from "./Chart.module.scss";
@@ -42,7 +45,9 @@ export type { Series, TooltipProp, ReferenceLine, ReferenceBand };
4245

4346
const clickIndexMeta = (index: number) => ({ index });
4447

45-
export interface LineChartProps extends React.ComponentPropsWithoutRef<"div"> {
48+
export interface LineChartProps
49+
extends React.ComponentPropsWithoutRef<"div">,
50+
XAxisLabelProps {
4651
/**
4752
* Array of data objects. Each object should contain keys matching `dataKey` or `series[].key`.
4853
*/
@@ -154,6 +159,8 @@ export const Line = React.forwardRef<HTMLDivElement, LineChartProps>(
154159
formatValue,
155160
formatXLabel,
156161
formatYLabel,
162+
xAxisLabels = "fixed",
163+
xAxisEdgeLabels = "show",
157164
connectNulls = true,
158165
initialWidth,
159166
className,
@@ -394,20 +401,22 @@ export const Line = React.forwardRef<HTMLDivElement, LineChartProps>(
394401
// X axis labels
395402
const xLabels = React.useMemo(() => {
396403
if (!xKey || data.length === 0 || plotWidth <= 0) return [];
397-
const maxLabels = Math.max(2, Math.floor(plotWidth / 60));
404+
const labels = data.map((d) => {
405+
const raw = d[xKey];
406+
return formatXLabel ? formatXLabel(raw) : formatChartDatumValue(raw);
407+
});
408+
const maxLabels = xAxisTickTarget(xAxisLabels, plotWidth, () => labels);
398409
const indices = thinIndices(data.length, maxLabels);
399-
return indices.map((i) => {
410+
const xLabels = indices.map((i) => {
400411
const x =
401412
data.length === 1
402413
? plotWidth / 2
403414
: (i / (data.length - 1)) * plotWidth;
404-
const raw = data[i][xKey];
405-
const text = formatXLabel
406-
? formatXLabel(raw)
407-
: formatChartDatumValue(raw);
415+
const text = labels[i];
408416
return { x, text, index: i };
409417
});
410-
}, [xKey, data, plotWidth, formatXLabel]);
418+
return applyEdgeLabels(xAxisEdgeLabels, xLabels);
419+
}, [xKey, data, plotWidth, formatXLabel, xAxisLabels, xAxisEdgeLabels]);
411420

412421
// Y axis labels
413422
const yLabels = React.useMemo(() => {
@@ -899,9 +908,9 @@ export const Line = React.forwardRef<HTMLDivElement, LineChartProps>(
899908
y={plotHeight + 20}
900909
className={styles.axisLabel}
901910
textAnchor={
902-
i === 0
911+
labelIndex === 0
903912
? "start"
904-
: i === xLabels.length - 1
913+
: labelIndex === data.length - 1
905914
? "end"
906915
: "middle"
907916
}

0 commit comments

Comments
 (0)