@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
@@ -41,11 +41,13 @@ import {
41
41
  } from '@opendata-ai/openchart-core';
42
42
  import { format as d3Format } from 'd3-format';
43
43
 
44
- import type { NormalizedChartSpec, NormalizedChrome } from '../compiler/types';
44
+ import type { NormalizedChartSpec } from '../compiler/types';
45
45
  import { predictEndpointLabelsWidth } from '../endpoint-labels/predict';
46
46
  import { countColorSeries, resolveSuppression } from '../legend/suppression';
47
47
  import { legendGap } from '../legend/wrap';
48
48
  import { computeMetricBar, type MetricFontSizes, metricBarHeight } from './metrics';
49
+ import type { LayoutPlan } from './plan';
50
+ import { bottomMargin, chromeToInput, INLINE_TICK_OVERHANG_PAD, scalePadding } from './shared';
49
51
 
50
52
  /** Pull the metric-row font sizes from the resolved theme. */
51
53
  function metricFonts(theme: ResolvedTheme): MetricFontSizes {
@@ -81,57 +83,10 @@ export interface LayoutDimensions {
81
83
  xAxisHeight: number;
82
84
  }
83
85
 
84
- // ---------------------------------------------------------------------------
85
- // Helpers
86
- // ---------------------------------------------------------------------------
87
-
88
- /** Convert NormalizedChrome back to a Chrome-compatible shape for computeChrome. */
89
- function chromeToInput(chrome: NormalizedChrome): import('@opendata-ai/openchart-core').Chrome {
90
- return {
91
- eyebrow: chrome.eyebrow,
92
- title: chrome.title,
93
- subtitle: chrome.subtitle,
94
- source: chrome.source,
95
- byline: chrome.byline,
96
- footer: chrome.footer,
97
- brand: chrome.brand,
98
- };
99
- }
100
-
101
- /**
102
- * Compute the bottom margin contribution from chrome.
103
- * Padding always applies (gap between x-axis ticks and the chrome below).
104
- * bottomHeight is additive on top — it covers source/watermark/legend space
105
- * including its own internal padding when content is present.
106
- */
107
- function bottomMargin(bottomHeight: number, padding: number, xAxisHeight: number): number {
108
- return padding + bottomHeight + xAxisHeight;
109
- }
110
-
111
- /**
112
- * Scale padding based on the smaller container dimension.
113
- * At >= 500px, padding is unchanged. At <= 200px, padding is halved (min 4px).
114
- * Linear interpolation between 200-500px.
115
- */
116
- function scalePadding(basePadding: number, width: number, height: number): number {
117
- const minDim = Math.min(width, height);
118
- if (minDim >= 500) return basePadding;
119
- if (minDim <= 200) return Math.max(Math.round(basePadding * 0.5), 4);
120
- const t = (minDim - 200) / 300;
121
- return Math.max(Math.round(basePadding * (0.5 + t * 0.5)), 4);
122
- }
123
-
124
86
  /** Minimum chart area dimensions before guardrails kick in. */
125
87
  const MIN_CHART_WIDTH = 60;
126
88
  const MIN_CHART_HEIGHT = 40;
127
89
 
128
- /**
129
- * Vertical breathing room added to the inline-tick label height so the
130
- * topmost tick has clearance from chrome. Inline ticks sit above their
131
- * gridline by `axisTick + INLINE_TICK_OVERHANG_PAD` pixels.
132
- */
133
- const INLINE_TICK_OVERHANG_PAD = 6;
134
-
135
90
  /**
136
91
  * Per-display minimum chart dimensions. Sparkline mode allows much smaller
137
92
  * containers (down to ~30x20px) since the entire chart is just the mark.
@@ -199,6 +154,7 @@ export function computeDimensions(
199
154
  theme: ResolvedTheme,
200
155
  strategy?: LayoutStrategy,
201
156
  watermark: boolean = true,
157
+ plan?: LayoutPlan,
202
158
  ): LayoutDimensions {
203
159
  const { width, height } = options;
204
160
 
@@ -223,43 +179,41 @@ export function computeDimensions(
223
179
 
224
180
  // Pre-compute the bottom-legend reservation (legend height + gap) so the
225
181
  // chrome layout can stack source/byline/footer below the legend band.
226
- // Chart-side bottom legends only — right/top/bottom-right legends don't
227
- // share vertical space with bottom chrome.
228
- //
229
- // We narrow on `'entries' in` (a structural brand) rather than the
230
- // `legendLayout.type` discriminator because `type` is optional on
231
- // CategoricalLegendLayout for back-compat with older legend payloads.
232
- // Once the discriminator is required everywhere, this can collapse to
233
- // `legendLayout.type !== 'gradient'`.
234
- const bottomLegendReservation =
235
- 'entries' in legendLayout &&
236
- legendLayout.entries.length > 0 &&
237
- legendLayout.position === 'bottom'
182
+ // When a layout plan is present, derive legend info from plan.legendContent;
183
+ // otherwise fall back to the legendLayout parameter (back-compat).
184
+ const bottomLegendReservation = plan
185
+ ? plan.legendContent.entries.length > 0 && plan.legendContent.position === 'bottom'
186
+ ? plan.legendContent.height + legendGap(width)
187
+ : 0
188
+ : 'entries' in legendLayout &&
189
+ legendLayout.entries.length > 0 &&
190
+ legendLayout.position === 'bottom'
238
191
  ? legendLayout.bounds.height + legendGap(width)
239
192
  : 0;
240
193
 
241
- // Compute chrome with mode and scaled padding. `bottomLegendReservation`
242
- // pushes bottom chrome below the legend band; the returned bottomHeight
243
- // already accounts for it, so margin math below must not re-add it.
194
+ // When a layout plan is present, use its pre-computed chrome directly.
195
+ // Otherwise compute chrome with mode and scaled padding.
244
196
  //
245
197
  // Invariant: bottom-legend space is owned by `chrome.bottomHeight`, not
246
198
  // `margins.bottom`. The legend reservation flows like this:
247
199
  // bottomLegendReservation = legend.height + legendGap(width)
248
- // chrome.bottomHeight ⊇ bottomLegendReservation (via computeChrome)
200
+ // chrome.bottomHeight >= bottomLegendReservation (via computeChrome)
249
201
  // margins.bottom = padding + chrome.bottomHeight + xAxisHeight
250
202
  // So the legend band is implicitly inside margins.bottom exactly once.
251
203
  // The legend-reservation block further down explicitly skips position
252
204
  // 'bottom' to avoid double-counting.
253
- const chrome = computeChrome(
254
- chromeToInput(spec.chrome),
255
- theme,
256
- width,
257
- options.measureText,
258
- chromeMode,
259
- padding,
260
- watermark,
261
- bottomLegendReservation,
262
- );
205
+ const chrome =
206
+ plan?.chrome ??
207
+ computeChrome(
208
+ chromeToInput(spec.chrome),
209
+ theme,
210
+ width,
211
+ options.measureText,
212
+ chromeMode,
213
+ padding,
214
+ watermark,
215
+ bottomLegendReservation,
216
+ );
263
217
 
264
218
  // Sparkline mode: produce a near-edge-to-edge layout. Only stroke-width-based
265
219
  // safety padding plus chrome (if user-explicit). Skip axis space, label
@@ -320,7 +274,9 @@ export function computeDimensions(
320
274
  const xTickAngle = xAxis?.labelAngle;
321
275
 
322
276
  let xAxisHeight: number;
323
- if (isRadial || xAxisSuppressed) {
277
+ if (plan) {
278
+ xAxisHeight = plan.xAxisExtent;
279
+ } else if (isRadial || xAxisSuppressed) {
324
280
  xAxisHeight = 0;
325
281
  } else if (xTickAngle && Math.abs(xTickAngle) > 10) {
326
282
  // Rotated labels: estimate height from the longest label text.
@@ -506,11 +462,12 @@ export function computeDimensions(
506
462
  const yIsInline = yIsInlinePre;
507
463
  if (encoding.y && !isRadial && !yAxisSuppressed && !yIsInline) {
508
464
  if (
509
- spec.markType === 'bar' ||
510
- spec.markType === 'circle' ||
511
- spec.markType === 'lollipop' ||
512
- encoding.y.type === 'nominal' ||
513
- encoding.y.type === 'ordinal'
465
+ !plan &&
466
+ (spec.markType === 'bar' ||
467
+ spec.markType === 'circle' ||
468
+ spec.markType === 'lollipop' ||
469
+ encoding.y.type === 'nominal' ||
470
+ encoding.y.type === 'ordinal')
514
471
  ) {
515
472
  // Category labels on the left for bar/dot charts
516
473
  const yField = encoding.y.field;
@@ -552,8 +509,10 @@ export function computeDimensions(
552
509
  const reserved = Math.min(hPad + maxLabelWidth + labelGap, maxLeftReserved);
553
510
  margins.left = Math.max(margins.left, reserved);
554
511
  }
555
- } else if (encoding.y.type === 'quantitative' || encoding.y.type === 'temporal') {
512
+ } else if ((encoding.y.type === 'quantitative' || encoding.y.type === 'temporal') && !plan) {
556
513
  // Numeric tick labels on the left. Estimate width from the data range.
514
+ // Skipped when a layout plan is present -- the plan measured real tick
515
+ // labels and provides plan.leftGutter below.
557
516
  const yField = encoding.y.field;
558
517
  const yAxisFormat = (encoding.y.axis as Record<string, unknown> | undefined)?.format as
559
518
  | string
@@ -599,8 +558,10 @@ export function computeDimensions(
599
558
  // The renderer computes a dynamic offset that accounts for wide tick labels (e.g.
600
559
  // "$100,000" is ~62px wide and would overlap a fixed 45px offset). We replicate
601
560
  // the same formula here so the reserved space matches what the renderer places.
561
+ // Skipped when a layout plan is present -- the plan already accounts for the
562
+ // y-axis title in its leftGutter calculation.
602
563
  const yAxis = encoding.y?.axis as Record<string, unknown> | undefined;
603
- if (yAxis && (yAxis.title || yAxis.label) && !isRadial) {
564
+ if (yAxis && (yAxis.title || yAxis.label) && !isRadial && !plan) {
604
565
  // Estimate the widest y-axis tick label width to mirror the renderer's dynamic offset.
605
566
  const yFieldForTitle = encoding.y?.field;
606
567
  const yAxisFormatForTitle = yAxis?.format as string | undefined;
@@ -650,6 +611,14 @@ export function computeDimensions(
650
611
  margins.left = Math.max(margins.left, hPad + rotatedLabelMargin);
651
612
  }
652
613
 
614
+ // When a layout plan is present, its leftGutter was measured from real tick
615
+ // labels and already accounts for quantitative guess + y-axis title. Apply
616
+ // it as a floor so the plan's measurement wins over the category-label
617
+ // reservation above (which stays for bar/dot charts).
618
+ if (plan) {
619
+ margins.left = Math.max(margins.left, plan.leftGutter);
620
+ }
621
+
653
622
  // Reserve space for a secondary (right) y-axis in dual-axis charts.
654
623
  // Use Math.max (not +=) to mirror the left-margin pattern: the reserve
655
624
  // replaces the base axisMargin when it's larger, instead of stacking.
@@ -664,16 +633,36 @@ export function computeDimensions(
664
633
  // here. The legend lands below the x-axis tick row (which is reserved via
665
634
  // `xAxisHeight` in the base bottom margin) and source/byline/footer chrome
666
635
  // stacks underneath the legend band rather than colliding with it.
667
- const hasTopLegend =
668
- 'entries' in legendLayout && legendLayout.entries.length > 0 && legendLayout.position === 'top';
669
- if ('entries' in legendLayout && legendLayout.entries.length > 0) {
636
+ // When a plan is present, derive legend position/size from plan.legendContent;
637
+ // otherwise use the legendLayout parameter (back-compat for non-plan callers).
638
+ const legendHasEntries = plan
639
+ ? plan.legendContent.entries.length > 0
640
+ : 'entries' in legendLayout && legendLayout.entries.length > 0;
641
+ const legendPos = plan
642
+ ? plan.legendContent.position
643
+ : 'entries' in legendLayout
644
+ ? legendLayout.position
645
+ : 'top';
646
+ const legendHeight = plan
647
+ ? plan.legendContent.height
648
+ : 'entries' in legendLayout
649
+ ? legendLayout.bounds.height
650
+ : 0;
651
+ const legendBoundsWidth = plan
652
+ ? plan.legendContent.legendWidth
653
+ : 'entries' in legendLayout
654
+ ? legendLayout.bounds.width
655
+ : 0;
656
+
657
+ const hasTopLegend = legendHasEntries && legendPos === 'top';
658
+ if (legendHasEntries) {
670
659
  const gap = legendGap(width);
671
- if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
672
- margins.right += legendLayout.bounds.width + 8;
673
- } else if (legendLayout.position === 'top') {
674
- margins.top += legendLayout.bounds.height + gap;
660
+ if (legendPos === 'right' || legendPos === 'bottom-right') {
661
+ margins.right += legendBoundsWidth + 8;
662
+ } else if (legendPos === 'top') {
663
+ margins.top += legendHeight + gap;
675
664
  }
676
- // 'bottom' is intentionally not handled here — see bottomLegendReservation
665
+ // 'bottom' is intentionally not handled here -- see bottomLegendReservation
677
666
  // above.
678
667
  }
679
668
 
@@ -726,14 +715,7 @@ export function computeDimensions(
726
715
 
727
716
  if (topDelta > 0 || bottomDelta > 0) {
728
717
  const gap = legendGap(width);
729
- margins.top =
730
- newTop +
731
- ('entries' in legendLayout &&
732
- legendLayout.entries.length > 0 &&
733
- legendLayout.position === 'top'
734
- ? legendLayout.bounds.height + gap
735
- : 0) +
736
- fallbackEffectiveAxisGap;
718
+ margins.top = newTop + (hasTopLegend ? legendHeight + gap : 0) + fallbackEffectiveAxisGap;
737
719
  margins.bottom = newBottom;
738
720
 
739
721
  chartArea = {