@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.
- package/dist/index.d.ts +88 -39
- package/dist/index.js +4134 -2930
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +143 -78
- package/src/__tests__/axes.test.ts +129 -1
- package/src/__tests__/compile-chart.test.ts +6 -0
- package/src/__tests__/compile-layer.test.ts +49 -0
- package/src/__tests__/dimensions.test.ts +70 -2
- package/src/__tests__/helpers/invariants.ts +265 -0
- package/src/__tests__/known-bugs.test.ts +113 -0
- package/src/__tests__/layout-fuzz.test.ts +358 -0
- package/src/__tests__/layout-invariants.test.ts +535 -0
- package/src/__tests__/legend.test.ts +37 -0
- package/src/annotations/__tests__/compute.test.ts +8 -15
- package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
- package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
- package/src/annotations/__tests__/placement.test.ts +316 -0
- package/src/annotations/collisions.ts +19 -7
- package/src/annotations/compute.ts +257 -16
- package/src/annotations/geometry.ts +73 -15
- package/src/annotations/placement.ts +588 -0
- package/src/annotations/resolve-text.ts +58 -10
- package/src/charts/__tests__/post-process.test.ts +3 -3
- package/src/charts/bar/__tests__/compute.test.ts +131 -45
- package/src/charts/bar/compute.ts +44 -4
- package/src/charts/bar/index.ts +9 -2
- package/src/charts/bar/labels.ts +77 -26
- package/src/charts/column/labels.ts +29 -16
- package/src/charts/post-process.ts +21 -7
- package/src/charts/registry.ts +4 -2
- package/src/compile/layer.ts +31 -9
- package/src/compile.ts +88 -149
- package/src/index.ts +9 -0
- package/src/layout/axes/rotation.ts +36 -0
- package/src/layout/axes.ts +130 -8
- package/src/layout/dimensions.ts +80 -98
- package/src/layout/plan.ts +550 -0
- package/src/layout/scales.ts +22 -0
- package/src/layout/shared.ts +44 -0
- package/src/legend/compute.ts +315 -78
- package/src/legend/wrap.ts +11 -7
- package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
- package/src/tables/__tests__/heatmap.test.ts +24 -20
- package/src/tables/heatmap.ts +25 -9
package/src/layout/dimensions.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
//
|
|
242
|
-
//
|
|
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
|
|
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 =
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 (
|
|
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
|
-
|
|
510
|
-
spec.markType === '
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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 (
|
|
672
|
-
margins.right +=
|
|
673
|
-
} else if (
|
|
674
|
-
margins.top +=
|
|
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
|
|
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 = {
|