@opendata-ai/openchart-engine 7.10.0 → 8.0.0-rc.1
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 +142 -7
- package/dist/index.js +16182 -10032
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
- package/src/__tests__/annotation-behavior.test.ts +721 -0
- package/src/__tests__/axes.test.ts +50 -3
- package/src/__tests__/compile-chart.test.ts +30 -0
- package/src/__tests__/compile-footnotes.test.ts +277 -0
- package/src/__tests__/compile-layer.test.ts +120 -1
- package/src/__tests__/dimensions.test.ts +49 -4
- package/src/__tests__/helpers/invariants.ts +54 -20
- package/src/__tests__/known-bugs.test.ts +455 -2
- package/src/__tests__/legend.test.ts +120 -4
- package/src/__tests__/ranking-recipes.test.ts +260 -0
- package/src/__tests__/scales.test.ts +9 -3
- package/src/__tests__/series-search.test.ts +92 -0
- package/src/__tests__/spec-sugar.test.ts +801 -0
- package/src/__tests__/you-draw-it.test.ts +160 -0
- package/src/annotations/__tests__/compute.test.ts +207 -92
- package/src/annotations/__tests__/geometry.test.ts +66 -0
- package/src/annotations/__tests__/resolve-text.test.ts +527 -27
- package/src/annotations/__tests__/rich-text.test.ts +91 -0
- package/src/annotations/__tests__/thinning.test.ts +148 -0
- package/src/annotations/collisions.ts +130 -84
- package/src/annotations/compute.ts +83 -85
- package/src/annotations/constants.ts +116 -6
- package/src/annotations/geometry.ts +222 -77
- package/src/annotations/move.ts +135 -0
- package/src/annotations/placement.ts +62 -14
- package/src/annotations/resolve-range.ts +2 -0
- package/src/annotations/resolve-refline.ts +2 -0
- package/src/annotations/resolve-text.ts +312 -111
- package/src/annotations/rich-text.ts +65 -0
- package/src/annotations/thinning.ts +84 -10
- package/src/barlist/compile-barlist.ts +16 -6
- package/src/charts/_shared/format-label-value.ts +9 -8
- package/src/charts/bar/__tests__/compute.test.ts +57 -10
- package/src/charts/bar/__tests__/labels.test.ts +5 -4
- package/src/charts/bar/compute.ts +2 -6
- package/src/charts/bar/index.ts +8 -1
- package/src/charts/bar/labels.ts +3 -3
- package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
- package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
- package/src/charts/beeswarm/compute.ts +203 -0
- package/src/charts/beeswarm/dodge.ts +155 -0
- package/src/charts/beeswarm/index.ts +32 -0
- package/src/charts/builtin.ts +12 -1
- package/src/charts/calendar/__tests__/compute.test.ts +417 -0
- package/src/charts/calendar/compute.ts +319 -0
- package/src/charts/calendar/index.ts +32 -0
- package/src/charts/column/__tests__/compute.test.ts +21 -7
- package/src/charts/column/__tests__/labels.test.ts +76 -1
- package/src/charts/column/compute.ts +2 -6
- package/src/charts/column/index.ts +10 -2
- package/src/charts/column/labels.ts +95 -21
- package/src/charts/dot/index.ts +8 -1
- package/src/charts/dot/labels.ts +3 -3
- package/src/charts/line/__tests__/compute.test.ts +15 -19
- package/src/charts/line/area.ts +8 -23
- package/src/charts/line/compute.ts +32 -2
- package/src/charts/parliament/__tests__/compute.test.ts +411 -0
- package/src/charts/parliament/compute.ts +313 -0
- package/src/charts/parliament/index.ts +32 -0
- package/src/charts/pie/__tests__/compute.test.ts +103 -0
- package/src/charts/pie/compute.ts +63 -8
- package/src/charts/pie/labels.ts +1 -0
- package/src/charts/range/__tests__/compute.test.ts +486 -0
- package/src/charts/range/compute.ts +273 -0
- package/src/charts/range/index.ts +82 -0
- package/src/charts/range/labels.ts +169 -0
- package/src/charts/rect/__tests__/compute.test.ts +120 -0
- package/src/charts/rect/index.ts +127 -0
- package/src/charts/scatter/compute.ts +7 -21
- package/src/charts/text/__tests__/compute.test.ts +307 -0
- package/src/charts/text/index.ts +44 -7
- package/src/charts/tick/__tests__/compute.test.ts +75 -0
- package/src/charts/tick/index.ts +7 -4
- package/src/charts/utils.ts +5 -3
- package/src/charts/waffle/__tests__/compute.test.ts +305 -0
- package/src/charts/waffle/compute.ts +197 -0
- package/src/charts/waffle/index.ts +31 -0
- package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
- package/src/compile/__tests__/data-clip.test.ts +68 -0
- package/src/compile/__tests__/fill-patterns.test.ts +154 -0
- package/src/compile/__tests__/size-scale.test.ts +108 -0
- package/src/compile/color-scale-range.ts +54 -33
- package/src/compile/data-clip.ts +21 -6
- package/src/compile/fill-patterns.ts +74 -0
- package/src/compile/layer.ts +117 -21
- package/src/compile/size-scale.ts +139 -0
- package/src/compile/spec-sugar.ts +758 -0
- package/src/compile/you-draw-it.ts +123 -0
- package/src/compile.ts +315 -103
- package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
- package/src/compiler/__tests__/normalize.test.ts +65 -1
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
- package/src/compiler/__tests__/validate.test.ts +156 -0
- package/src/compiler/__tests__/vl-parity.test.ts +68 -0
- package/src/compiler/a11y-warnings.ts +110 -0
- package/src/compiler/normalize.ts +150 -7
- package/src/compiler/types.ts +11 -0
- package/src/compiler/validate.ts +664 -18
- package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
- package/src/endpoint-labels/compute.ts +40 -12
- package/src/endpoint-labels/format.ts +45 -15
- package/src/endpoint-labels/predict.ts +50 -36
- package/src/format/__tests__/field-format.test.ts +164 -0
- package/src/format/field-format.ts +27 -0
- package/src/geo/__tests__/compile-map.test.ts +621 -0
- package/src/geo/compile-map.ts +856 -0
- package/src/geo/join.ts +115 -0
- package/src/geo/projections.ts +45 -0
- package/src/geo/types.ts +24 -0
- package/src/graphs/__tests__/compile-graph.test.ts +25 -0
- package/src/graphs/compile-graph.ts +27 -18
- package/src/index.ts +28 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +30 -2
- package/src/layout/dimensions.ts +178 -95
- package/src/layout/facet.ts +14 -3
- package/src/layout/plan.ts +86 -35
- package/src/layout/scales.ts +132 -15
- package/src/legend/__tests__/continuous.test.ts +318 -0
- package/src/legend/__tests__/size.test.ts +348 -0
- package/src/legend/__tests__/suppression.test.ts +17 -1
- package/src/legend/compute.ts +126 -14
- package/src/legend/continuous.ts +327 -0
- package/src/legend/size.ts +407 -0
- package/src/legend/suppression.ts +8 -7
- package/src/legend/wrap.ts +8 -0
- package/src/sankey/compile-sankey.ts +31 -19
- package/src/tables/format-cells.ts +16 -7
- package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
- package/src/tilemap/compile-tilemap.ts +24 -7
- package/src/tooltips/__tests__/compute.test.ts +2 -2
- package/src/tooltips/compute.ts +422 -41
- package/src/transforms/__tests__/conditional.test.ts +18 -0
- package/src/transforms/aggregate.ts +2 -1
package/src/layout/dimensions.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
Rect,
|
|
21
21
|
ResolvedChrome,
|
|
22
22
|
ResolvedMetricBar,
|
|
23
|
+
ResolvedSeriesSearch,
|
|
23
24
|
ResolvedTheme,
|
|
24
25
|
} from '@opendata-ai/openchart-core';
|
|
25
26
|
import {
|
|
@@ -27,9 +28,12 @@ import {
|
|
|
27
28
|
axisTitleOffset,
|
|
28
29
|
BREAKPOINT_COMPACT_MAX,
|
|
29
30
|
computeChrome,
|
|
31
|
+
computeFieldFormatContext,
|
|
32
|
+
defaultNumberFormatter,
|
|
30
33
|
estimateTextWidth,
|
|
31
34
|
HPAD_COMPACT_FRACTION,
|
|
32
35
|
HPAD_COMPACT_MIN,
|
|
36
|
+
isAxislessMark,
|
|
33
37
|
LABEL_GAP_COMPACT,
|
|
34
38
|
LABEL_GAP_DEFAULT,
|
|
35
39
|
MAX_LEFT_LABEL_FRACTION_COMPACT,
|
|
@@ -37,14 +41,16 @@ import {
|
|
|
37
41
|
MAX_LEFT_LABEL_FRACTION_MEDIUM,
|
|
38
42
|
MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
|
|
39
43
|
NARROW_VIEWPORT_MAX,
|
|
44
|
+
resolveNumberFormatter,
|
|
40
45
|
TOP_PAD_EXTRA_NARROW,
|
|
41
46
|
} from '@opendata-ai/openchart-core';
|
|
42
|
-
import { format as d3Format } from 'd3-format';
|
|
43
47
|
|
|
44
48
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
45
49
|
import { isEndsBoth, predictEndpointLabelsWidth } from '../endpoint-labels/predict';
|
|
50
|
+
import { hasLegendContent } from '../legend/compute';
|
|
51
|
+
import { SIZE_LEGEND_GAP } from '../legend/size';
|
|
46
52
|
import { countColorSeries, resolveSuppression } from '../legend/suppression';
|
|
47
|
-
import { legendGap } from '../legend/wrap';
|
|
53
|
+
import { legendGap, TOP_LEGEND_GAP_ABOVE } from '../legend/wrap';
|
|
48
54
|
import { yTickPositionIsInline } from './axes';
|
|
49
55
|
import { computeMetricBar, type MetricFontSizes, metricBarHeight } from './metrics';
|
|
50
56
|
import type { LayoutPlan } from './plan';
|
|
@@ -55,6 +61,12 @@ function metricFonts(theme: ResolvedTheme): MetricFontSizes {
|
|
|
55
61
|
return { label: theme.fonts.sizes.metricLabel, value: theme.fonts.sizes.metricValue };
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
/** True when a placed legend layout has visible content (back-compat path). */
|
|
65
|
+
function legendLayoutHasContent(legendLayout: LegendLayout): boolean {
|
|
66
|
+
if (legendLayout.type === 'continuous') return legendLayout.bounds.height > 0;
|
|
67
|
+
return 'entries' in legendLayout && legendLayout.entries.length > 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
58
70
|
// ---------------------------------------------------------------------------
|
|
59
71
|
// Types
|
|
60
72
|
// ---------------------------------------------------------------------------
|
|
@@ -76,18 +88,36 @@ export interface LayoutDimensions {
|
|
|
76
88
|
* spec.metrics is supplied AND the bar fits the container.
|
|
77
89
|
*/
|
|
78
90
|
metrics?: ResolvedMetricBar;
|
|
91
|
+
/**
|
|
92
|
+
* Reserved series-search band (below chrome and the metric bar, above the
|
|
93
|
+
* top legend). Present only when spec.seriesSearch is enabled.
|
|
94
|
+
*/
|
|
95
|
+
seriesSearch?: ResolvedSeriesSearch;
|
|
79
96
|
/**
|
|
80
97
|
* Height reserved below the chart area for x-axis tick labels and the
|
|
81
98
|
* (optional) axis title. Exposed so downstream layout code (e.g. the
|
|
82
99
|
* second legend pass) can position elements below the axis row.
|
|
83
100
|
*/
|
|
84
101
|
xAxisHeight: number;
|
|
102
|
+
/**
|
|
103
|
+
* The axis gap the margin stack placed between the top legend (or chrome,
|
|
104
|
+
* when there is no top legend) and the chart area. With a top legend this
|
|
105
|
+
* is the inline-tick overhang, and `placeLegend` must receive it as
|
|
106
|
+
* `axisGapBelowLegend` so the legend sits above the reserved tick zone
|
|
107
|
+
* instead of flush against `chartArea.y` (where inline y-tick labels draw).
|
|
108
|
+
*/
|
|
109
|
+
effectiveAxisGap: number;
|
|
85
110
|
}
|
|
86
111
|
|
|
87
112
|
/** Minimum chart area dimensions before guardrails kick in. */
|
|
88
113
|
const MIN_CHART_WIDTH = 60;
|
|
89
114
|
const MIN_CHART_HEIGHT = 40;
|
|
90
115
|
|
|
116
|
+
/** Input row height for the series-search band (matches the DOM input's CSS height). */
|
|
117
|
+
const SERIES_SEARCH_INPUT_HEIGHT = 32;
|
|
118
|
+
/** Gap between the series-search input row and whatever stacks below it. */
|
|
119
|
+
const SERIES_SEARCH_GAP = 12;
|
|
120
|
+
|
|
91
121
|
/**
|
|
92
122
|
* Per-display minimum chart dimensions. Sparkline mode allows much smaller
|
|
93
123
|
* containers (down to ~30x20px) since the entire chart is just the mark.
|
|
@@ -183,12 +213,10 @@ export function computeDimensions(
|
|
|
183
213
|
// When a layout plan is present, derive legend info from plan.legendContent;
|
|
184
214
|
// otherwise fall back to the legendLayout parameter (back-compat).
|
|
185
215
|
const bottomLegendReservation = plan
|
|
186
|
-
? plan.legendContent
|
|
216
|
+
? hasLegendContent(plan.legendContent) && plan.legendContent.position === 'bottom'
|
|
187
217
|
? plan.legendContent.height + legendGap(width)
|
|
188
218
|
: 0
|
|
189
|
-
:
|
|
190
|
-
legendLayout.entries.length > 0 &&
|
|
191
|
-
legendLayout.position === 'bottom'
|
|
219
|
+
: legendLayoutHasContent(legendLayout) && legendLayout.position === 'bottom'
|
|
192
220
|
? legendLayout.bounds.height + legendGap(width)
|
|
193
221
|
: 0;
|
|
194
222
|
|
|
@@ -236,7 +264,7 @@ export function computeDimensions(
|
|
|
236
264
|
};
|
|
237
265
|
|
|
238
266
|
// Reserve legend space only when user explicitly opted into a legend.
|
|
239
|
-
if (userExplicit.legend &&
|
|
267
|
+
if (userExplicit.legend && legendLayoutHasContent(legendLayout)) {
|
|
240
268
|
const gap = legendGap(width);
|
|
241
269
|
if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
|
|
242
270
|
margins.right += legendLayout.bounds.width + 8;
|
|
@@ -254,14 +282,22 @@ export function computeDimensions(
|
|
|
254
282
|
height: Math.max(0, height - margins.top - margins.bottom),
|
|
255
283
|
};
|
|
256
284
|
|
|
257
|
-
return {
|
|
285
|
+
return {
|
|
286
|
+
total,
|
|
287
|
+
chrome,
|
|
288
|
+
chartArea,
|
|
289
|
+
margins,
|
|
290
|
+
theme,
|
|
291
|
+
xAxisHeight: xAxisSpace,
|
|
292
|
+
effectiveAxisGap: 0,
|
|
293
|
+
};
|
|
258
294
|
}
|
|
259
295
|
|
|
260
296
|
// Start with the total rect
|
|
261
297
|
const total: Rect = { x: 0, y: 0, width, height };
|
|
262
298
|
|
|
263
|
-
//
|
|
264
|
-
const isRadial = spec.markType
|
|
299
|
+
// Axisless charts (arc, waffle, calendar) don't have axes, so skip axis space
|
|
300
|
+
const isRadial = isAxislessMark(spec.markType);
|
|
265
301
|
const encoding = spec.encoding as Encoding;
|
|
266
302
|
|
|
267
303
|
// Estimate x-axis height below chart area: tick labels sit 14px below,
|
|
@@ -328,13 +364,17 @@ export function computeDimensions(
|
|
|
328
364
|
// is kept; the rollback path subtracts it back when stripped.
|
|
329
365
|
const wantsMetrics = !!spec.metrics && spec.metrics.length > 0 && chromeMode !== 'hidden';
|
|
330
366
|
const tentativeMetricsHeight = wantsMetrics ? metricBarHeight(metricFonts(theme)) : 0;
|
|
367
|
+
// Series-search band: reserved empty SVG space directly below chrome and
|
|
368
|
+
// the metric bar. The vanilla adapter overlays a DOM combobox on this rect.
|
|
369
|
+
const wantsSearch = !!spec.seriesSearch && chromeMode !== 'hidden';
|
|
370
|
+
const searchReservation = wantsSearch ? SERIES_SEARCH_INPUT_HEIGHT + SERIES_SEARCH_GAP : 0;
|
|
331
371
|
// topAxisGap sits between the legend (or chrome, if no legend) and the
|
|
332
372
|
// chart area. It accounts for the general axis margin plus any inline
|
|
333
373
|
// tick-label overhang. Placing it after the legend (below) keeps the
|
|
334
374
|
// subtitle-to-legend gap tight while reserving physical space for ticks
|
|
335
375
|
// that protrude above the chart area.
|
|
336
376
|
const margins: Margins = {
|
|
337
|
-
top: topPad + chrome.topHeight + tentativeMetricsHeight,
|
|
377
|
+
top: topPad + chrome.topHeight + tentativeMetricsHeight + searchReservation,
|
|
338
378
|
right: hPad + (isRadial ? hPad : axisMargin),
|
|
339
379
|
bottom: bottomMargin(chrome.bottomHeight, padding, xAxisHeight),
|
|
340
380
|
left: hPad + (isRadial ? hPad : axisMargin),
|
|
@@ -362,8 +402,10 @@ export function computeDimensions(
|
|
|
362
402
|
// (1) Endpoint-labels column reservation. predictEndpointLabelsWidth returns 0
|
|
363
403
|
// when the column would be suppressed. `labels.density` is intentionally
|
|
364
404
|
// not checked here — that switch controls only the legacy end-of-line labels.
|
|
405
|
+
// No extra strategy check either: `sup.showEndpointLabels` already accounts
|
|
406
|
+
// for the compact strip (and its explicit-opt-in override).
|
|
365
407
|
let endpointWidth = 0;
|
|
366
|
-
if (sup.showEndpointLabels
|
|
408
|
+
if (sup.showEndpointLabels) {
|
|
367
409
|
endpointWidth = predictEndpointLabelsWidth(spec, theme);
|
|
368
410
|
if (endpointWidth > 0) {
|
|
369
411
|
// 16px gap between chart area edge and the column.
|
|
@@ -405,17 +447,15 @@ export function computeDimensions(
|
|
|
405
447
|
}
|
|
406
448
|
}
|
|
407
449
|
|
|
408
|
-
// (3) Right-edge text annotations.
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
// is reserved, behaves as before (max-of with the existing margin).
|
|
450
|
+
// (3) Right-edge text annotations. The annotation text and the endpoint
|
|
451
|
+
// column occupy the same right-side space, so take the max rather than
|
|
452
|
+
// stacking additively.
|
|
412
453
|
if (
|
|
413
454
|
strategy?.annotationPosition !== 'tooltip-only' &&
|
|
414
455
|
spec.annotations.length > 0 &&
|
|
415
456
|
encoding.x
|
|
416
457
|
) {
|
|
417
458
|
const xField = encoding.x.field;
|
|
418
|
-
// Find the maximum x value in the data
|
|
419
459
|
let maxX: string | number | undefined;
|
|
420
460
|
for (const row of spec.data) {
|
|
421
461
|
const v = row[xField];
|
|
@@ -427,28 +467,12 @@ export function computeDimensions(
|
|
|
427
467
|
if (ann.type === 'text' && String(ann.x) === maxXStr) {
|
|
428
468
|
const textWidth = estimateTextWidth(ann.text, ann.fontSize ?? 11, ann.fontWeight ?? 600);
|
|
429
469
|
const dx = ann.offset?.dx ?? 0;
|
|
430
|
-
// How much text extends right of the anchor point depends on alignment:
|
|
431
|
-
// - anchor "right" or "left": text is off to one side, full width extends
|
|
432
|
-
// - anchor "top"/"bottom"/"auto"/undefined: text is centered, half extends right
|
|
433
470
|
const anchor = ann.anchor ?? 'auto';
|
|
434
471
|
const baseRightExtent =
|
|
435
|
-
anchor === 'left'
|
|
436
|
-
? textWidth
|
|
437
|
-
: // text is to the right of anchor
|
|
438
|
-
anchor === 'right'
|
|
439
|
-
? 0
|
|
440
|
-
: // text is to the left of anchor
|
|
441
|
-
textWidth / 2; // centered (top/bottom/auto)
|
|
472
|
+
anchor === 'right' ? textWidth : anchor === 'left' ? 0 : textWidth / 2;
|
|
442
473
|
const rightOverflow = Math.max(0, baseRightExtent + dx);
|
|
443
474
|
if (rightOverflow > 0) {
|
|
444
|
-
|
|
445
|
-
// Endpoint column already reserved space at the far right; the
|
|
446
|
-
// annotation lands BETWEEN the chart edge and the column, so
|
|
447
|
-
// stack additively rather than max-of.
|
|
448
|
-
margins.right += rightOverflow + 12;
|
|
449
|
-
} else {
|
|
450
|
-
margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
|
|
451
|
-
}
|
|
475
|
+
margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
|
|
452
476
|
}
|
|
453
477
|
}
|
|
454
478
|
}
|
|
@@ -522,25 +546,16 @@ export function computeDimensions(
|
|
|
522
546
|
if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
|
|
523
547
|
}
|
|
524
548
|
|
|
549
|
+
const ctx = computeFieldFormatContext(spec.data.map((r) => r[yField]));
|
|
525
550
|
let sampleLabel: string;
|
|
526
551
|
if (yAxisFormat) {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const fmt = d3Format(yAxisFormat);
|
|
530
|
-
sampleLabel = fmt(maxAbsVal);
|
|
531
|
-
} catch {
|
|
532
|
-
sampleLabel = String(maxAbsVal);
|
|
533
|
-
}
|
|
552
|
+
const fmt = resolveNumberFormatter(yAxisFormat, ctx);
|
|
553
|
+
sampleLabel = fmt ? fmt(maxAbsVal) : String(maxAbsVal);
|
|
534
554
|
} else {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
else if (maxAbsVal >= 1_000) sampleLabel = '1.5K';
|
|
539
|
-
else if (maxAbsVal >= 100) sampleLabel = '100';
|
|
540
|
-
else if (maxAbsVal >= 10) sampleLabel = '10';
|
|
541
|
-
else sampleLabel = '0.0';
|
|
555
|
+
sampleLabel = defaultNumberFormatter({ ...ctx, extent: ctx.extent ?? [0, maxAbsVal] })(
|
|
556
|
+
maxAbsVal,
|
|
557
|
+
);
|
|
542
558
|
}
|
|
543
|
-
// Account for negative sign
|
|
544
559
|
const negPrefix = spec.data.some((r) => Number(r[yField]) < 0) ? '-' : '';
|
|
545
560
|
const labelEst = negPrefix + sampleLabel;
|
|
546
561
|
const labelWidth = estimateTextWidth(
|
|
@@ -579,21 +594,16 @@ export function computeDimensions(
|
|
|
579
594
|
const v = Number(row[yFieldForTitle]);
|
|
580
595
|
if (Number.isFinite(v) && Math.abs(v) > maxAbsValForTitle) maxAbsValForTitle = Math.abs(v);
|
|
581
596
|
}
|
|
597
|
+
const ctxForTitle = computeFieldFormatContext(spec.data.map((r) => r[yFieldForTitle!]));
|
|
582
598
|
let sampleLabelForTitle: string;
|
|
583
599
|
if (yAxisFormatForTitle) {
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
sampleLabelForTitle = fmt(maxAbsValForTitle);
|
|
587
|
-
} catch {
|
|
588
|
-
sampleLabelForTitle = String(maxAbsValForTitle);
|
|
589
|
-
}
|
|
600
|
+
const fmt = resolveNumberFormatter(yAxisFormatForTitle, ctxForTitle);
|
|
601
|
+
sampleLabelForTitle = fmt ? fmt(maxAbsValForTitle) : String(maxAbsValForTitle);
|
|
590
602
|
} else {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
else if (maxAbsValForTitle >= 10) sampleLabelForTitle = '10';
|
|
596
|
-
else sampleLabelForTitle = '0.0';
|
|
603
|
+
sampleLabelForTitle = defaultNumberFormatter({
|
|
604
|
+
...ctxForTitle,
|
|
605
|
+
extent: ctxForTitle.extent ?? [0, maxAbsValForTitle],
|
|
606
|
+
})(maxAbsValForTitle);
|
|
597
607
|
}
|
|
598
608
|
const negPrefixForTitle = spec.data.some((r) => Number(r[yFieldForTitle]) < 0) ? '-' : '';
|
|
599
609
|
estTickLabelWidth = estimateTextWidth(
|
|
@@ -630,6 +640,13 @@ export function computeDimensions(
|
|
|
630
640
|
margins.right = Math.max(margins.right, hPad + options.rightAxisReserve);
|
|
631
641
|
}
|
|
632
642
|
|
|
643
|
+
// Reserve space for the auto-thinning footnote list. Additive, not Math.max:
|
|
644
|
+
// the footnotes stack above the source/byline/footer row that bottomHeight
|
|
645
|
+
// already covers, rather than replacing it.
|
|
646
|
+
if (options.footnoteReserve && options.footnoteReserve > 0) {
|
|
647
|
+
margins.bottom += options.footnoteReserve;
|
|
648
|
+
}
|
|
649
|
+
|
|
633
650
|
// Reserve legend space.
|
|
634
651
|
//
|
|
635
652
|
// Bottom legend: reservation is already baked into `chrome.bottomHeight`
|
|
@@ -640,42 +657,57 @@ export function computeDimensions(
|
|
|
640
657
|
// When a plan is present, derive legend position/size from plan.legendContent;
|
|
641
658
|
// otherwise use the legendLayout parameter (back-compat for non-plan callers).
|
|
642
659
|
const legendHasEntries = plan
|
|
643
|
-
? plan.legendContent
|
|
644
|
-
:
|
|
645
|
-
const legendPos = plan
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
: 'entries' in legendLayout
|
|
653
|
-
? legendLayout.bounds.height
|
|
654
|
-
: 0;
|
|
655
|
-
const legendBoundsWidth = plan
|
|
656
|
-
? plan.legendContent.legendWidth
|
|
657
|
-
: 'entries' in legendLayout
|
|
658
|
-
? legendLayout.bounds.width
|
|
659
|
-
: 0;
|
|
660
|
+
? hasLegendContent(plan.legendContent)
|
|
661
|
+
: legendLayoutHasContent(legendLayout);
|
|
662
|
+
const legendPos = plan ? plan.legendContent.position : legendLayout.position;
|
|
663
|
+
const legendHeight = plan ? plan.legendContent.height : legendLayout.bounds.height;
|
|
664
|
+
const legendBoundsWidth = plan ? plan.legendContent.legendWidth : legendLayout.bounds.width;
|
|
665
|
+
|
|
666
|
+
const sizeLegend = plan?.sizeLegendContent;
|
|
667
|
+
const colorLegendInGutter =
|
|
668
|
+
legendHasEntries && (legendPos === 'right' || legendPos === 'bottom-right');
|
|
660
669
|
|
|
661
670
|
const hasTopLegend = legendHasEntries && legendPos === 'top';
|
|
662
671
|
if (legendHasEntries) {
|
|
663
672
|
const gap = legendGap(width);
|
|
664
|
-
if (legendPos === '
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
margins.top += legendHeight + gap;
|
|
673
|
+
if (legendPos === 'top') {
|
|
674
|
+
// TOP_LEGEND_GAP_ABOVE keeps the legend from sitting flush against the
|
|
675
|
+
// subtitle/metric bar now that the axis gap sits BELOW the legend.
|
|
676
|
+
margins.top += TOP_LEGEND_GAP_ABOVE + legendHeight + gap;
|
|
668
677
|
}
|
|
669
|
-
// 'bottom'
|
|
670
|
-
//
|
|
678
|
+
// 'right'/'bottom-right' fall through to the shared right-column reservation
|
|
679
|
+
// below; 'bottom' is intentionally not handled here -- see
|
|
680
|
+
// bottomLegendReservation above.
|
|
671
681
|
}
|
|
672
682
|
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
|
|
683
|
+
// Right-hand legend column. The color legend and the size legend SHARE it,
|
|
684
|
+
// stacked vertically (see placeSizeLegend), so it is reserved at the width of
|
|
685
|
+
// the wider block -- not the sum.
|
|
686
|
+
//
|
|
687
|
+
// Reserving the sum is what the additive version did, and it charged the plot
|
|
688
|
+
// twice: a bubble chart keying continent (67px) and population (92px) gave up
|
|
689
|
+
// ~213px of a 934px canvas to render two blocks that are each ~70px tall. The
|
|
690
|
+
// column below them was dead space. Taking the max spends that empty vertical
|
|
691
|
+
// room instead and hands the difference back to the plot.
|
|
692
|
+
//
|
|
693
|
+
// Right column, not top: graduated circles are as tall as the largest bubble's
|
|
694
|
+
// diameter, and a ~60px band across the top of a 400px chart eats the plot.
|
|
695
|
+
//
|
|
696
|
+
// Each block keeps its own gap (the color legend's 8, the size legend's 12) so
|
|
697
|
+
// a chart with only one of them reserves exactly what it did before.
|
|
698
|
+
const colorReserve = colorLegendInGutter ? legendBoundsWidth + 8 : 0;
|
|
699
|
+
const sizeReserve = sizeLegend ? sizeLegend.width + SIZE_LEGEND_GAP : 0;
|
|
700
|
+
margins.right += Math.max(colorReserve, sizeReserve);
|
|
701
|
+
|
|
702
|
+
// effectiveAxisGap sits between the legend (or chrome, if no legend) and
|
|
703
|
+
// the chart area. When a top legend is present, the legendGap already
|
|
704
|
+
// provides breathing room, so only the inlineTickOverhang is needed (the
|
|
705
|
+
// axisMargin component would double up with legendGap). Without a top
|
|
706
|
+
// legend, the full topAxisGap (axisMargin + inlineTickOverhang) applies.
|
|
707
|
+
// The value is returned so placeLegend can position the top legend above
|
|
708
|
+
// this reserved zone (inline y-tick labels draw inside it).
|
|
709
|
+
const effectiveAxisGap = hasTopLegend ? inlineTickOverhang : topAxisGap;
|
|
710
|
+
margins.top += effectiveAxisGap;
|
|
679
711
|
|
|
680
712
|
// Chart area is what's left after margins
|
|
681
713
|
let chartArea: Rect = {
|
|
@@ -712,14 +744,17 @@ export function computeDimensions(
|
|
|
712
744
|
const fallbackTopAxisGap =
|
|
713
745
|
isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
|
|
714
746
|
const fallbackEffectiveAxisGap = hasTopLegend ? inlineTickOverhang : fallbackTopAxisGap;
|
|
715
|
-
const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight;
|
|
747
|
+
const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight + searchReservation;
|
|
716
748
|
const topDelta = margins.top - newTop;
|
|
717
749
|
const newBottom = bottomMargin(fallbackChrome.bottomHeight, padding, xAxisHeight);
|
|
718
750
|
const bottomDelta = margins.bottom - newBottom;
|
|
719
751
|
|
|
720
752
|
if (topDelta > 0 || bottomDelta > 0) {
|
|
721
753
|
const gap = legendGap(width);
|
|
722
|
-
margins.top =
|
|
754
|
+
margins.top =
|
|
755
|
+
newTop +
|
|
756
|
+
(hasTopLegend ? TOP_LEGEND_GAP_ABOVE + legendHeight + gap : 0) +
|
|
757
|
+
fallbackEffectiveAxisGap;
|
|
723
758
|
margins.bottom = newBottom;
|
|
724
759
|
|
|
725
760
|
chartArea = {
|
|
@@ -764,7 +799,15 @@ export function computeDimensions(
|
|
|
764
799
|
margins,
|
|
765
800
|
theme,
|
|
766
801
|
metrics: fallbackMetrics,
|
|
802
|
+
seriesSearch: wantsSearch
|
|
803
|
+
? resolveSeriesSearch(
|
|
804
|
+
spec,
|
|
805
|
+
fallbackMetricsTopY + (fallbackMetrics?.height ?? 0),
|
|
806
|
+
fallbackMetricsArea,
|
|
807
|
+
)
|
|
808
|
+
: undefined,
|
|
767
809
|
xAxisHeight,
|
|
810
|
+
effectiveAxisGap: fallbackEffectiveAxisGap,
|
|
768
811
|
};
|
|
769
812
|
}
|
|
770
813
|
}
|
|
@@ -773,8 +816,9 @@ export function computeDimensions(
|
|
|
773
816
|
// topPad
|
|
774
817
|
// chrome.topHeight (title / subtitle / eyebrow)
|
|
775
818
|
// tentativeMetricsHeight (KPI bar — placed here)
|
|
776
|
-
// [optional top legend band]
|
|
777
|
-
//
|
|
819
|
+
// [optional top legend band (TOP_LEGEND_GAP_ABOVE + legend + legendGap)]
|
|
820
|
+
// effectiveAxisGap (inlineTickOverhang with a top legend,
|
|
821
|
+
// else axisMargin + inlineTickOverhang)
|
|
778
822
|
// chartArea
|
|
779
823
|
// The metric bar belongs with chrome, above the legend, so its y is
|
|
780
824
|
// computed off chrome.topHeight only — not the full legend-inclusive
|
|
@@ -800,7 +844,46 @@ export function computeDimensions(
|
|
|
800
844
|
margins,
|
|
801
845
|
theme,
|
|
802
846
|
metrics: resolvedMetrics,
|
|
847
|
+
seriesSearch: wantsSearch
|
|
848
|
+
? resolveSeriesSearch(spec, metricsTopY + (resolvedMetrics?.height ?? 0), metricsArea)
|
|
849
|
+
: undefined,
|
|
803
850
|
xAxisHeight,
|
|
851
|
+
effectiveAxisGap,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Resolve the series-search band. Spans the full chrome content width
|
|
857
|
+
* (hPad to width - hPad, same as the metric bar) directly below chrome and
|
|
858
|
+
* the metric bar. The band itself is empty SVG space: the vanilla adapter
|
|
859
|
+
* absolutely positions a DOM combobox over it, so mounting the input never
|
|
860
|
+
* changes the observed container size.
|
|
861
|
+
*/
|
|
862
|
+
function resolveSeriesSearch(
|
|
863
|
+
spec: NormalizedChartSpec,
|
|
864
|
+
y: number,
|
|
865
|
+
area: { x: number; width: number },
|
|
866
|
+
): ResolvedSeriesSearch | undefined {
|
|
867
|
+
const config = spec.seriesSearch;
|
|
868
|
+
if (!config) return undefined;
|
|
869
|
+
const colorEnc = spec.encoding.color;
|
|
870
|
+
// Normalization already guarantees a categorical color encoding when
|
|
871
|
+
// seriesSearch survives; these guards narrow the union for TypeScript.
|
|
872
|
+
if (!colorEnc || 'condition' in colorEnc || !('field' in colorEnc) || !colorEnc.field) {
|
|
873
|
+
return undefined;
|
|
874
|
+
}
|
|
875
|
+
const field = colorEnc.field;
|
|
876
|
+
// Same enumeration rule as legend entries: skip rows missing the color field.
|
|
877
|
+
const values = [
|
|
878
|
+
...new Set(spec.data.filter((d) => d[field] != null).map((d) => String(d[field]))),
|
|
879
|
+
];
|
|
880
|
+
return {
|
|
881
|
+
x: area.x,
|
|
882
|
+
y,
|
|
883
|
+
width: area.width,
|
|
884
|
+
height: SERIES_SEARCH_INPUT_HEIGHT,
|
|
885
|
+
placeholder: config.placeholder ?? 'Find a series',
|
|
886
|
+
values,
|
|
804
887
|
};
|
|
805
888
|
}
|
|
806
889
|
|
package/src/layout/facet.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Rect } from '@opendata-ai/openchart-core';
|
|
2
2
|
|
|
3
3
|
const MIN_PANEL_WIDTH = 200;
|
|
4
|
+
export const MIN_PANEL_HEIGHT = 100;
|
|
4
5
|
const PANEL_GAP = 12;
|
|
5
6
|
const HEADER_HEIGHT = 20;
|
|
7
|
+
const X_AXIS_HEIGHT = 24;
|
|
6
8
|
|
|
7
9
|
export interface FacetGridPanel {
|
|
8
10
|
key: string;
|
|
@@ -24,6 +26,11 @@ export function autoColumns(panelCount: number, availableWidth: number): number
|
|
|
24
26
|
return Math.min(sqrtCols, fitCols, panelCount);
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
export function facetMinHeight(panelCount: number, columns: number): number {
|
|
30
|
+
const rows = Math.ceil(panelCount / columns);
|
|
31
|
+
return rows * (MIN_PANEL_HEIGHT + HEADER_HEIGHT + X_AXIS_HEIGHT) + (rows - 1) * PANEL_GAP;
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
export function computeFacetGrid(
|
|
28
35
|
facetValues: string[],
|
|
29
36
|
requestedColumns: number | undefined,
|
|
@@ -45,19 +52,23 @@ export function computeFacetGrid(
|
|
|
45
52
|
|
|
46
53
|
const rows = Math.ceil(n / columns);
|
|
47
54
|
const panelWidth = Math.max(0, (usableWidth - (columns - 1) * PANEL_GAP) / columns);
|
|
48
|
-
|
|
55
|
+
// Every row includes its own x-axis labels, so the bottom reservation
|
|
56
|
+
// applies per-row (as overhead), not just at the grid bottom.
|
|
57
|
+
const xAxisPerRow = outerAxisReservation.bottom;
|
|
58
|
+
const usableHeight = totalArea.height;
|
|
49
59
|
const panelHeight = Math.max(
|
|
50
60
|
0,
|
|
51
|
-
(usableHeight - rows * HEADER_HEIGHT - (rows - 1) * PANEL_GAP) / rows,
|
|
61
|
+
(usableHeight - rows * (HEADER_HEIGHT + xAxisPerRow) - (rows - 1) * PANEL_GAP) / rows,
|
|
52
62
|
);
|
|
53
63
|
|
|
64
|
+
const rowStride = HEADER_HEIGHT + panelHeight + xAxisPerRow + PANEL_GAP;
|
|
54
65
|
const panels: FacetGridPanel[] = [];
|
|
55
66
|
|
|
56
67
|
for (let i = 0; i < n; i++) {
|
|
57
68
|
const col = i % columns;
|
|
58
69
|
const row = Math.floor(i / columns);
|
|
59
70
|
const x = totalArea.x + outerAxisReservation.left + col * (panelWidth + PANEL_GAP);
|
|
60
|
-
const y = totalArea.y + row *
|
|
71
|
+
const y = totalArea.y + row * rowStride;
|
|
61
72
|
|
|
62
73
|
panels.push({
|
|
63
74
|
key: facetValues[i],
|