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