@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.10
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 +124 -14
- package/dist/index.js +16254 -9417
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +232 -56
- package/src/__tests__/annotation-behavior.test.ts +852 -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__/facet.test.ts +144 -0
- package/src/__tests__/helpers/invariants.ts +54 -20
- package/src/__tests__/known-bugs.test.ts +455 -2
- package/src/__tests__/legend.test.ts +69 -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 +228 -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 +318 -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 +121 -21
- package/src/compile/size-scale.ts +141 -0
- package/src/compile/spec-sugar.ts +767 -0
- package/src/compile/you-draw-it.ts +123 -0
- package/src/compile.ts +415 -110
- 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__/keys.test.ts +3 -9
- package/src/compiler/__tests__/normalize.test.ts +65 -1
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +170 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +251 -0
- package/src/compiler/__tests__/validate.test.ts +258 -0
- package/src/compiler/__tests__/vl-parity.test.ts +68 -0
- package/src/compiler/a11y-warnings.ts +110 -0
- package/src/compiler/keys.ts +5 -4
- package/src/compiler/normalize.ts +151 -7
- package/src/compiler/types.ts +11 -0
- package/src/compiler/validate.ts +961 -32
- 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 +1128 -0
- package/src/geo/compile-map.ts +1376 -0
- package/src/geo/join.ts +115 -0
- package/src/geo/projections.ts +65 -0
- package/src/geo/types.ts +26 -0
- package/src/graphs/__tests__/compile-graph.test.ts +25 -0
- package/src/graphs/compile-graph.ts +27 -18
- package/src/index.ts +20 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +35 -6
- package/src/layout/dimensions.ts +139 -83
- package/src/layout/facet.ts +14 -3
- package/src/layout/plan.ts +83 -33
- 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 +123 -16
- package/src/legend/continuous.ts +327 -0
- package/src/legend/size.ts +407 -0
- package/src/legend/suppression.ts +8 -7
- 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,12 +41,14 @@ 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
53
|
import { legendGap, TOP_LEGEND_GAP_ABOVE } from '../legend/wrap';
|
|
48
54
|
import { yTickPositionIsInline } from './axes';
|
|
@@ -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,6 +88,11 @@ 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
|
|
@@ -96,6 +113,11 @@ export interface LayoutDimensions {
|
|
|
96
113
|
const MIN_CHART_WIDTH = 60;
|
|
97
114
|
const MIN_CHART_HEIGHT = 40;
|
|
98
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
|
+
|
|
99
121
|
/**
|
|
100
122
|
* Per-display minimum chart dimensions. Sparkline mode allows much smaller
|
|
101
123
|
* containers (down to ~30x20px) since the entire chart is just the mark.
|
|
@@ -191,12 +213,10 @@ export function computeDimensions(
|
|
|
191
213
|
// When a layout plan is present, derive legend info from plan.legendContent;
|
|
192
214
|
// otherwise fall back to the legendLayout parameter (back-compat).
|
|
193
215
|
const bottomLegendReservation = plan
|
|
194
|
-
? plan.legendContent
|
|
216
|
+
? hasLegendContent(plan.legendContent) && plan.legendContent.position === 'bottom'
|
|
195
217
|
? plan.legendContent.height + legendGap(width)
|
|
196
218
|
: 0
|
|
197
|
-
:
|
|
198
|
-
legendLayout.entries.length > 0 &&
|
|
199
|
-
legendLayout.position === 'bottom'
|
|
219
|
+
: legendLayoutHasContent(legendLayout) && legendLayout.position === 'bottom'
|
|
200
220
|
? legendLayout.bounds.height + legendGap(width)
|
|
201
221
|
: 0;
|
|
202
222
|
|
|
@@ -244,7 +264,7 @@ export function computeDimensions(
|
|
|
244
264
|
};
|
|
245
265
|
|
|
246
266
|
// Reserve legend space only when user explicitly opted into a legend.
|
|
247
|
-
if (userExplicit.legend &&
|
|
267
|
+
if (userExplicit.legend && legendLayoutHasContent(legendLayout)) {
|
|
248
268
|
const gap = legendGap(width);
|
|
249
269
|
if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
|
|
250
270
|
margins.right += legendLayout.bounds.width + 8;
|
|
@@ -276,8 +296,8 @@ export function computeDimensions(
|
|
|
276
296
|
// Start with the total rect
|
|
277
297
|
const total: Rect = { x: 0, y: 0, width, height };
|
|
278
298
|
|
|
279
|
-
//
|
|
280
|
-
const isRadial = spec.markType
|
|
299
|
+
// Axisless charts (arc, waffle, calendar) don't have axes, so skip axis space
|
|
300
|
+
const isRadial = isAxislessMark(spec.markType);
|
|
281
301
|
const encoding = spec.encoding as Encoding;
|
|
282
302
|
|
|
283
303
|
// Estimate x-axis height below chart area: tick labels sit 14px below,
|
|
@@ -344,13 +364,17 @@ export function computeDimensions(
|
|
|
344
364
|
// is kept; the rollback path subtracts it back when stripped.
|
|
345
365
|
const wantsMetrics = !!spec.metrics && spec.metrics.length > 0 && chromeMode !== 'hidden';
|
|
346
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;
|
|
347
371
|
// topAxisGap sits between the legend (or chrome, if no legend) and the
|
|
348
372
|
// chart area. It accounts for the general axis margin plus any inline
|
|
349
373
|
// tick-label overhang. Placing it after the legend (below) keeps the
|
|
350
374
|
// subtitle-to-legend gap tight while reserving physical space for ticks
|
|
351
375
|
// that protrude above the chart area.
|
|
352
376
|
const margins: Margins = {
|
|
353
|
-
top: topPad + chrome.topHeight + tentativeMetricsHeight,
|
|
377
|
+
top: topPad + chrome.topHeight + tentativeMetricsHeight + searchReservation,
|
|
354
378
|
right: hPad + (isRadial ? hPad : axisMargin),
|
|
355
379
|
bottom: bottomMargin(chrome.bottomHeight, padding, xAxisHeight),
|
|
356
380
|
left: hPad + (isRadial ? hPad : axisMargin),
|
|
@@ -378,8 +402,10 @@ export function computeDimensions(
|
|
|
378
402
|
// (1) Endpoint-labels column reservation. predictEndpointLabelsWidth returns 0
|
|
379
403
|
// when the column would be suppressed. `labels.density` is intentionally
|
|
380
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).
|
|
381
407
|
let endpointWidth = 0;
|
|
382
|
-
if (sup.showEndpointLabels
|
|
408
|
+
if (sup.showEndpointLabels) {
|
|
383
409
|
endpointWidth = predictEndpointLabelsWidth(spec, theme);
|
|
384
410
|
if (endpointWidth > 0) {
|
|
385
411
|
// 16px gap between chart area edge and the column.
|
|
@@ -421,17 +447,15 @@ export function computeDimensions(
|
|
|
421
447
|
}
|
|
422
448
|
}
|
|
423
449
|
|
|
424
|
-
// (3) Right-edge text annotations.
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
// 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.
|
|
428
453
|
if (
|
|
429
454
|
strategy?.annotationPosition !== 'tooltip-only' &&
|
|
430
455
|
spec.annotations.length > 0 &&
|
|
431
456
|
encoding.x
|
|
432
457
|
) {
|
|
433
458
|
const xField = encoding.x.field;
|
|
434
|
-
// Find the maximum x value in the data
|
|
435
459
|
let maxX: string | number | undefined;
|
|
436
460
|
for (const row of spec.data) {
|
|
437
461
|
const v = row[xField];
|
|
@@ -443,28 +467,12 @@ export function computeDimensions(
|
|
|
443
467
|
if (ann.type === 'text' && String(ann.x) === maxXStr) {
|
|
444
468
|
const textWidth = estimateTextWidth(ann.text, ann.fontSize ?? 11, ann.fontWeight ?? 600);
|
|
445
469
|
const dx = ann.offset?.dx ?? 0;
|
|
446
|
-
// How much text extends right of the anchor point depends on alignment:
|
|
447
|
-
// - anchor "right" or "left": text is off to one side, full width extends
|
|
448
|
-
// - anchor "top"/"bottom"/"auto"/undefined: text is centered, half extends right
|
|
449
470
|
const anchor = ann.anchor ?? 'auto';
|
|
450
471
|
const baseRightExtent =
|
|
451
|
-
anchor === 'left'
|
|
452
|
-
? textWidth
|
|
453
|
-
: // text is to the right of anchor
|
|
454
|
-
anchor === 'right'
|
|
455
|
-
? 0
|
|
456
|
-
: // text is to the left of anchor
|
|
457
|
-
textWidth / 2; // centered (top/bottom/auto)
|
|
472
|
+
anchor === 'right' ? textWidth : anchor === 'left' ? 0 : textWidth / 2;
|
|
458
473
|
const rightOverflow = Math.max(0, baseRightExtent + dx);
|
|
459
474
|
if (rightOverflow > 0) {
|
|
460
|
-
|
|
461
|
-
// Endpoint column already reserved space at the far right; the
|
|
462
|
-
// annotation lands BETWEEN the chart edge and the column, so
|
|
463
|
-
// stack additively rather than max-of.
|
|
464
|
-
margins.right += rightOverflow + 12;
|
|
465
|
-
} else {
|
|
466
|
-
margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
|
|
467
|
-
}
|
|
475
|
+
margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
|
|
468
476
|
}
|
|
469
477
|
}
|
|
470
478
|
}
|
|
@@ -538,25 +546,16 @@ export function computeDimensions(
|
|
|
538
546
|
if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
|
|
539
547
|
}
|
|
540
548
|
|
|
549
|
+
const ctx = computeFieldFormatContext(spec.data.map((r) => r[yField]));
|
|
541
550
|
let sampleLabel: string;
|
|
542
551
|
if (yAxisFormat) {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
const fmt = d3Format(yAxisFormat);
|
|
546
|
-
sampleLabel = fmt(maxAbsVal);
|
|
547
|
-
} catch {
|
|
548
|
-
sampleLabel = String(maxAbsVal);
|
|
549
|
-
}
|
|
552
|
+
const fmt = resolveNumberFormatter(yAxisFormat, ctx);
|
|
553
|
+
sampleLabel = fmt ? fmt(maxAbsVal) : String(maxAbsVal);
|
|
550
554
|
} else {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
else if (maxAbsVal >= 1_000) sampleLabel = '1.5K';
|
|
555
|
-
else if (maxAbsVal >= 100) sampleLabel = '100';
|
|
556
|
-
else if (maxAbsVal >= 10) sampleLabel = '10';
|
|
557
|
-
else sampleLabel = '0.0';
|
|
555
|
+
sampleLabel = defaultNumberFormatter({ ...ctx, extent: ctx.extent ?? [0, maxAbsVal] })(
|
|
556
|
+
maxAbsVal,
|
|
557
|
+
);
|
|
558
558
|
}
|
|
559
|
-
// Account for negative sign
|
|
560
559
|
const negPrefix = spec.data.some((r) => Number(r[yField]) < 0) ? '-' : '';
|
|
561
560
|
const labelEst = negPrefix + sampleLabel;
|
|
562
561
|
const labelWidth = estimateTextWidth(
|
|
@@ -595,21 +594,16 @@ export function computeDimensions(
|
|
|
595
594
|
const v = Number(row[yFieldForTitle]);
|
|
596
595
|
if (Number.isFinite(v) && Math.abs(v) > maxAbsValForTitle) maxAbsValForTitle = Math.abs(v);
|
|
597
596
|
}
|
|
597
|
+
const ctxForTitle = computeFieldFormatContext(spec.data.map((r) => r[yFieldForTitle!]));
|
|
598
598
|
let sampleLabelForTitle: string;
|
|
599
599
|
if (yAxisFormatForTitle) {
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
sampleLabelForTitle = fmt(maxAbsValForTitle);
|
|
603
|
-
} catch {
|
|
604
|
-
sampleLabelForTitle = String(maxAbsValForTitle);
|
|
605
|
-
}
|
|
600
|
+
const fmt = resolveNumberFormatter(yAxisFormatForTitle, ctxForTitle);
|
|
601
|
+
sampleLabelForTitle = fmt ? fmt(maxAbsValForTitle) : String(maxAbsValForTitle);
|
|
606
602
|
} else {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
else if (maxAbsValForTitle >= 10) sampleLabelForTitle = '10';
|
|
612
|
-
else sampleLabelForTitle = '0.0';
|
|
603
|
+
sampleLabelForTitle = defaultNumberFormatter({
|
|
604
|
+
...ctxForTitle,
|
|
605
|
+
extent: ctxForTitle.extent ?? [0, maxAbsValForTitle],
|
|
606
|
+
})(maxAbsValForTitle);
|
|
613
607
|
}
|
|
614
608
|
const negPrefixForTitle = spec.data.some((r) => Number(r[yFieldForTitle]) < 0) ? '-' : '';
|
|
615
609
|
estTickLabelWidth = estimateTextWidth(
|
|
@@ -646,6 +640,13 @@ export function computeDimensions(
|
|
|
646
640
|
margins.right = Math.max(margins.right, hPad + options.rightAxisReserve);
|
|
647
641
|
}
|
|
648
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
|
+
|
|
649
650
|
// Reserve legend space.
|
|
650
651
|
//
|
|
651
652
|
// Bottom legend: reservation is already baked into `chrome.bottomHeight`
|
|
@@ -656,38 +657,48 @@ export function computeDimensions(
|
|
|
656
657
|
// When a plan is present, derive legend position/size from plan.legendContent;
|
|
657
658
|
// otherwise use the legendLayout parameter (back-compat for non-plan callers).
|
|
658
659
|
const legendHasEntries = plan
|
|
659
|
-
? plan.legendContent
|
|
660
|
-
:
|
|
661
|
-
const legendPos = plan
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
: 'entries' in legendLayout
|
|
669
|
-
? legendLayout.bounds.height
|
|
670
|
-
: 0;
|
|
671
|
-
const legendBoundsWidth = plan
|
|
672
|
-
? plan.legendContent.legendWidth
|
|
673
|
-
: 'entries' in legendLayout
|
|
674
|
-
? legendLayout.bounds.width
|
|
675
|
-
: 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');
|
|
676
669
|
|
|
677
670
|
const hasTopLegend = legendHasEntries && legendPos === 'top';
|
|
678
671
|
if (legendHasEntries) {
|
|
679
672
|
const gap = legendGap(width);
|
|
680
|
-
if (legendPos === '
|
|
681
|
-
margins.right += legendBoundsWidth + 8;
|
|
682
|
-
} else if (legendPos === 'top') {
|
|
673
|
+
if (legendPos === 'top') {
|
|
683
674
|
// TOP_LEGEND_GAP_ABOVE keeps the legend from sitting flush against the
|
|
684
675
|
// subtitle/metric bar now that the axis gap sits BELOW the legend.
|
|
685
676
|
margins.top += TOP_LEGEND_GAP_ABOVE + legendHeight + gap;
|
|
686
677
|
}
|
|
687
|
-
// 'bottom'
|
|
688
|
-
//
|
|
678
|
+
// 'right'/'bottom-right' fall through to the shared right-column reservation
|
|
679
|
+
// below; 'bottom' is intentionally not handled here -- see
|
|
680
|
+
// bottomLegendReservation above.
|
|
689
681
|
}
|
|
690
682
|
|
|
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
|
+
|
|
691
702
|
// effectiveAxisGap sits between the legend (or chrome, if no legend) and
|
|
692
703
|
// the chart area. When a top legend is present, the legendGap already
|
|
693
704
|
// provides breathing room, so only the inlineTickOverhang is needed (the
|
|
@@ -733,7 +744,7 @@ export function computeDimensions(
|
|
|
733
744
|
const fallbackTopAxisGap =
|
|
734
745
|
isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
|
|
735
746
|
const fallbackEffectiveAxisGap = hasTopLegend ? inlineTickOverhang : fallbackTopAxisGap;
|
|
736
|
-
const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight;
|
|
747
|
+
const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight + searchReservation;
|
|
737
748
|
const topDelta = margins.top - newTop;
|
|
738
749
|
const newBottom = bottomMargin(fallbackChrome.bottomHeight, padding, xAxisHeight);
|
|
739
750
|
const bottomDelta = margins.bottom - newBottom;
|
|
@@ -788,6 +799,13 @@ export function computeDimensions(
|
|
|
788
799
|
margins,
|
|
789
800
|
theme,
|
|
790
801
|
metrics: fallbackMetrics,
|
|
802
|
+
seriesSearch: wantsSearch
|
|
803
|
+
? resolveSeriesSearch(
|
|
804
|
+
spec,
|
|
805
|
+
fallbackMetricsTopY + (fallbackMetrics?.height ?? 0),
|
|
806
|
+
fallbackMetricsArea,
|
|
807
|
+
)
|
|
808
|
+
: undefined,
|
|
791
809
|
xAxisHeight,
|
|
792
810
|
effectiveAxisGap: fallbackEffectiveAxisGap,
|
|
793
811
|
};
|
|
@@ -826,11 +844,49 @@ export function computeDimensions(
|
|
|
826
844
|
margins,
|
|
827
845
|
theme,
|
|
828
846
|
metrics: resolvedMetrics,
|
|
847
|
+
seriesSearch: wantsSearch
|
|
848
|
+
? resolveSeriesSearch(spec, metricsTopY + (resolvedMetrics?.height ?? 0), metricsArea)
|
|
849
|
+
: undefined,
|
|
829
850
|
xAxisHeight,
|
|
830
851
|
effectiveAxisGap,
|
|
831
852
|
};
|
|
832
853
|
}
|
|
833
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,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
834
890
|
/**
|
|
835
891
|
* Resolve the metric bar layout. The bar spans the full chrome content width
|
|
836
892
|
* (from hPad to width - hPad), aligning with the title/eyebrow rather than
|
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],
|