@opendata-ai/openchart-engine 7.8.0 → 7.9.1

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.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Mark key utilities for data-update transitions.
3
+ *
4
+ * Keys uniquely identify marks across data updates so the renderer can
5
+ * match old marks to new marks and animate the difference. Each chart
6
+ * type stamps keys during its compute phase; these helpers provide the
7
+ * shared serialization and deduplication logic.
8
+ */
9
+
10
+ /** Sentinel string for null/undefined values. Uses a Unicode symbol
11
+ * that won't collide with real data values. */
12
+ const NULL_KEY = '∅';
13
+
14
+ /**
15
+ * Serialize a datum value into a stable key fragment.
16
+ * - Date -> epoch ms string (avoids locale-dependent Date.toString)
17
+ * - number/string/boolean -> String(v)
18
+ * - null/undefined -> null sentinel
19
+ */
20
+ export function serializeKeyValue(v: unknown): string {
21
+ if (v === null || v === undefined) return NULL_KEY;
22
+ if (v instanceof Date) return String(v.getTime());
23
+ return String(v);
24
+ }
25
+
26
+ /**
27
+ * Append occurrence suffixes to duplicate keys, in order.
28
+ *
29
+ * Only keys that appear more than once get suffixed. Unique keys pass
30
+ * through unchanged so keys remain human-readable when possible.
31
+ * Uses SOH (\x01) as separator to avoid collisions with real data values.
32
+ */
33
+ export function dedupeKeys(keys: string[]): string[] {
34
+ // Count occurrences
35
+ const counts = new Map<string, number>();
36
+ for (const k of keys) {
37
+ counts.set(k, (counts.get(k) ?? 0) + 1);
38
+ }
39
+
40
+ // Assign suffixes only to duplicates
41
+ const seen = new Map<string, number>();
42
+ const result: string[] = [];
43
+ for (const k of keys) {
44
+ if (counts.get(k)! > 1) {
45
+ const idx = seen.get(k) ?? 0;
46
+ result.push(`${k}\x01${idx}`);
47
+ seen.set(k, idx + 1);
48
+ } else {
49
+ result.push(k);
50
+ }
51
+ }
52
+
53
+ return result;
54
+ }
@@ -132,7 +132,7 @@ function inferFieldType(data: DataRow[], field: string): FieldType {
132
132
  function inferEncodingTypes(encoding: Encoding, data: DataRow[], warnings: string[]): Encoding {
133
133
  const result = { ...encoding };
134
134
 
135
- for (const channel of ['x', 'y', 'color', 'size', 'detail'] as const) {
135
+ for (const channel of ['x', 'y', 'color', 'size', 'detail', 'key'] as const) {
136
136
  const spec = result[channel];
137
137
  if (!spec) continue;
138
138
 
package/src/index.ts CHANGED
@@ -26,7 +26,25 @@ export {
26
26
  // Animation resolution
27
27
  // ---------------------------------------------------------------------------
28
28
 
29
- export { clampStaggerDelay, resolveAnimation } from './compiler/animation';
29
+ export {
30
+ clampStaggerDelay,
31
+ ENTER_DEFAULTS,
32
+ EXIT_DEFAULTS,
33
+ resolveAnimation,
34
+ UPDATE_DEFAULTS,
35
+ } from './compiler/animation';
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Mark keying for data-update transitions
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export { dedupeKeys, serializeKeyValue } from './compiler/keys';
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Geometry helpers for path reconstruction
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export { buildAreaPath, buildLinePath } from './charts/line/curves';
30
48
 
31
49
  // ---------------------------------------------------------------------------
32
50
  // Graph compilation types
@@ -43,6 +43,59 @@ import type { ResolvedScales } from './scales';
43
43
  // them from './layout/axes'.
44
44
  export { thinTicksUntilFit, ticksOverlap } from './axes/thinning';
45
45
 
46
+ /** Scale types that render as a categorical (non-continuous) y-axis. */
47
+ const CATEGORICAL_SCALE_TYPES = new Set(['band', 'point', 'ordinal']);
48
+
49
+ /**
50
+ * Whether the y-axis renders tick labels inline (above their gridline inside
51
+ * the plot) rather than in a left gutter.
52
+ *
53
+ * This is the single source of truth shared by margin reservation (plan.ts,
54
+ * dimensions.ts) and the renderer's title placement. All three must agree or
55
+ * the reserved left margin won't match where the title is drawn.
56
+ *
57
+ * Editorial line/area charts with a continuous y-axis default to inline ticks.
58
+ * Continuity is decided by the RESOLVED scale type, not the field type: an
59
+ * explicit `scale.type` override (e.g. a band scale forced onto a quantitative
60
+ * field) wins, mirroring buildPositionalScale() in scales.ts. Right-side
61
+ * (dual-axis) y-axes always use the gutter. An explicit axis.tickPosition wins
62
+ * over the default.
63
+ */
64
+ export function yTickPositionIsInline(
65
+ yChannel:
66
+ | {
67
+ type?: string;
68
+ scale?: { type?: string };
69
+ axis?: unknown;
70
+ }
71
+ | undefined,
72
+ markType: string,
73
+ /**
74
+ * Resolved continuity of the y-scale, when the caller already has the built
75
+ * scale (computeAxes). When omitted, continuity is inferred from the encoding
76
+ * (explicit scale.type override, else field type) so the reservation paths
77
+ * predict the same answer computeAxes will produce.
78
+ */
79
+ resolvedContinuous?: boolean,
80
+ ): boolean {
81
+ if (!yChannel) return false;
82
+ const axisCfg = (yChannel.axis as Record<string, unknown> | undefined) ?? undefined;
83
+ const explicit = axisCfg?.tickPosition as 'inline' | 'gutter' | undefined;
84
+ if (explicit) return explicit === 'inline';
85
+
86
+ const isLineOrArea = markType === 'line' || markType === 'area';
87
+ if (!isLineOrArea) return false;
88
+ if ((axisCfg?.orient as string | undefined) === 'right') return false;
89
+
90
+ if (resolvedContinuous !== undefined) return resolvedContinuous;
91
+
92
+ // Predict the resolved scale type: explicit override wins, else field type.
93
+ const explicitScaleType = yChannel.scale?.type;
94
+ return explicitScaleType
95
+ ? !CATEGORICAL_SCALE_TYPES.has(explicitScaleType)
96
+ : yChannel.type === 'quantitative' || yChannel.type === 'temporal';
97
+ }
98
+
46
99
  // ---------------------------------------------------------------------------
47
100
  // Constants
48
101
  // ---------------------------------------------------------------------------
@@ -242,36 +295,46 @@ function fitContinuousTicks(
242
295
  }
243
296
 
244
297
  /**
245
- * Check whether band-scale tick labels overlap at a given rotation angle,
246
- * using position-based detection. Rotated labels extend diagonally past
247
- * their band boundaries without colliding, so we check actual footprint
248
- * along the axis rather than comparing against bandwidth.
298
+ * Horizontal footprint `[left, right]` of a rotated band tick label, in axis
299
+ * pixels. Rotated labels are anchored at the tick position (the rotation
300
+ * pivot), not centered on it: at a negative angle the renderer right-anchors
301
+ * the text so it extends left of the pivot; at a positive angle it left-anchors
302
+ * so it extends right. The horizontal projection is `width * cos(angle)`.
303
+ *
304
+ * Modeling the footprint as centered (the old behavior) over-estimates each
305
+ * label's reach by half its width in the wrong direction, which made a single
306
+ * wide label (e.g. "2026 (to wk 17)") report overlaps against neighbors that
307
+ * actually clear it — triggering needless thinning of the whole axis.
249
308
  */
250
- function bandTicksOverlapAtAngle(
251
- ticks: AxisTick[],
309
+ function labelSpanAtAngle(
310
+ tick: AxisTick,
252
311
  angleDeg: number,
253
312
  fontSize: number,
254
313
  fontWeight: number,
255
314
  measureText?: MeasureTextFn,
256
- ): boolean {
257
- if (ticks.length < 2) return false;
315
+ ): { left: number; right: number } {
258
316
  const angleRad = (Math.abs(angleDeg) * Math.PI) / 180;
259
317
  const cosA = angleRad > 0 ? Math.abs(Math.cos(angleRad)) : 1;
260
- const minGap = fontSize * 0.5;
261
- for (let i = 0; i < ticks.length - 1; i++) {
262
- const aWidth = measureLabel(ticks[i].label, fontSize, fontWeight, measureText) * cosA;
263
- const bWidth = measureLabel(ticks[i + 1].label, fontSize, fontWeight, measureText) * cosA;
264
- const aRight = ticks[i].position + aWidth / 2;
265
- const bLeft = ticks[i + 1].position - bWidth / 2;
266
- if (aRight + minGap > bLeft) return true;
318
+ const projected = measureLabel(tick.label, fontSize, fontWeight, measureText) * cosA;
319
+ if (angleDeg === 0) {
320
+ // Horizontal labels are centered on the tick.
321
+ return { left: tick.position - projected / 2, right: tick.position + projected / 2 };
322
+ }
323
+ if (angleDeg < 0) {
324
+ // Right-anchored: pivot is the right edge, text extends left.
325
+ return { left: tick.position - projected, right: tick.position };
267
326
  }
268
- return false;
327
+ // Left-anchored: pivot is the left edge, text extends right.
328
+ return { left: tick.position, right: tick.position + projected };
269
329
  }
270
330
 
271
331
  /**
272
- * Thin band-scale tick labels only when they actually overlap at their
273
- * effective angle. Most grouped bar charts keep every label even at -45°.
274
- * Only extremely dense charts (50+ categories) will thin.
332
+ * Thin band-scale tick labels only where they actually collide at their
333
+ * effective angle, greedily keeping every label that clears the last one
334
+ * retained. This preserves narrow labels that have room even when a single
335
+ * wide label elsewhere on the axis forces some thinning near it, instead of
336
+ * decimating the whole axis every-other. First and last labels are always
337
+ * kept. Most grouped bar charts keep every label even at -45°.
275
338
  */
276
339
  function thinBandTicksIfNeeded(
277
340
  ticks: AxisTick[],
@@ -280,19 +343,42 @@ function thinBandTicksIfNeeded(
280
343
  fontWeight: number,
281
344
  measureText?: MeasureTextFn,
282
345
  ): AxisTick[] {
283
- if (!bandTicksOverlapAtAngle(ticks, angleDeg, fontSize, fontWeight, measureText)) return ticks;
346
+ if (ticks.length < 3) return ticks;
347
+ const minGap = fontSize * 0.5;
284
348
 
285
- let current = ticks;
286
- while (current.length > 2) {
287
- const thinned = [current[0]];
288
- for (let i = 2; i < current.length - 1; i += 2) {
289
- thinned.push(current[i]);
349
+ const spans = ticks.map((t) => labelSpanAtAngle(t, angleDeg, fontSize, fontWeight, measureText));
350
+
351
+ // Greedy left-to-right retention: keep a label only if its footprint clears
352
+ // the last kept label's right edge by minGap. Always keep the first label.
353
+ const keep = new Array<boolean>(ticks.length).fill(false);
354
+ keep[0] = true;
355
+ let lastRight = spans[0].right;
356
+ for (let i = 1; i < ticks.length; i++) {
357
+ if (spans[i].left >= lastRight + minGap) {
358
+ keep[i] = true;
359
+ lastRight = spans[i].right;
290
360
  }
291
- if (current.length > 1) thinned.push(current[current.length - 1]);
292
- current = thinned;
293
- if (!bandTicksOverlapAtAngle(current, angleDeg, fontSize, fontWeight, measureText)) break;
294
361
  }
295
- return current;
362
+
363
+ // Always keep the last label. If keeping it collides with previously kept
364
+ // labels, drop each colliding neighbor so the endpoint stays labeled. A very
365
+ // wide final label (long category name) projects across several bands at an
366
+ // angle, so clear every kept label its footprint overlaps, not just the
367
+ // nearest one — otherwise an earlier kept label still overlaps the endpoint.
368
+ const lastIdx = ticks.length - 1;
369
+ if (!keep[lastIdx]) {
370
+ keep[lastIdx] = true;
371
+ for (let i = lastIdx - 1; i > 0; i--) {
372
+ if (!keep[i]) continue;
373
+ if (spans[i].right + minGap > spans[lastIdx].left) {
374
+ keep[i] = false;
375
+ continue;
376
+ }
377
+ break;
378
+ }
379
+ }
380
+
381
+ return ticks.filter((_, i) => keep[i]);
296
382
  }
297
383
 
298
384
  // ---------------------------------------------------------------------------
@@ -561,13 +647,18 @@ export function computeAxes(
561
647
  const yLabelColor = axisConfig?.labelColor;
562
648
  // Editorial line/area y-axes default to inline tick labels above their
563
649
  // gridlines. Other mark types keep the classic gutter placement. Right-side
564
- // y-axes (dual-axis) always use gutter.
650
+ // y-axes (dual-axis) always use gutter. Uses the shared predicate so the
651
+ // margin-reservation paths (plan.ts, dimensions.ts) agree with this; here
652
+ // we pass the RESOLVED scale continuity since the scale is already built.
565
653
  const isContinuousYAxis =
566
654
  scales.y.type !== 'band' && scales.y.type !== 'point' && scales.y.type !== 'ordinal';
567
- const isLineOrArea = dataContext?.markType === 'line' || dataContext?.markType === 'area';
568
- const yTickPosition: 'inline' | 'gutter' =
569
- axisConfig?.tickPosition ??
570
- (isLineOrArea && isContinuousYAxis && axisConfig?.orient !== 'right' ? 'inline' : 'gutter');
655
+ const yTickPosition: 'inline' | 'gutter' = yTickPositionIsInline(
656
+ dataContext?.encoding.y,
657
+ dataContext?.markType ?? '',
658
+ isContinuousYAxis,
659
+ )
660
+ ? 'inline'
661
+ : 'gutter';
571
662
 
572
663
  // Inline mode hides the axis line and tick marks by default; the gridlines
573
664
  // themselves serve as the visual axis. Explicit user overrides win.
@@ -45,6 +45,7 @@ import type { NormalizedChartSpec } from '../compiler/types';
45
45
  import { isEndsBoth, predictEndpointLabelsWidth } from '../endpoint-labels/predict';
46
46
  import { countColorSeries, resolveSuppression } from '../legend/suppression';
47
47
  import { legendGap } from '../legend/wrap';
48
+ import { yTickPositionIsInline } from './axes';
48
49
  import { computeMetricBar, type MetricFontSizes, metricBarHeight } from './metrics';
49
50
  import type { LayoutPlan } from './plan';
50
51
  import { bottomMargin, chromeToInput, INLINE_TICK_OVERHANG_PAD, scalePadding } from './shared';
@@ -306,16 +307,9 @@ export function computeDimensions(
306
307
  // gridline inside the chart area; the topmost tick text extends roughly
307
308
  // (tickFontSize + INLINE_TICK_OVERHANG_PAD) pixels above area.y, which
308
309
  // would otherwise crowd the chrome→chart gap.
309
- const yAxisCfgPre = (encoding.y?.axis as Record<string, unknown> | undefined) ?? undefined;
310
- const yTickPositionExplicitPre = yAxisCfgPre?.tickPosition as 'inline' | 'gutter' | undefined;
311
- const yIsContinuousPre = encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal';
312
- const yIsLineOrAreaPre = spec.markType === 'line' || spec.markType === 'area';
313
- const yAxisOrientPre = yAxisCfgPre?.orient as 'left' | 'right' | 'top' | 'bottom' | undefined;
314
- const yIsInlinePre =
315
- (yTickPositionExplicitPre ??
316
- (yIsLineOrAreaPre && yIsContinuousPre && yAxisOrientPre !== 'right'
317
- ? 'inline'
318
- : 'gutter')) === 'inline';
310
+ // Inline y-tick placement must match computeAxes exactly (shared predicate),
311
+ // or the reserved left margin won't line up with where the title is drawn.
312
+ const yIsInlinePre = yTickPositionIsInline(encoding.y, spec.markType);
319
313
  const inlineTickOverhang = yIsInlinePre
320
314
  ? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD
321
315
  : 0;
@@ -571,8 +565,13 @@ export function computeDimensions(
571
565
  const yFieldForTitle = encoding.y?.field;
572
566
  const yAxisFormatForTitle = yAxis?.format as string | undefined;
573
567
  let estTickLabelWidth = 0;
568
+ // Inline y-tick labels render inside the chart area (above their gridlines),
569
+ // not in a left gutter, so they add no width the rotated title must clear.
570
+ // Counting them here would reserve a phantom gutter and leave a dead gap
571
+ // between the title and the plot.
574
572
  if (
575
573
  yFieldForTitle &&
574
+ !yIsInlinePre &&
576
575
  (encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal')
577
576
  ) {
578
577
  let maxAbsValForTitle = 0;
@@ -609,7 +608,7 @@ export function computeDimensions(
609
608
  // We add another halfGlyph here for the title glyph extending the other way,
610
609
  // toward the container edge, so the margin reaches the title's outer edge.
611
610
  const titleFontSize = theme.fonts.sizes.body;
612
- const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width);
611
+ const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width, yIsInlinePre);
613
612
  const halfGlyph = Math.ceil(titleFontSize / 2);
614
613
  const rotatedLabelMargin =
615
614
  offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
@@ -33,6 +33,7 @@ import type { NormalizedChartSpec } from '../compiler/types';
33
33
  import { predictEndpointLabelsWidth } from '../endpoint-labels/predict';
34
34
  import { computeLegendContent, type LegendContent } from '../legend/compute';
35
35
  import { legendGap } from '../legend/wrap';
36
+ import { yTickPositionIsInline } from './axes';
36
37
  import { resolveBandTickAngle } from './axes/rotation';
37
38
  import { buildContinuousTicks, scaleSupportsTickCount, targetTickCount } from './axes/ticks';
38
39
  import { computeScales, estimateBandwidth } from './scales';
@@ -177,17 +178,11 @@ export function resolveLayoutPlan(
177
178
  // Resolve y-axis inline status
178
179
  // -----------------------------------------------------------------------
179
180
  const yAxisCfg = (encoding.y?.axis as Record<string, unknown> | undefined) ?? undefined;
180
- const yTickPositionExplicit = yAxisCfg?.tickPosition as 'inline' | 'gutter' | undefined;
181
181
  const yIsContinuous = encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal';
182
- const yIsLineOrArea = renderSpec.markType === 'line' || renderSpec.markType === 'area';
183
- const yAxisOrient = yAxisCfg?.orient as string | undefined;
184
- // Sparkline display already returned early above, so no sparkline guard needed here.
185
- const yIsInline =
186
- yTickPositionExplicit === 'inline' ||
187
- (yTickPositionExplicit === undefined &&
188
- yIsLineOrArea &&
189
- yIsContinuous &&
190
- yAxisOrient !== 'right');
182
+ // Inline y-tick placement must match computeAxes exactly (shared predicate),
183
+ // or the reserved left margin won't line up with where the title is drawn.
184
+ // Sparkline display already returned early above, so no sparkline guard needed.
185
+ const yIsInline = yTickPositionIsInline(encoding.y, renderSpec.markType);
191
186
  const yAxisSuppressed = encoding.y?.axis === false;
192
187
 
193
188
  // -----------------------------------------------------------------------
@@ -517,7 +512,11 @@ export function resolveLayoutPlan(
517
512
  const yAxisDef = encoding.y?.axis as Record<string, unknown> | undefined;
518
513
  if (yAxisDef && (yAxisDef.title || yAxisDef.label) && !isRadial) {
519
514
  let estTickLabelWidth = 0;
520
- if (encoding.y?.field && yIsContinuous) {
515
+ // Inline y-tick labels sit above their gridlines inside the chart area,
516
+ // not in a left gutter, so they add no width the title must clear.
517
+ // Reserving their width here would leave a dead gap between the title
518
+ // and the plot. Only gutter (non-inline) tick labels count.
519
+ if (encoding.y?.field && yIsContinuous && !yIsInline) {
521
520
  if (yTickValues.length > 0) {
522
521
  const titleProvArea: Rect = {
523
522
  x: finalGutter,
@@ -536,7 +535,7 @@ export function resolveLayoutPlan(
536
535
  }
537
536
  }
538
537
  const titleFontSize = theme.fonts.sizes.body;
539
- const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width);
538
+ const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width, yIsInline);
540
539
  const halfGlyph = Math.ceil(titleFontSize / 2);
541
540
  const rotatedLabelMargin =
542
541
  offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
@@ -209,9 +209,9 @@ describe('compileTable', () => {
209
209
  it('includes resolved animation when spec has animation: true', () => {
210
210
  const layout = compileTable({ ...baseSpec, animation: true }, baseOptions);
211
211
  expect(layout.animation).toBeDefined();
212
- expect(layout.animation!.enabled).toBe(true);
213
- expect(layout.animation!.duration).toBe(500);
214
- expect(layout.animation!.staggerDelay).toBe(80);
212
+ expect(layout.animation!.enter).toBeDefined();
213
+ expect(layout.animation!.enter!.duration).toBe(500);
214
+ expect(layout.animation!.enter!.staggerDelay).toBe(80);
215
215
  });
216
216
 
217
217
  it('includes resolved animation with custom config', () => {
@@ -220,8 +220,8 @@ describe('compileTable', () => {
220
220
  baseOptions,
221
221
  );
222
222
  expect(layout.animation).toBeDefined();
223
- expect(layout.animation!.duration).toBe(800);
224
- expect(layout.animation!.ease).toBe('snappy');
223
+ expect(layout.animation!.enter!.duration).toBe(800);
224
+ expect(layout.animation!.enter!.ease).toBe('snappy');
225
225
  });
226
226
 
227
227
  it('does not include animation when spec omits it', () => {
@@ -38,6 +38,9 @@ import {
38
38
  computeChrome,
39
39
  estimateTextWidth,
40
40
  formatNumber,
41
+ getBreakpoint,
42
+ HPAD_COMPACT_FRACTION,
43
+ HPAD_COMPACT_MIN,
41
44
  pickLabelColor,
42
45
  resolveTheme,
43
46
  SEQUENTIAL_PALETTES,
@@ -59,6 +62,10 @@ const LEGEND_SWATCH_SIZE = 10;
59
62
  const LEGEND_SWATCH_GAP = 6;
60
63
  const LEGEND_ENTRY_GAP = 16;
61
64
 
65
+ function clamp(value: number, min: number, max: number): number {
66
+ return Math.min(max, Math.max(min, value));
67
+ }
68
+
62
69
  // ---------------------------------------------------------------------------
63
70
  // Public API
64
71
  // ---------------------------------------------------------------------------
@@ -118,7 +125,15 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
118
125
  );
119
126
 
120
127
  // 4. Compute drawing area (total space minus chrome)
121
- const padding = theme.spacing.padding;
128
+ // On compact (mobile) widths, reclaim horizontal space by shrinking the
129
+ // padding — every reclaimed pixel widens the tiles, which are width-bound at
130
+ // these sizes. Mirrors how the chart pipeline uses HPAD_COMPACT_* on narrow
131
+ // viewports. The tile-area, chrome, and content-height math all read this one
132
+ // value, so overriding it here keeps every derived measurement consistent.
133
+ const padding =
134
+ getBreakpoint(options.width) === 'compact'
135
+ ? Math.max(HPAD_COMPACT_MIN, Math.round(theme.spacing.padding * HPAD_COMPACT_FRACTION))
136
+ : theme.spacing.padding;
122
137
  const fullArea = {
123
138
  x: padding,
124
139
  y: padding + chrome.topHeight,
@@ -269,7 +284,9 @@ function compileQuantitative(
269
284
  };
270
285
 
271
286
  const resolvedAnimation: ResolvedAnimation | undefined = resolveAnimation(tilemapSpec.animation);
272
- const padding = theme.spacing.padding;
287
+ // fullArea.x is the (possibly compact-reduced) padding chosen in compileTileMap;
288
+ // reuse it so contentHeight matches the drawing area exactly.
289
+ const padding = fullArea.x;
273
290
  const contentHeight =
274
291
  tileGridOffsetY +
275
292
  tilePositions.gridHeight +
@@ -354,11 +371,18 @@ function compileCategorical(
354
371
  }
355
372
 
356
373
  const showLegend = tilemapSpec.legend?.show !== false;
374
+ const legendLabelFontSize = 11;
357
375
  const legendRowHeight = LEGEND_SWATCH_SIZE + 6;
358
- const legendTotalHeight = showLegend ? legendRowHeight : 0;
376
+ const legendLabels = categories.map(formatCategoryLabel);
359
377
 
378
+ // Estimate legend rows against the full width to reserve tile-area height.
379
+ // The renderer wraps against the (narrower) grid width, so we recompute the
380
+ // real row count below and use that for final placement — never under-reserve.
360
381
  const legendGap = showLegend ? 8 : 0;
361
- const tileAreaHeight = fullArea.height - legendTotalHeight - legendGap;
382
+ const estimatedRows = showLegend
383
+ ? countLegendRows(legendLabels, fullArea.width, legendLabelFontSize)
384
+ : 0;
385
+ const tileAreaHeight = fullArea.height - legendRowHeight * estimatedRows - legendGap;
362
386
  const tilePositions = computeTilePositions(fullArea.width, tileAreaHeight, 5);
363
387
 
364
388
  const tileGridOffsetX = fullArea.x + (fullArea.width - tilePositions.gridWidth) / 2;
@@ -367,6 +391,13 @@ function compileCategorical(
367
391
  const legendY = tileGridOffsetY + tilePositions.gridHeight + legendGap;
368
392
  const legendWidth = tilePositions.gridWidth;
369
393
 
394
+ // Reserve height for every wrapped row so a legend that spills onto a second
395
+ // line (common on narrow mobile widths) doesn't collide with the source text.
396
+ const legendRows = showLegend
397
+ ? countLegendRows(legendLabels, legendWidth, legendLabelFontSize)
398
+ : 0;
399
+ const legendTotalHeight = showLegend ? legendRowHeight * legendRows : 0;
400
+
370
401
  const neutralFill = isDarkMode ? '#1e2a30' : '#e0e0e0';
371
402
  const neutralStroke = isDarkMode ? 'rgba(255,255,255,0.08)' : '#d0d0d0';
372
403
 
@@ -411,7 +442,7 @@ function compileCategorical(
411
442
  if (showLegend) {
412
443
  const labelStyle: TextStyle = {
413
444
  fontFamily: theme.fonts.family,
414
- fontSize: 11,
445
+ fontSize: legendLabelFontSize,
415
446
  fontWeight: 400,
416
447
  fill: theme.colors.text,
417
448
  lineHeight: 1.2,
@@ -426,7 +457,7 @@ function compileCategorical(
426
457
  categoricalLegend = {
427
458
  type: 'categorical',
428
459
  position: 'bottom',
429
- bounds: { x: legendX, y: legendY, width: legendWidth, height: legendRowHeight },
460
+ bounds: { x: legendX, y: legendY, width: legendWidth, height: legendTotalHeight },
430
461
  labelStyle,
431
462
  entries,
432
463
  swatchSize: LEGEND_SWATCH_SIZE,
@@ -446,7 +477,9 @@ function compileCategorical(
446
477
  };
447
478
 
448
479
  const resolvedAnimation: ResolvedAnimation | undefined = resolveAnimation(tilemapSpec.animation);
449
- const padding = theme.spacing.padding;
480
+ // fullArea.x is the (possibly compact-reduced) padding chosen in compileTileMap;
481
+ // reuse it so contentHeight matches the drawing area exactly.
482
+ const padding = fullArea.x;
450
483
  const contentHeight =
451
484
  tileGridOffsetY +
452
485
  tilePositions.gridHeight +
@@ -518,9 +551,20 @@ function buildTileMark(opts: TileMarkOptions): TileMapTileMark {
518
551
  const textColor = hasData ? pickLabelColor(fill, isDarkMode) : '#ffffff';
519
552
  const isLightText = textColor === '#ffffff';
520
553
 
554
+ // A two-line layout (code over value) only fits once tiles are large enough;
555
+ // below that we drop the value and center the code so nothing overflows.
556
+ const showValue = tileSize >= 24;
557
+
558
+ // Scale the code font with the tile so a 2-char label always fits its box,
559
+ // rather than snapping between two fixed sizes. A 2-char string is ~1.2x the
560
+ // font size wide; capping font at ~0.5x tile width keeps it inside the tile
561
+ // with margin, then clamp to a legible [6.5, 11] range.
562
+ const codeFontSize = clamp(Math.round(tileSize * 0.5 * 10) / 10, 6.5, 11);
563
+ const valueFontSize = clamp(Math.round(tileSize * 0.42 * 10) / 10, 6.5, 10);
564
+
521
565
  const labelStyle: TextStyle = {
522
566
  fontFamily: theme.fonts.family,
523
- fontSize: tileSize > 24 ? 10 : 7,
567
+ fontSize: codeFontSize,
524
568
  fontWeight: 700,
525
569
  fill: textColor,
526
570
  lineHeight: 1.2,
@@ -528,22 +572,27 @@ function buildTileMark(opts: TileMarkOptions): TileMapTileMark {
528
572
 
529
573
  const valueLabelStyle: TextStyle = {
530
574
  fontFamily: theme.fonts.family,
531
- fontSize: tileSize > 24 ? 10 : 7,
575
+ fontSize: valueFontSize,
532
576
  fontWeight: 300,
533
577
  fill: isLightText ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.45)',
534
578
  lineHeight: 1.2,
535
579
  };
536
580
 
537
- const valueLabel =
538
- tileSize < 24
539
- ? { text: '', x: 0, y: 0, style: valueLabelStyle, visible: false }
540
- : {
541
- text: formattedValue,
542
- x: tileCenterX,
543
- y: tileTopY + tileSize * 0.78,
544
- style: valueLabelStyle,
545
- visible: true,
546
- };
581
+ // Code sits in the upper third when paired with a value, otherwise centered.
582
+ const codeCenterY = showValue ? tileTopY + tileSize * 0.28 : tileTopY + tileSize * 0.5;
583
+
584
+ const valueLabel = showValue
585
+ ? {
586
+ text: formattedValue,
587
+ x: tileCenterX,
588
+ y: tileTopY + tileSize * 0.78,
589
+ style: valueLabelStyle,
590
+ visible: true,
591
+ }
592
+ : { text: '', x: 0, y: 0, style: valueLabelStyle, visible: false };
593
+
594
+ // Keep corners visibly rounded-rectangular, never near-circular on small tiles.
595
+ const cornerRadius = Math.min(TILE_CORNER_RADIUS, Math.round(tileSize * 0.22));
547
596
 
548
597
  const data: Record<string, unknown> = {
549
598
  state: stateCode,
@@ -564,14 +613,14 @@ function buildTileMark(opts: TileMarkOptions): TileMapTileMark {
564
613
  fillOpacity,
565
614
  stroke,
566
615
  strokeWidth: TILE_STROKE_WIDTH,
567
- cornerRadius: TILE_CORNER_RADIUS,
616
+ cornerRadius,
568
617
  value,
569
618
  formattedValue,
570
619
  hasData,
571
620
  label: {
572
621
  text: stateCode,
573
622
  x: tileCenterX,
574
- y: tileTopY + tileSize * 0.28,
623
+ y: codeCenterY,
575
624
  style: labelStyle,
576
625
  visible: true,
577
626
  },
@@ -617,6 +666,29 @@ function formatCategoryLabel(category: string): string {
617
666
  return category.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
618
667
  }
619
668
 
669
+ /**
670
+ * Count how many rows a horizontal categorical legend wraps into for a given
671
+ * available width. Mirrors the wrap logic in the legend renderer so the compiler
672
+ * can reserve enough vertical space and avoid the legend overlapping the source
673
+ * line on narrow (mobile) containers.
674
+ */
675
+ function countLegendRows(labels: string[], availableWidth: number, fontSize: number): number {
676
+ if (labels.length === 0) return 0;
677
+ let rows = 1;
678
+ let offsetX = 0;
679
+ for (let i = 0; i < labels.length; i++) {
680
+ const labelWidth = estimateTextWidth(labels[i], fontSize);
681
+ const entryWidth = LEGEND_SWATCH_SIZE + LEGEND_SWATCH_GAP + labelWidth + LEGEND_ENTRY_GAP;
682
+ if (i > 0 && offsetX + entryWidth > availableWidth) {
683
+ rows++;
684
+ offsetX = entryWidth;
685
+ } else {
686
+ offsetX += entryWidth;
687
+ }
688
+ }
689
+ return rows;
690
+ }
691
+
620
692
  // ---------------------------------------------------------------------------
621
693
  // Empty layout fallback
622
694
  // ---------------------------------------------------------------------------