@opendata-ai/openchart-engine 7.2.4 → 7.4.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.
Files changed (45) hide show
  1. package/dist/index.d.ts +88 -39
  2. package/dist/index.js +4134 -2930
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +143 -78
  6. package/src/__tests__/axes.test.ts +129 -1
  7. package/src/__tests__/compile-chart.test.ts +6 -0
  8. package/src/__tests__/compile-layer.test.ts +49 -0
  9. package/src/__tests__/dimensions.test.ts +70 -2
  10. package/src/__tests__/helpers/invariants.ts +265 -0
  11. package/src/__tests__/known-bugs.test.ts +113 -0
  12. package/src/__tests__/layout-fuzz.test.ts +358 -0
  13. package/src/__tests__/layout-invariants.test.ts +535 -0
  14. package/src/__tests__/legend.test.ts +37 -0
  15. package/src/annotations/__tests__/compute.test.ts +8 -15
  16. package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
  17. package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
  18. package/src/annotations/__tests__/placement.test.ts +316 -0
  19. package/src/annotations/collisions.ts +19 -7
  20. package/src/annotations/compute.ts +257 -16
  21. package/src/annotations/geometry.ts +73 -15
  22. package/src/annotations/placement.ts +588 -0
  23. package/src/annotations/resolve-text.ts +58 -10
  24. package/src/charts/__tests__/post-process.test.ts +3 -3
  25. package/src/charts/bar/__tests__/compute.test.ts +131 -45
  26. package/src/charts/bar/compute.ts +44 -4
  27. package/src/charts/bar/index.ts +9 -2
  28. package/src/charts/bar/labels.ts +77 -26
  29. package/src/charts/column/labels.ts +29 -16
  30. package/src/charts/post-process.ts +21 -7
  31. package/src/charts/registry.ts +4 -2
  32. package/src/compile/layer.ts +31 -9
  33. package/src/compile.ts +88 -149
  34. package/src/index.ts +9 -0
  35. package/src/layout/axes/rotation.ts +36 -0
  36. package/src/layout/axes.ts +130 -8
  37. package/src/layout/dimensions.ts +80 -98
  38. package/src/layout/plan.ts +550 -0
  39. package/src/layout/scales.ts +22 -0
  40. package/src/layout/shared.ts +44 -0
  41. package/src/legend/compute.ts +315 -78
  42. package/src/legend/wrap.ts +11 -7
  43. package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
  44. package/src/tables/__tests__/heatmap.test.ts +24 -20
  45. package/src/tables/heatmap.ts +25 -9
@@ -0,0 +1,550 @@
1
+ import type {
2
+ CompileOptions,
3
+ Encoding,
4
+ LayoutStrategy,
5
+ MeasureTextFn,
6
+ Rect,
7
+ ResolvedChrome,
8
+ ResolvedTheme,
9
+ } from '@opendata-ai/openchart-core';
10
+ import {
11
+ AXIS_TITLE_TRAILING_PAD,
12
+ abbreviateNumber,
13
+ axisTitleOffset,
14
+ BREAKPOINT_COMPACT_MAX,
15
+ computeChrome,
16
+ computeXAxisExtentFromLabels,
17
+ estimateTextWidth,
18
+ formatNumber,
19
+ HPAD_COMPACT_FRACTION,
20
+ HPAD_COMPACT_MIN,
21
+ LABEL_GAP_COMPACT,
22
+ LABEL_GAP_DEFAULT,
23
+ MAX_LEFT_LABEL_FRACTION_COMPACT,
24
+ MAX_LEFT_LABEL_FRACTION_DEFAULT,
25
+ MAX_LEFT_LABEL_FRACTION_MEDIUM,
26
+ MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
27
+ NARROW_VIEWPORT_MAX,
28
+ TOP_PAD_EXTRA_NARROW,
29
+ } from '@opendata-ai/openchart-core';
30
+ import { format as d3Format } from 'd3-format';
31
+
32
+ import type { NormalizedChartSpec } from '../compiler/types';
33
+ import { computeLegendContent, type LegendContent } from '../legend/compute';
34
+ import { legendGap } from '../legend/wrap';
35
+ import { resolveBandTickAngle } from './axes/rotation';
36
+ import { buildContinuousTicks, scaleSupportsTickCount, targetTickCount } from './axes/ticks';
37
+ import { computeScales, estimateBandwidth } from './scales';
38
+ import { bottomMargin, chromeToInput, INLINE_TICK_OVERHANG_PAD, scalePadding } from './shared';
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // MeasureFn -- simplified width-only text measurement
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** Width-only text measurement function used by the layout planner. */
45
+ export type MeasureFn = (text: string, fontSize: number, fontWeight: number) => number;
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // LayoutPlan -- frozen dimensions from the measure phase
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /** Frozen layout dimensions produced by the measure phase. */
52
+ export interface LayoutPlan {
53
+ leftGutter: number;
54
+ xAxisExtent: number;
55
+ legendContent: LegendContent;
56
+ chrome: ResolvedChrome;
57
+ yTickValues: unknown[];
58
+ yTickCount: number;
59
+ converged: boolean;
60
+ inlineYLabelInset: number;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // createMeasureFn -- adapter from MeasureTextFn to MeasureFn
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * Wraps an optional adapter-provided `MeasureTextFn` (returns `{ width, height }`)
69
+ * into a simpler `MeasureFn` that returns width only. Falls back to the
70
+ * heuristic `estimateTextWidth` when no real measurement is available.
71
+ */
72
+ export function createMeasureFn(measureText?: MeasureTextFn): MeasureFn {
73
+ if (measureText) {
74
+ return (text: string, fontSize: number, fontWeight: number) =>
75
+ measureText(text, fontSize, fontWeight).width;
76
+ }
77
+ return estimateTextWidth;
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Helpers
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /** Format a sample label from magnitude for gutter seeding. */
85
+ function sampleLabelFromMagnitude(maxAbsVal: number): string {
86
+ return maxAbsVal >= 1_000 ? abbreviateNumber(maxAbsVal) : formatNumber(maxAbsVal);
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // resolveLayoutPlan
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /**
94
+ * Measure real axis labels and legend content, then freeze the chart layout.
95
+ *
96
+ * Runs up to 2 iterations: build provisional scales at an estimated gutter,
97
+ * generate real y-axis tick labels, measure their widths, and check whether
98
+ * the gutter changed. If it converged on the first pass, we're done;
99
+ * otherwise a second pass locks in the final gutter as max(pass0, pass1).
100
+ *
101
+ * @param chartSpec - UNFILTERED spec (used for legend -- retains all series)
102
+ * @param renderSpec - FILTERED spec (used for measuring data and scales)
103
+ * @param options - CompileOptions (width, height, measureText, etc.)
104
+ * @param theme - Resolved theme
105
+ * @param strategy - Responsive layout strategy
106
+ * @param watermark - Whether the brand watermark is shown
107
+ * @param measure - Width-only text measurement function
108
+ */
109
+ export function resolveLayoutPlan(
110
+ chartSpec: NormalizedChartSpec,
111
+ renderSpec: NormalizedChartSpec,
112
+ options: CompileOptions,
113
+ theme: ResolvedTheme,
114
+ strategy: LayoutStrategy,
115
+ watermark: boolean,
116
+ measure: MeasureFn,
117
+ ): LayoutPlan {
118
+ const { width, height } = options;
119
+ const padding = scalePadding(theme.spacing.padding, width, height);
120
+ const hPad =
121
+ width < BREAKPOINT_COMPACT_MAX
122
+ ? Math.max(Math.round(padding * HPAD_COMPACT_FRACTION), HPAD_COMPACT_MIN)
123
+ : padding;
124
+ const encoding = renderSpec.encoding as Encoding;
125
+ const isRadial = renderSpec.markType === 'arc';
126
+ const axisMargin = theme.spacing.axisMargin;
127
+
128
+ // Resolve chromeMode
129
+ let chromeMode = strategy?.chromeMode ?? 'full';
130
+ if (renderSpec.display === 'sparkline' && !renderSpec.userExplicit?.chrome) {
131
+ chromeMode = 'hidden';
132
+ }
133
+
134
+ // -----------------------------------------------------------------------
135
+ // Sparkline / radial: trivial plan (no y-axis measurement needed)
136
+ // -----------------------------------------------------------------------
137
+ if (renderSpec.display === 'sparkline' || isRadial) {
138
+ const legendContent = computeLegendContent(
139
+ chartSpec,
140
+ strategy,
141
+ theme,
142
+ width,
143
+ height,
144
+ watermark,
145
+ measure,
146
+ );
147
+ const bottomLegendReservation =
148
+ legendContent.entries.length > 0 && legendContent.position === 'bottom'
149
+ ? legendContent.height + legendGap(width)
150
+ : 0;
151
+ const chrome = computeChrome(
152
+ chromeToInput(renderSpec.chrome),
153
+ theme,
154
+ width,
155
+ options.measureText,
156
+ chromeMode,
157
+ padding,
158
+ watermark,
159
+ bottomLegendReservation,
160
+ );
161
+ return {
162
+ leftGutter: hPad,
163
+ xAxisExtent: 0,
164
+ legendContent,
165
+ chrome,
166
+ yTickValues: [],
167
+ yTickCount: 0,
168
+ converged: true,
169
+ inlineYLabelInset: 0,
170
+ };
171
+ }
172
+
173
+ // -----------------------------------------------------------------------
174
+ // Resolve y-axis inline status
175
+ // -----------------------------------------------------------------------
176
+ const yAxisCfg = (encoding.y?.axis as Record<string, unknown> | undefined) ?? undefined;
177
+ const yTickPositionExplicit = yAxisCfg?.tickPosition as 'inline' | 'gutter' | undefined;
178
+ const yIsContinuous = encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal';
179
+ const yIsLineOrArea = renderSpec.markType === 'line' || renderSpec.markType === 'area';
180
+ const yAxisOrient = yAxisCfg?.orient as string | undefined;
181
+ // Sparkline display already returned early above, so no sparkline guard needed here.
182
+ const yIsInline =
183
+ yTickPositionExplicit === 'inline' ||
184
+ (yTickPositionExplicit === undefined &&
185
+ yIsLineOrArea &&
186
+ yIsContinuous &&
187
+ yAxisOrient !== 'right');
188
+ const yAxisSuppressed = encoding.y?.axis === false;
189
+
190
+ // -----------------------------------------------------------------------
191
+ // Seed gutter from formatted max |y|
192
+ // -----------------------------------------------------------------------
193
+ let leftGutter = hPad + axisMargin; // base: pad + axis margin
194
+ if (encoding.y && !isRadial && !yAxisSuppressed && !yIsInline) {
195
+ if (
196
+ encoding.y.type === 'nominal' ||
197
+ encoding.y.type === 'ordinal' ||
198
+ renderSpec.markType === 'bar' ||
199
+ renderSpec.markType === 'circle' ||
200
+ renderSpec.markType === 'lollipop'
201
+ ) {
202
+ // Category labels: measure real data values
203
+ const yField = encoding.y.field;
204
+ const yLabelField = yAxisCfg?.labelField as string | undefined;
205
+ let maxLabelWidth = 0;
206
+ for (const row of renderSpec.data) {
207
+ const label = String(row[yField] ?? '');
208
+ let w = measure(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
209
+ if (yLabelField) {
210
+ const subtitle = String(row[yLabelField] ?? '');
211
+ if (subtitle) {
212
+ const gap = theme.fonts.sizes.axisTick * 0.6;
213
+ const subtitleWidth = measure(
214
+ subtitle,
215
+ theme.fonts.sizes.axisTick,
216
+ theme.fonts.weights.normal,
217
+ );
218
+ w += gap + subtitleWidth;
219
+ }
220
+ }
221
+ if (w > maxLabelWidth) maxLabelWidth = w;
222
+ }
223
+ if (maxLabelWidth > 0) {
224
+ const labelGap = width < NARROW_VIEWPORT_MAX ? LABEL_GAP_COMPACT : LABEL_GAP_DEFAULT;
225
+ const maxLeftFraction =
226
+ width < BREAKPOINT_COMPACT_MAX
227
+ ? MAX_LEFT_LABEL_FRACTION_COMPACT
228
+ : width < MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX
229
+ ? MAX_LEFT_LABEL_FRACTION_MEDIUM
230
+ : MAX_LEFT_LABEL_FRACTION_DEFAULT;
231
+ const maxLeftReserved = Math.floor(width * maxLeftFraction);
232
+ const reserved = Math.min(hPad + maxLabelWidth + labelGap, maxLeftReserved);
233
+ leftGutter = Math.max(leftGutter, reserved);
234
+ }
235
+ } else {
236
+ // Quantitative: seed with a formatted label (better than '1.5B' guess)
237
+ const yField = encoding.y.field;
238
+ const yAxisFormat = yAxisCfg?.format as string | undefined;
239
+ let maxAbsVal = 0;
240
+ for (const row of renderSpec.data) {
241
+ const v = Number(row[yField]);
242
+ if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
243
+ }
244
+ let sampleLabel: string;
245
+ if (yAxisFormat) {
246
+ try {
247
+ sampleLabel = d3Format(yAxisFormat)(maxAbsVal);
248
+ } catch {
249
+ sampleLabel = String(maxAbsVal);
250
+ }
251
+ } else {
252
+ sampleLabel = sampleLabelFromMagnitude(maxAbsVal);
253
+ }
254
+ const negPrefix = renderSpec.data.some((r) => Number(r[yField]) < 0) ? '-' : '';
255
+ const labelWidth = measure(
256
+ negPrefix + sampleLabel,
257
+ theme.fonts.sizes.axisTick,
258
+ theme.fonts.weights.normal,
259
+ );
260
+ leftGutter = Math.max(leftGutter, hPad + labelWidth + 10);
261
+ }
262
+ }
263
+
264
+ // -----------------------------------------------------------------------
265
+ // X-axis setup (doesn't change between iterations)
266
+ // -----------------------------------------------------------------------
267
+ const xAxisSuppressed = encoding.x?.axis === false;
268
+ const xAxis = (!xAxisSuppressed && encoding.x?.axis) as Record<string, unknown> | undefined;
269
+ const hasXAxisTitle = !!xAxis?.title;
270
+ const xTickAngle = xAxis?.labelAngle as number | undefined;
271
+
272
+ // Collect x labels for extent computation
273
+ const xLabels: string[] = [];
274
+ if (!isRadial && !xAxisSuppressed && encoding.x) {
275
+ const xField = encoding.x.field;
276
+ if (encoding.x.type === 'nominal' || encoding.x.type === 'ordinal') {
277
+ const seen = new Set<string>();
278
+ for (const row of renderSpec.data) {
279
+ const label = String(row[xField] ?? '');
280
+ if (!seen.has(label)) {
281
+ seen.add(label);
282
+ xLabels.push(label);
283
+ }
284
+ }
285
+ }
286
+ }
287
+
288
+ // -----------------------------------------------------------------------
289
+ // Iteration loop (max 2)
290
+ // -----------------------------------------------------------------------
291
+ let prev: { gutter: number; rowCount: number; xAxisExtent: number } | null = null;
292
+ let finalPlan: LayoutPlan | null = null;
293
+
294
+ for (let iter = 0; iter < 2; iter++) {
295
+ // Legend content
296
+ const legendAvailWidth = width - padding - leftGutter;
297
+ const legendContent = computeLegendContent(
298
+ chartSpec,
299
+ strategy,
300
+ theme,
301
+ legendAvailWidth,
302
+ height,
303
+ watermark,
304
+ measure,
305
+ );
306
+
307
+ // Chrome
308
+ const bottomLegendReservation =
309
+ legendContent.entries.length > 0 && legendContent.position === 'bottom'
310
+ ? legendContent.height + legendGap(width)
311
+ : 0;
312
+ const chrome = computeChrome(
313
+ chromeToInput(renderSpec.chrome),
314
+ theme,
315
+ width,
316
+ options.measureText,
317
+ chromeMode,
318
+ padding,
319
+ watermark,
320
+ bottomLegendReservation,
321
+ );
322
+
323
+ // Effective x tick angle. Band x-axes auto-rotate to -45° when horizontal
324
+ // labels don't fit their band; computeAxes makes the same call later. The
325
+ // planner must know the angle now so it reserves the rotated-label
326
+ // footprint in the bottom margin instead of the (smaller) flat one.
327
+ let effectiveXTickAngle = xTickAngle;
328
+ if (xTickAngle === undefined && xLabels.length > 1) {
329
+ const rightMarginEst = hPad + (isRadial ? hPad : axisMargin);
330
+ const plotWidth = Math.max(0, width - leftGutter - Math.max(rightMarginEst, hPad));
331
+ // Mirror d3 scaleBand: step = plotWidth / (n - paddingInner + 2*paddingOuter),
332
+ // bandwidth = step * (1 - paddingInner). Uses the same override resolution
333
+ // as buildBandScale so paddingInner/paddingOuter are honored here too.
334
+ const bandwidth = estimateBandwidth(encoding.x?.scale, plotWidth, xLabels.length);
335
+ let maxXLabelWidth = 0;
336
+ for (const label of xLabels) {
337
+ const w = measure(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
338
+ if (w > maxXLabelWidth) maxXLabelWidth = w;
339
+ }
340
+ effectiveXTickAngle = resolveBandTickAngle(
341
+ undefined,
342
+ maxXLabelWidth,
343
+ bandwidth,
344
+ xLabels.length,
345
+ );
346
+ }
347
+
348
+ // X-axis extent
349
+ const xAxisExtent =
350
+ isRadial || xAxisSuppressed
351
+ ? 0
352
+ : computeXAxisExtentFromLabels({
353
+ labels: xLabels,
354
+ tickAngle: effectiveXTickAngle,
355
+ hasTitle: hasXAxisTitle,
356
+ tickFontSize: theme.fonts.sizes.axisTick,
357
+ tickFontWeight: theme.fonts.weights.normal,
358
+ xAxisHeight: theme.spacing.xAxisHeight,
359
+ measure,
360
+ });
361
+
362
+ // Top margin
363
+ const topPad = width < NARROW_VIEWPORT_MAX ? padding + TOP_PAD_EXTRA_NARROW : padding;
364
+ const hasTopLegend = legendContent.entries.length > 0 && legendContent.position === 'top';
365
+ const gap = legendGap(width);
366
+ const inlineTickOverhang = yIsInline
367
+ ? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD
368
+ : 0;
369
+ const topAxisGap = axisMargin + inlineTickOverhang;
370
+ const effectiveTopAxisGap = hasTopLegend ? inlineTickOverhang : topAxisGap;
371
+
372
+ let topMargin = topPad + chrome.topHeight;
373
+ if (hasTopLegend) topMargin += legendContent.height + gap;
374
+ topMargin += effectiveTopAxisGap;
375
+
376
+ const bMargin = bottomMargin(chrome.bottomHeight, padding, xAxisExtent);
377
+ const chartHeight = Math.max(0, height - topMargin - bMargin);
378
+
379
+ // Provisional area
380
+ const rightMargin = hPad + (isRadial ? hPad : axisMargin);
381
+ const rightAxisReserve = options.rightAxisReserve ?? 0;
382
+ const provisionalArea: Rect = {
383
+ x: leftGutter,
384
+ y: topMargin,
385
+ width: Math.max(0, width - leftGutter - Math.max(rightMargin, hPad + rightAxisReserve)),
386
+ height: chartHeight,
387
+ };
388
+
389
+ // Build provisional scales and generate real y ticks
390
+ let yTickValues: unknown[] = [];
391
+ let yTickCount = 0;
392
+ let newGutter = leftGutter;
393
+
394
+ if (encoding.y && yIsContinuous && !yAxisSuppressed) {
395
+ const scales = computeScales(renderSpec, provisionalArea, renderSpec.data);
396
+ if (scales.y && scaleSupportsTickCount(scales.y)) {
397
+ const yTargetCount = targetTickCount(chartHeight, 'full', 'y');
398
+ const axisTickCount = yAxisCfg?.tickCount as number | undefined;
399
+ const effectiveCount = axisTickCount ?? yTargetCount;
400
+ const ticks = buildContinuousTicks(scales.y, effectiveCount);
401
+ yTickValues = ticks.map((t) => t.value);
402
+ yTickCount = ticks.length;
403
+
404
+ // Measure real tick labels for gutter
405
+ if (!yIsInline) {
406
+ let maxLabelWidth = 0;
407
+ for (const t of ticks) {
408
+ const w = measure(t.label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
409
+ if (w > maxLabelWidth) maxLabelWidth = w;
410
+ }
411
+ newGutter = hPad + maxLabelWidth + 10;
412
+ }
413
+ }
414
+ }
415
+
416
+ // Convergence check
417
+ const converged =
418
+ prev != null &&
419
+ Math.abs(newGutter - prev.gutter) <= 0.5 &&
420
+ legendContent.rowCount === prev.rowCount &&
421
+ Math.abs(xAxisExtent - prev.xAxisExtent) <= 0.5;
422
+
423
+ if (converged || iter === 1) {
424
+ // On non-convergence at iter=1: gutter = max(iter0, iter1)
425
+ const finalGutter = prev && !converged ? Math.max(prev.gutter, newGutter) : newGutter;
426
+
427
+ // If non-convergent, regenerate ticks once at the final height
428
+ if (prev && !converged && encoding.y && yIsContinuous && !yAxisSuppressed) {
429
+ const finalProvArea: Rect = {
430
+ x: finalGutter,
431
+ y: topMargin,
432
+ width: Math.max(0, width - finalGutter - Math.max(rightMargin, hPad + rightAxisReserve)),
433
+ height: chartHeight,
434
+ };
435
+ const scales = computeScales(renderSpec, finalProvArea, renderSpec.data);
436
+ if (scales.y && scaleSupportsTickCount(scales.y)) {
437
+ const yTargetCount = targetTickCount(chartHeight, 'full', 'y');
438
+ const axisTickCount = yAxisCfg?.tickCount as number | undefined;
439
+ const effectiveCount = axisTickCount ?? yTargetCount;
440
+ const ticks = buildContinuousTicks(scales.y, effectiveCount);
441
+ yTickValues = ticks.map((t) => t.value);
442
+ yTickCount = ticks.length;
443
+ }
444
+ }
445
+
446
+ // Compute inline y-label inset
447
+ let inlineYLabelInset = 0;
448
+ if (yIsInline && encoding.y && yIsContinuous && encoding.y.axis !== false) {
449
+ if (yTickValues.length > 0) {
450
+ // Build ticks from the final scales to get formatted labels
451
+ const finalProvArea: Rect = {
452
+ x: finalGutter,
453
+ y: topMargin,
454
+ width: Math.max(0, width - finalGutter - rightMargin),
455
+ height: chartHeight,
456
+ };
457
+ const scales = computeScales(renderSpec, finalProvArea, renderSpec.data);
458
+ if (scales.y && scaleSupportsTickCount(scales.y)) {
459
+ const ticks = buildContinuousTicks(scales.y, yTickCount || 5);
460
+ let maxLabelWidth = 0;
461
+ for (const t of ticks) {
462
+ const w = measure(t.label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
463
+ if (w > maxLabelWidth) maxLabelWidth = w;
464
+ }
465
+ if (maxLabelWidth > 0) {
466
+ inlineYLabelInset = Math.ceil(maxLabelWidth + 8);
467
+ }
468
+ }
469
+ } else {
470
+ // Fallback: measure from data max
471
+ const yField = encoding.y.field;
472
+ const yAxisFormat = yAxisCfg?.format as string | undefined;
473
+ let maxAbsVal = 0;
474
+ for (const row of renderSpec.data) {
475
+ const v = Number(row[yField]);
476
+ if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
477
+ }
478
+ let sample: string;
479
+ if (yAxisFormat) {
480
+ try {
481
+ sample = d3Format(yAxisFormat)(maxAbsVal);
482
+ } catch {
483
+ sample = String(maxAbsVal);
484
+ }
485
+ } else {
486
+ sample = sampleLabelFromMagnitude(maxAbsVal);
487
+ }
488
+ const neg = renderSpec.data.some((r) => Number(r[encoding.y!.field]) < 0) ? '-' : '';
489
+ const maxLabelWidth = measure(
490
+ neg + sample,
491
+ theme.fonts.sizes.axisTick,
492
+ theme.fonts.weights.normal,
493
+ );
494
+ if (maxLabelWidth > 0) {
495
+ inlineYLabelInset = Math.ceil(maxLabelWidth + 8);
496
+ }
497
+ }
498
+ }
499
+
500
+ // Account for rotated y-axis title in the gutter
501
+ let gutterWithTitle = finalGutter;
502
+ const yAxisDef = encoding.y?.axis as Record<string, unknown> | undefined;
503
+ if (yAxisDef && (yAxisDef.title || yAxisDef.label) && !isRadial) {
504
+ let estTickLabelWidth = 0;
505
+ if (encoding.y?.field && yIsContinuous) {
506
+ if (yTickValues.length > 0) {
507
+ const titleProvArea: Rect = {
508
+ x: finalGutter,
509
+ y: topMargin,
510
+ width: Math.max(0, width - finalGutter - hPad),
511
+ height: chartHeight,
512
+ };
513
+ const scales = computeScales(renderSpec, titleProvArea, renderSpec.data);
514
+ if (scales.y && scaleSupportsTickCount(scales.y)) {
515
+ const ticks = buildContinuousTicks(scales.y, yTickCount || 5);
516
+ for (const t of ticks) {
517
+ const w = measure(t.label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
518
+ if (w > estTickLabelWidth) estTickLabelWidth = w;
519
+ }
520
+ }
521
+ }
522
+ }
523
+ const titleFontSize = theme.fonts.sizes.body;
524
+ const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width);
525
+ const halfGlyph = Math.ceil(titleFontSize / 2);
526
+ const rotatedLabelMargin =
527
+ offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
528
+ gutterWithTitle = Math.max(finalGutter, hPad + rotatedLabelMargin);
529
+ }
530
+
531
+ finalPlan = {
532
+ leftGutter: gutterWithTitle,
533
+ xAxisExtent,
534
+ legendContent,
535
+ chrome,
536
+ yTickValues,
537
+ yTickCount,
538
+ converged: prev === null || converged,
539
+ inlineYLabelInset,
540
+ };
541
+ break;
542
+ }
543
+
544
+ prev = { gutter: newGutter, rowCount: legendContent.rowCount, xAxisExtent };
545
+ leftGutter = newGutter;
546
+ }
547
+
548
+ if (!finalPlan) throw new Error('resolveLayoutPlan: loop exited without producing a plan');
549
+ return finalPlan;
550
+ }
@@ -463,6 +463,28 @@ function evenRange(start: number, end: number, count: number): number[] {
463
463
  return Array.from({ length: count }, (_, i) => start + step * i);
464
464
  }
465
465
 
466
+ /**
467
+ * Estimate d3 scaleBand bandwidth for a given plot width and category count,
468
+ * mirroring buildBandScale's padding resolution. d3 sets both paddingInner and
469
+ * paddingOuter to `padding`, then paddingInner/paddingOuter overrides adjust
470
+ * each independently. step = width / (n - paddingInner + 2*paddingOuter);
471
+ * bandwidth = step * (1 - paddingInner).
472
+ */
473
+ export function estimateBandwidth(
474
+ scaleConfig: EncodingChannel['scale'] | undefined,
475
+ plotWidth: number,
476
+ n: number,
477
+ ): number {
478
+ if (n <= 0) return 0;
479
+ const padding = scaleConfig?.padding ?? 0.35;
480
+ const paddingInner = scaleConfig?.paddingInner ?? padding;
481
+ const paddingOuter = scaleConfig?.paddingOuter ?? padding;
482
+ const denom = n - paddingInner + 2 * paddingOuter;
483
+ if (denom <= 0) return 0;
484
+ const step = plotWidth / denom;
485
+ return step * (1 - paddingInner);
486
+ }
487
+
466
488
  function buildBandScale(
467
489
  channel: EncodingChannel,
468
490
  data: DataRow[],
@@ -0,0 +1,44 @@
1
+ import type { NormalizedChrome } from '../compiler/types';
2
+
3
+ /** Convert NormalizedChrome back to a Chrome-compatible shape for computeChrome. */
4
+ export function chromeToInput(
5
+ chrome: NormalizedChrome,
6
+ ): import('@opendata-ai/openchart-core').Chrome {
7
+ return {
8
+ eyebrow: chrome.eyebrow,
9
+ title: chrome.title,
10
+ subtitle: chrome.subtitle,
11
+ source: chrome.source,
12
+ byline: chrome.byline,
13
+ footer: chrome.footer,
14
+ brand: chrome.brand,
15
+ };
16
+ }
17
+
18
+ /**
19
+ * Scale padding based on the smaller container dimension.
20
+ * At >= 500px, padding is unchanged. At <= 200px, padding is halved (min 4px).
21
+ * Linear interpolation between 200-500px.
22
+ */
23
+ export function scalePadding(basePadding: number, width: number, height: number): number {
24
+ const minDim = Math.min(width, height);
25
+ if (minDim >= 500) return basePadding;
26
+ if (minDim <= 200) return Math.max(Math.round(basePadding * 0.5), 4);
27
+ const t = (minDim - 200) / 300;
28
+ return Math.max(Math.round(basePadding * (0.5 + t * 0.5)), 4);
29
+ }
30
+
31
+ /**
32
+ * Compute the bottom margin contribution from chrome.
33
+ * Padding always applies (gap between x-axis ticks and the chrome below).
34
+ * bottomHeight is additive on top.
35
+ */
36
+ export function bottomMargin(bottomHeight: number, padding: number, xAxisHeight: number): number {
37
+ return padding + bottomHeight + xAxisHeight;
38
+ }
39
+
40
+ /**
41
+ * Vertical breathing room added to the inline-tick label height so the
42
+ * topmost tick has clearance from chrome.
43
+ */
44
+ export const INLINE_TICK_OVERHANG_PAD = 6;