@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/compile.ts
CHANGED
|
@@ -14,35 +14,34 @@
|
|
|
14
14
|
|
|
15
15
|
import type {
|
|
16
16
|
AnimationSpec,
|
|
17
|
-
BinParams,
|
|
18
|
-
BinTransform,
|
|
19
17
|
ChartLayout,
|
|
20
18
|
ChartSpec,
|
|
21
19
|
CompileOptions,
|
|
22
20
|
CompileTableOptions,
|
|
23
21
|
Encoding,
|
|
24
|
-
EncodingChannel,
|
|
25
22
|
FacetChannel,
|
|
26
23
|
FacetPanelLayout,
|
|
27
24
|
LayerSpec,
|
|
28
25
|
LayoutStrategy,
|
|
26
|
+
LegendLayout,
|
|
29
27
|
Mark,
|
|
30
28
|
ResolvedAnimation,
|
|
31
29
|
ResolvedAnnotation,
|
|
32
30
|
ResolvedTheme,
|
|
31
|
+
ResolveMode,
|
|
32
|
+
ScaleInvert,
|
|
33
33
|
TableLayout,
|
|
34
|
-
TimeUnit,
|
|
35
|
-
TimeUnitTransform,
|
|
36
|
-
Transform,
|
|
37
34
|
} from '@opendata-ai/openchart-core';
|
|
38
35
|
import {
|
|
39
36
|
adaptTheme,
|
|
40
37
|
computeLabelBounds,
|
|
38
|
+
footnoteBandHeight,
|
|
41
39
|
generateAltText,
|
|
42
40
|
generateDataTable,
|
|
43
41
|
getBreakpoint,
|
|
44
42
|
getHeightClass,
|
|
45
43
|
getLayoutStrategy,
|
|
44
|
+
isAxislessMark,
|
|
46
45
|
resolveTheme,
|
|
47
46
|
} from '@opendata-ai/openchart-core';
|
|
48
47
|
import { computeAnnotations } from './annotations/compute';
|
|
@@ -64,8 +63,12 @@ import { getChartRenderer } from './charts/registry';
|
|
|
64
63
|
import { groupByField } from './charts/utils';
|
|
65
64
|
import { applyColorScaleRange } from './compile/color-scale-range';
|
|
66
65
|
import { filterClippedDomains } from './compile/data-clip';
|
|
66
|
+
import { applyFillPatterns } from './compile/fill-patterns';
|
|
67
67
|
import { compileLayer as compileLayerImpl } from './compile/layer';
|
|
68
|
+
import { emitSpecWarnings, expandSpecSugar } from './compile/spec-sugar';
|
|
68
69
|
import { computeWatermarkObstacle } from './compile/watermark-obstacle';
|
|
70
|
+
import { resolveYouDrawIt } from './compile/you-draw-it';
|
|
71
|
+
import { collectContrastWarnings } from './compiler/a11y-warnings';
|
|
69
72
|
import { resolveAnimation } from './compiler/animation';
|
|
70
73
|
import { compile as compileSpec } from './compiler/index';
|
|
71
74
|
import {
|
|
@@ -75,15 +78,17 @@ import {
|
|
|
75
78
|
trendColor,
|
|
76
79
|
} from './compiler/sparkline-defaults';
|
|
77
80
|
import type { NormalizedChartSpec, NormalizedTableSpec } from './compiler/types';
|
|
81
|
+
import { compileMap as compileMapImpl } from './geo/compile-map';
|
|
78
82
|
import { compileGraph as compileGraphImpl } from './graphs/compile-graph';
|
|
79
83
|
import type { GraphCompilation } from './graphs/types';
|
|
80
84
|
import { computeAxes } from './layout/axes';
|
|
81
85
|
import { computeDimensions } from './layout/dimensions';
|
|
82
|
-
import { computeFacetGrid } from './layout/facet';
|
|
86
|
+
import { computeFacetGrid, facetMinHeight, MIN_PANEL_HEIGHT } from './layout/facet';
|
|
83
87
|
import { computeGridlines } from './layout/gridlines';
|
|
84
88
|
import { createMeasureFn, resolveLayoutPlan } from './layout/plan';
|
|
85
|
-
import { computeScales } from './layout/scales';
|
|
89
|
+
import { computeScales, type ResolvedScale } from './layout/scales';
|
|
86
90
|
import { placeLegend } from './legend/compute';
|
|
91
|
+
import { placeSizeLegend } from './legend/size';
|
|
87
92
|
import { compileSankey as compileSankeyImpl } from './sankey/compile-sankey';
|
|
88
93
|
import { compileTableLayout } from './tables/compile-table';
|
|
89
94
|
import { compileTileMap as compileTileMapImpl } from './tilemap/compile-tilemap';
|
|
@@ -91,76 +96,26 @@ import { computeTooltipDescriptors } from './tooltips/compute';
|
|
|
91
96
|
import { runTransforms } from './transforms';
|
|
92
97
|
|
|
93
98
|
// ---------------------------------------------------------------------------
|
|
94
|
-
//
|
|
99
|
+
// Spec sugar expansion (VL idioms, bin/timeUnit, deprecation warnings)
|
|
95
100
|
// ---------------------------------------------------------------------------
|
|
96
101
|
|
|
102
|
+
// Implementation lives in ./compile/spec-sugar; re-exported here so existing
|
|
103
|
+
// deep imports (`import { expandEncodingSugar } from '.../compile'`) keep working.
|
|
104
|
+
export { expandEncodingSugar, expandSpecSugar } from './compile/spec-sugar';
|
|
105
|
+
|
|
97
106
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* This function detects those shorthands, generates the corresponding transforms,
|
|
102
|
-
* updates encoding field references to the output field names, and prepends the
|
|
103
|
-
* transforms to the spec's transform array.
|
|
104
|
-
*
|
|
105
|
-
* Mutates nothing; returns a new spec object (shallow copy).
|
|
107
|
+
* Apply top-level spec `width`/`height` (VL fixed-size sugar) as overrides on
|
|
108
|
+
* the compile options. Returns the options untouched when the spec carries no
|
|
109
|
+
* size.
|
|
106
110
|
*/
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const generatedTransforms: Transform[] = [];
|
|
112
|
-
const updatedEncoding = { ...encoding };
|
|
113
|
-
let changed = false;
|
|
114
|
-
|
|
115
|
-
for (const channel of Object.keys(encoding)) {
|
|
116
|
-
const ch = encoding[channel];
|
|
117
|
-
if (!ch || !ch.field) continue;
|
|
118
|
-
|
|
119
|
-
// Expand bin shorthand
|
|
120
|
-
if (ch.bin != null && ch.bin !== false) {
|
|
121
|
-
const field = ch.field;
|
|
122
|
-
const outputField = `bin_${field}`;
|
|
123
|
-
const binTransform: BinTransform = {
|
|
124
|
-
bin: ch.bin === true ? true : (ch.bin as BinParams),
|
|
125
|
-
field,
|
|
126
|
-
as: outputField,
|
|
127
|
-
};
|
|
128
|
-
generatedTransforms.push(binTransform);
|
|
129
|
-
|
|
130
|
-
// Update encoding to reference binned output field, remove bin property
|
|
131
|
-
const { bin: _bin, ...rest } = ch;
|
|
132
|
-
updatedEncoding[channel] = { ...rest, field: outputField } as EncodingChannel;
|
|
133
|
-
changed = true;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Expand timeUnit shorthand (read from updated encoding in case bin already ran)
|
|
137
|
-
const current = updatedEncoding[channel] ?? ch;
|
|
138
|
-
if (current.timeUnit) {
|
|
139
|
-
const field = current.field;
|
|
140
|
-
const unit = current.timeUnit as TimeUnit;
|
|
141
|
-
const outputField = `${unit}_${field}`;
|
|
142
|
-
const timeUnitTransform: TimeUnitTransform = {
|
|
143
|
-
timeUnit: unit,
|
|
144
|
-
field,
|
|
145
|
-
as: outputField,
|
|
146
|
-
};
|
|
147
|
-
generatedTransforms.push(timeUnitTransform);
|
|
148
|
-
|
|
149
|
-
// Update encoding to reference timeUnit output field, remove timeUnit property
|
|
150
|
-
const { timeUnit: _tu, ...rest } = current;
|
|
151
|
-
updatedEncoding[channel] = { ...rest, field: outputField } as EncodingChannel;
|
|
152
|
-
changed = true;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (!changed) return spec;
|
|
157
|
-
|
|
158
|
-
// Prepend generated transforms before any user-defined transforms
|
|
159
|
-
const existingTransforms = (spec.transform as Transform[] | undefined) ?? [];
|
|
111
|
+
function applySpecSize(spec: unknown, options: CompileOptions): CompileOptions {
|
|
112
|
+
if (!spec || typeof spec !== 'object') return options;
|
|
113
|
+
const sized = spec as Record<string, unknown>;
|
|
114
|
+
if (typeof sized.width !== 'number' && typeof sized.height !== 'number') return options;
|
|
160
115
|
return {
|
|
161
|
-
...
|
|
162
|
-
|
|
163
|
-
|
|
116
|
+
...options,
|
|
117
|
+
width: typeof sized.width === 'number' ? sized.width : options.width,
|
|
118
|
+
height: typeof sized.height === 'number' ? sized.height : options.height,
|
|
164
119
|
};
|
|
165
120
|
}
|
|
166
121
|
|
|
@@ -168,6 +123,15 @@ export function expandEncodingSugar(spec: Record<string, unknown>): Record<strin
|
|
|
168
123
|
// Chart compilation
|
|
169
124
|
// ---------------------------------------------------------------------------
|
|
170
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Cap on footnote-reserve convergence passes in compileChart. Reserving space
|
|
128
|
+
* for the footnote list shrinks the plot, which can demote one more label and
|
|
129
|
+
* grow the list; the count only ever grows and is bounded by the annotation
|
|
130
|
+
* count, so this terminates on its own. The cap is a guard against a
|
|
131
|
+
* measurement quirk oscillating, not the normal exit condition.
|
|
132
|
+
*/
|
|
133
|
+
const MAX_FOOTNOTE_PASSES = 3;
|
|
134
|
+
|
|
171
135
|
/**
|
|
172
136
|
* Inject sparkline-mode visual defaults that depend on the resolved theme.
|
|
173
137
|
*
|
|
@@ -225,6 +189,52 @@ function applySparklineDefaults(
|
|
|
225
189
|
return { ...spec, markDef: newMarkDef };
|
|
226
190
|
}
|
|
227
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Extract scale inversion anchors from a resolved scale so the vanilla layer
|
|
194
|
+
* can convert pixel positions back to data-space values without holding the
|
|
195
|
+
* d3 scale object. Generalizes the YouDrawItYInvert precedent to any axis.
|
|
196
|
+
*/
|
|
197
|
+
function resolveScaleInvert(resolved: ResolvedScale | undefined): ScaleInvert | undefined {
|
|
198
|
+
if (!resolved) return undefined;
|
|
199
|
+
const { scale, type } = resolved;
|
|
200
|
+
|
|
201
|
+
// Band / point scales: snap to nearest domain value
|
|
202
|
+
if (type === 'band' || type === 'point') {
|
|
203
|
+
const domain = (scale as { domain(): string[] }).domain() as (string | number)[];
|
|
204
|
+
const positions = domain.map((v) => {
|
|
205
|
+
const pos = (scale as (v: string | number) => number | undefined)(v) ?? 0;
|
|
206
|
+
return type === 'band' ? pos + (scale as { bandwidth(): number }).bandwidth() / 2 : pos;
|
|
207
|
+
});
|
|
208
|
+
const range = (scale as { range(): number[] }).range();
|
|
209
|
+
return {
|
|
210
|
+
topPixel: range[0],
|
|
211
|
+
bottomPixel: range[range.length - 1],
|
|
212
|
+
topData: 0,
|
|
213
|
+
bottomData: 0,
|
|
214
|
+
domain,
|
|
215
|
+
positions,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Continuous scales (linear, time, log, pow, sqrt, symlog)
|
|
220
|
+
const invert = (scale as { invert?: (px: number) => unknown }).invert;
|
|
221
|
+
if (typeof invert !== 'function') return undefined;
|
|
222
|
+
|
|
223
|
+
const range = (scale as { range(): number[] }).range();
|
|
224
|
+
const topPx = range[0];
|
|
225
|
+
const bottomPx = range[range.length - 1];
|
|
226
|
+
const topVal = invert.call(scale, topPx);
|
|
227
|
+
const bottomVal = invert.call(scale, bottomPx);
|
|
228
|
+
|
|
229
|
+
// For time scales, convert Date to epoch ms
|
|
230
|
+
const topData = topVal instanceof Date ? +topVal : Number(topVal);
|
|
231
|
+
const bottomData = bottomVal instanceof Date ? +bottomVal : Number(bottomVal);
|
|
232
|
+
if (!Number.isFinite(topData) || !Number.isFinite(bottomData)) return undefined;
|
|
233
|
+
|
|
234
|
+
const scaleType = type as ScaleInvert['scaleType'];
|
|
235
|
+
return { topPixel: topPx, bottomPixel: bottomPx, topData, bottomData, scaleType };
|
|
236
|
+
}
|
|
237
|
+
|
|
228
238
|
/**
|
|
229
239
|
* Compile a chart spec into a ChartLayout.
|
|
230
240
|
*
|
|
@@ -233,20 +243,32 @@ function applySparklineDefaults(
|
|
|
233
243
|
* ChartLayout with positions, colors, and marks ready for rendering.
|
|
234
244
|
*
|
|
235
245
|
* @param spec - Raw chart spec (validated and normalized internally).
|
|
236
|
-
* @param
|
|
246
|
+
* @param optionsInput - Compile options (width, height, theme, darkMode).
|
|
247
|
+
* Top-level spec width/height, when present, override the option values.
|
|
237
248
|
* @returns ChartLayout with all computed positions.
|
|
238
249
|
* @throws Error if spec is invalid or not a chart type.
|
|
239
250
|
*/
|
|
240
|
-
export function compileChart(spec: unknown,
|
|
241
|
-
// Expand encoding-level
|
|
242
|
-
//
|
|
251
|
+
export function compileChart(spec: unknown, optionsInput: CompileOptions): ChartLayout {
|
|
252
|
+
// Expand VL-idiom and encoding-level sugar (data {values}, top-level title,
|
|
253
|
+
// sort-by-value, value defs, scheme names, theta, bin/timeUnit, ...) before
|
|
254
|
+
// validation + normalization, surfacing any deprecation warnings.
|
|
255
|
+
const sugarWarnings: string[] = [];
|
|
243
256
|
const expandedSpec =
|
|
244
257
|
spec && typeof spec === 'object' && !Array.isArray(spec)
|
|
245
|
-
?
|
|
258
|
+
? expandSpecSugar(spec as Record<string, unknown>, sugarWarnings)
|
|
246
259
|
: spec;
|
|
247
260
|
|
|
248
|
-
//
|
|
249
|
-
|
|
261
|
+
// Top-level width/height are fixed-size overrides (VL alignment): the
|
|
262
|
+
// authored size wins over the container-derived compile options.
|
|
263
|
+
const options = applySpecSize(expandedSpec, optionsInput);
|
|
264
|
+
|
|
265
|
+
// Validate + normalize. Normalize-stage warnings (type mismatches,
|
|
266
|
+
// beeswarm point budget, sparkline mark advice) surface through the same
|
|
267
|
+
// console channel as the sugar warnings. Emit both together in one call so
|
|
268
|
+
// dedup spans the whole compile: a message produced by both a sugar pass and
|
|
269
|
+
// a normalize pass prints once, not once per stage.
|
|
270
|
+
const { spec: normalized, warnings: normalizeWarnings } = compileSpec(expandedSpec);
|
|
271
|
+
emitSpecWarnings([...sugarWarnings, ...normalizeWarnings], options.onWarn);
|
|
250
272
|
|
|
251
273
|
if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'table') {
|
|
252
274
|
throw new Error('compileChart received a table spec. Use compileTable instead.');
|
|
@@ -260,6 +282,9 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
260
282
|
) {
|
|
261
283
|
throw new Error('compileChart received a sankey spec. Use compileSankey instead.');
|
|
262
284
|
}
|
|
285
|
+
if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'map') {
|
|
286
|
+
throw new Error('compileChart received a map spec. Use compileMap instead.');
|
|
287
|
+
}
|
|
263
288
|
|
|
264
289
|
let chartSpec = normalized as NormalizedChartSpec;
|
|
265
290
|
|
|
@@ -593,20 +618,46 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
593
618
|
}
|
|
594
619
|
|
|
595
620
|
// ---------------------------------------------------------------------------
|
|
596
|
-
// Faceted compilation:
|
|
621
|
+
// Faceted compilation: desugar row/column into facet, then take faceted path
|
|
597
622
|
// ---------------------------------------------------------------------------
|
|
623
|
+
let facetDirection: 'row' | 'column' | 'wrap' = 'wrap';
|
|
624
|
+
const rowChannel = renderSpec.encoding.row;
|
|
625
|
+
const colChannel = renderSpec.encoding.column;
|
|
626
|
+
if (rowChannel?.field || colChannel?.field) {
|
|
627
|
+
const channel = (rowChannel?.field ? rowChannel : colChannel)!;
|
|
628
|
+
facetDirection = rowChannel?.field ? 'row' : 'column';
|
|
629
|
+
const desugared = { ...renderSpec.encoding } as Record<string, unknown>;
|
|
630
|
+
delete desugared.row;
|
|
631
|
+
delete desugared.column;
|
|
632
|
+
desugared.facet = {
|
|
633
|
+
field: channel.field,
|
|
634
|
+
type: channel.type,
|
|
635
|
+
sort: channel.sort,
|
|
636
|
+
...(facetDirection === 'row' ? { columns: 1 } : {}),
|
|
637
|
+
};
|
|
638
|
+
renderSpec = { ...renderSpec, encoding: desugared as typeof renderSpec.encoding };
|
|
639
|
+
const chartDesugared = { ...chartSpec.encoding } as Record<string, unknown>;
|
|
640
|
+
delete chartDesugared.row;
|
|
641
|
+
delete chartDesugared.column;
|
|
642
|
+
chartDesugared.facet = desugared.facet;
|
|
643
|
+
chartSpec = { ...chartSpec, encoding: chartDesugared as typeof chartSpec.encoding };
|
|
644
|
+
}
|
|
645
|
+
|
|
598
646
|
const facetChannel = renderSpec.encoding.facet;
|
|
599
647
|
if (facetChannel?.field) {
|
|
600
648
|
return compileFaceted(
|
|
601
649
|
chartSpec,
|
|
602
650
|
renderSpec,
|
|
603
651
|
facetChannel,
|
|
652
|
+
facetDirection,
|
|
604
653
|
options,
|
|
605
654
|
theme,
|
|
606
655
|
strategy,
|
|
607
656
|
watermark,
|
|
608
657
|
resolvedAnimation,
|
|
609
658
|
crosshair,
|
|
659
|
+
spec,
|
|
660
|
+
optionsInput,
|
|
610
661
|
);
|
|
611
662
|
}
|
|
612
663
|
|
|
@@ -651,6 +702,15 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
651
702
|
dims.effectiveAxisGap,
|
|
652
703
|
);
|
|
653
704
|
|
|
705
|
+
// Every legend on the chart. The color legend is primary (and stays in the
|
|
706
|
+
// singular `legend` slot); a size legend joins it when a quantitative `size`
|
|
707
|
+
// channel is encoded. Consumers that reserve space or hit-test must iterate
|
|
708
|
+
// this rather than read `legend` -- see ChartLayout.legends.
|
|
709
|
+
const sizeLegend = plan.sizeLegendContent
|
|
710
|
+
? placeSizeLegend(plan.sizeLegendContent, chartArea, theme, finalLegend)
|
|
711
|
+
: null;
|
|
712
|
+
const allLegends: LegendLayout[] = [finalLegend, ...(sizeLegend ? [sizeLegend] : [])];
|
|
713
|
+
|
|
654
714
|
// Inline y-axis labels: inset the x-scale range start by the widest tick
|
|
655
715
|
// label width so data marks start to the right of the labels. The plan
|
|
656
716
|
// already measured the real tick labels.
|
|
@@ -677,8 +737,16 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
677
737
|
scales.defaultColor =
|
|
678
738
|
chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
|
|
679
739
|
|
|
680
|
-
//
|
|
681
|
-
|
|
740
|
+
// Dev-mode WCAG contrast diagnostics. Host-gated via options.dev (never an
|
|
741
|
+
// env sniff; the engine is isomorphic). Advisory console.warn only.
|
|
742
|
+
if (options.dev) {
|
|
743
|
+
emitSpecWarnings(collectContrastWarnings(scales, chartSpec.markType, theme), options.onWarn);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Axisless marks (arc, waffle, calendar, parliament) don't use axes or
|
|
747
|
+
// gridlines: arcs and parliament hemicycles are radial, waffles and calendars
|
|
748
|
+
// compute their own grid geometry.
|
|
749
|
+
const isRadial = isAxislessMark(chartSpec.markType);
|
|
682
750
|
|
|
683
751
|
// Compute axes (skip for radial charts).
|
|
684
752
|
// Sparkline mode skips axes by default unless the user explicitly opted into
|
|
@@ -718,6 +786,18 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
718
786
|
? renderer(renderSpec, scales, chartArea, strategy, theme, options.width)
|
|
719
787
|
: [];
|
|
720
788
|
|
|
789
|
+
// Opt-in per-series fill patterns (mark.fillPattern: 'auto'). Mutates
|
|
790
|
+
// filled marks in place; assignment is deterministic and theme-aware.
|
|
791
|
+
applyFillPatterns(marks, chartSpec.markDef, theme);
|
|
792
|
+
|
|
793
|
+
// Resolve "you draw it" geometry (pixel fromX, x-sample positions, target
|
|
794
|
+
// line color/key). Reads marks so it must run after they're computed.
|
|
795
|
+
const xFieldForYdi =
|
|
796
|
+
chartSpec.encoding.x && 'field' in chartSpec.encoding.x
|
|
797
|
+
? (chartSpec.encoding.x.field as string | undefined)
|
|
798
|
+
: undefined;
|
|
799
|
+
const youDrawIt = resolveYouDrawIt(chartSpec.youDrawIt, marks, scales, chartArea, xFieldForYdi);
|
|
800
|
+
|
|
721
801
|
// Compute the right-side endpoint labels column for multi-series line/area
|
|
722
802
|
// charts. Reads `mark.dataPoints` so it must run AFTER marks are computed.
|
|
723
803
|
// dimensions.ts already reserved the right margin via predictEndpointLabelsWidth.
|
|
@@ -765,6 +845,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
765
845
|
obstacles,
|
|
766
846
|
svg: { width: dims.total.width, height: dims.total.height },
|
|
767
847
|
measure: annotationMeasure,
|
|
848
|
+
fontFamily: theme.fonts.family,
|
|
768
849
|
autoThin: chartSpec.autoThin,
|
|
769
850
|
});
|
|
770
851
|
|
|
@@ -773,26 +854,67 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
773
854
|
// alignment with the resolved array is preserved.
|
|
774
855
|
let footnotes: import('@opendata-ai/openchart-core').AnnotationFootnote[] | undefined;
|
|
775
856
|
if (chartSpec.autoThin && annotations.length > 1) {
|
|
776
|
-
const result = thinAnnotations(
|
|
857
|
+
const result = thinAnnotations(
|
|
858
|
+
annotations,
|
|
859
|
+
chartSpec.annotations,
|
|
860
|
+
annotationMeasure,
|
|
861
|
+
chartArea,
|
|
862
|
+
{ x: 0, y: 0, width: dims.total.width, height: dims.total.height },
|
|
863
|
+
);
|
|
777
864
|
annotations = result.annotations;
|
|
778
865
|
if (result.footnotes.length > 0) {
|
|
779
866
|
footnotes = result.footnotes;
|
|
867
|
+
|
|
868
|
+
// The footnote list renders below the plot, but nothing reserved space for
|
|
869
|
+
// it — thinning only just now discovered how many lines there are. Recompile
|
|
870
|
+
// with that band carved out of the bottom margin so the list can't overrun
|
|
871
|
+
// the source row and the brand watermark.
|
|
872
|
+
//
|
|
873
|
+
// Reserving shrinks the plot, which can push a further label out of bounds
|
|
874
|
+
// and grow the list by one, so re-reserve until the count settles. The
|
|
875
|
+
// count only ever grows (a smaller plot never keeps more labels inline) and
|
|
876
|
+
// is bounded by the annotation count, so this terminates; the explicit cap
|
|
877
|
+
// is a belt-and-braces guard against a measurement quirk oscillating.
|
|
878
|
+
//
|
|
879
|
+
// A frozen area short-circuits all of this. `compileLayer` pins every
|
|
880
|
+
// leaf to the primary layout's coordinate space, and `chartArea` above
|
|
881
|
+
// takes `frozenChartArea` over anything `computeDimensions` produces --
|
|
882
|
+
// so a grown bottom margin is computed and then thrown away. The
|
|
883
|
+
// recursion would re-run the whole compile up to MAX_FOOTNOTE_PASSES
|
|
884
|
+
// times and return the identical layout. Skip it rather than pay for it.
|
|
885
|
+
const reserve = footnoteBandHeight(footnotes.length, theme);
|
|
886
|
+
const currentReserve = options.footnoteReserve ?? 0;
|
|
887
|
+
if (
|
|
888
|
+
!options.frozenChartArea &&
|
|
889
|
+
reserve > currentReserve &&
|
|
890
|
+
(options.footnotePass ?? 0) < MAX_FOOTNOTE_PASSES
|
|
891
|
+
) {
|
|
892
|
+
return compileChart(spec, {
|
|
893
|
+
...optionsInput,
|
|
894
|
+
footnoteReserve: reserve,
|
|
895
|
+
footnotePass: (options.footnotePass ?? 0) + 1,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
780
898
|
}
|
|
781
899
|
}
|
|
782
900
|
|
|
783
901
|
// Compute tooltip descriptors from marks and encoding
|
|
784
902
|
const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
|
|
785
903
|
|
|
786
|
-
// Compute accessibility
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
904
|
+
// Compute accessibility. An author-provided description (a11y.description,
|
|
905
|
+
// or top-level `description` folded in by the sugar expansion) replaces the
|
|
906
|
+
// auto-generated alt text; auto-generation stays the fallback.
|
|
907
|
+
const altText =
|
|
908
|
+
chartSpec.a11y?.description ??
|
|
909
|
+
generateAltText(
|
|
910
|
+
{
|
|
911
|
+
mark: chartSpec.markType,
|
|
912
|
+
data: chartSpec.data,
|
|
913
|
+
encoding: chartSpec.encoding,
|
|
914
|
+
chrome: chartSpec.chrome,
|
|
915
|
+
} as ChartSpec,
|
|
916
|
+
chartSpec.data,
|
|
917
|
+
);
|
|
796
918
|
const dataTableFallback = generateDataTable(
|
|
797
919
|
{
|
|
798
920
|
mark: chartSpec.markType,
|
|
@@ -807,10 +929,16 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
807
929
|
|
|
808
930
|
const chrome = footnotes ? { ...dims.chrome, footnotes } : dims.chrome;
|
|
809
931
|
|
|
932
|
+
// Scale inversion anchors for edit-mode anchor drag (pixel -> data-space)
|
|
933
|
+
const xInvert = resolveScaleInvert(scales.x);
|
|
934
|
+
const yInvert = resolveScaleInvert(scales.y);
|
|
935
|
+
|
|
810
936
|
return {
|
|
811
937
|
area: chartArea,
|
|
812
938
|
chrome,
|
|
813
939
|
metrics: dims.metrics,
|
|
940
|
+
...(dims.seriesSearch ? { seriesSearch: dims.seriesSearch } : {}),
|
|
941
|
+
...(youDrawIt ? { youDrawIt } : {}),
|
|
814
942
|
axes: {
|
|
815
943
|
x: axes.x,
|
|
816
944
|
y: axes.y,
|
|
@@ -818,6 +946,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
818
946
|
marks,
|
|
819
947
|
annotations,
|
|
820
948
|
legend: finalLegend,
|
|
949
|
+
legends: allLegends,
|
|
821
950
|
...(endpointLabels.entries.length > 0 ? { endpointLabels } : {}),
|
|
822
951
|
tooltipDescriptors,
|
|
823
952
|
a11y: {
|
|
@@ -825,6 +954,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
825
954
|
dataTableFallback,
|
|
826
955
|
role: 'img',
|
|
827
956
|
keyboardNavigable: marks.length > 0,
|
|
957
|
+
...(chartSpec.a11y?.hidden ? { hidden: true } : {}),
|
|
828
958
|
},
|
|
829
959
|
theme,
|
|
830
960
|
dimensions: {
|
|
@@ -836,6 +966,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
836
966
|
display: chartSpec.display,
|
|
837
967
|
crosshair,
|
|
838
968
|
measureText: options.measureText,
|
|
969
|
+
...(xInvert ? { xInvert } : {}),
|
|
970
|
+
...(yInvert ? { yInvert } : {}),
|
|
839
971
|
};
|
|
840
972
|
}
|
|
841
973
|
|
|
@@ -847,12 +979,15 @@ function compileFaceted(
|
|
|
847
979
|
chartSpec: NormalizedChartSpec,
|
|
848
980
|
renderSpec: NormalizedChartSpec,
|
|
849
981
|
facetChannel: FacetChannel,
|
|
982
|
+
facetDirection: 'row' | 'column' | 'wrap',
|
|
850
983
|
options: CompileOptions,
|
|
851
984
|
theme: ResolvedTheme,
|
|
852
985
|
strategy: LayoutStrategy,
|
|
853
986
|
watermark: boolean,
|
|
854
987
|
resolvedAnimation: ResolvedAnimation | undefined,
|
|
855
988
|
crosshair: boolean,
|
|
989
|
+
rawSpec: unknown,
|
|
990
|
+
optionsInput: CompileOptions,
|
|
856
991
|
): ChartLayout {
|
|
857
992
|
const measure = createMeasureFn(options.measureText);
|
|
858
993
|
|
|
@@ -917,15 +1052,38 @@ function compileFaceted(
|
|
|
917
1052
|
const bottomReservation = plan.xAxisExtent;
|
|
918
1053
|
|
|
919
1054
|
// Compute facet grid geometry
|
|
920
|
-
const
|
|
1055
|
+
const effectiveColumns = facetDirection === 'column' ? facetValues.length : facetChannel.columns;
|
|
1056
|
+
const grid = computeFacetGrid(facetValues, effectiveColumns, chartArea, {
|
|
921
1057
|
left: leftReservation,
|
|
922
1058
|
bottom: bottomReservation,
|
|
923
1059
|
});
|
|
924
1060
|
|
|
925
|
-
//
|
|
1061
|
+
// If panels are too short, grow the compile height and restart (once).
|
|
1062
|
+
if (
|
|
1063
|
+
!optionsInput.facetHeightGrown &&
|
|
1064
|
+
grid.panels.length > 0 &&
|
|
1065
|
+
grid.panels[0].area.height < MIN_PANEL_HEIGHT
|
|
1066
|
+
) {
|
|
1067
|
+
const neededChartHeight = facetMinHeight(facetValues.length, grid.columns);
|
|
1068
|
+
const heightGrowth = neededChartHeight - chartArea.height;
|
|
1069
|
+
if (heightGrowth > 0) {
|
|
1070
|
+
return compileChart(rawSpec, {
|
|
1071
|
+
...optionsInput,
|
|
1072
|
+
height: options.height + heightGrowth,
|
|
1073
|
+
facetHeightGrown: true,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Resolve scale: defaults depend on facet direction.
|
|
1079
|
+
// row: x shared (comparable values), y independent (different categories)
|
|
1080
|
+
// column: y shared (comparable values), x independent (different categories)
|
|
1081
|
+
// wrap: both shared (original facet behavior)
|
|
926
1082
|
const resolveConfig = chartSpec.resolve;
|
|
927
|
-
const
|
|
928
|
-
const
|
|
1083
|
+
const xDefault: ResolveMode = facetDirection === 'column' ? 'independent' : 'shared';
|
|
1084
|
+
const yDefault: ResolveMode = facetDirection === 'row' ? 'independent' : 'shared';
|
|
1085
|
+
const yResolve = resolveConfig?.scale?.y ?? yDefault;
|
|
1086
|
+
const xResolve = resolveConfig?.scale?.x ?? xDefault;
|
|
929
1087
|
|
|
930
1088
|
// Compute shared scale domains from the full dataset (when shared)
|
|
931
1089
|
const fullScales = computeScales(panelSpec, chartArea, renderSpec.data);
|
|
@@ -934,7 +1092,7 @@ function compileFaceted(
|
|
|
934
1092
|
const sharedYDomain =
|
|
935
1093
|
yResolve === 'shared' && fullScales.y ? fullScales.y.scale.domain() : undefined;
|
|
936
1094
|
|
|
937
|
-
const isRadial = chartSpec.markType
|
|
1095
|
+
const isRadial = isAxislessMark(chartSpec.markType);
|
|
938
1096
|
const rendererKey = resolveRendererKey(
|
|
939
1097
|
renderSpec.markType,
|
|
940
1098
|
panelEncoding as Encoding,
|
|
@@ -944,6 +1102,11 @@ function compileFaceted(
|
|
|
944
1102
|
|
|
945
1103
|
const allMarks: Mark[] = [];
|
|
946
1104
|
const panelLayouts: FacetPanelLayout[] = [];
|
|
1105
|
+
// Footnotes pool across panels into one figure-level list (the renderer draws
|
|
1106
|
+
// a single list below the grid from `chrome.footnotes`), so the numbering is
|
|
1107
|
+
// continuous and each panel's markers index into it.
|
|
1108
|
+
const allFootnotes: import('@opendata-ai/openchart-core').AnnotationFootnote[] = [];
|
|
1109
|
+
const footnoteNumberBySpecIndex = new Map<number, number>();
|
|
947
1110
|
|
|
948
1111
|
for (const gridPanel of grid.panels) {
|
|
949
1112
|
const panelData = groups.get(gridPanel.key) ?? [];
|
|
@@ -976,18 +1139,22 @@ function compileFaceted(
|
|
|
976
1139
|
panelScales.defaultColor =
|
|
977
1140
|
chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
|
|
978
1141
|
|
|
979
|
-
//
|
|
980
|
-
//
|
|
1142
|
+
// Dev-mode contrast diagnostics: panels share one palette resolution, so
|
|
1143
|
+
// checking the first panel covers the figure without repeating warnings.
|
|
1144
|
+
if (options.dev && panelLayouts.length === 0) {
|
|
1145
|
+
emitSpecWarnings(
|
|
1146
|
+
collectContrastWarnings(panelScales, chartSpec.markType, theme),
|
|
1147
|
+
options.onWarn,
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
981
1151
|
const isLeftCol = gridPanel.col === 0;
|
|
982
|
-
const isBottomRow = gridPanel.row === grid.rows - 1;
|
|
983
1152
|
|
|
984
1153
|
const panelAxes = isRadial
|
|
985
1154
|
? { x: undefined, y: undefined }
|
|
986
1155
|
: computeAxes(panelScales, gridPanel.area, strategy, theme, options.measureText, {
|
|
987
1156
|
data: panelData,
|
|
988
1157
|
encoding: panelSpecWithDomains.encoding as Encoding,
|
|
989
|
-
skipX: xResolve === 'shared' ? !isBottomRow : false,
|
|
990
|
-
skipY: yResolve === 'shared' ? !isLeftCol : false,
|
|
991
1158
|
markType: chartSpec.markType,
|
|
992
1159
|
totalWidth: gridPanel.area.width,
|
|
993
1160
|
});
|
|
@@ -996,6 +1163,29 @@ function compileFaceted(
|
|
|
996
1163
|
computeGridlines(panelAxes, gridPanel.area);
|
|
997
1164
|
}
|
|
998
1165
|
|
|
1166
|
+
// Strip y-axis tick labels from non-leftmost panels (gridlines stay).
|
|
1167
|
+
if (yResolve === 'shared' && !isLeftCol && panelAxes.y) {
|
|
1168
|
+
panelAxes.y = {
|
|
1169
|
+
...panelAxes.y,
|
|
1170
|
+
ticks: [],
|
|
1171
|
+
label: undefined,
|
|
1172
|
+
domainLine: false,
|
|
1173
|
+
tickMarks: false,
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Strip x-axis tick labels from non-bottom panels for row faceting.
|
|
1178
|
+
const isBottomRow = gridPanel.row === grid.rows - 1;
|
|
1179
|
+
if (xResolve === 'shared' && facetDirection === 'row' && !isBottomRow && panelAxes.x) {
|
|
1180
|
+
panelAxes.x = {
|
|
1181
|
+
...panelAxes.x,
|
|
1182
|
+
ticks: [],
|
|
1183
|
+
label: undefined,
|
|
1184
|
+
domainLine: false,
|
|
1185
|
+
tickMarks: false,
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
999
1189
|
// Compute marks for this panel
|
|
1000
1190
|
const panelMarks: Mark[] = renderer
|
|
1001
1191
|
? renderer(
|
|
@@ -1012,7 +1202,7 @@ function compileFaceted(
|
|
|
1012
1202
|
const annotationMeasure: AnnotationMeasureTextFn = options.measureText
|
|
1013
1203
|
? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width
|
|
1014
1204
|
: heuristicMeasure;
|
|
1015
|
-
|
|
1205
|
+
let panelAnnotations = computeAnnotations(panelSpecWithDomains, {
|
|
1016
1206
|
scales: panelScales,
|
|
1017
1207
|
chartArea: gridPanel.area,
|
|
1018
1208
|
strategy,
|
|
@@ -1020,9 +1210,55 @@ function compileFaceted(
|
|
|
1020
1210
|
obstacles: [],
|
|
1021
1211
|
svg: { width: options.width, height: options.height },
|
|
1022
1212
|
measure: annotationMeasure,
|
|
1213
|
+
fontFamily: theme.fonts.family,
|
|
1023
1214
|
autoThin: panelSpecWithDomains.autoThin,
|
|
1024
1215
|
});
|
|
1025
1216
|
|
|
1217
|
+
// Auto-thinning, per panel. A panel is its own little chart: the box a label
|
|
1218
|
+
// must stay inside is the panel, not the figure. Thinning against the whole
|
|
1219
|
+
// SVG (what the single-chart path passes) would let a label from the left
|
|
1220
|
+
// column sprawl across its neighbour and still count as "fits".
|
|
1221
|
+
//
|
|
1222
|
+
// The spec's annotations resolve into *every* panel, so the same annotation
|
|
1223
|
+
// can demote in more than one. The footnote list is figure-level and its text
|
|
1224
|
+
// is per-spec-annotation, so number by spec index and reuse that number in
|
|
1225
|
+
// every panel that demoted it -- otherwise the list repeats itself once per
|
|
1226
|
+
// panel.
|
|
1227
|
+
if (chartSpec.autoThin && panelAnnotations.length > 1) {
|
|
1228
|
+
const thinned = thinAnnotations(
|
|
1229
|
+
panelAnnotations,
|
|
1230
|
+
chartSpec.annotations,
|
|
1231
|
+
annotationMeasure,
|
|
1232
|
+
gridPanel.area,
|
|
1233
|
+
gridPanel.area,
|
|
1234
|
+
);
|
|
1235
|
+
panelAnnotations = thinned.annotations;
|
|
1236
|
+
// `thinAnnotations` numbers footnotes 1..n *within the panel it was given*,
|
|
1237
|
+
// so restamp each demoted annotation with its figure-level number. Take the
|
|
1238
|
+
// text from `thinned.footnotes` (the 1-based panel-local index it just
|
|
1239
|
+
// assigned) rather than re-reading `label.text`: thinning owns that string,
|
|
1240
|
+
// and re-deriving it here would be a second producer to drift out of sync.
|
|
1241
|
+
//
|
|
1242
|
+
// Key on `specIndex`, NOT on position: with `resolve.scale.x: 'independent'`
|
|
1243
|
+
// each panel derives its own domain, so an annotation can resolve in one
|
|
1244
|
+
// panel and be dropped in another. Position i then means a different spec
|
|
1245
|
+
// annotation in each panel, and the markers point at other panels' text.
|
|
1246
|
+
for (const a of panelAnnotations) {
|
|
1247
|
+
const panelIndex = a.footnoteIndex;
|
|
1248
|
+
if (panelIndex == null || a.specIndex === undefined) continue;
|
|
1249
|
+
let num = footnoteNumberBySpecIndex.get(a.specIndex);
|
|
1250
|
+
if (num === undefined) {
|
|
1251
|
+
num = allFootnotes.length + 1;
|
|
1252
|
+
footnoteNumberBySpecIndex.set(a.specIndex, num);
|
|
1253
|
+
allFootnotes.push({
|
|
1254
|
+
index: num,
|
|
1255
|
+
text: thinned.footnotes[panelIndex - 1]?.text ?? '',
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
a.footnoteIndex = num;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1026
1262
|
allMarks.push(...panelMarks);
|
|
1027
1263
|
|
|
1028
1264
|
panelLayouts.push({
|
|
@@ -1033,18 +1269,46 @@ function compileFaceted(
|
|
|
1033
1269
|
annotations: panelAnnotations,
|
|
1034
1270
|
header: {
|
|
1035
1271
|
text: gridPanel.key,
|
|
1036
|
-
x: gridPanel.headerPos.x,
|
|
1272
|
+
x: facetDirection === 'row' ? gridPanel.area.x : gridPanel.headerPos.x,
|
|
1037
1273
|
y: gridPanel.headerPos.y,
|
|
1038
1274
|
fontSize: theme.fonts.sizes.body,
|
|
1039
1275
|
fontWeight: theme.fonts.weights.medium,
|
|
1276
|
+
...(facetDirection === 'row' ? { textAnchor: 'start' as const } : {}),
|
|
1040
1277
|
},
|
|
1041
1278
|
});
|
|
1042
1279
|
}
|
|
1043
1280
|
|
|
1281
|
+
// The footnote list renders below the grid, but nothing reserved space for it
|
|
1282
|
+
// -- thinning only just now discovered how many lines there are. Recompile with
|
|
1283
|
+
// that band carved out of the bottom margin, same contract as the single-chart
|
|
1284
|
+
// path (see the matching block in compileChart). computeDimensions applies
|
|
1285
|
+
// options.footnoteReserve generically, so the facet grid shrinks with it.
|
|
1286
|
+
//
|
|
1287
|
+
// A frozen area short-circuits this, same as the single-chart path: compileLayer
|
|
1288
|
+
// pins every leaf to the primary layout's coordinate space, so the grown bottom
|
|
1289
|
+
// margin is computed and then thrown away. Recursing would re-run the whole
|
|
1290
|
+
// compile up to MAX_FOOTNOTE_PASSES times for an identical layout.
|
|
1291
|
+
if (allFootnotes.length > 0 && !options.frozenChartArea) {
|
|
1292
|
+
const reserve = footnoteBandHeight(allFootnotes.length, theme);
|
|
1293
|
+
const currentReserve = options.footnoteReserve ?? 0;
|
|
1294
|
+
if (reserve > currentReserve && (options.footnotePass ?? 0) < MAX_FOOTNOTE_PASSES) {
|
|
1295
|
+
return compileChart(rawSpec, {
|
|
1296
|
+
...optionsInput,
|
|
1297
|
+
footnoteReserve: reserve,
|
|
1298
|
+
footnotePass: (options.footnotePass ?? 0) + 1,
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1044
1303
|
// Compute tooltip descriptors from all marks across panels
|
|
1045
1304
|
const tooltipDescriptors = computeTooltipDescriptors(panelChartSpec, allMarks);
|
|
1046
1305
|
|
|
1047
|
-
//
|
|
1306
|
+
// Opt-in per-series fill patterns across all panels at once so a series
|
|
1307
|
+
// gets the same pattern in every panel.
|
|
1308
|
+
applyFillPatterns(allMarks, chartSpec.markDef, theme);
|
|
1309
|
+
|
|
1310
|
+
// A11y: mention faceting in alt text. An author description is used
|
|
1311
|
+
// verbatim (the author already describes the whole figure).
|
|
1048
1312
|
const panelCount = facetValues.length;
|
|
1049
1313
|
const baseAltText = generateAltText(
|
|
1050
1314
|
{
|
|
@@ -1055,7 +1319,9 @@ function compileFaceted(
|
|
|
1055
1319
|
} as ChartSpec,
|
|
1056
1320
|
chartSpec.data,
|
|
1057
1321
|
);
|
|
1058
|
-
const altText =
|
|
1322
|
+
const altText =
|
|
1323
|
+
chartSpec.a11y?.description ??
|
|
1324
|
+
`${baseAltText} Faceted into ${panelCount} panels by ${facetChannel.field}.`;
|
|
1059
1325
|
|
|
1060
1326
|
const dataTableFallback = generateDataTable(
|
|
1061
1327
|
{
|
|
@@ -1073,18 +1339,23 @@ function compileFaceted(
|
|
|
1073
1339
|
// Figure-level marks are the union of all panel marks (for tooltip/keyboard nav)
|
|
1074
1340
|
return {
|
|
1075
1341
|
area: chartArea,
|
|
1076
|
-
chrome: dims.chrome,
|
|
1342
|
+
chrome: allFootnotes.length > 0 ? { ...dims.chrome, footnotes: allFootnotes } : dims.chrome,
|
|
1077
1343
|
metrics: dims.metrics,
|
|
1344
|
+
...(dims.seriesSearch ? { seriesSearch: dims.seriesSearch } : {}),
|
|
1078
1345
|
axes: { x: undefined, y: undefined },
|
|
1079
1346
|
marks: allMarks,
|
|
1080
1347
|
annotations: [],
|
|
1081
1348
|
legend: finalLegend,
|
|
1349
|
+
// Facets share one color legend across the grid; the size channel isn't
|
|
1350
|
+
// supported in facet specs, so there is no second legend to carry.
|
|
1351
|
+
legends: [finalLegend],
|
|
1082
1352
|
tooltipDescriptors,
|
|
1083
1353
|
a11y: {
|
|
1084
1354
|
altText,
|
|
1085
1355
|
dataTableFallback,
|
|
1086
1356
|
role: 'img',
|
|
1087
1357
|
keyboardNavigable: allMarks.length > 0,
|
|
1358
|
+
...(chartSpec.a11y?.hidden ? { hidden: true } : {}),
|
|
1088
1359
|
},
|
|
1089
1360
|
theme,
|
|
1090
1361
|
dimensions: { width: options.width, height: options.height },
|
|
@@ -1098,6 +1369,7 @@ function compileFaceted(
|
|
|
1098
1369
|
facetField: facetChannel.field,
|
|
1099
1370
|
columns: grid.columns,
|
|
1100
1371
|
sharedScales: yResolve === 'shared' && xResolve === 'shared',
|
|
1372
|
+
direction: facetDirection,
|
|
1101
1373
|
},
|
|
1102
1374
|
};
|
|
1103
1375
|
}
|
|
@@ -1107,7 +1379,17 @@ function compileFaceted(
|
|
|
1107
1379
|
// ---------------------------------------------------------------------------
|
|
1108
1380
|
|
|
1109
1381
|
export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLayout {
|
|
1110
|
-
|
|
1382
|
+
// Expand VL-idiom sugar on the layer spec and every child up front. Leaf
|
|
1383
|
+
// compiles re-run the (idempotent) expansion after encoding inheritance;
|
|
1384
|
+
// warning triggers are stripped or stamped here so each warns exactly once.
|
|
1385
|
+
const sugarWarnings: string[] = [];
|
|
1386
|
+
const expanded = expandSpecSugar(
|
|
1387
|
+
spec as unknown as Record<string, unknown>,
|
|
1388
|
+
sugarWarnings,
|
|
1389
|
+
) as unknown as LayerSpec;
|
|
1390
|
+
emitSpecWarnings(sugarWarnings, options.onWarn);
|
|
1391
|
+
const resolvedOptions = applySpecSize(expanded, options);
|
|
1392
|
+
return compileLayerImpl(expanded, resolvedOptions, compileChart);
|
|
1111
1393
|
}
|
|
1112
1394
|
|
|
1113
1395
|
// ---------------------------------------------------------------------------
|
|
@@ -1219,6 +1501,29 @@ export function compileTileMap(
|
|
|
1219
1501
|
return compileTileMapImpl(spec, options);
|
|
1220
1502
|
}
|
|
1221
1503
|
|
|
1504
|
+
// ---------------------------------------------------------------------------
|
|
1505
|
+
// Map compilation
|
|
1506
|
+
// ---------------------------------------------------------------------------
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* Compile a map spec into a MapLayout.
|
|
1510
|
+
*
|
|
1511
|
+
* Takes a raw map spec, validates, normalizes, resolves theme and chrome,
|
|
1512
|
+
* projects geo features, joins data, builds feature marks with color fills,
|
|
1513
|
+
* and returns a MapLayout ready for rendering.
|
|
1514
|
+
*
|
|
1515
|
+
* @param spec - Raw map spec (validated and normalized internally).
|
|
1516
|
+
* @param options - Compile options (width, height, theme, darkMode).
|
|
1517
|
+
* @returns MapLayout with computed positions and visual properties.
|
|
1518
|
+
* @throws Error if spec is invalid or not a map type.
|
|
1519
|
+
*/
|
|
1520
|
+
export function compileMap(
|
|
1521
|
+
spec: unknown,
|
|
1522
|
+
options: CompileOptions,
|
|
1523
|
+
): import('@opendata-ai/openchart-core').MapLayout {
|
|
1524
|
+
return compileMapImpl(spec, options);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1222
1527
|
// ---------------------------------------------------------------------------
|
|
1223
1528
|
// BarList compilation
|
|
1224
1529
|
// ---------------------------------------------------------------------------
|