@opendata-ai/openchart-engine 8.0.0-rc.9 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +175 -12
- package/dist/index.js +917 -168
- package/dist/index.js.map +1 -1
- package/package.json +3 -4
- package/src/__test-fixtures__/specs.ts +0 -182
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2401
- package/src/__tests__/annotation-behavior.test.ts +0 -852
- package/src/__tests__/axes.test.ts +0 -1579
- package/src/__tests__/compile-animation.test.ts +0 -161
- package/src/__tests__/compile-chart.test.ts +0 -1303
- package/src/__tests__/compile-footnotes.test.ts +0 -277
- package/src/__tests__/compile-layer.test.ts +0 -969
- package/src/__tests__/compile-snapshot.test.ts +0 -156
- package/src/__tests__/compound-labels.test.ts +0 -147
- package/src/__tests__/dimensions.test.ts +0 -918
- package/src/__tests__/encoding-sugar.test.ts +0 -390
- package/src/__tests__/facet.test.ts +0 -215
- package/src/__tests__/helpers/invariants.ts +0 -299
- package/src/__tests__/known-bugs.test.ts +0 -566
- package/src/__tests__/layout-fuzz.test.ts +0 -358
- package/src/__tests__/layout-invariants.test.ts +0 -535
- package/src/__tests__/legend.test.ts +0 -721
- package/src/__tests__/mark-keys.test.ts +0 -294
- package/src/__tests__/ranking-recipes.test.ts +0 -260
- package/src/__tests__/rotation.test.ts +0 -83
- package/src/__tests__/scales.test.ts +0 -323
- package/src/__tests__/series-search.test.ts +0 -92
- package/src/__tests__/spec-sugar.test.ts +0 -801
- package/src/__tests__/you-draw-it.test.ts +0 -160
- package/src/annotations/__tests__/compute.test.ts +0 -1672
- package/src/annotations/__tests__/geometry.test.ts +0 -66
- package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
- package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
- package/src/annotations/__tests__/placement.test.ts +0 -316
- package/src/annotations/__tests__/resolve-text.test.ts +0 -788
- package/src/annotations/__tests__/rich-text.test.ts +0 -91
- package/src/annotations/__tests__/thinning.test.ts +0 -285
- package/src/annotations/collisions.ts +0 -326
- package/src/annotations/compute.ts +0 -333
- package/src/annotations/constants.ts +0 -162
- package/src/annotations/geometry.ts +0 -376
- package/src/annotations/move.ts +0 -135
- package/src/annotations/placement.ts +0 -636
- package/src/annotations/position.ts +0 -131
- package/src/annotations/resolve-range.ts +0 -117
- package/src/annotations/resolve-refline.ts +0 -157
- package/src/annotations/resolve-text.ts +0 -545
- package/src/annotations/rich-text.ts +0 -65
- package/src/annotations/thinning.ts +0 -190
- package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
- package/src/barlist/compile-barlist.ts +0 -391
- package/src/barlist/types.ts +0 -28
- package/src/charts/__tests__/post-process.test.ts +0 -237
- package/src/charts/__tests__/registry.test.ts +0 -116
- package/src/charts/__tests__/utils.test.ts +0 -195
- package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
- package/src/charts/_shared/density-filter.ts +0 -26
- package/src/charts/_shared/format-label-value.ts +0 -16
- package/src/charts/bar/__tests__/compute.test.ts +0 -934
- package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
- package/src/charts/bar/__tests__/labels.test.ts +0 -292
- package/src/charts/bar/compute.ts +0 -539
- package/src/charts/bar/index.ts +0 -60
- package/src/charts/bar/labels.ts +0 -311
- package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
- package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
- package/src/charts/beeswarm/compute.ts +0 -203
- package/src/charts/beeswarm/dodge.ts +0 -155
- package/src/charts/beeswarm/index.ts +0 -32
- package/src/charts/builtin.ts +0 -75
- package/src/charts/calendar/__tests__/compute.test.ts +0 -417
- package/src/charts/calendar/compute.ts +0 -319
- package/src/charts/calendar/index.ts +0 -32
- package/src/charts/column/__tests__/compute.test.ts +0 -540
- package/src/charts/column/__tests__/labels.test.ts +0 -179
- package/src/charts/column/compute.ts +0 -493
- package/src/charts/column/index.ts +0 -53
- package/src/charts/column/labels.ts +0 -229
- package/src/charts/dot/__tests__/compute.test.ts +0 -380
- package/src/charts/dot/__tests__/labels.test.ts +0 -98
- package/src/charts/dot/compute.ts +0 -285
- package/src/charts/dot/index.ts +0 -62
- package/src/charts/dot/labels.ts +0 -121
- package/src/charts/line/__tests__/compute.test.ts +0 -1194
- package/src/charts/line/__tests__/curves.test.ts +0 -51
- package/src/charts/line/__tests__/labels.test.ts +0 -94
- package/src/charts/line/__tests__/mark-options.test.ts +0 -175
- package/src/charts/line/area.ts +0 -484
- package/src/charts/line/compute.ts +0 -356
- package/src/charts/line/curves.ts +0 -85
- package/src/charts/line/index.ts +0 -176
- package/src/charts/line/labels.ts +0 -177
- package/src/charts/parliament/__tests__/compute.test.ts +0 -411
- package/src/charts/parliament/compute.ts +0 -313
- package/src/charts/parliament/index.ts +0 -32
- package/src/charts/pie/__tests__/compute.test.ts +0 -384
- package/src/charts/pie/__tests__/labels.test.ts +0 -132
- package/src/charts/pie/compute.ts +0 -324
- package/src/charts/pie/index.ts +0 -49
- package/src/charts/pie/labels.ts +0 -143
- package/src/charts/post-process.ts +0 -229
- package/src/charts/range/__tests__/compute.test.ts +0 -486
- package/src/charts/range/compute.ts +0 -273
- package/src/charts/range/index.ts +0 -82
- package/src/charts/range/labels.ts +0 -169
- package/src/charts/rect/__tests__/compute.test.ts +0 -120
- package/src/charts/rect/index.ts +0 -127
- package/src/charts/registry.ts +0 -72
- package/src/charts/rule/index.ts +0 -139
- package/src/charts/scatter/__tests__/compute.test.ts +0 -488
- package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
- package/src/charts/scatter/compute.ts +0 -192
- package/src/charts/scatter/index.ts +0 -46
- package/src/charts/scatter/trendline.ts +0 -100
- package/src/charts/text/__tests__/compute.test.ts +0 -307
- package/src/charts/text/index.ts +0 -141
- package/src/charts/tick/__tests__/compute.test.ts +0 -75
- package/src/charts/tick/index.ts +0 -99
- package/src/charts/utils.ts +0 -170
- package/src/charts/waffle/__tests__/compute.test.ts +0 -305
- package/src/charts/waffle/compute.ts +0 -197
- package/src/charts/waffle/index.ts +0 -31
- package/src/compile/__tests__/color-scale-range.test.ts +0 -158
- package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
- package/src/compile/__tests__/data-clip.test.ts +0 -127
- package/src/compile/__tests__/fill-patterns.test.ts +0 -154
- package/src/compile/__tests__/size-scale.test.ts +0 -108
- package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
- package/src/compile/color-scale-range.ts +0 -101
- package/src/compile/data-clip.ts +0 -48
- package/src/compile/fill-patterns.ts +0 -74
- package/src/compile/layer.ts +0 -630
- package/src/compile/size-scale.ts +0 -141
- package/src/compile/spec-sugar.ts +0 -758
- package/src/compile/watermark-obstacle.ts +0 -54
- package/src/compile/you-draw-it.ts +0 -123
- package/src/compile.ts +0 -1503
- package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
- package/src/compiler/__tests__/animation.test.ts +0 -177
- package/src/compiler/__tests__/compile.test.ts +0 -87
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
- package/src/compiler/__tests__/keys.test.ts +0 -83
- package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
- package/src/compiler/__tests__/normalize.test.ts +0 -399
- package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -170
- package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
- package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
- package/src/compiler/__tests__/validate.test.ts +0 -629
- package/src/compiler/__tests__/vl-parity.test.ts +0 -68
- package/src/compiler/a11y-warnings.ts +0 -110
- package/src/compiler/animation.ts +0 -142
- package/src/compiler/index.ts +0 -47
- package/src/compiler/keys.ts +0 -55
- package/src/compiler/normalize.ts +0 -734
- package/src/compiler/sparkline-defaults.ts +0 -138
- package/src/compiler/types.ts +0 -241
- package/src/compiler/validate.ts +0 -2041
- package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
- package/src/endpoint-labels/compute.ts +0 -621
- package/src/endpoint-labels/constants.ts +0 -54
- package/src/endpoint-labels/format.ts +0 -60
- package/src/endpoint-labels/predict.ts +0 -139
- package/src/format/__tests__/field-format.test.ts +0 -164
- package/src/format/field-format.ts +0 -27
- package/src/geo/__tests__/compile-map.test.ts +0 -1128
- package/src/geo/compile-map.ts +0 -1376
- package/src/geo/join.ts +0 -115
- package/src/geo/projections.ts +0 -65
- package/src/geo/types.ts +0 -26
- package/src/graphs/__tests__/community.test.ts +0 -228
- package/src/graphs/__tests__/compile-graph.test.ts +0 -396
- package/src/graphs/__tests__/encoding.test.ts +0 -415
- package/src/graphs/community.ts +0 -92
- package/src/graphs/compile-graph.ts +0 -329
- package/src/graphs/encoding.ts +0 -356
- package/src/graphs/types.ts +0 -106
- package/src/index.ts +0 -177
- package/src/layout/__tests__/metrics.test.ts +0 -97
- package/src/layout/__tests__/scales-highlight.test.ts +0 -192
- package/src/layout/axes/rotation.ts +0 -152
- package/src/layout/axes/thinning.ts +0 -98
- package/src/layout/axes/ticks.ts +0 -409
- package/src/layout/axes.ts +0 -757
- package/src/layout/dimensions.ts +0 -912
- package/src/layout/facet.ts +0 -91
- package/src/layout/gridlines.ts +0 -84
- package/src/layout/metrics.ts +0 -132
- package/src/layout/plan.ts +0 -631
- package/src/layout/scales.ts +0 -1053
- package/src/layout/shared.ts +0 -44
- package/src/legend/__tests__/continuous.test.ts +0 -318
- package/src/legend/__tests__/redundancy.test.ts +0 -108
- package/src/legend/__tests__/size.test.ts +0 -348
- package/src/legend/__tests__/suppression.test.ts +0 -382
- package/src/legend/compute.ts +0 -748
- package/src/legend/continuous.ts +0 -327
- package/src/legend/size.ts +0 -407
- package/src/legend/suppression.ts +0 -226
- package/src/legend/wrap.ts +0 -117
- package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
- package/src/sankey/__tests__/layout.test.ts +0 -165
- package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
- package/src/sankey/__tests__/node-sort.test.ts +0 -45
- package/src/sankey/compile-sankey.ts +0 -706
- package/src/sankey/layout.ts +0 -184
- package/src/sankey/types.ts +0 -41
- package/src/tables/__tests__/bar-column.test.ts +0 -147
- package/src/tables/__tests__/category-colors.test.ts +0 -230
- package/src/tables/__tests__/compile-table.test.ts +0 -236
- package/src/tables/__tests__/format-cells.test.ts +0 -143
- package/src/tables/__tests__/heatmap.test.ts +0 -148
- package/src/tables/__tests__/pagination.test.ts +0 -78
- package/src/tables/__tests__/search.test.ts +0 -94
- package/src/tables/__tests__/sort.test.ts +0 -107
- package/src/tables/__tests__/sparkline.test.ts +0 -122
- package/src/tables/bar-column.ts +0 -94
- package/src/tables/category-colors.ts +0 -78
- package/src/tables/compile-table.ts +0 -427
- package/src/tables/format-cells.ts +0 -115
- package/src/tables/heatmap.ts +0 -141
- package/src/tables/pagination.ts +0 -46
- package/src/tables/search.ts +0 -66
- package/src/tables/sort.ts +0 -69
- package/src/tables/sparkline.ts +0 -113
- package/src/tables/utils.ts +0 -16
- package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
- package/src/tilemap/compile-tilemap.ts +0 -755
- package/src/tilemap/layout.ts +0 -172
- package/src/tilemap/types.ts +0 -33
- package/src/tooltips/__tests__/compute.test.ts +0 -582
- package/src/tooltips/compute.ts +0 -700
- package/src/transforms/__tests__/aggregate.test.ts +0 -159
- package/src/transforms/__tests__/bin.test.ts +0 -88
- package/src/transforms/__tests__/calculate.test.ts +0 -146
- package/src/transforms/__tests__/conditional.test.ts +0 -127
- package/src/transforms/__tests__/filter-relative.test.ts +0 -202
- package/src/transforms/__tests__/filter.test.ts +0 -59
- package/src/transforms/__tests__/fold.test.ts +0 -79
- package/src/transforms/__tests__/index.test.ts +0 -93
- package/src/transforms/__tests__/predicates.test.ts +0 -193
- package/src/transforms/__tests__/timeunit.test.ts +0 -129
- package/src/transforms/__tests__/window.test.ts +0 -286
- package/src/transforms/aggregate.ts +0 -131
- package/src/transforms/bin.ts +0 -87
- package/src/transforms/calculate.ts +0 -60
- package/src/transforms/conditional.ts +0 -46
- package/src/transforms/filter.ts +0 -122
- package/src/transforms/fold.ts +0 -49
- package/src/transforms/index.ts +0 -60
- package/src/transforms/predicates.ts +0 -123
- package/src/transforms/timeunit.ts +0 -88
- package/src/transforms/window.ts +0 -185
package/src/layout/dimensions.ts
DELETED
|
@@ -1,912 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dimension computation for the chart layout.
|
|
3
|
-
*
|
|
4
|
-
* Takes the normalized spec + compile options + legend layout and produces
|
|
5
|
-
* LayoutDimensions with the total area, chrome layout, chart drawing area,
|
|
6
|
-
* and margins. The chart area is what's left after subtracting chrome,
|
|
7
|
-
* legend space, and axis margins.
|
|
8
|
-
*
|
|
9
|
-
* Padding and chrome scale down at smaller container sizes to maximize
|
|
10
|
-
* the usable chart area. When the chart area is still too small after
|
|
11
|
-
* scaling, chrome is progressively stripped as a fallback.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type {
|
|
15
|
-
CompileOptions,
|
|
16
|
-
Encoding,
|
|
17
|
-
LayoutStrategy,
|
|
18
|
-
LegendLayout,
|
|
19
|
-
Margins,
|
|
20
|
-
Rect,
|
|
21
|
-
ResolvedChrome,
|
|
22
|
-
ResolvedMetricBar,
|
|
23
|
-
ResolvedSeriesSearch,
|
|
24
|
-
ResolvedTheme,
|
|
25
|
-
} from '@opendata-ai/openchart-core';
|
|
26
|
-
import {
|
|
27
|
-
AXIS_TITLE_TRAILING_PAD,
|
|
28
|
-
axisTitleOffset,
|
|
29
|
-
BREAKPOINT_COMPACT_MAX,
|
|
30
|
-
computeChrome,
|
|
31
|
-
computeFieldFormatContext,
|
|
32
|
-
defaultNumberFormatter,
|
|
33
|
-
estimateTextWidth,
|
|
34
|
-
HPAD_COMPACT_FRACTION,
|
|
35
|
-
HPAD_COMPACT_MIN,
|
|
36
|
-
isAxislessMark,
|
|
37
|
-
LABEL_GAP_COMPACT,
|
|
38
|
-
LABEL_GAP_DEFAULT,
|
|
39
|
-
MAX_LEFT_LABEL_FRACTION_COMPACT,
|
|
40
|
-
MAX_LEFT_LABEL_FRACTION_DEFAULT,
|
|
41
|
-
MAX_LEFT_LABEL_FRACTION_MEDIUM,
|
|
42
|
-
MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
|
|
43
|
-
NARROW_VIEWPORT_MAX,
|
|
44
|
-
resolveNumberFormatter,
|
|
45
|
-
TOP_PAD_EXTRA_NARROW,
|
|
46
|
-
} from '@opendata-ai/openchart-core';
|
|
47
|
-
|
|
48
|
-
import type { NormalizedChartSpec } from '../compiler/types';
|
|
49
|
-
import { isEndsBoth, predictEndpointLabelsWidth } from '../endpoint-labels/predict';
|
|
50
|
-
import { hasLegendContent } from '../legend/compute';
|
|
51
|
-
import { SIZE_LEGEND_GAP } from '../legend/size';
|
|
52
|
-
import { countColorSeries, resolveSuppression } from '../legend/suppression';
|
|
53
|
-
import { legendGap, TOP_LEGEND_GAP_ABOVE } from '../legend/wrap';
|
|
54
|
-
import { yTickPositionIsInline } from './axes';
|
|
55
|
-
import { computeMetricBar, type MetricFontSizes, metricBarHeight } from './metrics';
|
|
56
|
-
import type { LayoutPlan } from './plan';
|
|
57
|
-
import { bottomMargin, chromeToInput, INLINE_TICK_OVERHANG_PAD, scalePadding } from './shared';
|
|
58
|
-
|
|
59
|
-
/** Pull the metric-row font sizes from the resolved theme. */
|
|
60
|
-
function metricFonts(theme: ResolvedTheme): MetricFontSizes {
|
|
61
|
-
return { label: theme.fonts.sizes.metricLabel, value: theme.fonts.sizes.metricValue };
|
|
62
|
-
}
|
|
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
|
-
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
|
-
// Types
|
|
72
|
-
// ---------------------------------------------------------------------------
|
|
73
|
-
|
|
74
|
-
/** The complete dimension layout for a chart. */
|
|
75
|
-
export interface LayoutDimensions {
|
|
76
|
-
/** Total available space. */
|
|
77
|
-
total: Rect;
|
|
78
|
-
/** Resolved chrome (title, subtitle, source, etc.). */
|
|
79
|
-
chrome: ResolvedChrome;
|
|
80
|
-
/** The chart drawing area (after subtracting chrome, legend, margins). */
|
|
81
|
-
chartArea: Rect;
|
|
82
|
-
/** Margins around the chart area. */
|
|
83
|
-
margins: Margins;
|
|
84
|
-
/** Resolved theme used for this layout. */
|
|
85
|
-
theme: ResolvedTheme;
|
|
86
|
-
/**
|
|
87
|
-
* Resolved metric bar (KPI cells above the chart area). Present only when
|
|
88
|
-
* spec.metrics is supplied AND the bar fits the container.
|
|
89
|
-
*/
|
|
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;
|
|
96
|
-
/**
|
|
97
|
-
* Height reserved below the chart area for x-axis tick labels and the
|
|
98
|
-
* (optional) axis title. Exposed so downstream layout code (e.g. the
|
|
99
|
-
* second legend pass) can position elements below the axis row.
|
|
100
|
-
*/
|
|
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;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Minimum chart area dimensions before guardrails kick in. */
|
|
113
|
-
const MIN_CHART_WIDTH = 60;
|
|
114
|
-
const MIN_CHART_HEIGHT = 40;
|
|
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
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Per-display minimum chart dimensions. Sparkline mode allows much smaller
|
|
123
|
-
* containers (down to ~30x20px) since the entire chart is just the mark.
|
|
124
|
-
*/
|
|
125
|
-
function getMinChartDims(display: import('@opendata-ai/openchart-core').Display): {
|
|
126
|
-
width: number;
|
|
127
|
-
height: number;
|
|
128
|
-
} {
|
|
129
|
-
return display === 'sparkline'
|
|
130
|
-
? { width: 30, height: 20 }
|
|
131
|
-
: { width: MIN_CHART_WIDTH, height: MIN_CHART_HEIGHT };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Resolve per-side safety padding for sparkline mode. Stroke-based padding
|
|
136
|
-
* applies to every side so a thick line doesn't clip at the container edge.
|
|
137
|
-
* Endpoint-dot padding applies only to the side that actually carries a dot:
|
|
138
|
-
* `point: 'last'` reserves space on the right, `'first'` on the left, and
|
|
139
|
-
* `true | 'endpoints' | 'transparent'` on both. This keeps tiny sparklines
|
|
140
|
-
* flush left when the endpoint dot only renders at the right edge.
|
|
141
|
-
*/
|
|
142
|
-
function getSparklinePad(spec: NormalizedChartSpec): {
|
|
143
|
-
left: number;
|
|
144
|
-
right: number;
|
|
145
|
-
top: number;
|
|
146
|
-
bottom: number;
|
|
147
|
-
} {
|
|
148
|
-
const strokeWidth = (spec.markDef as { strokeWidth?: number }).strokeWidth ?? 2;
|
|
149
|
-
const point = (spec.markDef as { point?: unknown }).point;
|
|
150
|
-
const strokePad = Math.max(strokeWidth / 2 + 1, 2);
|
|
151
|
-
const dotPad = 4; // r=3.5 + 0.5 — matches the terminator dot size
|
|
152
|
-
|
|
153
|
-
const dotRight =
|
|
154
|
-
point === 'last' || point === true || point === 'endpoints' || point === 'transparent';
|
|
155
|
-
const dotLeft =
|
|
156
|
-
point === 'first' || point === true || point === 'endpoints' || point === 'transparent';
|
|
157
|
-
const hasDots = dotRight || dotLeft;
|
|
158
|
-
|
|
159
|
-
return {
|
|
160
|
-
left: dotLeft ? Math.max(strokePad, dotPad) : strokePad,
|
|
161
|
-
right: dotRight ? Math.max(strokePad, dotPad) : strokePad,
|
|
162
|
-
top: hasDots ? Math.max(strokePad, dotPad) : strokePad,
|
|
163
|
-
bottom: hasDots ? Math.max(strokePad, dotPad) : strokePad,
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// ---------------------------------------------------------------------------
|
|
168
|
-
// Public API
|
|
169
|
-
// ---------------------------------------------------------------------------
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Compute chart dimensions, reserving space for chrome, legend, and axes.
|
|
173
|
-
*
|
|
174
|
-
* @param spec - Normalized chart spec.
|
|
175
|
-
* @param options - Compile options (width, height, theme, darkMode).
|
|
176
|
-
* @param legendLayout - Pre-computed legend layout (used to reserve space).
|
|
177
|
-
* @param theme - Already-resolved theme (resolved once in compileChart).
|
|
178
|
-
* @param strategy - Responsive layout strategy (controls chrome mode).
|
|
179
|
-
* @returns LayoutDimensions with chart area rect.
|
|
180
|
-
*/
|
|
181
|
-
export function computeDimensions(
|
|
182
|
-
spec: NormalizedChartSpec,
|
|
183
|
-
options: CompileOptions,
|
|
184
|
-
legendLayout: LegendLayout,
|
|
185
|
-
theme: ResolvedTheme,
|
|
186
|
-
strategy?: LayoutStrategy,
|
|
187
|
-
watermark: boolean = true,
|
|
188
|
-
plan?: LayoutPlan,
|
|
189
|
-
): LayoutDimensions {
|
|
190
|
-
const { width, height } = options;
|
|
191
|
-
|
|
192
|
-
const padding = scalePadding(theme.spacing.padding, width, height);
|
|
193
|
-
// Horizontal padding can be tighter than the chrome text padding on narrow
|
|
194
|
-
// containers because axis titles and tick labels tolerate closer edges.
|
|
195
|
-
const hPad =
|
|
196
|
-
width < BREAKPOINT_COMPACT_MAX
|
|
197
|
-
? Math.max(Math.round(padding * HPAD_COMPACT_FRACTION), HPAD_COMPACT_MIN)
|
|
198
|
-
: padding;
|
|
199
|
-
const axisMargin = theme.spacing.axisMargin;
|
|
200
|
-
const userExplicit = spec.userExplicit;
|
|
201
|
-
const isSparkline = spec.display === 'sparkline';
|
|
202
|
-
|
|
203
|
-
// Sparkline mode forces chrome hidden unless the user opted in explicitly.
|
|
204
|
-
// Force-hiding chrome here also short-circuits the watermark (which is
|
|
205
|
-
// rendered as part of chrome), so we don't need a separate watermark gate.
|
|
206
|
-
let chromeMode = strategy?.chromeMode ?? 'full';
|
|
207
|
-
if (isSparkline && !userExplicit.chrome) {
|
|
208
|
-
chromeMode = 'hidden';
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Pre-compute the bottom-legend reservation (legend height + gap) so the
|
|
212
|
-
// chrome layout can stack source/byline/footer below the legend band.
|
|
213
|
-
// When a layout plan is present, derive legend info from plan.legendContent;
|
|
214
|
-
// otherwise fall back to the legendLayout parameter (back-compat).
|
|
215
|
-
const bottomLegendReservation = plan
|
|
216
|
-
? hasLegendContent(plan.legendContent) && plan.legendContent.position === 'bottom'
|
|
217
|
-
? plan.legendContent.height + legendGap(width)
|
|
218
|
-
: 0
|
|
219
|
-
: legendLayoutHasContent(legendLayout) && legendLayout.position === 'bottom'
|
|
220
|
-
? legendLayout.bounds.height + legendGap(width)
|
|
221
|
-
: 0;
|
|
222
|
-
|
|
223
|
-
// When a layout plan is present, use its pre-computed chrome directly.
|
|
224
|
-
// Otherwise compute chrome with mode and scaled padding.
|
|
225
|
-
//
|
|
226
|
-
// Invariant: bottom-legend space is owned by `chrome.bottomHeight`, not
|
|
227
|
-
// `margins.bottom`. The legend reservation flows like this:
|
|
228
|
-
// bottomLegendReservation = legend.height + legendGap(width)
|
|
229
|
-
// chrome.bottomHeight >= bottomLegendReservation (via computeChrome)
|
|
230
|
-
// margins.bottom = padding + chrome.bottomHeight + xAxisHeight
|
|
231
|
-
// So the legend band is implicitly inside margins.bottom exactly once.
|
|
232
|
-
// The legend-reservation block further down explicitly skips position
|
|
233
|
-
// 'bottom' to avoid double-counting.
|
|
234
|
-
const chrome =
|
|
235
|
-
plan?.chrome ??
|
|
236
|
-
computeChrome(
|
|
237
|
-
chromeToInput(spec.chrome),
|
|
238
|
-
theme,
|
|
239
|
-
width,
|
|
240
|
-
options.measureText,
|
|
241
|
-
chromeMode,
|
|
242
|
-
padding,
|
|
243
|
-
watermark,
|
|
244
|
-
bottomLegendReservation,
|
|
245
|
-
);
|
|
246
|
-
|
|
247
|
-
// Sparkline mode: produce a near-edge-to-edge layout. Only stroke-width-based
|
|
248
|
-
// safety padding plus chrome (if user-explicit). Skip axis space, label
|
|
249
|
-
// reservations, annotation reservations, and legend reservations unless the
|
|
250
|
-
// user opted in to those individually.
|
|
251
|
-
if (isSparkline) {
|
|
252
|
-
const total: Rect = { x: 0, y: 0, width, height };
|
|
253
|
-
const sparkPad = getSparklinePad(spec);
|
|
254
|
-
|
|
255
|
-
// Axis space only when user explicitly set encoding.x/y.axis.
|
|
256
|
-
const xAxisSpace = userExplicit.xAxis ? 26 : 0;
|
|
257
|
-
const yAxisSpace = userExplicit.yAxis ? 30 : 0;
|
|
258
|
-
|
|
259
|
-
const margins: Margins = {
|
|
260
|
-
top: chrome.topHeight + sparkPad.top,
|
|
261
|
-
right: sparkPad.right,
|
|
262
|
-
bottom: chrome.bottomHeight + sparkPad.bottom + xAxisSpace,
|
|
263
|
-
left: sparkPad.left + yAxisSpace,
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
// Reserve legend space only when user explicitly opted into a legend.
|
|
267
|
-
if (userExplicit.legend && legendLayoutHasContent(legendLayout)) {
|
|
268
|
-
const gap = legendGap(width);
|
|
269
|
-
if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
|
|
270
|
-
margins.right += legendLayout.bounds.width + 8;
|
|
271
|
-
} else if (legendLayout.position === 'top') {
|
|
272
|
-
margins.top += legendLayout.bounds.height + gap;
|
|
273
|
-
} else if (legendLayout.position === 'bottom') {
|
|
274
|
-
margins.bottom += legendLayout.bounds.height + gap;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const chartArea: Rect = {
|
|
279
|
-
x: margins.left,
|
|
280
|
-
y: margins.top,
|
|
281
|
-
width: Math.max(0, width - margins.left - margins.right),
|
|
282
|
-
height: Math.max(0, height - margins.top - margins.bottom),
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
return {
|
|
286
|
-
total,
|
|
287
|
-
chrome,
|
|
288
|
-
chartArea,
|
|
289
|
-
margins,
|
|
290
|
-
theme,
|
|
291
|
-
xAxisHeight: xAxisSpace,
|
|
292
|
-
effectiveAxisGap: 0,
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// Start with the total rect
|
|
297
|
-
const total: Rect = { x: 0, y: 0, width, height };
|
|
298
|
-
|
|
299
|
-
// Axisless charts (arc, waffle, calendar) don't have axes, so skip axis space
|
|
300
|
-
const isRadial = isAxislessMark(spec.markType);
|
|
301
|
-
const encoding = spec.encoding as Encoding;
|
|
302
|
-
|
|
303
|
-
// Estimate x-axis height below chart area: tick labels sit 14px below,
|
|
304
|
-
// axis title sits 35px below. These extend past the chart area bottom
|
|
305
|
-
// and source/footer chrome must be positioned below them.
|
|
306
|
-
const xAxisSuppressed = encoding.x?.axis === false;
|
|
307
|
-
const xAxis = (!xAxisSuppressed && encoding.x?.axis) as
|
|
308
|
-
| (Record<string, unknown> & { labelAngle?: number })
|
|
309
|
-
| undefined;
|
|
310
|
-
const hasXAxisLabel = !!xAxis?.title;
|
|
311
|
-
const xTickAngle = xAxis?.labelAngle;
|
|
312
|
-
|
|
313
|
-
let xAxisHeight: number;
|
|
314
|
-
if (plan) {
|
|
315
|
-
xAxisHeight = plan.xAxisExtent;
|
|
316
|
-
} else if (isRadial || xAxisSuppressed) {
|
|
317
|
-
xAxisHeight = 0;
|
|
318
|
-
} else if (xTickAngle && Math.abs(xTickAngle) > 10) {
|
|
319
|
-
// Rotated labels: estimate height from the longest label text.
|
|
320
|
-
// At -90 degrees, the label height = text width. At -45, it's width * sin(45).
|
|
321
|
-
const angleRad = Math.abs(xTickAngle) * (Math.PI / 180);
|
|
322
|
-
const xField = encoding.x?.field;
|
|
323
|
-
let maxLabelWidth = 40; // fallback
|
|
324
|
-
if (xField) {
|
|
325
|
-
for (const row of spec.data) {
|
|
326
|
-
const label = String(row[xField] ?? '');
|
|
327
|
-
const w = estimateTextWidth(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
|
|
328
|
-
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
// Rotated label height: width * sin(angle), plus a small gap
|
|
332
|
-
const rotatedHeight = maxLabelWidth * Math.sin(angleRad) + 6;
|
|
333
|
-
// Cap at a reasonable max to avoid absurd margins
|
|
334
|
-
const labelHeight = Math.min(rotatedHeight, 120);
|
|
335
|
-
xAxisHeight = hasXAxisLabel ? labelHeight + 20 : labelHeight;
|
|
336
|
-
} else {
|
|
337
|
-
const base = theme.spacing.xAxisHeight;
|
|
338
|
-
xAxisHeight = hasXAxisLabel ? base + 22 : base;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// Resolve effective y-axis tickPosition early so margin math can account
|
|
342
|
-
// for the inline-tick overhang. Inline y-tick labels render above their
|
|
343
|
-
// gridline inside the chart area; the topmost tick text extends roughly
|
|
344
|
-
// (tickFontSize + INLINE_TICK_OVERHANG_PAD) pixels above area.y, which
|
|
345
|
-
// would otherwise crowd the chrome→chart gap.
|
|
346
|
-
// Inline y-tick placement must match computeAxes exactly (shared predicate),
|
|
347
|
-
// or the reserved left margin won't line up with where the title is drawn.
|
|
348
|
-
const yIsInlinePre = yTickPositionIsInline(encoding.y, spec.markType);
|
|
349
|
-
const inlineTickOverhang = yIsInlinePre
|
|
350
|
-
? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD
|
|
351
|
-
: 0;
|
|
352
|
-
|
|
353
|
-
// Build margins: padding + chrome + axis space.
|
|
354
|
-
// For radial charts (arc/donut), axes don't exist, so axisMargin is only
|
|
355
|
-
// added when there's actual chrome content that needs separation from the
|
|
356
|
-
// chart area. When chrome is empty the margin is just padding.
|
|
357
|
-
const topAxisGap = isRadial && chrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
|
|
358
|
-
// Extra top padding on narrow viewports prevents iOS Safari from clipping
|
|
359
|
-
// the title chrome behind the browser UI.
|
|
360
|
-
const topPad = width < NARROW_VIEWPORT_MAX ? padding + TOP_PAD_EXTRA_NARROW : padding;
|
|
361
|
-
// Tentative metric-bar reservation. The bar's final inclusion is decided
|
|
362
|
-
// below by computeMetricBar, which can strip it on overflow / narrow areas.
|
|
363
|
-
// We reserve optimistically so the chart-area math is correct when the bar
|
|
364
|
-
// is kept; the rollback path subtracts it back when stripped.
|
|
365
|
-
const wantsMetrics = !!spec.metrics && spec.metrics.length > 0 && chromeMode !== 'hidden';
|
|
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;
|
|
371
|
-
// topAxisGap sits between the legend (or chrome, if no legend) and the
|
|
372
|
-
// chart area. It accounts for the general axis margin plus any inline
|
|
373
|
-
// tick-label overhang. Placing it after the legend (below) keeps the
|
|
374
|
-
// subtitle-to-legend gap tight while reserving physical space for ticks
|
|
375
|
-
// that protrude above the chart area.
|
|
376
|
-
const margins: Margins = {
|
|
377
|
-
top: topPad + chrome.topHeight + tentativeMetricsHeight + searchReservation,
|
|
378
|
-
right: hPad + (isRadial ? hPad : axisMargin),
|
|
379
|
-
bottom: bottomMargin(chrome.bottomHeight, padding, xAxisHeight),
|
|
380
|
-
left: hPad + (isRadial ? hPad : axisMargin),
|
|
381
|
-
};
|
|
382
|
-
|
|
383
|
-
// Right-margin reservation for the three-way label suppression truth table:
|
|
384
|
-
//
|
|
385
|
-
// 1. Endpoint-labels column (predicted width, default ON for ≥2-series
|
|
386
|
-
// line/area). Reserves chart-width + ENDPOINT_COLUMN_GAP + col-width.
|
|
387
|
-
// 2. Legacy end-of-line labels — only when the truth table resolves to
|
|
388
|
-
// `showEndOfLineLabels: true` (legend hidden AND endpoint column off).
|
|
389
|
-
// 3. Right-edge text annotations — stack ADDITIVELY on top of (1) and (2)
|
|
390
|
-
// so a callout at maxX lands between the chart area and any column to
|
|
391
|
-
// its right.
|
|
392
|
-
const labelDensity = spec.labels.density;
|
|
393
|
-
const labelsHiddenByStrategy = strategy?.labelMode === 'none';
|
|
394
|
-
const seriesCount = countColorSeries(spec);
|
|
395
|
-
const sup = resolveSuppression(spec, {
|
|
396
|
-
seriesCount,
|
|
397
|
-
labelsHiddenByStrategy,
|
|
398
|
-
labelsDensityNone: labelDensity === 'none',
|
|
399
|
-
endpointLabelsDemoted: plan?.endpointLabelsDemoted,
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
// (1) Endpoint-labels column reservation. predictEndpointLabelsWidth returns 0
|
|
403
|
-
// when the column would be suppressed. `labels.density` is intentionally
|
|
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).
|
|
407
|
-
let endpointWidth = 0;
|
|
408
|
-
if (sup.showEndpointLabels) {
|
|
409
|
-
endpointWidth = predictEndpointLabelsWidth(spec, theme);
|
|
410
|
-
if (endpointWidth > 0) {
|
|
411
|
-
// 16px gap between chart area edge and the column.
|
|
412
|
-
margins.right = Math.max(margins.right, hPad) + endpointWidth + 16;
|
|
413
|
-
// Both-ends mode: mirror the column on the left for first-value labels.
|
|
414
|
-
if (isEndsBoth(spec)) {
|
|
415
|
-
margins.left = Math.max(margins.left, hPad) + endpointWidth + 16;
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// (2) Legacy end-of-line label reservation — fires only in the truth-table
|
|
421
|
-
// cell where end-of-line labels still render (legend hidden AND endpoint
|
|
422
|
-
// column off AND ≥2 series AND labels visible). When the endpoint column
|
|
423
|
-
// is on, this reservation is redundant and is skipped.
|
|
424
|
-
if (
|
|
425
|
-
endpointWidth === 0 &&
|
|
426
|
-
sup.showEndOfLineLabels &&
|
|
427
|
-
(spec.markType === 'line' || spec.markType === 'area') &&
|
|
428
|
-
labelDensity !== 'none' &&
|
|
429
|
-
!labelsHiddenByStrategy
|
|
430
|
-
) {
|
|
431
|
-
const colorEnc = encoding.color;
|
|
432
|
-
const colorField = colorEnc && 'field' in colorEnc ? colorEnc.field : undefined;
|
|
433
|
-
if (colorField) {
|
|
434
|
-
let maxLabelWidth = 0;
|
|
435
|
-
const seen = new Set<string>();
|
|
436
|
-
for (const row of spec.data) {
|
|
437
|
-
const label = String(row[colorField] ?? '');
|
|
438
|
-
if (!seen.has(label)) {
|
|
439
|
-
seen.add(label);
|
|
440
|
-
const w = estimateTextWidth(label, 11, 600);
|
|
441
|
-
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
if (maxLabelWidth > 0) {
|
|
445
|
-
margins.right = Math.max(margins.right, hPad + maxLabelWidth + 8);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
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.
|
|
453
|
-
if (
|
|
454
|
-
strategy?.annotationPosition !== 'tooltip-only' &&
|
|
455
|
-
spec.annotations.length > 0 &&
|
|
456
|
-
encoding.x
|
|
457
|
-
) {
|
|
458
|
-
const xField = encoding.x.field;
|
|
459
|
-
let maxX: string | number | undefined;
|
|
460
|
-
for (const row of spec.data) {
|
|
461
|
-
const v = row[xField];
|
|
462
|
-
if (v != null && (maxX == null || String(v) >= String(maxX))) maxX = v as string | number;
|
|
463
|
-
}
|
|
464
|
-
if (maxX != null) {
|
|
465
|
-
const maxXStr = String(maxX);
|
|
466
|
-
for (const ann of spec.annotations) {
|
|
467
|
-
if (ann.type === 'text' && String(ann.x) === maxXStr) {
|
|
468
|
-
const textWidth = estimateTextWidth(ann.text, ann.fontSize ?? 11, ann.fontWeight ?? 600);
|
|
469
|
-
const dx = ann.offset?.dx ?? 0;
|
|
470
|
-
const anchor = ann.anchor ?? 'auto';
|
|
471
|
-
const baseRightExtent =
|
|
472
|
-
anchor === 'right' ? textWidth : anchor === 'left' ? 0 : textWidth / 2;
|
|
473
|
-
const rightOverflow = Math.max(0, baseRightExtent + dx);
|
|
474
|
-
if (rightOverflow > 0) {
|
|
475
|
-
margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// Dynamic left margin for y-axis labels (yIsInline already resolved above
|
|
483
|
-
// for inline-tick top-margin reservation).
|
|
484
|
-
const yAxisSuppressed = encoding.y?.axis === false;
|
|
485
|
-
const yIsInline = yIsInlinePre;
|
|
486
|
-
if (encoding.y && !isRadial && !yAxisSuppressed && !yIsInline) {
|
|
487
|
-
if (
|
|
488
|
-
!plan &&
|
|
489
|
-
(spec.markType === 'bar' ||
|
|
490
|
-
spec.markType === 'circle' ||
|
|
491
|
-
spec.markType === 'lollipop' ||
|
|
492
|
-
encoding.y.type === 'nominal' ||
|
|
493
|
-
encoding.y.type === 'ordinal')
|
|
494
|
-
) {
|
|
495
|
-
// Category labels on the left for bar/dot charts
|
|
496
|
-
const yField = encoding.y.field;
|
|
497
|
-
const yLabelField = (encoding.y.axis as Record<string, unknown> | undefined)?.labelField as
|
|
498
|
-
| string
|
|
499
|
-
| undefined;
|
|
500
|
-
let maxLabelWidth = 0;
|
|
501
|
-
for (const row of spec.data) {
|
|
502
|
-
const label = String(row[yField] ?? '');
|
|
503
|
-
let w = estimateTextWidth(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
|
|
504
|
-
// When labelField is set, add a gap and the subtitle width
|
|
505
|
-
if (yLabelField) {
|
|
506
|
-
const subtitle = String(row[yLabelField] ?? '');
|
|
507
|
-
if (subtitle) {
|
|
508
|
-
const gap = theme.fonts.sizes.axisTick * 0.6;
|
|
509
|
-
const subtitleWidth = estimateTextWidth(
|
|
510
|
-
subtitle,
|
|
511
|
-
theme.fonts.sizes.axisTick,
|
|
512
|
-
theme.fonts.weights.normal,
|
|
513
|
-
);
|
|
514
|
-
w += gap + subtitleWidth;
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
518
|
-
}
|
|
519
|
-
if (maxLabelWidth > 0) {
|
|
520
|
-
// Tighter label-to-chart gap on narrow containers
|
|
521
|
-
const labelGap = width < NARROW_VIEWPORT_MAX ? LABEL_GAP_COMPACT : LABEL_GAP_DEFAULT;
|
|
522
|
-
// Clamp reservation so bars keep at least ~45% of container width on
|
|
523
|
-
// narrow viewports. Labels that exceed the cap will be truncated by
|
|
524
|
-
// the axis renderer (see axes.ts).
|
|
525
|
-
const maxLeftFraction =
|
|
526
|
-
width < BREAKPOINT_COMPACT_MAX
|
|
527
|
-
? MAX_LEFT_LABEL_FRACTION_COMPACT
|
|
528
|
-
: width < MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX
|
|
529
|
-
? MAX_LEFT_LABEL_FRACTION_MEDIUM
|
|
530
|
-
: MAX_LEFT_LABEL_FRACTION_DEFAULT;
|
|
531
|
-
const maxLeftReserved = Math.floor(width * maxLeftFraction);
|
|
532
|
-
const reserved = Math.min(hPad + maxLabelWidth + labelGap, maxLeftReserved);
|
|
533
|
-
margins.left = Math.max(margins.left, reserved);
|
|
534
|
-
}
|
|
535
|
-
} else if ((encoding.y.type === 'quantitative' || encoding.y.type === 'temporal') && !plan) {
|
|
536
|
-
// Numeric tick labels on the left. Estimate width from the data range.
|
|
537
|
-
// Skipped when a layout plan is present -- the plan measured real tick
|
|
538
|
-
// labels and provides plan.leftGutter below.
|
|
539
|
-
const yField = encoding.y.field;
|
|
540
|
-
const yAxisFormat = (encoding.y.axis as Record<string, unknown> | undefined)?.format as
|
|
541
|
-
| string
|
|
542
|
-
| undefined;
|
|
543
|
-
let maxAbsVal = 0;
|
|
544
|
-
for (const row of spec.data) {
|
|
545
|
-
const v = Number(row[yField]);
|
|
546
|
-
if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
const ctx = computeFieldFormatContext(spec.data.map((r) => r[yField]));
|
|
550
|
-
let sampleLabel: string;
|
|
551
|
-
if (yAxisFormat) {
|
|
552
|
-
const fmt = resolveNumberFormatter(yAxisFormat, ctx);
|
|
553
|
-
sampleLabel = fmt ? fmt(maxAbsVal) : String(maxAbsVal);
|
|
554
|
-
} else {
|
|
555
|
-
sampleLabel = defaultNumberFormatter({ ...ctx, extent: ctx.extent ?? [0, maxAbsVal] })(
|
|
556
|
-
maxAbsVal,
|
|
557
|
-
);
|
|
558
|
-
}
|
|
559
|
-
const negPrefix = spec.data.some((r) => Number(r[yField]) < 0) ? '-' : '';
|
|
560
|
-
const labelEst = negPrefix + sampleLabel;
|
|
561
|
-
const labelWidth = estimateTextWidth(
|
|
562
|
-
labelEst,
|
|
563
|
-
theme.fonts.sizes.axisTick,
|
|
564
|
-
theme.fonts.weights.normal,
|
|
565
|
-
);
|
|
566
|
-
// 6px gap between label and chart area edge
|
|
567
|
-
margins.left = Math.max(margins.left, hPad + labelWidth + 10);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
// Rotated y-axis label needs extra left margin (rendered at area.x - offset in SVG).
|
|
572
|
-
// The renderer computes a dynamic offset that accounts for wide tick labels (e.g.
|
|
573
|
-
// "$100,000" is ~62px wide and would overlap a fixed 45px offset). We replicate
|
|
574
|
-
// the same formula here so the reserved space matches what the renderer places.
|
|
575
|
-
// Skipped when a layout plan is present -- the plan already accounts for the
|
|
576
|
-
// y-axis title in its leftGutter calculation.
|
|
577
|
-
const yAxis = encoding.y?.axis as Record<string, unknown> | undefined;
|
|
578
|
-
if (yAxis && (yAxis.title || yAxis.label) && !isRadial && !plan) {
|
|
579
|
-
// Estimate the widest y-axis tick label width to mirror the renderer's dynamic offset.
|
|
580
|
-
const yFieldForTitle = encoding.y?.field;
|
|
581
|
-
const yAxisFormatForTitle = yAxis?.format as string | undefined;
|
|
582
|
-
let estTickLabelWidth = 0;
|
|
583
|
-
// Inline y-tick labels render inside the chart area (above their gridlines),
|
|
584
|
-
// not in a left gutter, so they add no width the rotated title must clear.
|
|
585
|
-
// Counting them here would reserve a phantom gutter and leave a dead gap
|
|
586
|
-
// between the title and the plot.
|
|
587
|
-
if (
|
|
588
|
-
yFieldForTitle &&
|
|
589
|
-
!yIsInlinePre &&
|
|
590
|
-
(encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal')
|
|
591
|
-
) {
|
|
592
|
-
let maxAbsValForTitle = 0;
|
|
593
|
-
for (const row of spec.data) {
|
|
594
|
-
const v = Number(row[yFieldForTitle]);
|
|
595
|
-
if (Number.isFinite(v) && Math.abs(v) > maxAbsValForTitle) maxAbsValForTitle = Math.abs(v);
|
|
596
|
-
}
|
|
597
|
-
const ctxForTitle = computeFieldFormatContext(spec.data.map((r) => r[yFieldForTitle!]));
|
|
598
|
-
let sampleLabelForTitle: string;
|
|
599
|
-
if (yAxisFormatForTitle) {
|
|
600
|
-
const fmt = resolveNumberFormatter(yAxisFormatForTitle, ctxForTitle);
|
|
601
|
-
sampleLabelForTitle = fmt ? fmt(maxAbsValForTitle) : String(maxAbsValForTitle);
|
|
602
|
-
} else {
|
|
603
|
-
sampleLabelForTitle = defaultNumberFormatter({
|
|
604
|
-
...ctxForTitle,
|
|
605
|
-
extent: ctxForTitle.extent ?? [0, maxAbsValForTitle],
|
|
606
|
-
})(maxAbsValForTitle);
|
|
607
|
-
}
|
|
608
|
-
const negPrefixForTitle = spec.data.some((r) => Number(r[yFieldForTitle]) < 0) ? '-' : '';
|
|
609
|
-
estTickLabelWidth = estimateTextWidth(
|
|
610
|
-
negPrefixForTitle + sampleLabelForTitle,
|
|
611
|
-
theme.fonts.sizes.axisTick,
|
|
612
|
-
theme.fonts.weights.normal,
|
|
613
|
-
);
|
|
614
|
-
}
|
|
615
|
-
// Mirror the renderer's title placement so the reserved space matches where
|
|
616
|
-
// the title is drawn. axisTitleOffset() returns the distance to the title's
|
|
617
|
-
// center; it already includes the title half-glyph on the tick-label side.
|
|
618
|
-
// We add another halfGlyph here for the title glyph extending the other way,
|
|
619
|
-
// toward the container edge, so the margin reaches the title's outer edge.
|
|
620
|
-
const titleFontSize = theme.fonts.sizes.body;
|
|
621
|
-
const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width, yIsInlinePre);
|
|
622
|
-
const halfGlyph = Math.ceil(titleFontSize / 2);
|
|
623
|
-
const rotatedLabelMargin =
|
|
624
|
-
offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
|
|
625
|
-
margins.left = Math.max(margins.left, hPad + rotatedLabelMargin);
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// When a layout plan is present, its leftGutter was measured from real tick
|
|
629
|
-
// labels and already accounts for quantitative guess + y-axis title. Apply
|
|
630
|
-
// it as a floor so the plan's measurement wins over the category-label
|
|
631
|
-
// reservation above (which stays for bar/dot charts).
|
|
632
|
-
if (plan) {
|
|
633
|
-
margins.left = Math.max(margins.left, plan.leftGutter);
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// Reserve space for a secondary (right) y-axis in dual-axis charts.
|
|
637
|
-
// Use Math.max (not +=) to mirror the left-margin pattern: the reserve
|
|
638
|
-
// replaces the base axisMargin when it's larger, instead of stacking.
|
|
639
|
-
if (options.rightAxisReserve && options.rightAxisReserve > 0) {
|
|
640
|
-
margins.right = Math.max(margins.right, hPad + options.rightAxisReserve);
|
|
641
|
-
}
|
|
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
|
-
|
|
650
|
-
// Reserve legend space.
|
|
651
|
-
//
|
|
652
|
-
// Bottom legend: reservation is already baked into `chrome.bottomHeight`
|
|
653
|
-
// via `bottomLegendReservation`, so no additional bottom margin is needed
|
|
654
|
-
// here. The legend lands below the x-axis tick row (which is reserved via
|
|
655
|
-
// `xAxisHeight` in the base bottom margin) and source/byline/footer chrome
|
|
656
|
-
// stacks underneath the legend band rather than colliding with it.
|
|
657
|
-
// When a plan is present, derive legend position/size from plan.legendContent;
|
|
658
|
-
// otherwise use the legendLayout parameter (back-compat for non-plan callers).
|
|
659
|
-
const legendHasEntries = plan
|
|
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');
|
|
669
|
-
|
|
670
|
-
const hasTopLegend = legendHasEntries && legendPos === 'top';
|
|
671
|
-
if (legendHasEntries) {
|
|
672
|
-
const gap = legendGap(width);
|
|
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;
|
|
677
|
-
}
|
|
678
|
-
// 'right'/'bottom-right' fall through to the shared right-column reservation
|
|
679
|
-
// below; 'bottom' is intentionally not handled here -- see
|
|
680
|
-
// bottomLegendReservation above.
|
|
681
|
-
}
|
|
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
|
-
|
|
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;
|
|
711
|
-
|
|
712
|
-
// Chart area is what's left after margins
|
|
713
|
-
let chartArea: Rect = {
|
|
714
|
-
x: margins.left,
|
|
715
|
-
y: margins.top,
|
|
716
|
-
width: Math.max(0, width - margins.left - margins.right),
|
|
717
|
-
height: Math.max(0, height - margins.top - margins.bottom),
|
|
718
|
-
};
|
|
719
|
-
|
|
720
|
-
// Guardrail: if chart area is too small, progressively strip chrome
|
|
721
|
-
const minDims = getMinChartDims(spec.display);
|
|
722
|
-
if (
|
|
723
|
-
(chartArea.width < minDims.width || chartArea.height < minDims.height) &&
|
|
724
|
-
chromeMode !== 'hidden'
|
|
725
|
-
) {
|
|
726
|
-
// Try compact first, then hidden
|
|
727
|
-
const fallbackMode = chromeMode === 'full' ? 'compact' : 'hidden';
|
|
728
|
-
const fallbackChrome = computeChrome(
|
|
729
|
-
chromeToInput(spec.chrome),
|
|
730
|
-
theme,
|
|
731
|
-
width,
|
|
732
|
-
options.measureText,
|
|
733
|
-
fallbackMode as 'compact' | 'hidden',
|
|
734
|
-
padding,
|
|
735
|
-
watermark,
|
|
736
|
-
bottomLegendReservation,
|
|
737
|
-
);
|
|
738
|
-
|
|
739
|
-
// Recalculate top/bottom margins with stripped chrome.
|
|
740
|
-
// Use topPad (not padding) to preserve the iOS Safari clearance on narrow viewports.
|
|
741
|
-
// Include the tentative metric reservation so the rollback below mirrors
|
|
742
|
-
// the primary path's invariant (margins.top includes tentativeMetricsHeight
|
|
743
|
-
// until resolveMetrics decides otherwise).
|
|
744
|
-
const fallbackTopAxisGap =
|
|
745
|
-
isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
|
|
746
|
-
const fallbackEffectiveAxisGap = hasTopLegend ? inlineTickOverhang : fallbackTopAxisGap;
|
|
747
|
-
const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight + searchReservation;
|
|
748
|
-
const topDelta = margins.top - newTop;
|
|
749
|
-
const newBottom = bottomMargin(fallbackChrome.bottomHeight, padding, xAxisHeight);
|
|
750
|
-
const bottomDelta = margins.bottom - newBottom;
|
|
751
|
-
|
|
752
|
-
if (topDelta > 0 || bottomDelta > 0) {
|
|
753
|
-
const gap = legendGap(width);
|
|
754
|
-
margins.top =
|
|
755
|
-
newTop +
|
|
756
|
-
(hasTopLegend ? TOP_LEGEND_GAP_ABOVE + legendHeight + gap : 0) +
|
|
757
|
-
fallbackEffectiveAxisGap;
|
|
758
|
-
margins.bottom = newBottom;
|
|
759
|
-
|
|
760
|
-
chartArea = {
|
|
761
|
-
x: margins.left,
|
|
762
|
-
y: margins.top,
|
|
763
|
-
width: Math.max(0, width - margins.left - margins.right),
|
|
764
|
-
height: Math.max(0, height - margins.top - margins.bottom),
|
|
765
|
-
};
|
|
766
|
-
|
|
767
|
-
// Same chrome-anchored positioning as the primary path; see comment
|
|
768
|
-
// near the primary `metricsTopY` for the full stacking order.
|
|
769
|
-
const fallbackMetricsTopY = topPad + fallbackChrome.topHeight;
|
|
770
|
-
const fallbackMetricsArea = { x: hPad, width: Math.max(0, width - hPad * 2) };
|
|
771
|
-
const fallbackMetrics = wantsMetrics
|
|
772
|
-
? resolveMetrics(
|
|
773
|
-
spec,
|
|
774
|
-
fallbackMetricsTopY,
|
|
775
|
-
fallbackMetricsArea,
|
|
776
|
-
chartArea.height,
|
|
777
|
-
options.measureText,
|
|
778
|
-
theme,
|
|
779
|
-
)
|
|
780
|
-
: undefined;
|
|
781
|
-
if (wantsMetrics && !fallbackMetrics) {
|
|
782
|
-
// Bar was tentatively reserved but didn't fit — roll back the top margin.
|
|
783
|
-
// Clamp at 0 as a defensive guard: even though the reservation was
|
|
784
|
-
// additive (margins.top = topPad + chrome + tentative + axisGap [+ legend])
|
|
785
|
-
// and so subtraction is mathematically safe, a negative top margin would
|
|
786
|
-
// shift the chart above the SVG viewport if any future change ever
|
|
787
|
-
// reordered the additions.
|
|
788
|
-
margins.top = Math.max(0, margins.top - tentativeMetricsHeight);
|
|
789
|
-
chartArea = {
|
|
790
|
-
...chartArea,
|
|
791
|
-
y: margins.top,
|
|
792
|
-
height: Math.max(0, height - margins.top - margins.bottom),
|
|
793
|
-
};
|
|
794
|
-
}
|
|
795
|
-
return {
|
|
796
|
-
total,
|
|
797
|
-
chrome: fallbackChrome,
|
|
798
|
-
chartArea,
|
|
799
|
-
margins,
|
|
800
|
-
theme,
|
|
801
|
-
metrics: fallbackMetrics,
|
|
802
|
-
seriesSearch: wantsSearch
|
|
803
|
-
? resolveSeriesSearch(
|
|
804
|
-
spec,
|
|
805
|
-
fallbackMetricsTopY + (fallbackMetrics?.height ?? 0),
|
|
806
|
-
fallbackMetricsArea,
|
|
807
|
-
)
|
|
808
|
-
: undefined,
|
|
809
|
-
xAxisHeight,
|
|
810
|
-
effectiveAxisGap: fallbackEffectiveAxisGap,
|
|
811
|
-
};
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
// Vertical stacking order from the SVG top edge:
|
|
816
|
-
// topPad
|
|
817
|
-
// chrome.topHeight (title / subtitle / eyebrow)
|
|
818
|
-
// tentativeMetricsHeight (KPI bar — placed here)
|
|
819
|
-
// [optional top legend band (TOP_LEGEND_GAP_ABOVE + legend + legendGap)]
|
|
820
|
-
// effectiveAxisGap (inlineTickOverhang with a top legend,
|
|
821
|
-
// else axisMargin + inlineTickOverhang)
|
|
822
|
-
// chartArea
|
|
823
|
-
// The metric bar belongs with chrome, above the legend, so its y is
|
|
824
|
-
// computed off chrome.topHeight only — not the full legend-inclusive
|
|
825
|
-
// margins.top.
|
|
826
|
-
const metricsTopY = topPad + chrome.topHeight;
|
|
827
|
-
const metricsArea = { x: hPad, width: Math.max(0, width - hPad * 2) };
|
|
828
|
-
const resolvedMetrics = wantsMetrics
|
|
829
|
-
? resolveMetrics(spec, metricsTopY, metricsArea, chartArea.height, options.measureText, theme)
|
|
830
|
-
: undefined;
|
|
831
|
-
if (wantsMetrics && !resolvedMetrics) {
|
|
832
|
-
// See fallback path above for the clamp rationale.
|
|
833
|
-
margins.top = Math.max(0, margins.top - tentativeMetricsHeight);
|
|
834
|
-
chartArea = {
|
|
835
|
-
...chartArea,
|
|
836
|
-
y: margins.top,
|
|
837
|
-
height: Math.max(0, height - margins.top - margins.bottom),
|
|
838
|
-
};
|
|
839
|
-
}
|
|
840
|
-
return {
|
|
841
|
-
total,
|
|
842
|
-
chrome,
|
|
843
|
-
chartArea,
|
|
844
|
-
margins,
|
|
845
|
-
theme,
|
|
846
|
-
metrics: resolvedMetrics,
|
|
847
|
-
seriesSearch: wantsSearch
|
|
848
|
-
? resolveSeriesSearch(spec, metricsTopY + (resolvedMetrics?.height ?? 0), metricsArea)
|
|
849
|
-
: undefined,
|
|
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,
|
|
887
|
-
};
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
/**
|
|
891
|
-
* Resolve the metric bar layout. The bar spans the full chrome content width
|
|
892
|
-
* (from hPad to width - hPad), aligning with the title/eyebrow rather than
|
|
893
|
-
* indenting to the chart area's left gutter. Its `y` sits directly below
|
|
894
|
-
* chrome and above any top legend.
|
|
895
|
-
*/
|
|
896
|
-
function resolveMetrics(
|
|
897
|
-
spec: NormalizedChartSpec,
|
|
898
|
-
metricsTopY: number,
|
|
899
|
-
metricsArea: { x: number; width: number },
|
|
900
|
-
remainingChartHeight: number,
|
|
901
|
-
measureText: import('@opendata-ai/openchart-core').MeasureTextFn | undefined,
|
|
902
|
-
theme: ResolvedTheme,
|
|
903
|
-
): ResolvedMetricBar | undefined {
|
|
904
|
-
return computeMetricBar(
|
|
905
|
-
spec.metrics,
|
|
906
|
-
metricsTopY,
|
|
907
|
-
metricsArea,
|
|
908
|
-
remainingChartHeight,
|
|
909
|
-
measureText,
|
|
910
|
-
metricFonts(theme),
|
|
911
|
-
);
|
|
912
|
-
}
|