@opendata-ai/openchart-engine 6.28.6 → 7.0.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/README.md +1 -0
- package/dist/index.d.ts +8 -11
- package/dist/index.js +12296 -11337
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +3 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +452 -377
- package/src/__tests__/axes.test.ts +75 -0
- package/src/__tests__/compile-chart.test.ts +304 -0
- package/src/__tests__/dimensions.test.ts +224 -0
- package/src/__tests__/legend.test.ts +44 -3
- package/src/annotations/__tests__/compute.test.ts +111 -0
- package/src/annotations/__tests__/resolve-text.test.ts +288 -0
- package/src/annotations/constants.ts +20 -0
- package/src/annotations/resolve-text.ts +161 -7
- package/src/charts/bar/compute.ts +24 -0
- package/src/charts/bar/labels.ts +1 -0
- package/src/charts/column/compute.ts +33 -1
- package/src/charts/column/labels.ts +1 -0
- package/src/charts/dot/labels.ts +1 -0
- package/src/charts/line/__tests__/compute.test.ts +153 -3
- package/src/charts/line/area.ts +111 -23
- package/src/charts/line/compute.ts +40 -10
- package/src/charts/line/index.ts +34 -7
- package/src/charts/line/labels.ts +29 -0
- package/src/charts/pie/labels.ts +1 -0
- package/src/compile/layer.ts +497 -0
- package/src/compile.ts +211 -586
- package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
- package/src/compiler/normalize.ts +6 -1
- package/src/compiler/sparkline-defaults.ts +138 -0
- package/src/compiler/types.ts +8 -0
- package/src/endpoint-labels/__tests__/compute.test.ts +438 -0
- package/src/endpoint-labels/compute.ts +417 -0
- package/src/endpoint-labels/constants.ts +54 -0
- package/src/endpoint-labels/format.ts +30 -0
- package/src/endpoint-labels/predict.ts +108 -0
- package/src/graphs/compile-graph.ts +1 -0
- package/src/layout/axes.ts +27 -2
- package/src/layout/dimensions.ts +270 -33
- package/src/layout/metrics.ts +118 -0
- package/src/layout/scales.ts +41 -4
- package/src/legend/__tests__/suppression.test.ts +294 -0
- package/src/legend/compute.ts +50 -40
- package/src/legend/suppression.ts +204 -0
- package/src/sankey/compile-sankey.ts +2 -0
package/src/layout/dimensions.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
Margins,
|
|
20
20
|
Rect,
|
|
21
21
|
ResolvedChrome,
|
|
22
|
+
ResolvedMetricBar,
|
|
22
23
|
ResolvedTheme,
|
|
23
24
|
} from '@opendata-ai/openchart-core';
|
|
24
25
|
import {
|
|
@@ -41,7 +42,10 @@ import {
|
|
|
41
42
|
import { format as d3Format } from 'd3-format';
|
|
42
43
|
|
|
43
44
|
import type { NormalizedChartSpec, NormalizedChrome } from '../compiler/types';
|
|
45
|
+
import { predictEndpointLabelsWidth } from '../endpoint-labels/predict';
|
|
46
|
+
import { countColorSeries, resolveSuppression } from '../legend/suppression';
|
|
44
47
|
import { legendGap } from '../legend/wrap';
|
|
48
|
+
import { computeMetricBar, metricBarHeight } from './metrics';
|
|
45
49
|
|
|
46
50
|
// ---------------------------------------------------------------------------
|
|
47
51
|
// Types
|
|
@@ -59,6 +63,17 @@ export interface LayoutDimensions {
|
|
|
59
63
|
margins: Margins;
|
|
60
64
|
/** Resolved theme used for this layout. */
|
|
61
65
|
theme: ResolvedTheme;
|
|
66
|
+
/**
|
|
67
|
+
* Resolved metric bar (KPI cells above the chart area). Present only when
|
|
68
|
+
* spec.metrics is supplied AND the bar fits the container.
|
|
69
|
+
*/
|
|
70
|
+
metrics?: ResolvedMetricBar;
|
|
71
|
+
/**
|
|
72
|
+
* Height reserved below the chart area for x-axis tick labels and the
|
|
73
|
+
* (optional) axis title. Exposed so downstream layout code (e.g. the
|
|
74
|
+
* second legend pass) can position elements below the axis row.
|
|
75
|
+
*/
|
|
76
|
+
xAxisHeight: number;
|
|
62
77
|
}
|
|
63
78
|
|
|
64
79
|
// ---------------------------------------------------------------------------
|
|
@@ -68,11 +83,13 @@ export interface LayoutDimensions {
|
|
|
68
83
|
/** Convert NormalizedChrome back to a Chrome-compatible shape for computeChrome. */
|
|
69
84
|
function chromeToInput(chrome: NormalizedChrome): import('@opendata-ai/openchart-core').Chrome {
|
|
70
85
|
return {
|
|
86
|
+
eyebrow: chrome.eyebrow,
|
|
71
87
|
title: chrome.title,
|
|
72
88
|
subtitle: chrome.subtitle,
|
|
73
89
|
source: chrome.source,
|
|
74
90
|
byline: chrome.byline,
|
|
75
91
|
footer: chrome.footer,
|
|
92
|
+
brand: chrome.brand,
|
|
76
93
|
};
|
|
77
94
|
}
|
|
78
95
|
|
|
@@ -93,6 +110,13 @@ function scalePadding(basePadding: number, width: number, height: number): numbe
|
|
|
93
110
|
const MIN_CHART_WIDTH = 60;
|
|
94
111
|
const MIN_CHART_HEIGHT = 40;
|
|
95
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Vertical breathing room added to the inline-tick label height so the
|
|
115
|
+
* topmost tick has clearance from chrome. Inline ticks sit above their
|
|
116
|
+
* gridline by `axisTick + INLINE_TICK_OVERHANG_PAD` pixels.
|
|
117
|
+
*/
|
|
118
|
+
const INLINE_TICK_OVERHANG_PAD = 6;
|
|
119
|
+
|
|
96
120
|
/**
|
|
97
121
|
* Per-display minimum chart dimensions. Sparkline mode allows much smaller
|
|
98
122
|
* containers (down to ~30x20px) since the entire chart is just the mark.
|
|
@@ -107,16 +131,35 @@ function getMinChartDims(display: import('@opendata-ai/openchart-core').Display)
|
|
|
107
131
|
}
|
|
108
132
|
|
|
109
133
|
/**
|
|
110
|
-
* Resolve
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
134
|
+
* Resolve per-side safety padding for sparkline mode. Stroke-based padding
|
|
135
|
+
* applies to every side so a thick line doesn't clip at the container edge.
|
|
136
|
+
* Endpoint-dot padding applies only to the side that actually carries a dot:
|
|
137
|
+
* `point: 'last'` reserves space on the right, `'first'` on the left, and
|
|
138
|
+
* `true | 'endpoints' | 'transparent'` on both. This keeps tiny sparklines
|
|
139
|
+
* flush left when the endpoint dot only renders at the right edge.
|
|
114
140
|
*/
|
|
115
|
-
function getSparklinePad(spec: NormalizedChartSpec):
|
|
141
|
+
function getSparklinePad(spec: NormalizedChartSpec): {
|
|
142
|
+
left: number;
|
|
143
|
+
right: number;
|
|
144
|
+
top: number;
|
|
145
|
+
bottom: number;
|
|
146
|
+
} {
|
|
116
147
|
const strokeWidth = (spec.markDef as { strokeWidth?: number }).strokeWidth ?? 2;
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
148
|
+
const point = (spec.markDef as { point?: unknown }).point;
|
|
149
|
+
const strokePad = Math.max(strokeWidth / 2 + 1, 2);
|
|
150
|
+
const dotPad = 4; // r=3.5 + 0.5 — matches the terminator dot size
|
|
151
|
+
|
|
152
|
+
const dotRight =
|
|
153
|
+
point === 'last' || point === true || point === 'endpoints' || point === 'transparent';
|
|
154
|
+
const dotLeft =
|
|
155
|
+
point === 'first' || point === true || point === 'endpoints' || point === 'transparent';
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
left: dotLeft ? Math.max(strokePad, dotPad) : strokePad,
|
|
159
|
+
right: dotRight ? Math.max(strokePad, dotPad) : strokePad,
|
|
160
|
+
top: strokePad,
|
|
161
|
+
bottom: strokePad,
|
|
162
|
+
};
|
|
120
163
|
}
|
|
121
164
|
|
|
122
165
|
// ---------------------------------------------------------------------------
|
|
@@ -162,7 +205,35 @@ export function computeDimensions(
|
|
|
162
205
|
chromeMode = 'hidden';
|
|
163
206
|
}
|
|
164
207
|
|
|
165
|
-
//
|
|
208
|
+
// Pre-compute the bottom-legend reservation (legend height + gap) so the
|
|
209
|
+
// chrome layout can stack source/byline/footer below the legend band.
|
|
210
|
+
// Chart-side bottom legends only — right/top/bottom-right legends don't
|
|
211
|
+
// share vertical space with bottom chrome.
|
|
212
|
+
//
|
|
213
|
+
// We narrow on `'entries' in` (a structural brand) rather than the
|
|
214
|
+
// `legendLayout.type` discriminator because `type` is optional on
|
|
215
|
+
// CategoricalLegendLayout for back-compat with older legend payloads.
|
|
216
|
+
// Once the discriminator is required everywhere, this can collapse to
|
|
217
|
+
// `legendLayout.type !== 'gradient'`.
|
|
218
|
+
const bottomLegendReservation =
|
|
219
|
+
'entries' in legendLayout &&
|
|
220
|
+
legendLayout.entries.length > 0 &&
|
|
221
|
+
legendLayout.position === 'bottom'
|
|
222
|
+
? legendLayout.bounds.height + legendGap(width)
|
|
223
|
+
: 0;
|
|
224
|
+
|
|
225
|
+
// Compute chrome with mode and scaled padding. `bottomLegendReservation`
|
|
226
|
+
// pushes bottom chrome below the legend band; the returned bottomHeight
|
|
227
|
+
// already accounts for it, so margin math below must not re-add it.
|
|
228
|
+
//
|
|
229
|
+
// Invariant: bottom-legend space is owned by `chrome.bottomHeight`, not
|
|
230
|
+
// `margins.bottom`. The legend reservation flows like this:
|
|
231
|
+
// bottomLegendReservation = legend.height + legendGap(width)
|
|
232
|
+
// chrome.bottomHeight ⊇ bottomLegendReservation (via computeChrome)
|
|
233
|
+
// margins.bottom = padding + chrome.bottomHeight + xAxisHeight
|
|
234
|
+
// So the legend band is implicitly inside margins.bottom exactly once.
|
|
235
|
+
// The legend-reservation block further down explicitly skips position
|
|
236
|
+
// 'bottom' to avoid double-counting.
|
|
166
237
|
const chrome = computeChrome(
|
|
167
238
|
chromeToInput(spec.chrome),
|
|
168
239
|
theme,
|
|
@@ -171,6 +242,7 @@ export function computeDimensions(
|
|
|
171
242
|
chromeMode,
|
|
172
243
|
padding,
|
|
173
244
|
watermark,
|
|
245
|
+
bottomLegendReservation,
|
|
174
246
|
);
|
|
175
247
|
|
|
176
248
|
// Sparkline mode: produce a near-edge-to-edge layout. Only stroke-width-based
|
|
@@ -186,10 +258,10 @@ export function computeDimensions(
|
|
|
186
258
|
const yAxisSpace = userExplicit.yAxis ? 30 : 0;
|
|
187
259
|
|
|
188
260
|
const margins: Margins = {
|
|
189
|
-
top: chrome.topHeight + sparkPad,
|
|
190
|
-
right: sparkPad,
|
|
191
|
-
bottom: chrome.bottomHeight + sparkPad + xAxisSpace,
|
|
192
|
-
left: sparkPad + yAxisSpace,
|
|
261
|
+
top: chrome.topHeight + sparkPad.top,
|
|
262
|
+
right: sparkPad.right,
|
|
263
|
+
bottom: chrome.bottomHeight + sparkPad.bottom + xAxisSpace,
|
|
264
|
+
left: sparkPad.left + yAxisSpace,
|
|
193
265
|
};
|
|
194
266
|
|
|
195
267
|
// Reserve legend space only when user explicitly opted into a legend.
|
|
@@ -211,7 +283,7 @@ export function computeDimensions(
|
|
|
211
283
|
height: Math.max(0, height - margins.top - margins.bottom),
|
|
212
284
|
};
|
|
213
285
|
|
|
214
|
-
return { total, chrome, chartArea, margins, theme };
|
|
286
|
+
return { total, chrome, chartArea, margins, theme, xAxisHeight: xAxisSpace };
|
|
215
287
|
}
|
|
216
288
|
|
|
217
289
|
// Start with the total rect
|
|
@@ -256,31 +328,87 @@ export function computeDimensions(
|
|
|
256
328
|
xAxisHeight = hasXAxisLabel ? 48 : 26;
|
|
257
329
|
}
|
|
258
330
|
|
|
331
|
+
// Resolve effective y-axis tickPosition early so margin math can account
|
|
332
|
+
// for the inline-tick overhang. Inline y-tick labels render above their
|
|
333
|
+
// gridline inside the chart area; the topmost tick text extends roughly
|
|
334
|
+
// (tickFontSize + INLINE_TICK_OVERHANG_PAD) pixels above area.y, which
|
|
335
|
+
// would otherwise crowd the chrome→chart gap.
|
|
336
|
+
const yAxisCfgPre = (encoding.y?.axis as Record<string, unknown> | undefined) ?? undefined;
|
|
337
|
+
const yTickPositionExplicitPre = yAxisCfgPre?.tickPosition as 'inline' | 'gutter' | undefined;
|
|
338
|
+
const yIsContinuousPre = encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal';
|
|
339
|
+
const yIsLineOrAreaPre = spec.markType === 'line' || spec.markType === 'area';
|
|
340
|
+
const yAxisOrientPre = yAxisCfgPre?.orient as 'left' | 'right' | 'top' | 'bottom' | undefined;
|
|
341
|
+
const yIsInlinePre =
|
|
342
|
+
(yTickPositionExplicitPre ??
|
|
343
|
+
(yIsLineOrAreaPre && yIsContinuousPre && yAxisOrientPre !== 'right'
|
|
344
|
+
? 'inline'
|
|
345
|
+
: 'gutter')) === 'inline';
|
|
346
|
+
const inlineTickOverhang = yIsInlinePre
|
|
347
|
+
? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD
|
|
348
|
+
: 0;
|
|
349
|
+
|
|
259
350
|
// Build margins: padding + chrome + axis space.
|
|
260
351
|
// For radial charts (arc/donut), axes don't exist, so axisMargin is only
|
|
261
352
|
// added when there's actual chrome content that needs separation from the
|
|
262
353
|
// chart area. When chrome is empty the margin is just padding.
|
|
263
|
-
const topAxisGap = isRadial && chrome.topHeight === 0 ? 0 : axisMargin;
|
|
354
|
+
const topAxisGap = isRadial && chrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
|
|
264
355
|
// Extra top padding on narrow viewports prevents iOS Safari from clipping
|
|
265
356
|
// the title chrome behind the browser UI.
|
|
266
357
|
const topPad = width < NARROW_VIEWPORT_MAX ? padding + TOP_PAD_EXTRA_NARROW : padding;
|
|
358
|
+
// Tentative metric-bar reservation. The bar's final inclusion is decided
|
|
359
|
+
// below by computeMetricBar, which can strip it on overflow / narrow areas.
|
|
360
|
+
// We reserve optimistically so the chart-area math is correct when the bar
|
|
361
|
+
// is kept; the rollback path subtracts it back when stripped.
|
|
362
|
+
const wantsMetrics = !!spec.metrics && spec.metrics.length > 0 && chromeMode !== 'hidden';
|
|
363
|
+
const tentativeMetricsHeight = wantsMetrics ? metricBarHeight() : 0;
|
|
267
364
|
const margins: Margins = {
|
|
268
|
-
top: topPad + chrome.topHeight + topAxisGap,
|
|
365
|
+
top: topPad + chrome.topHeight + tentativeMetricsHeight + topAxisGap,
|
|
269
366
|
right: hPad + (isRadial ? hPad : axisMargin),
|
|
270
367
|
bottom: padding + chrome.bottomHeight + xAxisHeight,
|
|
271
368
|
left: hPad + (isRadial ? hPad : axisMargin),
|
|
272
369
|
};
|
|
273
370
|
|
|
274
|
-
//
|
|
275
|
-
//
|
|
371
|
+
// Right-margin reservation for the three-way label suppression truth table:
|
|
372
|
+
//
|
|
373
|
+
// 1. Endpoint-labels column (predicted width, default ON for ≥2-series
|
|
374
|
+
// line/area). Reserves chart-width + ENDPOINT_COLUMN_GAP + col-width.
|
|
375
|
+
// 2. Legacy end-of-line labels — only when the truth table resolves to
|
|
376
|
+
// `showEndOfLineLabels: true` (legend hidden AND endpoint column off).
|
|
377
|
+
// 3. Right-edge text annotations — stack ADDITIVELY on top of (1) and (2)
|
|
378
|
+
// so a callout at maxX lands between the chart area and any column to
|
|
379
|
+
// its right.
|
|
276
380
|
const labelDensity = spec.labels.density;
|
|
277
381
|
const labelsHiddenByStrategy = strategy?.labelMode === 'none';
|
|
382
|
+
const seriesCount = countColorSeries(spec);
|
|
383
|
+
const sup = resolveSuppression(spec, {
|
|
384
|
+
seriesCount,
|
|
385
|
+
labelsHiddenByStrategy,
|
|
386
|
+
labelsDensityNone: labelDensity === 'none',
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// (1) Endpoint-labels column reservation. predictEndpointLabelsWidth returns 0
|
|
390
|
+
// when the column would be suppressed. `labels.density` is intentionally
|
|
391
|
+
// not checked here — that switch controls only the legacy end-of-line labels.
|
|
392
|
+
let endpointWidth = 0;
|
|
393
|
+
if (sup.showEndpointLabels && !labelsHiddenByStrategy) {
|
|
394
|
+
endpointWidth = predictEndpointLabelsWidth(spec, theme);
|
|
395
|
+
if (endpointWidth > 0) {
|
|
396
|
+
// 16px gap between chart area edge and the column.
|
|
397
|
+
margins.right = Math.max(margins.right, hPad) + endpointWidth + 16;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// (2) Legacy end-of-line label reservation — fires only in the truth-table
|
|
402
|
+
// cell where end-of-line labels still render (legend hidden AND endpoint
|
|
403
|
+
// column off AND ≥2 series AND labels visible). When the endpoint column
|
|
404
|
+
// is on, this reservation is redundant and is skipped.
|
|
278
405
|
if (
|
|
406
|
+
endpointWidth === 0 &&
|
|
407
|
+
sup.showEndOfLineLabels &&
|
|
279
408
|
(spec.markType === 'line' || spec.markType === 'area') &&
|
|
280
409
|
labelDensity !== 'none' &&
|
|
281
410
|
!labelsHiddenByStrategy
|
|
282
411
|
) {
|
|
283
|
-
// Estimate label width from longest series name (color encoding domain)
|
|
284
412
|
const colorEnc = encoding.color;
|
|
285
413
|
const colorField = colorEnc && 'field' in colorEnc ? colorEnc.field : undefined;
|
|
286
414
|
if (colorField) {
|
|
@@ -300,10 +428,10 @@ export function computeDimensions(
|
|
|
300
428
|
}
|
|
301
429
|
}
|
|
302
430
|
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
431
|
+
// (3) Right-edge text annotations. Stacks ADDITIVELY on top of any
|
|
432
|
+
// endpoint-labels reservation so the annotation text lands between the
|
|
433
|
+
// chart area's right edge and the endpoint column. When no endpoint column
|
|
434
|
+
// is reserved, behaves as before (max-of with the existing margin).
|
|
307
435
|
if (
|
|
308
436
|
strategy?.annotationPosition !== 'tooltip-only' &&
|
|
309
437
|
spec.annotations.length > 0 &&
|
|
@@ -336,16 +464,25 @@ export function computeDimensions(
|
|
|
336
464
|
textWidth / 2; // centered (top/bottom/auto)
|
|
337
465
|
const rightOverflow = Math.max(0, baseRightExtent + dx);
|
|
338
466
|
if (rightOverflow > 0) {
|
|
339
|
-
|
|
467
|
+
if (endpointWidth > 0) {
|
|
468
|
+
// Endpoint column already reserved space at the far right; the
|
|
469
|
+
// annotation lands BETWEEN the chart edge and the column, so
|
|
470
|
+
// stack additively rather than max-of.
|
|
471
|
+
margins.right += rightOverflow + 12;
|
|
472
|
+
} else {
|
|
473
|
+
margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
|
|
474
|
+
}
|
|
340
475
|
}
|
|
341
476
|
}
|
|
342
477
|
}
|
|
343
478
|
}
|
|
344
479
|
}
|
|
345
480
|
|
|
346
|
-
// Dynamic left margin for y-axis labels
|
|
481
|
+
// Dynamic left margin for y-axis labels (yIsInline already resolved above
|
|
482
|
+
// for inline-tick top-margin reservation).
|
|
347
483
|
const yAxisSuppressed = encoding.y?.axis === false;
|
|
348
|
-
|
|
484
|
+
const yIsInline = yIsInlinePre;
|
|
485
|
+
if (encoding.y && !isRadial && !yAxisSuppressed && !yIsInline) {
|
|
349
486
|
if (
|
|
350
487
|
spec.markType === 'bar' ||
|
|
351
488
|
spec.markType === 'circle' ||
|
|
@@ -454,16 +591,22 @@ export function computeDimensions(
|
|
|
454
591
|
margins.right = Math.max(margins.right, hPad + options.rightAxisReserve);
|
|
455
592
|
}
|
|
456
593
|
|
|
457
|
-
// Reserve legend space
|
|
594
|
+
// Reserve legend space.
|
|
595
|
+
//
|
|
596
|
+
// Bottom legend: reservation is already baked into `chrome.bottomHeight`
|
|
597
|
+
// via `bottomLegendReservation`, so no additional bottom margin is needed
|
|
598
|
+
// here. The legend lands below the x-axis tick row (which is reserved via
|
|
599
|
+
// `xAxisHeight` in the base bottom margin) and source/byline/footer chrome
|
|
600
|
+
// stacks underneath the legend band rather than colliding with it.
|
|
458
601
|
if ('entries' in legendLayout && legendLayout.entries.length > 0) {
|
|
459
602
|
const gap = legendGap(width);
|
|
460
603
|
if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
|
|
461
604
|
margins.right += legendLayout.bounds.width + 8;
|
|
462
605
|
} else if (legendLayout.position === 'top') {
|
|
463
606
|
margins.top += legendLayout.bounds.height + gap;
|
|
464
|
-
} else if (legendLayout.position === 'bottom') {
|
|
465
|
-
margins.bottom += legendLayout.bounds.height + gap;
|
|
466
607
|
}
|
|
608
|
+
// 'bottom' is intentionally not handled here — see bottomLegendReservation
|
|
609
|
+
// above.
|
|
467
610
|
}
|
|
468
611
|
|
|
469
612
|
// Chart area is what's left after margins
|
|
@@ -490,12 +633,17 @@ export function computeDimensions(
|
|
|
490
633
|
fallbackMode as 'compact' | 'hidden',
|
|
491
634
|
padding,
|
|
492
635
|
watermark,
|
|
636
|
+
bottomLegendReservation,
|
|
493
637
|
);
|
|
494
638
|
|
|
495
639
|
// Recalculate top/bottom margins with stripped chrome.
|
|
496
640
|
// Use topPad (not padding) to preserve the iOS Safari clearance on narrow viewports.
|
|
497
|
-
|
|
498
|
-
|
|
641
|
+
// Include the tentative metric reservation so the rollback below mirrors
|
|
642
|
+
// the primary path's invariant (margins.top includes tentativeMetricsHeight
|
|
643
|
+
// until resolveMetrics decides otherwise).
|
|
644
|
+
const fallbackTopAxisGap =
|
|
645
|
+
isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
|
|
646
|
+
const newTop = topPad + fallbackChrome.topHeight + fallbackTopAxisGap + tentativeMetricsHeight;
|
|
499
647
|
const topDelta = margins.top - newTop;
|
|
500
648
|
const newBottom = padding + fallbackChrome.bottomHeight + xAxisHeight;
|
|
501
649
|
const bottomDelta = margins.bottom - newBottom;
|
|
@@ -518,9 +666,98 @@ export function computeDimensions(
|
|
|
518
666
|
height: Math.max(0, height - margins.top - margins.bottom),
|
|
519
667
|
};
|
|
520
668
|
|
|
521
|
-
|
|
669
|
+
// Same chrome-anchored positioning as the primary path; see comment
|
|
670
|
+
// near the primary `metricsTopY` for the full stacking order.
|
|
671
|
+
const fallbackMetricsTopY = topPad + fallbackChrome.topHeight;
|
|
672
|
+
const fallbackMetricsArea = { x: hPad, width: Math.max(0, width - hPad * 2) };
|
|
673
|
+
const fallbackMetrics = wantsMetrics
|
|
674
|
+
? resolveMetrics(
|
|
675
|
+
spec,
|
|
676
|
+
fallbackMetricsTopY,
|
|
677
|
+
fallbackMetricsArea,
|
|
678
|
+
chartArea.height,
|
|
679
|
+
options.measureText,
|
|
680
|
+
)
|
|
681
|
+
: undefined;
|
|
682
|
+
if (wantsMetrics && !fallbackMetrics) {
|
|
683
|
+
// Bar was tentatively reserved but didn't fit — roll back the top margin.
|
|
684
|
+
// Clamp at 0 as a defensive guard: even though the reservation was
|
|
685
|
+
// additive (margins.top = topPad + chrome + tentative + axisGap [+ legend])
|
|
686
|
+
// and so subtraction is mathematically safe, a negative top margin would
|
|
687
|
+
// shift the chart above the SVG viewport if any future change ever
|
|
688
|
+
// reordered the additions.
|
|
689
|
+
margins.top = Math.max(0, margins.top - tentativeMetricsHeight);
|
|
690
|
+
chartArea = {
|
|
691
|
+
...chartArea,
|
|
692
|
+
y: margins.top,
|
|
693
|
+
height: Math.max(0, height - margins.top - margins.bottom),
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
total,
|
|
698
|
+
chrome: fallbackChrome,
|
|
699
|
+
chartArea,
|
|
700
|
+
margins,
|
|
701
|
+
theme,
|
|
702
|
+
metrics: fallbackMetrics,
|
|
703
|
+
xAxisHeight,
|
|
704
|
+
};
|
|
522
705
|
}
|
|
523
706
|
}
|
|
524
707
|
|
|
525
|
-
|
|
708
|
+
// Vertical stacking order from the SVG top edge:
|
|
709
|
+
// topPad
|
|
710
|
+
// chrome.topHeight (title / subtitle / eyebrow)
|
|
711
|
+
// tentativeMetricsHeight (KPI bar — placed here)
|
|
712
|
+
// topAxisGap (axisMargin + inlineTickOverhang)
|
|
713
|
+
// [optional top legend band]
|
|
714
|
+
// chartArea
|
|
715
|
+
// The metric bar belongs with chrome, above the legend, so its y is
|
|
716
|
+
// computed off chrome.topHeight only — not the full legend-inclusive
|
|
717
|
+
// margins.top.
|
|
718
|
+
const metricsTopY = topPad + chrome.topHeight;
|
|
719
|
+
const metricsArea = { x: hPad, width: Math.max(0, width - hPad * 2) };
|
|
720
|
+
const resolvedMetrics = wantsMetrics
|
|
721
|
+
? resolveMetrics(spec, metricsTopY, metricsArea, chartArea.height, options.measureText)
|
|
722
|
+
: undefined;
|
|
723
|
+
if (wantsMetrics && !resolvedMetrics) {
|
|
724
|
+
// See fallback path above for the clamp rationale.
|
|
725
|
+
margins.top = Math.max(0, margins.top - tentativeMetricsHeight);
|
|
726
|
+
chartArea = {
|
|
727
|
+
...chartArea,
|
|
728
|
+
y: margins.top,
|
|
729
|
+
height: Math.max(0, height - margins.top - margins.bottom),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
return {
|
|
733
|
+
total,
|
|
734
|
+
chrome,
|
|
735
|
+
chartArea,
|
|
736
|
+
margins,
|
|
737
|
+
theme,
|
|
738
|
+
metrics: resolvedMetrics,
|
|
739
|
+
xAxisHeight,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Resolve the metric bar layout. The bar spans the full chrome content width
|
|
745
|
+
* (from hPad to width - hPad), aligning with the title/eyebrow rather than
|
|
746
|
+
* indenting to the chart area's left gutter. Its `y` sits directly below
|
|
747
|
+
* chrome and above any top legend.
|
|
748
|
+
*/
|
|
749
|
+
function resolveMetrics(
|
|
750
|
+
spec: NormalizedChartSpec,
|
|
751
|
+
metricsTopY: number,
|
|
752
|
+
metricsArea: { x: number; width: number },
|
|
753
|
+
remainingChartHeight: number,
|
|
754
|
+
measureText: import('@opendata-ai/openchart-core').MeasureTextFn | undefined,
|
|
755
|
+
): ResolvedMetricBar | undefined {
|
|
756
|
+
return computeMetricBar(
|
|
757
|
+
spec.metrics,
|
|
758
|
+
metricsTopY,
|
|
759
|
+
metricsArea,
|
|
760
|
+
remainingChartHeight,
|
|
761
|
+
measureText,
|
|
762
|
+
);
|
|
526
763
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metric bar layout: a row of KPI cells rendered above the chart area.
|
|
3
|
+
*
|
|
4
|
+
* Cells lay out evenly across the chart width. The bar is auto-stripped when
|
|
5
|
+
* the container is too narrow, when the chart area would be left too short,
|
|
6
|
+
* or when value text would overflow its allotted cell width. Sparkline mode
|
|
7
|
+
* never reserves space for metrics.
|
|
8
|
+
*/
|
|
9
|
+
import type {
|
|
10
|
+
MeasureTextFn,
|
|
11
|
+
Metric,
|
|
12
|
+
ResolvedMetricBar,
|
|
13
|
+
ResolvedMetricCell,
|
|
14
|
+
} from '@opendata-ai/openchart-core';
|
|
15
|
+
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
16
|
+
|
|
17
|
+
// Visual constants. Sized to match the editorial KPI mock: a 10px uppercase
|
|
18
|
+
// label sits above a 22px primary value, with breathing room below before the
|
|
19
|
+
// chart area starts. Derived from the 4px grid.
|
|
20
|
+
const LABEL_FONT_SIZE = 10;
|
|
21
|
+
const VALUE_FONT_SIZE = 22;
|
|
22
|
+
const LABEL_LINE_HEIGHT_RATIO = 1.4; // 14px
|
|
23
|
+
const VALUE_LINE_HEIGHT_RATIO = 1.15; // ~25.3px
|
|
24
|
+
const INTER_ROW_GAP = 4;
|
|
25
|
+
// Breathing room above labels (separates metric row from the subtitle).
|
|
26
|
+
const TOP_GAP = 16;
|
|
27
|
+
// Breathing room below values (separates metric row from legend / chart top).
|
|
28
|
+
const BOTTOM_GAP = 20;
|
|
29
|
+
|
|
30
|
+
/** Minimum container width that can fit a metric bar. */
|
|
31
|
+
const MIN_BAR_WIDTH = 480;
|
|
32
|
+
/** Minimum chart-area height after metric reservation. */
|
|
33
|
+
const MIN_CHART_HEIGHT = 150;
|
|
34
|
+
/** Inner cell gutter so adjacent cells don't visually touch. */
|
|
35
|
+
const CELL_INNER_PAD = 8;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Total height the metric bar reserves above the chart area.
|
|
39
|
+
* Always derived from the constants above; never hardcoded at the call site.
|
|
40
|
+
*/
|
|
41
|
+
export function metricBarHeight(): number {
|
|
42
|
+
const labelLine = LABEL_FONT_SIZE * LABEL_LINE_HEIGHT_RATIO;
|
|
43
|
+
const valueLine = VALUE_FONT_SIZE * VALUE_LINE_HEIGHT_RATIO;
|
|
44
|
+
return TOP_GAP + labelLine + INTER_ROW_GAP + valueLine + BOTTOM_GAP;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Concatenate the visible value text used for overflow detection. Joined with
|
|
49
|
+
* single spaces because the renderer separates the spans with `dx` attributes
|
|
50
|
+
* that consume roughly a space's worth of width.
|
|
51
|
+
*/
|
|
52
|
+
function valueRunText(metric: Metric): string {
|
|
53
|
+
const parts = [metric.value];
|
|
54
|
+
if (metric.delta) parts.push(metric.delta);
|
|
55
|
+
if (metric.secondary) parts.push(metric.secondary);
|
|
56
|
+
return parts.join(' ');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compute the metric bar layout. Returns `undefined` when the bar should be
|
|
61
|
+
* stripped (too narrow, chart too short, or any cell would overflow).
|
|
62
|
+
*/
|
|
63
|
+
export function computeMetricBar(
|
|
64
|
+
metrics: Metric[] | undefined,
|
|
65
|
+
metricsTopY: number,
|
|
66
|
+
metricsArea: { x: number; width: number },
|
|
67
|
+
remainingChartHeight: number,
|
|
68
|
+
measureText?: MeasureTextFn,
|
|
69
|
+
): ResolvedMetricBar | undefined {
|
|
70
|
+
if (!metrics || metrics.length === 0) return undefined;
|
|
71
|
+
if (metricsArea.width < MIN_BAR_WIDTH) return undefined;
|
|
72
|
+
if (remainingChartHeight < MIN_CHART_HEIGHT) return undefined;
|
|
73
|
+
|
|
74
|
+
const cellWidth = metricsArea.width / metrics.length;
|
|
75
|
+
|
|
76
|
+
// Bail if any cell's value run can't fit. Half-rendered metric rows look
|
|
77
|
+
// worse than no row at all on small viewports.
|
|
78
|
+
for (const metric of metrics) {
|
|
79
|
+
const text = valueRunText(metric);
|
|
80
|
+
const measured = measureText
|
|
81
|
+
? measureText(text, VALUE_FONT_SIZE, 510).width
|
|
82
|
+
: estimateTextWidth(text, VALUE_FONT_SIZE, 510);
|
|
83
|
+
if (measured > cellWidth - CELL_INNER_PAD) return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const labelLine = LABEL_FONT_SIZE * LABEL_LINE_HEIGHT_RATIO;
|
|
87
|
+
const labelY = metricsTopY + TOP_GAP + LABEL_FONT_SIZE; // baseline for uppercase label
|
|
88
|
+
const valueY = metricsTopY + TOP_GAP + labelLine + INTER_ROW_GAP + VALUE_FONT_SIZE;
|
|
89
|
+
|
|
90
|
+
const cells: ResolvedMetricCell[] = metrics.map((metric, i) => ({
|
|
91
|
+
x: metricsArea.x + i * cellWidth,
|
|
92
|
+
cellWidth,
|
|
93
|
+
labelY,
|
|
94
|
+
valueY,
|
|
95
|
+
metric,
|
|
96
|
+
overflowed: false,
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
y: metricsTopY,
|
|
101
|
+
height: metricBarHeight(),
|
|
102
|
+
cells,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Exposed for tests and consumers needing the same constants.
|
|
107
|
+
export const METRIC_BAR_INTERNALS = {
|
|
108
|
+
LABEL_FONT_SIZE,
|
|
109
|
+
VALUE_FONT_SIZE,
|
|
110
|
+
LABEL_LINE_HEIGHT_RATIO,
|
|
111
|
+
VALUE_LINE_HEIGHT_RATIO,
|
|
112
|
+
INTER_ROW_GAP,
|
|
113
|
+
TOP_GAP,
|
|
114
|
+
BOTTOM_GAP,
|
|
115
|
+
MIN_BAR_WIDTH,
|
|
116
|
+
MIN_CHART_HEIGHT,
|
|
117
|
+
CELL_INNER_PAD,
|
|
118
|
+
};
|
package/src/layout/scales.ts
CHANGED
|
@@ -738,13 +738,50 @@ export function computeScales(
|
|
|
738
738
|
spec.markType === 'bar' &&
|
|
739
739
|
(encoding.x?.type === 'nominal' || encoding.x?.type === 'ordinal') &&
|
|
740
740
|
encoding.y.type === 'quantitative';
|
|
741
|
-
|
|
741
|
+
// Bar default is stacked, so undefined counts as stacked. Area default is
|
|
742
|
+
// overlap (v6), so the stacked-domain expansion only applies when the user
|
|
743
|
+
// explicitly opts into stacking.
|
|
744
|
+
const stackProp = encoding.y.stack;
|
|
745
|
+
const isExplicitlyStacked =
|
|
746
|
+
stackProp === true ||
|
|
747
|
+
stackProp === 'zero' ||
|
|
748
|
+
stackProp === 'normalize' ||
|
|
749
|
+
stackProp === 'center';
|
|
750
|
+
const isAreaStacked = spec.markType === 'area' && isExplicitlyStacked;
|
|
751
|
+
const isBarStacked = isVerticalBar && stackProp !== null && stackProp !== false;
|
|
752
|
+
|
|
753
|
+
// Sparkline tightening: drop the default `zero: true` baseline so the
|
|
754
|
+
// y-domain hugs the actual data range. Without this, a series with
|
|
755
|
+
// values in the 4000s renders as a near-flat line because most of the
|
|
756
|
+
// chart area gets reserved for the gap between zero and the data.
|
|
757
|
+
//
|
|
758
|
+
// Applies to:
|
|
759
|
+
// - Line / area sparklines (always — variation is the whole point)
|
|
760
|
+
// - Vertical bar sparklines, but ONLY when no real stacking is in
|
|
761
|
+
// play. Two ways to opt OUT of bar tightening:
|
|
762
|
+
// 1. Real stacking — color/group encoding plus a non-disabled
|
|
763
|
+
// stack — needs the zero baseline to keep segment arithmetic
|
|
764
|
+
// summing.
|
|
765
|
+
// 2. Any explicit `encoding.y.stack` value signals the user
|
|
766
|
+
// wants stack semantics even on a single series; respect that.
|
|
767
|
+
const hasStackingGroup = isBarStacked && encoding.color !== undefined;
|
|
768
|
+
const userRequestedStack = isExplicitlyStacked;
|
|
769
|
+
const isLineOrArea = spec.markType === 'line' || spec.markType === 'area';
|
|
770
|
+
const sparklineTightenBar =
|
|
771
|
+
isVerticalBar && !hasStackingGroup && !userRequestedStack && !isAreaStacked;
|
|
772
|
+
const sparklineTightenLineArea = isLineOrArea && !isAreaStacked;
|
|
742
773
|
if (
|
|
743
|
-
|
|
744
|
-
|
|
774
|
+
spec.display === 'sparkline' &&
|
|
775
|
+
(sparklineTightenBar || sparklineTightenLineArea) &&
|
|
745
776
|
encoding.y.type === 'quantitative' &&
|
|
746
|
-
|
|
777
|
+
encoding.y.scale?.zero === undefined
|
|
747
778
|
) {
|
|
779
|
+
yChannel = {
|
|
780
|
+
...encoding.y,
|
|
781
|
+
scale: { ...encoding.y.scale, zero: false },
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
if ((isBarStacked || isAreaStacked) && encoding.color && encoding.y.type === 'quantitative') {
|
|
748
785
|
if (encoding.y.stack === 'normalize') {
|
|
749
786
|
// Normalize: domain is [0, 1] (VL convention)
|
|
750
787
|
yChannel = { ...encoding.y, scale: { ...encoding.y.scale, domain: [0, 1], nice: false } };
|