@opendata-ai/openchart-engine 6.28.6 → 7.0.2
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 +12307 -11338
- 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 +498 -0
- package/src/compile.ts +221 -586
- package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
- package/src/compiler/normalize.ts +12 -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 +282 -34
- 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,36 @@ 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
|
+
const hasDots = dotRight || dotLeft;
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
left: dotLeft ? Math.max(strokePad, dotPad) : strokePad,
|
|
160
|
+
right: dotRight ? Math.max(strokePad, dotPad) : strokePad,
|
|
161
|
+
top: hasDots ? Math.max(strokePad, dotPad) : strokePad,
|
|
162
|
+
bottom: hasDots ? Math.max(strokePad, dotPad) : strokePad,
|
|
163
|
+
};
|
|
120
164
|
}
|
|
121
165
|
|
|
122
166
|
// ---------------------------------------------------------------------------
|
|
@@ -162,7 +206,35 @@ export function computeDimensions(
|
|
|
162
206
|
chromeMode = 'hidden';
|
|
163
207
|
}
|
|
164
208
|
|
|
165
|
-
//
|
|
209
|
+
// Pre-compute the bottom-legend reservation (legend height + gap) so the
|
|
210
|
+
// chrome layout can stack source/byline/footer below the legend band.
|
|
211
|
+
// Chart-side bottom legends only — right/top/bottom-right legends don't
|
|
212
|
+
// share vertical space with bottom chrome.
|
|
213
|
+
//
|
|
214
|
+
// We narrow on `'entries' in` (a structural brand) rather than the
|
|
215
|
+
// `legendLayout.type` discriminator because `type` is optional on
|
|
216
|
+
// CategoricalLegendLayout for back-compat with older legend payloads.
|
|
217
|
+
// Once the discriminator is required everywhere, this can collapse to
|
|
218
|
+
// `legendLayout.type !== 'gradient'`.
|
|
219
|
+
const bottomLegendReservation =
|
|
220
|
+
'entries' in legendLayout &&
|
|
221
|
+
legendLayout.entries.length > 0 &&
|
|
222
|
+
legendLayout.position === 'bottom'
|
|
223
|
+
? legendLayout.bounds.height + legendGap(width)
|
|
224
|
+
: 0;
|
|
225
|
+
|
|
226
|
+
// Compute chrome with mode and scaled padding. `bottomLegendReservation`
|
|
227
|
+
// pushes bottom chrome below the legend band; the returned bottomHeight
|
|
228
|
+
// already accounts for it, so margin math below must not re-add it.
|
|
229
|
+
//
|
|
230
|
+
// Invariant: bottom-legend space is owned by `chrome.bottomHeight`, not
|
|
231
|
+
// `margins.bottom`. The legend reservation flows like this:
|
|
232
|
+
// bottomLegendReservation = legend.height + legendGap(width)
|
|
233
|
+
// chrome.bottomHeight ⊇ bottomLegendReservation (via computeChrome)
|
|
234
|
+
// margins.bottom = padding + chrome.bottomHeight + xAxisHeight
|
|
235
|
+
// So the legend band is implicitly inside margins.bottom exactly once.
|
|
236
|
+
// The legend-reservation block further down explicitly skips position
|
|
237
|
+
// 'bottom' to avoid double-counting.
|
|
166
238
|
const chrome = computeChrome(
|
|
167
239
|
chromeToInput(spec.chrome),
|
|
168
240
|
theme,
|
|
@@ -171,6 +243,7 @@ export function computeDimensions(
|
|
|
171
243
|
chromeMode,
|
|
172
244
|
padding,
|
|
173
245
|
watermark,
|
|
246
|
+
bottomLegendReservation,
|
|
174
247
|
);
|
|
175
248
|
|
|
176
249
|
// Sparkline mode: produce a near-edge-to-edge layout. Only stroke-width-based
|
|
@@ -186,10 +259,10 @@ export function computeDimensions(
|
|
|
186
259
|
const yAxisSpace = userExplicit.yAxis ? 30 : 0;
|
|
187
260
|
|
|
188
261
|
const margins: Margins = {
|
|
189
|
-
top: chrome.topHeight + sparkPad,
|
|
190
|
-
right: sparkPad,
|
|
191
|
-
bottom: chrome.bottomHeight + sparkPad + xAxisSpace,
|
|
192
|
-
left: sparkPad + yAxisSpace,
|
|
262
|
+
top: chrome.topHeight + sparkPad.top,
|
|
263
|
+
right: sparkPad.right,
|
|
264
|
+
bottom: chrome.bottomHeight + sparkPad.bottom + xAxisSpace,
|
|
265
|
+
left: sparkPad.left + yAxisSpace,
|
|
193
266
|
};
|
|
194
267
|
|
|
195
268
|
// Reserve legend space only when user explicitly opted into a legend.
|
|
@@ -211,7 +284,7 @@ export function computeDimensions(
|
|
|
211
284
|
height: Math.max(0, height - margins.top - margins.bottom),
|
|
212
285
|
};
|
|
213
286
|
|
|
214
|
-
return { total, chrome, chartArea, margins, theme };
|
|
287
|
+
return { total, chrome, chartArea, margins, theme, xAxisHeight: xAxisSpace };
|
|
215
288
|
}
|
|
216
289
|
|
|
217
290
|
// Start with the total rect
|
|
@@ -256,31 +329,92 @@ export function computeDimensions(
|
|
|
256
329
|
xAxisHeight = hasXAxisLabel ? 48 : 26;
|
|
257
330
|
}
|
|
258
331
|
|
|
332
|
+
// Resolve effective y-axis tickPosition early so margin math can account
|
|
333
|
+
// for the inline-tick overhang. Inline y-tick labels render above their
|
|
334
|
+
// gridline inside the chart area; the topmost tick text extends roughly
|
|
335
|
+
// (tickFontSize + INLINE_TICK_OVERHANG_PAD) pixels above area.y, which
|
|
336
|
+
// would otherwise crowd the chrome→chart gap.
|
|
337
|
+
const yAxisCfgPre = (encoding.y?.axis as Record<string, unknown> | undefined) ?? undefined;
|
|
338
|
+
const yTickPositionExplicitPre = yAxisCfgPre?.tickPosition as 'inline' | 'gutter' | undefined;
|
|
339
|
+
const yIsContinuousPre = encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal';
|
|
340
|
+
const yIsLineOrAreaPre = spec.markType === 'line' || spec.markType === 'area';
|
|
341
|
+
const yAxisOrientPre = yAxisCfgPre?.orient as 'left' | 'right' | 'top' | 'bottom' | undefined;
|
|
342
|
+
const yIsInlinePre =
|
|
343
|
+
(yTickPositionExplicitPre ??
|
|
344
|
+
(yIsLineOrAreaPre && yIsContinuousPre && yAxisOrientPre !== 'right'
|
|
345
|
+
? 'inline'
|
|
346
|
+
: 'gutter')) === 'inline';
|
|
347
|
+
const inlineTickOverhang = yIsInlinePre
|
|
348
|
+
? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD
|
|
349
|
+
: 0;
|
|
350
|
+
|
|
259
351
|
// Build margins: padding + chrome + axis space.
|
|
260
352
|
// For radial charts (arc/donut), axes don't exist, so axisMargin is only
|
|
261
353
|
// added when there's actual chrome content that needs separation from the
|
|
262
354
|
// chart area. When chrome is empty the margin is just padding.
|
|
263
|
-
const topAxisGap = isRadial && chrome.topHeight === 0 ? 0 : axisMargin;
|
|
355
|
+
const topAxisGap = isRadial && chrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
|
|
264
356
|
// Extra top padding on narrow viewports prevents iOS Safari from clipping
|
|
265
357
|
// the title chrome behind the browser UI.
|
|
266
358
|
const topPad = width < NARROW_VIEWPORT_MAX ? padding + TOP_PAD_EXTRA_NARROW : padding;
|
|
359
|
+
// Tentative metric-bar reservation. The bar's final inclusion is decided
|
|
360
|
+
// below by computeMetricBar, which can strip it on overflow / narrow areas.
|
|
361
|
+
// We reserve optimistically so the chart-area math is correct when the bar
|
|
362
|
+
// is kept; the rollback path subtracts it back when stripped.
|
|
363
|
+
const wantsMetrics = !!spec.metrics && spec.metrics.length > 0 && chromeMode !== 'hidden';
|
|
364
|
+
const tentativeMetricsHeight = wantsMetrics ? metricBarHeight() : 0;
|
|
365
|
+
// topAxisGap sits between the legend (or chrome, if no legend) and the
|
|
366
|
+
// chart area. It accounts for the general axis margin plus any inline
|
|
367
|
+
// tick-label overhang. Placing it after the legend (below) keeps the
|
|
368
|
+
// subtitle-to-legend gap tight while reserving physical space for ticks
|
|
369
|
+
// that protrude above the chart area.
|
|
267
370
|
const margins: Margins = {
|
|
268
|
-
top: topPad + chrome.topHeight +
|
|
371
|
+
top: topPad + chrome.topHeight + tentativeMetricsHeight,
|
|
269
372
|
right: hPad + (isRadial ? hPad : axisMargin),
|
|
270
373
|
bottom: padding + chrome.bottomHeight + xAxisHeight,
|
|
271
374
|
left: hPad + (isRadial ? hPad : axisMargin),
|
|
272
375
|
};
|
|
273
376
|
|
|
274
|
-
//
|
|
275
|
-
//
|
|
377
|
+
// Right-margin reservation for the three-way label suppression truth table:
|
|
378
|
+
//
|
|
379
|
+
// 1. Endpoint-labels column (predicted width, default ON for ≥2-series
|
|
380
|
+
// line/area). Reserves chart-width + ENDPOINT_COLUMN_GAP + col-width.
|
|
381
|
+
// 2. Legacy end-of-line labels — only when the truth table resolves to
|
|
382
|
+
// `showEndOfLineLabels: true` (legend hidden AND endpoint column off).
|
|
383
|
+
// 3. Right-edge text annotations — stack ADDITIVELY on top of (1) and (2)
|
|
384
|
+
// so a callout at maxX lands between the chart area and any column to
|
|
385
|
+
// its right.
|
|
276
386
|
const labelDensity = spec.labels.density;
|
|
277
387
|
const labelsHiddenByStrategy = strategy?.labelMode === 'none';
|
|
388
|
+
const seriesCount = countColorSeries(spec);
|
|
389
|
+
const sup = resolveSuppression(spec, {
|
|
390
|
+
seriesCount,
|
|
391
|
+
labelsHiddenByStrategy,
|
|
392
|
+
labelsDensityNone: labelDensity === 'none',
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// (1) Endpoint-labels column reservation. predictEndpointLabelsWidth returns 0
|
|
396
|
+
// when the column would be suppressed. `labels.density` is intentionally
|
|
397
|
+
// not checked here — that switch controls only the legacy end-of-line labels.
|
|
398
|
+
let endpointWidth = 0;
|
|
399
|
+
if (sup.showEndpointLabels && !labelsHiddenByStrategy) {
|
|
400
|
+
endpointWidth = predictEndpointLabelsWidth(spec, theme);
|
|
401
|
+
if (endpointWidth > 0) {
|
|
402
|
+
// 16px gap between chart area edge and the column.
|
|
403
|
+
margins.right = Math.max(margins.right, hPad) + endpointWidth + 16;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// (2) Legacy end-of-line label reservation — fires only in the truth-table
|
|
408
|
+
// cell where end-of-line labels still render (legend hidden AND endpoint
|
|
409
|
+
// column off AND ≥2 series AND labels visible). When the endpoint column
|
|
410
|
+
// is on, this reservation is redundant and is skipped.
|
|
278
411
|
if (
|
|
412
|
+
endpointWidth === 0 &&
|
|
413
|
+
sup.showEndOfLineLabels &&
|
|
279
414
|
(spec.markType === 'line' || spec.markType === 'area') &&
|
|
280
415
|
labelDensity !== 'none' &&
|
|
281
416
|
!labelsHiddenByStrategy
|
|
282
417
|
) {
|
|
283
|
-
// Estimate label width from longest series name (color encoding domain)
|
|
284
418
|
const colorEnc = encoding.color;
|
|
285
419
|
const colorField = colorEnc && 'field' in colorEnc ? colorEnc.field : undefined;
|
|
286
420
|
if (colorField) {
|
|
@@ -300,10 +434,10 @@ export function computeDimensions(
|
|
|
300
434
|
}
|
|
301
435
|
}
|
|
302
436
|
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
437
|
+
// (3) Right-edge text annotations. Stacks ADDITIVELY on top of any
|
|
438
|
+
// endpoint-labels reservation so the annotation text lands between the
|
|
439
|
+
// chart area's right edge and the endpoint column. When no endpoint column
|
|
440
|
+
// is reserved, behaves as before (max-of with the existing margin).
|
|
307
441
|
if (
|
|
308
442
|
strategy?.annotationPosition !== 'tooltip-only' &&
|
|
309
443
|
spec.annotations.length > 0 &&
|
|
@@ -336,16 +470,25 @@ export function computeDimensions(
|
|
|
336
470
|
textWidth / 2; // centered (top/bottom/auto)
|
|
337
471
|
const rightOverflow = Math.max(0, baseRightExtent + dx);
|
|
338
472
|
if (rightOverflow > 0) {
|
|
339
|
-
|
|
473
|
+
if (endpointWidth > 0) {
|
|
474
|
+
// Endpoint column already reserved space at the far right; the
|
|
475
|
+
// annotation lands BETWEEN the chart edge and the column, so
|
|
476
|
+
// stack additively rather than max-of.
|
|
477
|
+
margins.right += rightOverflow + 12;
|
|
478
|
+
} else {
|
|
479
|
+
margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
|
|
480
|
+
}
|
|
340
481
|
}
|
|
341
482
|
}
|
|
342
483
|
}
|
|
343
484
|
}
|
|
344
485
|
}
|
|
345
486
|
|
|
346
|
-
// Dynamic left margin for y-axis labels
|
|
487
|
+
// Dynamic left margin for y-axis labels (yIsInline already resolved above
|
|
488
|
+
// for inline-tick top-margin reservation).
|
|
347
489
|
const yAxisSuppressed = encoding.y?.axis === false;
|
|
348
|
-
|
|
490
|
+
const yIsInline = yIsInlinePre;
|
|
491
|
+
if (encoding.y && !isRadial && !yAxisSuppressed && !yIsInline) {
|
|
349
492
|
if (
|
|
350
493
|
spec.markType === 'bar' ||
|
|
351
494
|
spec.markType === 'circle' ||
|
|
@@ -454,18 +597,28 @@ export function computeDimensions(
|
|
|
454
597
|
margins.right = Math.max(margins.right, hPad + options.rightAxisReserve);
|
|
455
598
|
}
|
|
456
599
|
|
|
457
|
-
// Reserve legend space
|
|
600
|
+
// Reserve legend space.
|
|
601
|
+
//
|
|
602
|
+
// Bottom legend: reservation is already baked into `chrome.bottomHeight`
|
|
603
|
+
// via `bottomLegendReservation`, so no additional bottom margin is needed
|
|
604
|
+
// here. The legend lands below the x-axis tick row (which is reserved via
|
|
605
|
+
// `xAxisHeight` in the base bottom margin) and source/byline/footer chrome
|
|
606
|
+
// stacks underneath the legend band rather than colliding with it.
|
|
458
607
|
if ('entries' in legendLayout && legendLayout.entries.length > 0) {
|
|
459
608
|
const gap = legendGap(width);
|
|
460
609
|
if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
|
|
461
610
|
margins.right += legendLayout.bounds.width + 8;
|
|
462
611
|
} else if (legendLayout.position === 'top') {
|
|
463
612
|
margins.top += legendLayout.bounds.height + gap;
|
|
464
|
-
} else if (legendLayout.position === 'bottom') {
|
|
465
|
-
margins.bottom += legendLayout.bounds.height + gap;
|
|
466
613
|
}
|
|
614
|
+
// 'bottom' is intentionally not handled here — see bottomLegendReservation
|
|
615
|
+
// above.
|
|
467
616
|
}
|
|
468
617
|
|
|
618
|
+
// Add topAxisGap after legend so it sits between the legend (or chrome
|
|
619
|
+
// when there's no legend) and the chart area.
|
|
620
|
+
margins.top += topAxisGap;
|
|
621
|
+
|
|
469
622
|
// Chart area is what's left after margins
|
|
470
623
|
let chartArea: Rect = {
|
|
471
624
|
x: margins.left,
|
|
@@ -490,12 +643,17 @@ export function computeDimensions(
|
|
|
490
643
|
fallbackMode as 'compact' | 'hidden',
|
|
491
644
|
padding,
|
|
492
645
|
watermark,
|
|
646
|
+
bottomLegendReservation,
|
|
493
647
|
);
|
|
494
648
|
|
|
495
649
|
// Recalculate top/bottom margins with stripped chrome.
|
|
496
650
|
// Use topPad (not padding) to preserve the iOS Safari clearance on narrow viewports.
|
|
497
|
-
|
|
498
|
-
|
|
651
|
+
// Include the tentative metric reservation so the rollback below mirrors
|
|
652
|
+
// the primary path's invariant (margins.top includes tentativeMetricsHeight
|
|
653
|
+
// until resolveMetrics decides otherwise).
|
|
654
|
+
const fallbackTopAxisGap =
|
|
655
|
+
isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
|
|
656
|
+
const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight;
|
|
499
657
|
const topDelta = margins.top - newTop;
|
|
500
658
|
const newBottom = padding + fallbackChrome.bottomHeight + xAxisHeight;
|
|
501
659
|
const bottomDelta = margins.bottom - newBottom;
|
|
@@ -508,7 +666,8 @@ export function computeDimensions(
|
|
|
508
666
|
legendLayout.entries.length > 0 &&
|
|
509
667
|
legendLayout.position === 'top'
|
|
510
668
|
? legendLayout.bounds.height + gap
|
|
511
|
-
: 0)
|
|
669
|
+
: 0) +
|
|
670
|
+
fallbackTopAxisGap;
|
|
512
671
|
margins.bottom = newBottom;
|
|
513
672
|
|
|
514
673
|
chartArea = {
|
|
@@ -518,9 +677,98 @@ export function computeDimensions(
|
|
|
518
677
|
height: Math.max(0, height - margins.top - margins.bottom),
|
|
519
678
|
};
|
|
520
679
|
|
|
521
|
-
|
|
680
|
+
// Same chrome-anchored positioning as the primary path; see comment
|
|
681
|
+
// near the primary `metricsTopY` for the full stacking order.
|
|
682
|
+
const fallbackMetricsTopY = topPad + fallbackChrome.topHeight;
|
|
683
|
+
const fallbackMetricsArea = { x: hPad, width: Math.max(0, width - hPad * 2) };
|
|
684
|
+
const fallbackMetrics = wantsMetrics
|
|
685
|
+
? resolveMetrics(
|
|
686
|
+
spec,
|
|
687
|
+
fallbackMetricsTopY,
|
|
688
|
+
fallbackMetricsArea,
|
|
689
|
+
chartArea.height,
|
|
690
|
+
options.measureText,
|
|
691
|
+
)
|
|
692
|
+
: undefined;
|
|
693
|
+
if (wantsMetrics && !fallbackMetrics) {
|
|
694
|
+
// Bar was tentatively reserved but didn't fit — roll back the top margin.
|
|
695
|
+
// Clamp at 0 as a defensive guard: even though the reservation was
|
|
696
|
+
// additive (margins.top = topPad + chrome + tentative + axisGap [+ legend])
|
|
697
|
+
// and so subtraction is mathematically safe, a negative top margin would
|
|
698
|
+
// shift the chart above the SVG viewport if any future change ever
|
|
699
|
+
// reordered the additions.
|
|
700
|
+
margins.top = Math.max(0, margins.top - tentativeMetricsHeight);
|
|
701
|
+
chartArea = {
|
|
702
|
+
...chartArea,
|
|
703
|
+
y: margins.top,
|
|
704
|
+
height: Math.max(0, height - margins.top - margins.bottom),
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
return {
|
|
708
|
+
total,
|
|
709
|
+
chrome: fallbackChrome,
|
|
710
|
+
chartArea,
|
|
711
|
+
margins,
|
|
712
|
+
theme,
|
|
713
|
+
metrics: fallbackMetrics,
|
|
714
|
+
xAxisHeight,
|
|
715
|
+
};
|
|
522
716
|
}
|
|
523
717
|
}
|
|
524
718
|
|
|
525
|
-
|
|
719
|
+
// Vertical stacking order from the SVG top edge:
|
|
720
|
+
// topPad
|
|
721
|
+
// chrome.topHeight (title / subtitle / eyebrow)
|
|
722
|
+
// tentativeMetricsHeight (KPI bar — placed here)
|
|
723
|
+
// [optional top legend band]
|
|
724
|
+
// topAxisGap (axisMargin + inlineTickOverhang)
|
|
725
|
+
// chartArea
|
|
726
|
+
// The metric bar belongs with chrome, above the legend, so its y is
|
|
727
|
+
// computed off chrome.topHeight only — not the full legend-inclusive
|
|
728
|
+
// margins.top.
|
|
729
|
+
const metricsTopY = topPad + chrome.topHeight;
|
|
730
|
+
const metricsArea = { x: hPad, width: Math.max(0, width - hPad * 2) };
|
|
731
|
+
const resolvedMetrics = wantsMetrics
|
|
732
|
+
? resolveMetrics(spec, metricsTopY, metricsArea, chartArea.height, options.measureText)
|
|
733
|
+
: undefined;
|
|
734
|
+
if (wantsMetrics && !resolvedMetrics) {
|
|
735
|
+
// See fallback path above for the clamp rationale.
|
|
736
|
+
margins.top = Math.max(0, margins.top - tentativeMetricsHeight);
|
|
737
|
+
chartArea = {
|
|
738
|
+
...chartArea,
|
|
739
|
+
y: margins.top,
|
|
740
|
+
height: Math.max(0, height - margins.top - margins.bottom),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
return {
|
|
744
|
+
total,
|
|
745
|
+
chrome,
|
|
746
|
+
chartArea,
|
|
747
|
+
margins,
|
|
748
|
+
theme,
|
|
749
|
+
metrics: resolvedMetrics,
|
|
750
|
+
xAxisHeight,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Resolve the metric bar layout. The bar spans the full chrome content width
|
|
756
|
+
* (from hPad to width - hPad), aligning with the title/eyebrow rather than
|
|
757
|
+
* indenting to the chart area's left gutter. Its `y` sits directly below
|
|
758
|
+
* chrome and above any top legend.
|
|
759
|
+
*/
|
|
760
|
+
function resolveMetrics(
|
|
761
|
+
spec: NormalizedChartSpec,
|
|
762
|
+
metricsTopY: number,
|
|
763
|
+
metricsArea: { x: number; width: number },
|
|
764
|
+
remainingChartHeight: number,
|
|
765
|
+
measureText: import('@opendata-ai/openchart-core').MeasureTextFn | undefined,
|
|
766
|
+
): ResolvedMetricBar | undefined {
|
|
767
|
+
return computeMetricBar(
|
|
768
|
+
spec.metrics,
|
|
769
|
+
metricsTopY,
|
|
770
|
+
metricsArea,
|
|
771
|
+
remainingChartHeight,
|
|
772
|
+
measureText,
|
|
773
|
+
);
|
|
526
774
|
}
|
|
@@ -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 } };
|