@opendata-ai/openchart-engine 7.9.3 → 7.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "7.9.3",
3
+ "version": "7.11.0",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "7.9.3",
51
+ "@opendata-ai/openchart-core": "7.11.0",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -1365,3 +1365,168 @@ describe('quantitative axis minimum tick floor', () => {
1365
1365
  }
1366
1366
  });
1367
1367
  });
1368
+
1369
+ // ---------------------------------------------------------------------------
1370
+ // Compact temporal tick formats: pinned pre-change behavior
1371
+ // ---------------------------------------------------------------------------
1372
+ // These two tests pin the exact tick labels produced BEFORE the compact
1373
+ // temporal format rung lands in fitContinuousTicks. They must stay green
1374
+ // through that change:
1375
+ // - wide desktop axes have room for full-format labels, so compact never fires
1376
+ // - temporal y ticks are never compact (planner/renderer gutter agreement;
1377
+ // see plans/compact-temporal-ticks.md)
1378
+
1379
+ describe('compact temporal ticks: pinned pre-change output', () => {
1380
+ const monthlyData = Array.from({ length: 12 }, (_, i) => ({
1381
+ date: `2025-${String(i + 1).padStart(2, '0')}-01`,
1382
+ value: 100 + i * 10,
1383
+ }));
1384
+
1385
+ const monthlySpec: NormalizedChartSpec = {
1386
+ ...lineSpec,
1387
+ data: monthlyData,
1388
+ };
1389
+
1390
+ it('desktop unchanged: monthly temporal x at ~900px keeps full-format labels', () => {
1391
+ const wideArea = { x: 50, y: 50, width: 900, height: 300 };
1392
+ const scales = computeScales(monthlySpec, wideArea, monthlySpec.data);
1393
+ const axes = computeAxes(scales, wideArea, fullStrategy, theme);
1394
+
1395
+ expect(axes.x!.ticks.map((t) => t.label)).toEqual(['2025', 'Apr 2025', 'Jul 2025', 'Oct 2025']);
1396
+ });
1397
+
1398
+ it('temporal y-axis unaffected: labels at ~360px stay full-format', () => {
1399
+ const ySpec: NormalizedChartSpec = {
1400
+ ...monthlySpec,
1401
+ markType: 'point',
1402
+ markDef: { type: 'point' },
1403
+ encoding: {
1404
+ x: { field: 'value', type: 'quantitative' },
1405
+ y: { field: 'date', type: 'temporal' },
1406
+ },
1407
+ };
1408
+ const narrowArea = { x: 40, y: 40, width: 360, height: 300 };
1409
+ const scales = computeScales(ySpec, narrowArea, ySpec.data);
1410
+ const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
1411
+
1412
+ expect(axes.y!.ticks.map((t) => t.label)).toEqual(['2025', 'Apr 2025', 'Jul 2025', 'Oct 2025']);
1413
+ });
1414
+
1415
+ it('temporal y-axis stays full-format even at starved heights (fallback path)', () => {
1416
+ // Regression guard for the fallback path: compact is x-axis-only (the
1417
+ // planner/renderer y pair must format identically), so even a height so
1418
+ // short that fitContinuousTicks falls through to thinning must keep the
1419
+ // year on every label — never a bare compact month like 'Oct'.
1420
+ const ySpec: NormalizedChartSpec = {
1421
+ ...monthlySpec,
1422
+ markType: 'point',
1423
+ markDef: { type: 'point' },
1424
+ encoding: {
1425
+ x: { field: 'value', type: 'quantitative' },
1426
+ y: { field: 'date', type: 'temporal' },
1427
+ },
1428
+ };
1429
+ const starvedArea = { x: 40, y: 40, width: 360, height: 55 };
1430
+ const scales = computeScales(ySpec, starvedArea, ySpec.data);
1431
+ const axes = computeAxes(scales, starvedArea, fullStrategy, theme);
1432
+
1433
+ const labels = axes.y!.ticks.map((t) => t.label);
1434
+ expect(labels.length).toBeGreaterThanOrEqual(2);
1435
+ for (const label of labels) {
1436
+ expect(label).toMatch(/\d{4}/);
1437
+ }
1438
+ });
1439
+ });
1440
+
1441
+ // ---------------------------------------------------------------------------
1442
+ // Compact temporal tick formats: the fitting rung
1443
+ // ---------------------------------------------------------------------------
1444
+ // When full-format labels overlap on a temporal x-axis, fitContinuousTicks
1445
+ // tries the compact format table at the SAME count before dropping ticks.
1446
+ // Pre-change counts below were measured by running these exact inputs against
1447
+ // the unmodified engine (git stash) — see plans/compact-temporal-ticks.md.
1448
+
1449
+ describe('compact temporal ticks: fitting rung', () => {
1450
+ const monthlySpecOver = (
1451
+ months: number,
1452
+ axis?: Record<string, unknown>,
1453
+ ): NormalizedChartSpec => ({
1454
+ ...lineSpec,
1455
+ data: Array.from({ length: months }, (_, i) => ({
1456
+ date: `2025-${String(i + 1).padStart(2, '0')}-01`,
1457
+ value: 100 + i * 10,
1458
+ })),
1459
+ encoding: {
1460
+ x: { field: 'date', type: 'temporal', ...(axis ? { axis } : {}) },
1461
+ y: { field: 'value', type: 'quantitative' },
1462
+ },
1463
+ });
1464
+
1465
+ const xLabelsAt = (spec: NormalizedChartSpec, width: number): string[] => {
1466
+ const area = { x: 40, y: 40, width, height: 300 };
1467
+ const scales = computeScales(spec, area, spec.data);
1468
+ const axes = computeAxes(scales, area, fullStrategy, theme);
1469
+ return axes.x!.ticks.map((t) => t.label);
1470
+ };
1471
+
1472
+ const COMPACT_MONTH = /^[A-Z][a-z]{2}$/; // 'Feb'
1473
+ const FULL_MONTH = /^[A-Z][a-z]{2} \d{4}$/; // 'Feb 2025'
1474
+ const YEAR_ONLY = /^\d{4}$/; // year-boundary tick, same in both tables
1475
+
1476
+ it('keeps more ticks than pre-change when full labels overlap (Jan-Dec 2025 at 160px)', () => {
1477
+ const labels = xLabelsAt(monthlySpecOver(12), 160);
1478
+
1479
+ // Pre-change output at this width: ['2025', 'Oct 2025'] — the overlap
1480
+ // forced a count step-down to 2. Compact keeps all 4 quarterly ticks.
1481
+ expect(labels).toEqual(['2025', 'Apr', 'Jul', 'Oct']);
1482
+ expect(labels.length).toBeGreaterThan(2);
1483
+
1484
+ // No label carries a year except year-boundary ticks.
1485
+ for (const label of labels) {
1486
+ if (/\d{4}/.test(label)) expect(label).toMatch(YEAR_ONLY);
1487
+ }
1488
+ });
1489
+
1490
+ it('renders bare compact months at monthly tick granularity (Jan-Apr 2025 at 160px)', () => {
1491
+ // End the domain mid-April: a domain ending exactly on Apr 1 includes the
1492
+ // Apr tick only when the runner's timezone is UTC (local time scales place
1493
+ // month boundaries at local midnight, so negative offsets push Apr 1 past
1494
+ // the domain end).
1495
+ const base = monthlySpecOver(4);
1496
+ const spec = { ...base, data: [...base.data, { date: '2025-04-15', value: 145 }] };
1497
+ const labels = xLabelsAt(spec, 160);
1498
+
1499
+ // Pre-change output at this width: ['2025', 'Mar 2025'] (2 ticks).
1500
+ expect(labels).toEqual(['2025', 'Feb', 'Mar', 'Apr']);
1501
+ expect(labels).toContain('Feb');
1502
+ });
1503
+
1504
+ it('refit stays internally consistent when D3 overshoots the requested count', () => {
1505
+ // Jan-May 2025: D3 returns 5 monthly ticks for a request of 3 (> the 1.5x
1506
+ // overshoot tolerance), forcing the refit loop. Whatever count/format the
1507
+ // loop lands on, the returned set must be ONE format family — never a mix
1508
+ // of 'Feb' and 'Mar 2025'.
1509
+ const spec = monthlySpecOver(5);
1510
+ for (const width of [100, 140, 180, 240]) {
1511
+ const labels = xLabelsAt(spec, width);
1512
+ const monthLabels = labels.filter((l) => !YEAR_ONLY.test(l));
1513
+ expect(monthLabels.length, `width ${width}`).toBeGreaterThan(0);
1514
+ const allCompact = monthLabels.every((l) => COMPACT_MONTH.test(l));
1515
+ const allFull = monthLabels.every((l) => FULL_MONTH.test(l));
1516
+ expect(
1517
+ allCompact || allFull,
1518
+ `mixed format families at width ${width}: ${JSON.stringify(labels)}`,
1519
+ ).toBe(true);
1520
+ }
1521
+ });
1522
+
1523
+ it('explicit axis.format wins over compact at narrow width', () => {
1524
+ const labels = xLabelsAt(monthlySpecOver(12, { format: '%Y-%m' }), 160);
1525
+
1526
+ expect(labels.length).toBeGreaterThan(0);
1527
+ for (const label of labels) {
1528
+ expect(label).toMatch(/^\d{4}-\d{2}$/);
1529
+ }
1530
+ expect(labels).toEqual(['2025-01', '2025-10']);
1531
+ });
1532
+ });
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
4
4
  import { compileChart } from '../compile';
5
5
  import type { NormalizedChartSpec } from '../compiler/types';
6
6
  import { computeDimensions } from '../layout/dimensions';
7
+ import { INLINE_TICK_OVERHANG_PAD } from '../layout/shared';
7
8
  import { legendGap } from '../legend/wrap';
8
9
 
9
10
  const baseSpec: NormalizedChartSpec = {
@@ -596,16 +597,60 @@ describe('computeDimensions', () => {
596
597
  // topAxisGap). This means the chart area gains back ~axisMargin (6px)
597
598
  // that would otherwise be redundant spacing.
598
599
  //
599
- // The top margin with legend includes: legendHeight(28) + legendGap(8)
600
- // + inlineTickOverhang(17) instead of the no-legend topAxisGap(23).
601
- // Net: margin delta = 28 + 8 + 17 - 23 = 30px.
602
- // If axisMargin were doubling up: 28 + 8 + 23 - 23 = 36px.
600
+ // The top margin with legend includes: gapAbove(6) + legendHeight(28)
601
+ // + legendGap(8) + inlineTickOverhang(17) instead of the no-legend
602
+ // topAxisGap(23). Net: margin delta = 6 + 28 + 8 + 17 - 23 = 36px.
603
+ // If axisMargin were doubling up: 6 + 28 + 8 + 23 - 23 = 42px.
603
604
  const topMarginDelta = dimsWithTopLegend.margins.top - dimsNoLegend.margins.top;
604
605
  expect(topMarginDelta).toBeLessThan(topLegend.bounds.height + legendGap(600) + 1);
605
606
  // With a legend present, the chart area should still be shorter
606
607
  expect(dimsWithTopLegend.chartArea.height).toBeLessThan(dimsNoLegend.chartArea.height);
607
608
  });
608
609
 
610
+ it('returns effectiveAxisGap on the primary return path', () => {
611
+ const overhang = lightTheme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD;
612
+
613
+ // With a top legend only the inline-tick overhang separates legend and
614
+ // chart area (placeLegend consumes this to lift the legend above it).
615
+ const withTopLegend = computeDimensions(
616
+ baseSpec,
617
+ { width: 600, height: 400 },
618
+ topLegend,
619
+ lightTheme,
620
+ );
621
+ expect(withTopLegend.effectiveAxisGap).toBe(overhang);
622
+
623
+ // Without a top legend the full topAxisGap applies.
624
+ const noLegend = computeDimensions(
625
+ baseSpec,
626
+ { width: 600, height: 400 },
627
+ emptyLegend,
628
+ lightTheme,
629
+ );
630
+ expect(noLegend.effectiveAxisGap).toBe(lightTheme.spacing.axisMargin + overhang);
631
+ });
632
+
633
+ it('returns effectiveAxisGap on the chrome-strip guardrail path', () => {
634
+ // A cramped height forces the guardrail early-return with stripped chrome.
635
+ const talkativeSpec: NormalizedChartSpec = {
636
+ ...baseSpec,
637
+ chrome: {
638
+ title: { text: 'A chart title that occupies chrome space' },
639
+ subtitle: { text: 'A subtitle that gets stripped in compact chrome' },
640
+ source: { text: 'Source: somewhere' },
641
+ },
642
+ };
643
+ const dims = computeDimensions(
644
+ talkativeSpec,
645
+ { width: 600, height: 120 },
646
+ topLegend,
647
+ lightTheme,
648
+ );
649
+ // Prove the guardrail fired: compact chrome drops the subtitle.
650
+ expect(dims.chrome.subtitle).toBeUndefined();
651
+ expect(dims.effectiveAxisGap).toBe(lightTheme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD);
652
+ });
653
+
609
654
  it('tightens legend gap on narrow viewports', () => {
610
655
  const wideDims = computeDimensions(
611
656
  baseSpec,
@@ -3,6 +3,7 @@ import { resolveTheme } from '@opendata-ai/openchart-core';
3
3
  import { describe, expect, it } from 'vitest';
4
4
  import { compileChart } from '../compile';
5
5
  import type { NormalizedChartSpec } from '../compiler/types';
6
+ import { INLINE_TICK_OVERHANG_PAD } from '../layout/shared';
6
7
  import { computeLegend } from '../legend/compute';
7
8
 
8
9
  const specWithColor: NormalizedChartSpec = {
@@ -510,6 +511,56 @@ describe('computeLegend', () => {
510
511
  const gap = layout.area.y - legendBottom;
511
512
  expect(gap).toBe(0);
512
513
  });
514
+
515
+ // Inline y ticks (line/area) draw their topmost label ABOVE chartArea.y,
516
+ // inside the inlineTickOverhang zone the margin stack reserved. The legend
517
+ // must sit above that zone, not flush against chartArea.y.
518
+ const inlineTopLegendSpec = {
519
+ mark: 'line' as const,
520
+ data: [
521
+ { date: '2020-01-01', value: 10, country: 'US' },
522
+ { date: '2021-01-01', value: 40, country: 'US' },
523
+ { date: '2020-01-01', value: 15, country: 'UK' },
524
+ { date: '2021-01-01', value: 35, country: 'UK' },
525
+ ],
526
+ encoding: {
527
+ x: { field: 'date', type: 'temporal' as const },
528
+ y: { field: 'value', type: 'quantitative' as const },
529
+ color: { field: 'country', type: 'nominal' as const },
530
+ },
531
+ legend: { show: true, position: 'top' as const },
532
+ };
533
+
534
+ it('top legend clears the inline y-tick overhang at compact width', () => {
535
+ const layout = compileChart(inlineTopLegendSpec, { width: 360, height: 400 });
536
+
537
+ expect(layout.legend.position).toBe('top');
538
+ expect(layout.legend.entries.length).toBeGreaterThan(0);
539
+
540
+ const inlineTickOverhang = layout.theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD;
541
+ const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
542
+ expect(legendBottom).toBeLessThanOrEqual(layout.area.y - inlineTickOverhang + 1e-6);
543
+
544
+ // Pinned placement. Before the fix the legend bottom sat flush at
545
+ // chartArea.y (legend bottom was 62 = chartArea.y), overlapping the
546
+ // topmost inline tick label; it now sits the 17px overhang higher,
547
+ // while the 6px minimum gap above the legend shifts chartArea down.
548
+ expect(layout.legend.bounds.y).toBe(31);
549
+ });
550
+
551
+ it('gutter y-ticks keep the legend flush at the legendGap (no overhang shift)', () => {
552
+ // Bar charts use gutter y-tick labels, so inlineTickOverhang is 0 and
553
+ // the legend keeps its legendGap-only offset from the chart area.
554
+ const layout = compileChart(topLegendSpec, { width: 600, height: 400 });
555
+
556
+ const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
557
+ expect(layout.area.y - legendBottom).toBe(8);
558
+
559
+ // Pinned placement: 17 before the placement fix; the +6 comes from the
560
+ // TOP_LEGEND_GAP_ABOVE margin (not from any overhang shift — gutter
561
+ // ticks have no overhang, so the legend-to-chart gap above stays 8).
562
+ expect(layout.legend.bounds.y).toBe(23);
563
+ });
513
564
  });
514
565
 
515
566
  describe('entryPositions contract', () => {
package/src/compile.ts CHANGED
@@ -640,12 +640,15 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
640
640
  const chartArea = options.frozenChartArea ?? dims.chartArea;
641
641
 
642
642
  // Place legend in its final position relative to the computed chart area.
643
+ // effectiveAxisGap lifts a top legend above the inline-tick overhang zone
644
+ // the margin stack reserved below it.
643
645
  const finalLegend = placeLegend(
644
646
  plan.legendContent,
645
647
  chartArea,
646
648
  options.width,
647
649
  theme,
648
650
  plan.xAxisExtent,
651
+ dims.effectiveAxisGap,
649
652
  );
650
653
 
651
654
  // Inline y-axis labels: inset the x-scale range start by the widest tick
@@ -898,12 +901,14 @@ function compileFaceted(
898
901
  );
899
902
  const chartArea = dims.chartArea;
900
903
 
904
+ // Same effectiveAxisGap threading as the regular (non-faceted) path.
901
905
  const finalLegend = placeLegend(
902
906
  plan.legendContent,
903
907
  chartArea,
904
908
  options.width,
905
909
  theme,
906
910
  plan.xAxisExtent,
911
+ dims.effectiveAxisGap,
907
912
  );
908
913
 
909
914
  // Estimate y-axis gutter width (used as left reservation for outer axes)
package/src/index.ts CHANGED
@@ -112,6 +112,14 @@ export {
112
112
  registerChartRenderer,
113
113
  } from './charts/registry';
114
114
 
115
+ // ---------------------------------------------------------------------------
116
+ // Legend geometry
117
+ // ---------------------------------------------------------------------------
118
+
119
+ // Used by the vanilla mount to compute the top-legend block when growing
120
+ // auto-height figures; exporting it keeps the gap math single-sourced.
121
+ export { legendGap } from './legend/wrap';
122
+
115
123
  // ---------------------------------------------------------------------------
116
124
  // Data transforms
117
125
  // ---------------------------------------------------------------------------
@@ -123,16 +123,17 @@ const NUMERIC_SCALE_TYPES = new Set([
123
123
  const TEMPORAL_SCALE_TYPES = new Set(['time', 'utc']);
124
124
 
125
125
  /** Format a tick value based on the scale type. */
126
- function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
126
+ function formatTickLabel(value: unknown, resolvedScale: ResolvedScale, compact = false): string {
127
127
  const axisConfig = resolvedScale.channel.axis || undefined;
128
128
  const formatStr = axisConfig?.format;
129
129
  const suffix = axisConfig?.labelSuffix ?? '';
130
130
 
131
131
  if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
132
+ // Explicit user axis.format always wins, compact or not.
132
133
  const temporalFmt = buildTemporalFormatter(formatStr);
133
134
  if (temporalFmt) return temporalFmt(value as Date) + suffix;
134
135
  const useUtc = resolvedScale.type === 'utc';
135
- return formatDate(value as Date, undefined, undefined, useUtc) + suffix;
136
+ return formatDate(value as Date, undefined, undefined, useUtc, compact) + suffix;
136
137
  }
137
138
 
138
139
  if (NUMERIC_SCALE_TYPES.has(resolvedScale.type)) {
@@ -188,8 +189,15 @@ export function continuousTicks(
188
189
  * requesting a smaller `n` never produces squished neighbors — unlike
189
190
  * "keep first+last, drop middle" pruning which can stack the last tick
190
191
  * next to an endpoint and cascade to 2 ticks.
192
+ *
193
+ * `compact` switches temporal labels to the compact format table (bare '%b'
194
+ * months etc.); numeric and string labels ignore it.
191
195
  */
192
- export function buildContinuousTicks(resolvedScale: ResolvedScale, count: number): AxisTick[] {
196
+ export function buildContinuousTicks(
197
+ resolvedScale: ResolvedScale,
198
+ count: number,
199
+ compact = false,
200
+ ): AxisTick[] {
193
201
  const scale = resolvedScale.scale as D3ContinuousScale;
194
202
  if (!('ticks' in scale) || typeof scale.ticks !== 'function') {
195
203
  return continuousTicks(resolvedScale, 'full');
@@ -220,7 +228,7 @@ export function buildContinuousTicks(resolvedScale: ResolvedScale, count: number
220
228
  return ticks.map((value: unknown) => ({
221
229
  value,
222
230
  position: scale(value as number & Date) as number,
223
- label: formatTickLabel(value, resolvedScale),
231
+ label: formatTickLabel(value, resolvedScale, compact),
224
232
  }));
225
233
  }
226
234
 
@@ -315,7 +323,14 @@ export function categoricalTicks(
315
323
  return ticks;
316
324
  }
317
325
 
318
- /** Resolve explicit tick values from axis config into positioned ticks. */
326
+ /**
327
+ * Resolve explicit tick values from axis config into positioned ticks.
328
+ *
329
+ * Deliberately never compact: this renders the layout planner's precomputed
330
+ * ticks, and planner/renderer gutter agreement holds only because both sides
331
+ * format identically. Making this compact without making the planner compact
332
+ * recreates the reserved-space divergence bug class behind the 7.9.x saga.
333
+ */
319
334
  export function resolveExplicitTicks(values: unknown[], resolvedScale: ResolvedScale): AxisTick[] {
320
335
  const scale = resolvedScale.scale;
321
336
  return values.map((value) => {
@@ -208,13 +208,14 @@ function ensureMinContinuousTicks(
208
208
  scale: ResolvedScales['x' | 'y'],
209
209
  ticks: AxisTick[],
210
210
  requestedCount: number,
211
+ compact = false,
211
212
  ): AxisTick[] {
212
213
  if (ticks.length >= MIN_QUANTITATIVE_TICKS) return ticks;
213
214
  if (!scale || !scaleSupportsTickCount(scale)) return ticks;
214
215
 
215
216
  let best = ticks;
216
217
  for (let n = Math.max(requestedCount, MIN_QUANTITATIVE_TICKS) + 1; n <= 10; n++) {
217
- const candidate = buildContinuousTicks(scale, n);
218
+ const candidate = buildContinuousTicks(scale, n, compact);
218
219
  if (candidate.length >= MIN_QUANTITATIVE_TICKS) return candidate;
219
220
  if (candidate.length > best.length) best = candidate;
220
221
  }
@@ -260,6 +261,27 @@ function fitContinuousTicks(
260
261
  const overlaps = ticksOverlap(flooredTicks, fontSize, fontWeight, measureText, orientation);
261
262
  if (!overshoots && !overlaps) return flooredTicks;
262
263
 
264
+ // Compact temporal formats are a rung between "full labels overlap" and
265
+ // "drop a tick": at each candidate count, prefer the full format, then try
266
+ // the compact table at the SAME count before stepping the count down. Every
267
+ // returned set is internally consistent — one format family per layout pass.
268
+ // X-axes only: the planner/renderer y pair must keep formatting identically
269
+ // (see resolveExplicitTicks), so vertical axes never compact — including
270
+ // the fallback path below.
271
+ const useCompact =
272
+ (scale.type === 'time' || scale.type === 'utc') && orientation === 'horizontal';
273
+
274
+ const fitsCleanly = (candidate: AxisTick[]): boolean =>
275
+ candidate.length <= tolerance &&
276
+ !ticksOverlap(candidate, fontSize, fontWeight, measureText, orientation);
277
+
278
+ // Compact-saves-the-count case: full format overlapped at the initial
279
+ // count, but compact labels may fit without giving up a single tick.
280
+ if (useCompact) {
281
+ const compactAtInitial = buildContinuousTicks(scale, initialCount, true);
282
+ if (fitsCleanly(compactAtInitial)) return compactAtInitial;
283
+ }
284
+
263
285
  // Enforce the floor only when the axis is long enough to fit that many
264
286
  // labels without overlap. Very short axes can fall below.
265
287
  const minThreshold =
@@ -268,29 +290,32 @@ function fitContinuousTicks(
268
290
 
269
291
  // Track the smallest candidate that meets the floor. If no candidate fits
270
292
  // cleanly, we thin this instead of the overshot `initialTicks` so the
271
- // fallback doesn't reintroduce the cascading-to-2-ticks bug.
293
+ // fallback doesn't reintroduce the cascading-to-2-ticks bug. Bookkeeping
294
+ // runs against the full-format candidate, exactly as before the compact rung.
272
295
  let bestWithinFloor: AxisTick[] | undefined;
296
+ let bestWithinFloorCount: number | undefined;
273
297
  for (let n = initialCount - 1; n >= 2; n--) {
274
298
  const candidate = buildContinuousTicks(scale, n);
275
- const candidateOvershoots = candidate.length > tolerance;
276
- const candidateOverlaps = ticksOverlap(
277
- candidate,
278
- fontSize,
279
- fontWeight,
280
- measureText,
281
- orientation,
282
- );
283
- if (!candidateOvershoots && !candidateOverlaps) {
284
- return candidate;
299
+ if (fitsCleanly(candidate)) return candidate;
300
+ if (useCompact) {
301
+ const compact = buildContinuousTicks(scale, n, true);
302
+ if (fitsCleanly(compact)) return compact;
303
+ }
304
+ if (candidate.length >= floor) {
305
+ bestWithinFloor = candidate;
306
+ bestWithinFloorCount = n;
285
307
  }
286
- if (candidate.length >= floor) bestWithinFloor = candidate;
287
308
  }
288
309
 
289
310
  // No candidate fit cleanly. Thin whatever most recently met the floor; if
290
311
  // nothing did, synthesize a floor-count set directly from the scale so we
291
312
  // never hand the overshot initialTicks to the middle-pruning thinner.
292
- const rawFallback = bestWithinFloor ?? buildContinuousTicks(scale, floor);
293
- const fallback = ensureMinContinuousTicks(scale, rawFallback, floor);
313
+ // Compacting axes rebuild the fallback compact: if the axis is so starved
314
+ // that we're thinning below the floor, shorter labels are strictly better.
315
+ const rawFallback = useCompact
316
+ ? buildContinuousTicks(scale, bestWithinFloorCount ?? floor, true)
317
+ : (bestWithinFloor ?? buildContinuousTicks(scale, floor));
318
+ const fallback = ensureMinContinuousTicks(scale, rawFallback, floor, useCompact);
294
319
  return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
295
320
  }
296
321
 
@@ -44,7 +44,7 @@ import { format as d3Format } from 'd3-format';
44
44
  import type { NormalizedChartSpec } from '../compiler/types';
45
45
  import { isEndsBoth, predictEndpointLabelsWidth } from '../endpoint-labels/predict';
46
46
  import { countColorSeries, resolveSuppression } from '../legend/suppression';
47
- import { legendGap } from '../legend/wrap';
47
+ import { legendGap, TOP_LEGEND_GAP_ABOVE } from '../legend/wrap';
48
48
  import { yTickPositionIsInline } from './axes';
49
49
  import { computeMetricBar, type MetricFontSizes, metricBarHeight } from './metrics';
50
50
  import type { LayoutPlan } from './plan';
@@ -82,6 +82,14 @@ export interface LayoutDimensions {
82
82
  * second legend pass) can position elements below the axis row.
83
83
  */
84
84
  xAxisHeight: number;
85
+ /**
86
+ * The axis gap the margin stack placed between the top legend (or chrome,
87
+ * when there is no top legend) and the chart area. With a top legend this
88
+ * is the inline-tick overhang, and `placeLegend` must receive it as
89
+ * `axisGapBelowLegend` so the legend sits above the reserved tick zone
90
+ * instead of flush against `chartArea.y` (where inline y-tick labels draw).
91
+ */
92
+ effectiveAxisGap: number;
85
93
  }
86
94
 
87
95
  /** Minimum chart area dimensions before guardrails kick in. */
@@ -254,7 +262,15 @@ export function computeDimensions(
254
262
  height: Math.max(0, height - margins.top - margins.bottom),
255
263
  };
256
264
 
257
- return { total, chrome, chartArea, margins, theme, xAxisHeight: xAxisSpace };
265
+ return {
266
+ total,
267
+ chrome,
268
+ chartArea,
269
+ margins,
270
+ theme,
271
+ xAxisHeight: xAxisSpace,
272
+ effectiveAxisGap: 0,
273
+ };
258
274
  }
259
275
 
260
276
  // Start with the total rect
@@ -664,18 +680,23 @@ export function computeDimensions(
664
680
  if (legendPos === 'right' || legendPos === 'bottom-right') {
665
681
  margins.right += legendBoundsWidth + 8;
666
682
  } else if (legendPos === 'top') {
667
- margins.top += legendHeight + gap;
683
+ // TOP_LEGEND_GAP_ABOVE keeps the legend from sitting flush against the
684
+ // subtitle/metric bar now that the axis gap sits BELOW the legend.
685
+ margins.top += TOP_LEGEND_GAP_ABOVE + legendHeight + gap;
668
686
  }
669
687
  // 'bottom' is intentionally not handled here -- see bottomLegendReservation
670
688
  // above.
671
689
  }
672
690
 
673
- // topAxisGap sits between the legend (or chrome, if no legend) and the
674
- // chart area. When a top legend is present, the legendGap already provides
675
- // breathing room, so only the inlineTickOverhang is needed (the axisMargin
676
- // component would double up with legendGap). Without a top legend, the
677
- // full topAxisGap (axisMargin + inlineTickOverhang) applies.
678
- margins.top += hasTopLegend ? inlineTickOverhang : topAxisGap;
691
+ // effectiveAxisGap sits between the legend (or chrome, if no legend) and
692
+ // the chart area. When a top legend is present, the legendGap already
693
+ // provides breathing room, so only the inlineTickOverhang is needed (the
694
+ // axisMargin component would double up with legendGap). Without a top
695
+ // legend, the full topAxisGap (axisMargin + inlineTickOverhang) applies.
696
+ // The value is returned so placeLegend can position the top legend above
697
+ // this reserved zone (inline y-tick labels draw inside it).
698
+ const effectiveAxisGap = hasTopLegend ? inlineTickOverhang : topAxisGap;
699
+ margins.top += effectiveAxisGap;
679
700
 
680
701
  // Chart area is what's left after margins
681
702
  let chartArea: Rect = {
@@ -719,7 +740,10 @@ export function computeDimensions(
719
740
 
720
741
  if (topDelta > 0 || bottomDelta > 0) {
721
742
  const gap = legendGap(width);
722
- margins.top = newTop + (hasTopLegend ? legendHeight + gap : 0) + fallbackEffectiveAxisGap;
743
+ margins.top =
744
+ newTop +
745
+ (hasTopLegend ? TOP_LEGEND_GAP_ABOVE + legendHeight + gap : 0) +
746
+ fallbackEffectiveAxisGap;
723
747
  margins.bottom = newBottom;
724
748
 
725
749
  chartArea = {
@@ -765,6 +789,7 @@ export function computeDimensions(
765
789
  theme,
766
790
  metrics: fallbackMetrics,
767
791
  xAxisHeight,
792
+ effectiveAxisGap: fallbackEffectiveAxisGap,
768
793
  };
769
794
  }
770
795
  }
@@ -773,8 +798,9 @@ export function computeDimensions(
773
798
  // topPad
774
799
  // chrome.topHeight (title / subtitle / eyebrow)
775
800
  // tentativeMetricsHeight (KPI bar — placed here)
776
- // [optional top legend band]
777
- // topAxisGap (axisMargin + inlineTickOverhang)
801
+ // [optional top legend band (TOP_LEGEND_GAP_ABOVE + legend + legendGap)]
802
+ // effectiveAxisGap (inlineTickOverhang with a top legend,
803
+ // else axisMargin + inlineTickOverhang)
778
804
  // chartArea
779
805
  // The metric bar belongs with chrome, above the legend, so its y is
780
806
  // computed off chrome.topHeight only — not the full legend-inclusive
@@ -801,6 +827,7 @@ export function computeDimensions(
801
827
  theme,
802
828
  metrics: resolvedMetrics,
803
829
  xAxisHeight,
830
+ effectiveAxisGap,
804
831
  };
805
832
  }
806
833
 
@@ -32,7 +32,7 @@ import { format as d3Format } from 'd3-format';
32
32
  import type { NormalizedChartSpec } from '../compiler/types';
33
33
  import { predictEndpointLabelsWidth } from '../endpoint-labels/predict';
34
34
  import { computeLegendContent, type LegendContent } from '../legend/compute';
35
- import { legendGap } from '../legend/wrap';
35
+ import { legendGap, TOP_LEGEND_GAP_ABOVE } from '../legend/wrap';
36
36
  import { yTickPositionIsInline } from './axes';
37
37
  import { resolveBandTickAngle } from './axes/rotation';
38
38
  import { buildContinuousTicks, scaleSupportsTickCount, targetTickCount } from './axes/ticks';
@@ -395,7 +395,8 @@ export function resolveLayoutPlan(
395
395
  const effectiveTopAxisGap = hasTopLegend ? inlineTickOverhang : topAxisGap;
396
396
 
397
397
  let topMargin = topPad + chrome.topHeight;
398
- if (hasTopLegend) topMargin += legendContent.height + gap;
398
+ // Mirrors the dimensions.ts margin stack: min gap above the top legend.
399
+ if (hasTopLegend) topMargin += TOP_LEGEND_GAP_ABOVE + legendContent.height + gap;
399
400
  topMargin += effectiveTopAxisGap;
400
401
 
401
402
  const bMargin = bottomMargin(chrome.bottomHeight, padding, xAxisExtent);