Skip to content

Commit 265bf95

Browse files
committed
feat: centralize responsive layout metrics and fix mobile chart alignment
Fixes axis title clipping, bar label overlap, and right-axis margin stacking on narrow viewports. Responsive pixel constants (axis title offsets, tick label gaps, horizontal padding fractions) are now in a single module (packages/core/src/responsive/metrics.ts) so the engine and renderer can never drift apart on these values. - Add TICK_LABEL_OFFSET, AXIS_TITLE_OFFSET_*, AXIS_TITLE_TRAILING_PAD, NARROW_VIEWPORT_MAX, HPAD_COMPACT_*, LABEL_GAP_*, MAX_LEFT_LABEL_FRACTION_* constants to core/responsive - Replace all inline width < 400/500/600 checks and raw px values with named imports in dimensions.ts, compile.ts, and axes.ts - Fix Math.max (not +=) for right-axis margin reserve to avoid stacking - Fix guardrail fallback path to use topPad so iOS Safari clearance is preserved even when chrome is stripped - Thin band-scale x-axis labels at reduced/minimal density (mobile) to prevent overlapping tick labels on narrow containers - Use d3Format for accurate y-axis label width estimation when a format string is specified
1 parent 55e1fae commit 265bf95

7 files changed

Lines changed: 264 additions & 37 deletions

File tree

packages/core/src/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,30 @@ export type {
7777
LegendPosition,
7878
} from './responsive/index';
7979
export {
80+
AXIS_TITLE_OFFSET_COMPACT,
81+
AXIS_TITLE_OFFSET_DEFAULT,
82+
AXIS_TITLE_TRAILING_PAD,
83+
BREAKPOINT_COMPACT_MAX,
84+
BREAKPOINT_MEDIUM_MAX,
85+
getAxisTitleOffset,
8086
getBreakpoint,
8187
getHeightClass,
8288
getLayoutStrategy,
89+
HEIGHT_CRAMPED_MAX,
90+
HEIGHT_SHORT_MAX,
91+
HPAD_COMPACT_FRACTION,
92+
HPAD_COMPACT_MIN,
93+
LABEL_GAP_COMPACT,
94+
LABEL_GAP_DEFAULT,
95+
LABEL_GAP_NARROW_MAX,
96+
MAX_LEFT_LABEL_FRACTION_COMPACT,
97+
MAX_LEFT_LABEL_FRACTION_DEFAULT,
98+
MAX_LEFT_LABEL_FRACTION_MEDIUM,
99+
MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
100+
NARROW_VIEWPORT_MAX,
101+
TICK_LABEL_OFFSET,
102+
TOP_PAD_EXTRA_NARROW,
103+
TOP_PAD_NARROW_MAX,
83104
} from './responsive/index';
84105

85106
// ---------------------------------------------------------------------------

packages/core/src/responsive/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,22 @@ export {
2121
HEIGHT_CRAMPED_MAX,
2222
HEIGHT_SHORT_MAX,
2323
} from './breakpoints';
24+
export {
25+
AXIS_TITLE_OFFSET_COMPACT,
26+
AXIS_TITLE_OFFSET_DEFAULT,
27+
AXIS_TITLE_TRAILING_PAD,
28+
getAxisTitleOffset,
29+
HPAD_COMPACT_FRACTION,
30+
HPAD_COMPACT_MIN,
31+
LABEL_GAP_COMPACT,
32+
LABEL_GAP_DEFAULT,
33+
LABEL_GAP_NARROW_MAX,
34+
MAX_LEFT_LABEL_FRACTION_COMPACT,
35+
MAX_LEFT_LABEL_FRACTION_DEFAULT,
36+
MAX_LEFT_LABEL_FRACTION_MEDIUM,
37+
MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
38+
NARROW_VIEWPORT_MAX,
39+
TICK_LABEL_OFFSET,
40+
TOP_PAD_EXTRA_NARROW,
41+
TOP_PAD_NARROW_MAX,
42+
} from './metrics';
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Pixel-level responsive metrics for chart layout.
3+
*
4+
* These are the numeric tuning values that correspond to the semantic decisions
5+
* in LayoutStrategy. Centralizing them here ensures the engine and renderer
6+
* always agree on the same thresholds and offsets without duplicating raw numbers.
7+
*
8+
* LayoutStrategy is semantic (what to show), this module is metric (how much space).
9+
*/
10+
11+
import { BREAKPOINT_COMPACT_MAX } from './breakpoints';
12+
13+
// ---------------------------------------------------------------------------
14+
// Y-axis title offsets
15+
// ---------------------------------------------------------------------------
16+
17+
/** Distance from the chart edge to the rotated y-axis title center (compact viewports). */
18+
export const AXIS_TITLE_OFFSET_COMPACT = 35;
19+
20+
/** Distance from the chart edge to the rotated y-axis title center (standard viewports). */
21+
export const AXIS_TITLE_OFFSET_DEFAULT = 45;
22+
23+
/** Returns the y-axis title offset appropriate for the given container width. */
24+
export function getAxisTitleOffset(width: number): number {
25+
return width < BREAKPOINT_COMPACT_MAX ? AXIS_TITLE_OFFSET_COMPACT : AXIS_TITLE_OFFSET_DEFAULT;
26+
}
27+
28+
// ---------------------------------------------------------------------------
29+
// Horizontal padding
30+
// ---------------------------------------------------------------------------
31+
32+
/**
33+
* On compact viewports axis titles and tick labels tolerate closer container edges
34+
* than chrome text (title, subtitle). Reduce horizontal padding to reclaim space.
35+
*/
36+
export const HPAD_COMPACT_FRACTION = 0.5;
37+
38+
/** Minimum horizontal padding regardless of scaling. */
39+
export const HPAD_COMPACT_MIN = 4;
40+
41+
// ---------------------------------------------------------------------------
42+
// Tick label offset
43+
// ---------------------------------------------------------------------------
44+
45+
/**
46+
* Horizontal gap between the chart edge and the near edge of y-axis tick labels.
47+
* The engine uses this to reserve right-axis margin; the renderer uses it to position labels.
48+
* Both must agree on this value — do not change one without the other.
49+
*/
50+
export const TICK_LABEL_OFFSET = 6;
51+
52+
// ---------------------------------------------------------------------------
53+
// Axis title trailing padding
54+
// ---------------------------------------------------------------------------
55+
56+
/**
57+
* Extra padding beyond half-glyph height added to the rotated axis title margin on
58+
* standard (non-compact) viewports. Omitted on compact viewports to save space.
59+
*/
60+
export const AXIS_TITLE_TRAILING_PAD = 4;
61+
62+
// ---------------------------------------------------------------------------
63+
// Narrow viewport threshold (between compact and medium)
64+
// ---------------------------------------------------------------------------
65+
66+
/**
67+
* Width below which "narrow" adjustments apply: extra iOS Safari top padding and
68+
* tighter category label gaps. Sits between compact (< 400) and medium (400–700).
69+
* Not a semantic breakpoint — a layout heuristic for narrow-but-not-compact containers.
70+
*/
71+
export const NARROW_VIEWPORT_MAX = 500;
72+
73+
// ---------------------------------------------------------------------------
74+
// Top padding (iOS Safari address bar / notch clearance)
75+
// ---------------------------------------------------------------------------
76+
77+
/** Extra top padding added below NARROW_VIEWPORT_MAX to clear iOS Safari browser chrome. */
78+
export const TOP_PAD_EXTRA_NARROW = 10;
79+
80+
/** @deprecated Use NARROW_VIEWPORT_MAX instead. */
81+
export const TOP_PAD_NARROW_MAX = NARROW_VIEWPORT_MAX;
82+
83+
// ---------------------------------------------------------------------------
84+
// Category label gaps (left-axis bar/dot charts)
85+
// ---------------------------------------------------------------------------
86+
87+
/** Gap between category label text and chart edge on narrow viewports (< NARROW_VIEWPORT_MAX). */
88+
export const LABEL_GAP_COMPACT = 8;
89+
90+
/** Gap between category label text and chart edge on standard viewports. */
91+
export const LABEL_GAP_DEFAULT = 12;
92+
93+
/** @deprecated Use NARROW_VIEWPORT_MAX instead. */
94+
export const LABEL_GAP_NARROW_MAX = NARROW_VIEWPORT_MAX;
95+
96+
// ---------------------------------------------------------------------------
97+
// Max left-axis label fraction
98+
// ---------------------------------------------------------------------------
99+
100+
/**
101+
* Width threshold for the medium left-label fraction cap.
102+
* Sits between medium (400-700) and full (> 700) to prevent wide category labels
103+
* from consuming too much horizontal space at mid-range widths.
104+
*/
105+
export const MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX = 600;
106+
107+
/** Max fraction of container width reservable for left category labels (compact). */
108+
export const MAX_LEFT_LABEL_FRACTION_COMPACT = 0.45;
109+
110+
/** Max fraction of container width reservable for left category labels (mid-range). */
111+
export const MAX_LEFT_LABEL_FRACTION_MEDIUM = 0.55;
112+
113+
/** Max fraction of container width reservable for left category labels (standard). */
114+
export const MAX_LEFT_LABEL_FRACTION_DEFAULT = 1;

packages/engine/src/compile.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,21 @@ import type {
3333
Transform,
3434
} from '@opendata-ai/openchart-core';
3535
import {
36+
AXIS_TITLE_TRAILING_PAD,
3637
adaptTheme,
38+
BREAKPOINT_COMPACT_MAX,
3739
computeLabelBounds,
3840
estimateTextWidth,
3941
generateAltText,
4042
generateDataTable,
43+
getAxisTitleOffset,
4144
getBreakpoint,
4245
getHeightClass,
4346
getLayoutStrategy,
4447
resolveTheme,
48+
TICK_LABEL_OFFSET,
4549
} from '@opendata-ai/openchart-core';
50+
import { format as d3Format } from 'd3-format';
4651
import { scaleLinear } from 'd3-scale';
4752
import { curveMonotoneX, area as d3area, line as d3line } from 'd3-shape';
4853
import { computeAnnotations } from './annotations/compute';
@@ -548,18 +553,30 @@ function estimateYAxisLabelWidth(
548553
}
549554

550555
// Quantitative/temporal: estimate from the largest value
556+
const yAxisFormat = (encoding.y.axis as Record<string, unknown> | undefined)?.format as
557+
| string
558+
| undefined;
551559
let maxAbsVal = 0;
552560
for (const row of data) {
553561
const v = Number(row[yField]);
554562
if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
555563
}
556564
let sampleLabel: string;
557-
if (maxAbsVal >= 1_000_000_000) sampleLabel = '1.5B';
558-
else if (maxAbsVal >= 1_000_000) sampleLabel = '1.5M';
559-
else if (maxAbsVal >= 1_000) sampleLabel = '1.5K';
560-
else if (maxAbsVal >= 100) sampleLabel = '100';
561-
else if (maxAbsVal >= 10) sampleLabel = '10';
562-
else sampleLabel = '0.0';
565+
if (yAxisFormat) {
566+
try {
567+
const fmt = d3Format(yAxisFormat);
568+
sampleLabel = fmt(maxAbsVal);
569+
} catch {
570+
sampleLabel = String(maxAbsVal);
571+
}
572+
} else {
573+
if (maxAbsVal >= 1_000_000_000) sampleLabel = '1.5B';
574+
else if (maxAbsVal >= 1_000_000) sampleLabel = '1.5M';
575+
else if (maxAbsVal >= 1_000) sampleLabel = '1.5K';
576+
else if (maxAbsVal >= 100) sampleLabel = '100';
577+
else if (maxAbsVal >= 10) sampleLabel = '10';
578+
else sampleLabel = '0.0';
579+
}
563580
const hasNeg = data.some((r) => Number(r[yField]) < 0);
564581
const labelEst = (hasNeg ? '-' : '') + sampleLabel;
565582
return estimateTextWidth(labelEst, baseFontSize, 400) + 10;
@@ -596,13 +613,24 @@ function compileLayerIndependent(
596613
);
597614
}
598615

599-
// Estimate right-axis label width to reserve margin space
616+
// Estimate right-axis label width to reserve margin space.
617+
// Tick labels sit at chartEdge+6 and extend rightward by their width.
618+
// The rotated title sits at chartEdge+45 and extends by half the font height.
619+
// These overlap spatially, so use max (not sum) to mirror the left-margin pattern.
600620
const theme = resolveTheme(layerSpec.theme ?? leaf1.theme);
601621
const axisFontSize = theme.fonts?.sizes?.axisTick ?? 11;
602622
const rightAxisWidth = estimateYAxisLabelWidth(leaf1.data, leaf1.encoding, axisFontSize);
603-
// Add space for the rotated axis title if present (match left-axis 45px clearance)
604623
const hasRightAxisTitle = !!leaf1.encoding?.y?.axis?.title;
605-
const rightReserve = rightAxisWidth + (hasRightAxisTitle ? 45 : 0);
624+
const tickExtent = TICK_LABEL_OFFSET + rightAxisWidth;
625+
const bodyFontSize = theme.fonts?.sizes?.body ?? 13;
626+
const axisTitleOffset = getAxisTitleOffset(options.width);
627+
const halfGlyph = Math.ceil(bodyFontSize / 2);
628+
const titleExtent = hasRightAxisTitle
629+
? axisTitleOffset +
630+
halfGlyph +
631+
(options.width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD)
632+
: 0;
633+
const rightReserve = Math.max(tickExtent, titleExtent);
606634

607635
const optionsWithReserve: CompileOptions = {
608636
...options,
@@ -960,6 +988,7 @@ function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): ChartSpec
960988
data: allData,
961989
// Layer-level chrome overrides leaf chrome
962990
chrome: layerSpec.chrome ?? leaves[0].chrome,
991+
annotations: layerSpec.annotations ?? leaves[0].annotations,
963992
labels: layerSpec.labels ?? leaves[0].labels,
964993
legend: layerSpec.legend ?? leaves[0].legend,
965994
responsive: layerSpec.responsive ?? leaves[0].responsive,

packages/engine/src/layout/axes/ticks.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,10 +211,11 @@ export function categoricalTicks(
211211
const explicitTickCount = resolvedScale.channel.axis?.tickCount;
212212
const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
213213

214-
// Band scales (bar charts) show all category labels by default.
215-
// Only thin when there's an explicit tickCount override or for point/ordinal scales.
214+
// Band scales show all labels at full density but thin at reduced/minimal
215+
// to prevent overlap on narrow containers (e.g. 17 bars on mobile).
216216
let selectedValues = domain;
217-
if ((resolvedScale.type !== 'band' || explicitTickCount) && domain.length > maxTicks) {
217+
const shouldThinBand = resolvedScale.type === 'band' && (explicitTickCount || density !== 'full');
218+
if ((resolvedScale.type !== 'band' || shouldThinBand) && domain.length > maxTicks) {
218219
const step = Math.ceil(domain.length / maxTicks);
219220
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
220221
}

0 commit comments

Comments
 (0)