@opendata-ai/openchart-engine 8.0.0-rc.9 → 8.0.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 +149 -12
- package/dist/index.js +804 -155
- 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/compile.ts
DELETED
|
@@ -1,1503 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Main compile API: the public entry points for the engine.
|
|
3
|
-
*
|
|
4
|
-
* Pipeline for charts:
|
|
5
|
-
* validate spec -> normalize -> resolve theme -> dark mode adapt ->
|
|
6
|
-
* filter data -> resolveLayoutPlan (measure real labels) ->
|
|
7
|
-
* compute dimensions (with plan) -> placeLegend ->
|
|
8
|
-
* compute scales -> compute axes (with pinned ticks) -> compute gridlines ->
|
|
9
|
-
* get chart renderer -> compute marks -> compute a11y -> return ChartLayout
|
|
10
|
-
*
|
|
11
|
-
* Table compiler handles full data pipeline (sort, search, pagination, visual enhancements).
|
|
12
|
-
* Graph compiler is a stub for future implementation.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type {
|
|
16
|
-
AnimationSpec,
|
|
17
|
-
ChartLayout,
|
|
18
|
-
ChartSpec,
|
|
19
|
-
CompileOptions,
|
|
20
|
-
CompileTableOptions,
|
|
21
|
-
Encoding,
|
|
22
|
-
FacetChannel,
|
|
23
|
-
FacetPanelLayout,
|
|
24
|
-
LayerSpec,
|
|
25
|
-
LayoutStrategy,
|
|
26
|
-
LegendLayout,
|
|
27
|
-
Mark,
|
|
28
|
-
ResolvedAnimation,
|
|
29
|
-
ResolvedAnnotation,
|
|
30
|
-
ResolvedTheme,
|
|
31
|
-
ScaleInvert,
|
|
32
|
-
TableLayout,
|
|
33
|
-
} from '@opendata-ai/openchart-core';
|
|
34
|
-
import {
|
|
35
|
-
adaptTheme,
|
|
36
|
-
computeLabelBounds,
|
|
37
|
-
footnoteBandHeight,
|
|
38
|
-
generateAltText,
|
|
39
|
-
generateDataTable,
|
|
40
|
-
getBreakpoint,
|
|
41
|
-
getHeightClass,
|
|
42
|
-
getLayoutStrategy,
|
|
43
|
-
isAxislessMark,
|
|
44
|
-
resolveTheme,
|
|
45
|
-
} from '@opendata-ai/openchart-core';
|
|
46
|
-
import { computeAnnotations } from './annotations/compute';
|
|
47
|
-
import { type AnnotationMeasureTextFn, heuristicMeasure } from './annotations/geometry';
|
|
48
|
-
import type { PlacementObstacle } from './annotations/placement';
|
|
49
|
-
import { thinAnnotations } from './annotations/thinning';
|
|
50
|
-
import { computeEndpointLabels } from './endpoint-labels/compute';
|
|
51
|
-
// Side-effect import: registers all built-in chart renderers with the
|
|
52
|
-
// registry on module load. Tests that clear the registry can import
|
|
53
|
-
// `registerBuiltinRenderers` from `./charts/builtin` to restore defaults.
|
|
54
|
-
import './charts/builtin';
|
|
55
|
-
import { compileBarList as compileBarListImpl } from './barlist/compile-barlist';
|
|
56
|
-
import {
|
|
57
|
-
assignAnimationIndices,
|
|
58
|
-
computeMarkObstacles,
|
|
59
|
-
resolveRendererKey,
|
|
60
|
-
} from './charts/post-process';
|
|
61
|
-
import { getChartRenderer } from './charts/registry';
|
|
62
|
-
import { groupByField } from './charts/utils';
|
|
63
|
-
import { applyColorScaleRange } from './compile/color-scale-range';
|
|
64
|
-
import { filterClippedDomains } from './compile/data-clip';
|
|
65
|
-
import { applyFillPatterns } from './compile/fill-patterns';
|
|
66
|
-
import { compileLayer as compileLayerImpl } from './compile/layer';
|
|
67
|
-
import { emitSpecWarnings, expandSpecSugar } from './compile/spec-sugar';
|
|
68
|
-
import { computeWatermarkObstacle } from './compile/watermark-obstacle';
|
|
69
|
-
import { resolveYouDrawIt } from './compile/you-draw-it';
|
|
70
|
-
import { collectContrastWarnings } from './compiler/a11y-warnings';
|
|
71
|
-
import { resolveAnimation } from './compiler/animation';
|
|
72
|
-
import { compile as compileSpec } from './compiler/index';
|
|
73
|
-
import {
|
|
74
|
-
buildSparklineAreaGradient,
|
|
75
|
-
computeTrendFromData,
|
|
76
|
-
hasExplicitColor,
|
|
77
|
-
trendColor,
|
|
78
|
-
} from './compiler/sparkline-defaults';
|
|
79
|
-
import type { NormalizedChartSpec, NormalizedTableSpec } from './compiler/types';
|
|
80
|
-
import { compileMap as compileMapImpl } from './geo/compile-map';
|
|
81
|
-
import { compileGraph as compileGraphImpl } from './graphs/compile-graph';
|
|
82
|
-
import type { GraphCompilation } from './graphs/types';
|
|
83
|
-
import { computeAxes } from './layout/axes';
|
|
84
|
-
import { computeDimensions } from './layout/dimensions';
|
|
85
|
-
import { computeFacetGrid, facetMinHeight, MIN_PANEL_HEIGHT } from './layout/facet';
|
|
86
|
-
import { computeGridlines } from './layout/gridlines';
|
|
87
|
-
import { createMeasureFn, resolveLayoutPlan } from './layout/plan';
|
|
88
|
-
import { computeScales, type ResolvedScale } from './layout/scales';
|
|
89
|
-
import { placeLegend } from './legend/compute';
|
|
90
|
-
import { placeSizeLegend } from './legend/size';
|
|
91
|
-
import { compileSankey as compileSankeyImpl } from './sankey/compile-sankey';
|
|
92
|
-
import { compileTableLayout } from './tables/compile-table';
|
|
93
|
-
import { compileTileMap as compileTileMapImpl } from './tilemap/compile-tilemap';
|
|
94
|
-
import { computeTooltipDescriptors } from './tooltips/compute';
|
|
95
|
-
import { runTransforms } from './transforms';
|
|
96
|
-
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
|
-
// Spec sugar expansion (VL idioms, bin/timeUnit, deprecation warnings)
|
|
99
|
-
// ---------------------------------------------------------------------------
|
|
100
|
-
|
|
101
|
-
// Implementation lives in ./compile/spec-sugar; re-exported here so existing
|
|
102
|
-
// deep imports (`import { expandEncodingSugar } from '.../compile'`) keep working.
|
|
103
|
-
export { expandEncodingSugar, expandSpecSugar } from './compile/spec-sugar';
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Apply top-level spec `width`/`height` (VL fixed-size sugar) as overrides on
|
|
107
|
-
* the compile options. Returns the options untouched when the spec carries no
|
|
108
|
-
* size.
|
|
109
|
-
*/
|
|
110
|
-
function applySpecSize(spec: unknown, options: CompileOptions): CompileOptions {
|
|
111
|
-
if (!spec || typeof spec !== 'object') return options;
|
|
112
|
-
const sized = spec as Record<string, unknown>;
|
|
113
|
-
if (typeof sized.width !== 'number' && typeof sized.height !== 'number') return options;
|
|
114
|
-
return {
|
|
115
|
-
...options,
|
|
116
|
-
width: typeof sized.width === 'number' ? sized.width : options.width,
|
|
117
|
-
height: typeof sized.height === 'number' ? sized.height : options.height,
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// ---------------------------------------------------------------------------
|
|
122
|
-
// Chart compilation
|
|
123
|
-
// ---------------------------------------------------------------------------
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Cap on footnote-reserve convergence passes in compileChart. Reserving space
|
|
127
|
-
* for the footnote list shrinks the plot, which can demote one more label and
|
|
128
|
-
* grow the list; the count only ever grows and is bounded by the annotation
|
|
129
|
-
* count, so this terminates on its own. The cap is a guard against a
|
|
130
|
-
* measurement quirk oscillating, not the normal exit condition.
|
|
131
|
-
*/
|
|
132
|
-
const MAX_FOOTNOTE_PASSES = 3;
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Inject sparkline-mode visual defaults that depend on the resolved theme.
|
|
136
|
-
*
|
|
137
|
-
* Trend-aware color (positive/negative/neutral), default endpoint dot,
|
|
138
|
-
* default area gradient, and default pill cornerRadius for bars. Each
|
|
139
|
-
* default backs off when the user has set the corresponding markDef
|
|
140
|
-
* field. Bar marks do NOT get trend coloring — the design intent is a
|
|
141
|
-
* single palette color across all bars.
|
|
142
|
-
*/
|
|
143
|
-
function applySparklineDefaults(
|
|
144
|
-
spec: NormalizedChartSpec,
|
|
145
|
-
theme: ResolvedTheme,
|
|
146
|
-
): NormalizedChartSpec {
|
|
147
|
-
const markType = spec.markType;
|
|
148
|
-
const isLineFamily = markType === 'line' || markType === 'area';
|
|
149
|
-
const isBar = markType === 'bar';
|
|
150
|
-
if (!isLineFamily && !isBar) return spec;
|
|
151
|
-
|
|
152
|
-
const yField = spec.encoding.y && 'field' in spec.encoding.y ? spec.encoding.y.field : undefined;
|
|
153
|
-
const trend = computeTrendFromData(spec.data, yField);
|
|
154
|
-
const color = trendColor(trend, theme);
|
|
155
|
-
|
|
156
|
-
const encodingHasColor =
|
|
157
|
-
!!spec.encoding.color && (spec.encoding.color as { field?: unknown }).field !== undefined;
|
|
158
|
-
const explicit = hasExplicitColor(spec.markDef, encodingHasColor);
|
|
159
|
-
|
|
160
|
-
const newMarkDef = { ...spec.markDef };
|
|
161
|
-
|
|
162
|
-
if (isLineFamily) {
|
|
163
|
-
if (!explicit.stroke) newMarkDef.stroke = color;
|
|
164
|
-
if (markType === 'area' && !explicit.fill) {
|
|
165
|
-
newMarkDef.fill = buildSparklineAreaGradient(color);
|
|
166
|
-
}
|
|
167
|
-
// Endpoint dot is wired in the line compute path. We skip the default
|
|
168
|
-
// when the encoding has a color field, because the multi-series area
|
|
169
|
-
// path (linesFromAreas()) ignores markDef.point — the dot would be set
|
|
170
|
-
// on the spec but never rendered, and the line compute path's dot logic
|
|
171
|
-
// is per-series, not "one dot for the chart."
|
|
172
|
-
if (newMarkDef.point === undefined && !encodingHasColor) {
|
|
173
|
-
newMarkDef.point = 'last';
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (isBar) {
|
|
178
|
-
// Pill cornerRadius is safe on stacked bars too — the column compute
|
|
179
|
-
// path applies the radius only to the topmost segment via
|
|
180
|
-
// `cornerRadiusSides`, leaving the seams between segments square so
|
|
181
|
-
// they stay flush. Single-series bars get all four corners rounded
|
|
182
|
-
// since there's nothing below them to align against.
|
|
183
|
-
if (newMarkDef.cornerRadius === undefined) {
|
|
184
|
-
newMarkDef.cornerRadius = 'pill';
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return { ...spec, markDef: newMarkDef };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Extract scale inversion anchors from a resolved scale so the vanilla layer
|
|
193
|
-
* can convert pixel positions back to data-space values without holding the
|
|
194
|
-
* d3 scale object. Generalizes the YouDrawItYInvert precedent to any axis.
|
|
195
|
-
*/
|
|
196
|
-
function resolveScaleInvert(resolved: ResolvedScale | undefined): ScaleInvert | undefined {
|
|
197
|
-
if (!resolved) return undefined;
|
|
198
|
-
const { scale, type } = resolved;
|
|
199
|
-
|
|
200
|
-
// Band / point scales: snap to nearest domain value
|
|
201
|
-
if (type === 'band' || type === 'point') {
|
|
202
|
-
const domain = (scale as { domain(): string[] }).domain() as (string | number)[];
|
|
203
|
-
const positions = domain.map((v) => {
|
|
204
|
-
const pos = (scale as (v: string | number) => number | undefined)(v) ?? 0;
|
|
205
|
-
return type === 'band' ? pos + (scale as { bandwidth(): number }).bandwidth() / 2 : pos;
|
|
206
|
-
});
|
|
207
|
-
const range = (scale as { range(): number[] }).range();
|
|
208
|
-
return {
|
|
209
|
-
topPixel: range[0],
|
|
210
|
-
bottomPixel: range[range.length - 1],
|
|
211
|
-
topData: 0,
|
|
212
|
-
bottomData: 0,
|
|
213
|
-
domain,
|
|
214
|
-
positions,
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Continuous scales (linear, time, log, pow, sqrt, symlog)
|
|
219
|
-
const invert = (scale as { invert?: (px: number) => unknown }).invert;
|
|
220
|
-
if (typeof invert !== 'function') return undefined;
|
|
221
|
-
|
|
222
|
-
const range = (scale as { range(): number[] }).range();
|
|
223
|
-
const topPx = range[0];
|
|
224
|
-
const bottomPx = range[range.length - 1];
|
|
225
|
-
const topVal = invert.call(scale, topPx);
|
|
226
|
-
const bottomVal = invert.call(scale, bottomPx);
|
|
227
|
-
|
|
228
|
-
// For time scales, convert Date to epoch ms
|
|
229
|
-
const topData = topVal instanceof Date ? +topVal : Number(topVal);
|
|
230
|
-
const bottomData = bottomVal instanceof Date ? +bottomVal : Number(bottomVal);
|
|
231
|
-
if (!Number.isFinite(topData) || !Number.isFinite(bottomData)) return undefined;
|
|
232
|
-
|
|
233
|
-
const scaleType = type as ScaleInvert['scaleType'];
|
|
234
|
-
return { topPixel: topPx, bottomPixel: bottomPx, topData, bottomData, scaleType };
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Compile a chart spec into a ChartLayout.
|
|
239
|
-
*
|
|
240
|
-
* This is the main engine entry point. Takes a raw spec (any shape,
|
|
241
|
-
* validated at runtime) and compile options, produces a fully resolved
|
|
242
|
-
* ChartLayout with positions, colors, and marks ready for rendering.
|
|
243
|
-
*
|
|
244
|
-
* @param spec - Raw chart spec (validated and normalized internally).
|
|
245
|
-
* @param optionsInput - Compile options (width, height, theme, darkMode).
|
|
246
|
-
* Top-level spec width/height, when present, override the option values.
|
|
247
|
-
* @returns ChartLayout with all computed positions.
|
|
248
|
-
* @throws Error if spec is invalid or not a chart type.
|
|
249
|
-
*/
|
|
250
|
-
export function compileChart(spec: unknown, optionsInput: CompileOptions): ChartLayout {
|
|
251
|
-
// Expand VL-idiom and encoding-level sugar (data {values}, top-level title,
|
|
252
|
-
// sort-by-value, value defs, scheme names, theta, bin/timeUnit, ...) before
|
|
253
|
-
// validation + normalization, surfacing any deprecation warnings.
|
|
254
|
-
const sugarWarnings: string[] = [];
|
|
255
|
-
const expandedSpec =
|
|
256
|
-
spec && typeof spec === 'object' && !Array.isArray(spec)
|
|
257
|
-
? expandSpecSugar(spec as Record<string, unknown>, sugarWarnings)
|
|
258
|
-
: spec;
|
|
259
|
-
|
|
260
|
-
// Top-level width/height are fixed-size overrides (VL alignment): the
|
|
261
|
-
// authored size wins over the container-derived compile options.
|
|
262
|
-
const options = applySpecSize(expandedSpec, optionsInput);
|
|
263
|
-
|
|
264
|
-
// Validate + normalize. Normalize-stage warnings (type mismatches,
|
|
265
|
-
// beeswarm point budget, sparkline mark advice) surface through the same
|
|
266
|
-
// console channel as the sugar warnings. Emit both together in one call so
|
|
267
|
-
// dedup spans the whole compile: a message produced by both a sugar pass and
|
|
268
|
-
// a normalize pass prints once, not once per stage.
|
|
269
|
-
const { spec: normalized, warnings: normalizeWarnings } = compileSpec(expandedSpec);
|
|
270
|
-
emitSpecWarnings([...sugarWarnings, ...normalizeWarnings], options.onWarn);
|
|
271
|
-
|
|
272
|
-
if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'table') {
|
|
273
|
-
throw new Error('compileChart received a table spec. Use compileTable instead.');
|
|
274
|
-
}
|
|
275
|
-
if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'graph') {
|
|
276
|
-
throw new Error('compileChart received a graph spec. Use compileGraph instead.');
|
|
277
|
-
}
|
|
278
|
-
if (
|
|
279
|
-
'type' in normalized &&
|
|
280
|
-
(normalized as unknown as Record<string, unknown>).type === 'sankey'
|
|
281
|
-
) {
|
|
282
|
-
throw new Error('compileChart received a sankey spec. Use compileSankey instead.');
|
|
283
|
-
}
|
|
284
|
-
if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'map') {
|
|
285
|
-
throw new Error('compileChart received a map spec. Use compileMap instead.');
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
let chartSpec = normalized as NormalizedChartSpec;
|
|
289
|
-
|
|
290
|
-
// Resolve watermark: explicit spec value wins, then options fallback, then default true.
|
|
291
|
-
const rawWatermark = (expandedSpec as Record<string, unknown>).watermark;
|
|
292
|
-
let watermark = rawWatermark !== undefined ? chartSpec.watermark : (options.watermark ?? true);
|
|
293
|
-
|
|
294
|
-
// Run data transforms (filter, bin, calculate, timeUnit) before any other data processing.
|
|
295
|
-
// Transforms are defined on the expanded spec (which includes any auto-generated
|
|
296
|
-
// transforms from encoding-level bin/timeUnit sugar), not the normalized spec,
|
|
297
|
-
// since NormalizedChartSpec doesn't carry the transform field.
|
|
298
|
-
const rawTransforms = (expandedSpec as Record<string, unknown>).transform as
|
|
299
|
-
| import('@opendata-ai/openchart-core').Transform[]
|
|
300
|
-
| undefined;
|
|
301
|
-
if (rawTransforms && rawTransforms.length > 0) {
|
|
302
|
-
chartSpec = { ...chartSpec, data: runTransforms(chartSpec.data, rawTransforms) };
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// Responsive strategy
|
|
306
|
-
const breakpoint = getBreakpoint(options.width);
|
|
307
|
-
const heightClass = getHeightClass(options.height);
|
|
308
|
-
let strategy = getLayoutStrategy(breakpoint, heightClass);
|
|
309
|
-
|
|
310
|
-
// Apply breakpoint-conditional overrides from the expanded spec
|
|
311
|
-
const rawSpec = expandedSpec as Record<string, unknown>;
|
|
312
|
-
const overrides = rawSpec.overrides as
|
|
313
|
-
| Partial<
|
|
314
|
-
Record<
|
|
315
|
-
string,
|
|
316
|
-
{
|
|
317
|
-
chrome?: unknown;
|
|
318
|
-
labels?: unknown;
|
|
319
|
-
legend?: unknown;
|
|
320
|
-
annotations?: unknown;
|
|
321
|
-
animation?: unknown;
|
|
322
|
-
display?: unknown;
|
|
323
|
-
encoding?: unknown;
|
|
324
|
-
watermark?: unknown;
|
|
325
|
-
crosshair?: unknown;
|
|
326
|
-
}
|
|
327
|
-
>
|
|
328
|
-
>
|
|
329
|
-
| undefined;
|
|
330
|
-
|
|
331
|
-
// Build userExplicit descriptor BEFORE applying any overrides so we capture
|
|
332
|
-
// the union of "user wrote this at top-level" and "user wrote this in the
|
|
333
|
-
// active breakpoint override." Sparkline display mode reads this to decide
|
|
334
|
-
// whether to suppress chrome/axes/legend/etc. by default vs. respecting an
|
|
335
|
-
// explicit user opt-in. Precedence: explicit at any level wins.
|
|
336
|
-
const rawEncoding = rawSpec.encoding as
|
|
337
|
-
| { x?: { axis?: unknown }; y?: { axis?: unknown } }
|
|
338
|
-
| undefined;
|
|
339
|
-
const bpForExplicit = overrides?.[breakpoint];
|
|
340
|
-
const bpEncoding = bpForExplicit?.encoding as
|
|
341
|
-
| { x?: { axis?: unknown }; y?: { axis?: unknown } }
|
|
342
|
-
| undefined;
|
|
343
|
-
// chrome: {} (empty object) is not "explicit" — it's an idiom users write to
|
|
344
|
-
// silence defaults. Require at least one chrome key set to count as opt-in.
|
|
345
|
-
const hasChromeKeys = (v: unknown): boolean =>
|
|
346
|
-
!!v && typeof v === 'object' && Object.keys(v as Record<string, unknown>).length > 0;
|
|
347
|
-
const userExplicit = {
|
|
348
|
-
chrome: hasChromeKeys(rawSpec.chrome) || hasChromeKeys(bpForExplicit?.chrome),
|
|
349
|
-
legend: rawSpec.legend !== undefined || bpForExplicit?.legend !== undefined,
|
|
350
|
-
endpointLabels:
|
|
351
|
-
rawSpec.endpointLabels !== undefined ||
|
|
352
|
-
(bpForExplicit as Record<string, unknown> | undefined)?.endpointLabels !== undefined,
|
|
353
|
-
// Axis explicitness tracks "user opted IN to render the axis" — `axis: false`
|
|
354
|
-
// is an explicit opt-OUT and must not flip these flags on. Sparkline mode
|
|
355
|
-
// reads them to decide whether to reserve axis space; treating `false` as
|
|
356
|
-
// explicit would leave a phantom gutter for an axis that won't render.
|
|
357
|
-
xAxis:
|
|
358
|
-
(rawEncoding?.x?.axis !== undefined && rawEncoding.x.axis !== false) ||
|
|
359
|
-
(bpEncoding?.x?.axis !== undefined && bpEncoding.x.axis !== false),
|
|
360
|
-
yAxis:
|
|
361
|
-
(rawEncoding?.y?.axis !== undefined && rawEncoding.y.axis !== false) ||
|
|
362
|
-
(bpEncoding?.y?.axis !== undefined && bpEncoding.y.axis !== false),
|
|
363
|
-
labels: rawSpec.labels !== undefined || bpForExplicit?.labels !== undefined,
|
|
364
|
-
animation: rawSpec.animation !== undefined || bpForExplicit?.animation !== undefined,
|
|
365
|
-
watermark: rawSpec.watermark !== undefined || bpForExplicit?.watermark !== undefined,
|
|
366
|
-
crosshair: rawSpec.crosshair !== undefined || bpForExplicit?.crosshair !== undefined,
|
|
367
|
-
};
|
|
368
|
-
chartSpec = { ...chartSpec, userExplicit };
|
|
369
|
-
|
|
370
|
-
if (overrides?.[breakpoint]) {
|
|
371
|
-
const bp = overrides[breakpoint]!;
|
|
372
|
-
if (bp.chrome) {
|
|
373
|
-
chartSpec = {
|
|
374
|
-
...chartSpec,
|
|
375
|
-
chrome: {
|
|
376
|
-
...chartSpec.chrome,
|
|
377
|
-
...(bp.chrome as NormalizedChartSpec['chrome']),
|
|
378
|
-
},
|
|
379
|
-
};
|
|
380
|
-
}
|
|
381
|
-
if (bp.labels !== undefined) {
|
|
382
|
-
if (typeof bp.labels === 'boolean') {
|
|
383
|
-
chartSpec = {
|
|
384
|
-
...chartSpec,
|
|
385
|
-
labels: bp.labels
|
|
386
|
-
? { density: 'auto', format: '', prefix: '' }
|
|
387
|
-
: { density: 'none', format: '', prefix: '' },
|
|
388
|
-
};
|
|
389
|
-
} else {
|
|
390
|
-
chartSpec = {
|
|
391
|
-
...chartSpec,
|
|
392
|
-
labels: {
|
|
393
|
-
...chartSpec.labels,
|
|
394
|
-
...(bp.labels as NormalizedChartSpec['labels']),
|
|
395
|
-
},
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
if (bp.legend) {
|
|
400
|
-
chartSpec = {
|
|
401
|
-
...chartSpec,
|
|
402
|
-
legend: {
|
|
403
|
-
...chartSpec.legend,
|
|
404
|
-
...(bp.legend as NormalizedChartSpec['legend']),
|
|
405
|
-
},
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
|
-
if (bp.annotations) {
|
|
409
|
-
chartSpec = {
|
|
410
|
-
...chartSpec,
|
|
411
|
-
annotations: bp.annotations as NormalizedChartSpec['annotations'],
|
|
412
|
-
};
|
|
413
|
-
// User explicitly provided annotations at this breakpoint — override the
|
|
414
|
-
// responsive strategy so they render inline instead of being stripped.
|
|
415
|
-
strategy = { ...strategy, annotationPosition: 'inline' };
|
|
416
|
-
}
|
|
417
|
-
// New override branches for sparkline mode and related fields:
|
|
418
|
-
if (bp.display !== undefined) {
|
|
419
|
-
chartSpec = {
|
|
420
|
-
...chartSpec,
|
|
421
|
-
display: bp.display as NormalizedChartSpec['display'],
|
|
422
|
-
};
|
|
423
|
-
}
|
|
424
|
-
if (bp.encoding !== undefined) {
|
|
425
|
-
// Merge encoding so a breakpoint can flip on/off encoding.x.axis or
|
|
426
|
-
// encoding.y.axis (used by sparkline display mode to opt back in to
|
|
427
|
-
// axes at a specific breakpoint). Channels merge per-key, and `axis`
|
|
428
|
-
// and `scale` deep-merge one level so a breakpoint can set
|
|
429
|
-
// `axis: { title: 'foo' }` without dropping the base spec's
|
|
430
|
-
// `axis.tickCount` / `axis.format`.
|
|
431
|
-
const bpEnc = bp.encoding as Record<string, Record<string, unknown> | undefined>;
|
|
432
|
-
const mergedEncoding = { ...chartSpec.encoding } as Record<
|
|
433
|
-
string,
|
|
434
|
-
Record<string, unknown> | undefined
|
|
435
|
-
>;
|
|
436
|
-
const NESTED_CHANNEL_KEYS = ['axis', 'scale'];
|
|
437
|
-
for (const channel of Object.keys(bpEnc)) {
|
|
438
|
-
const baseCh = mergedEncoding[channel];
|
|
439
|
-
const bpCh = bpEnc[channel];
|
|
440
|
-
if (bpCh && baseCh) {
|
|
441
|
-
const merged: Record<string, unknown> = { ...baseCh, ...bpCh };
|
|
442
|
-
for (const key of NESTED_CHANNEL_KEYS) {
|
|
443
|
-
const baseNested = baseCh[key];
|
|
444
|
-
const bpNested = bpCh[key];
|
|
445
|
-
if (
|
|
446
|
-
baseNested &&
|
|
447
|
-
bpNested &&
|
|
448
|
-
typeof baseNested === 'object' &&
|
|
449
|
-
typeof bpNested === 'object' &&
|
|
450
|
-
!Array.isArray(baseNested) &&
|
|
451
|
-
!Array.isArray(bpNested)
|
|
452
|
-
) {
|
|
453
|
-
merged[key] = { ...baseNested, ...bpNested };
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
mergedEncoding[channel] = merged;
|
|
457
|
-
} else if (bpCh) {
|
|
458
|
-
mergedEncoding[channel] = bpCh;
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
chartSpec = {
|
|
462
|
-
...chartSpec,
|
|
463
|
-
encoding: mergedEncoding as unknown as NormalizedChartSpec['encoding'],
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
|
-
if (typeof bp.watermark === 'boolean') {
|
|
467
|
-
// Update the resolved watermark value used downstream. ChartSpec carries
|
|
468
|
-
// this in its normalized shape; the local `watermark` variable controls
|
|
469
|
-
// chrome computation and rendering.
|
|
470
|
-
watermark = bp.watermark;
|
|
471
|
-
chartSpec = { ...chartSpec, watermark };
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// Sparkline mode: default labels off. Mark renderers draw value labels per
|
|
476
|
-
// labels.density (default 'auto'), which fills tiny sparklines with text and
|
|
477
|
-
// is never what you want. Explicit user labels at any level wins via
|
|
478
|
-
// userExplicit.labels.
|
|
479
|
-
if (chartSpec.display === 'sparkline' && !chartSpec.userExplicit.labels) {
|
|
480
|
-
chartSpec = {
|
|
481
|
-
...chartSpec,
|
|
482
|
-
labels: { ...chartSpec.labels, density: 'none' },
|
|
483
|
-
};
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// Resolve animation spec. Breakpoint override wins over base spec (matching
|
|
487
|
-
// chrome, labels, legend, and annotation override precedence).
|
|
488
|
-
// Precedence rule for sparkline mode: an explicit user animation at ANY
|
|
489
|
-
// level (top-level OR breakpoint) always wins, regardless of display mode.
|
|
490
|
-
// resolveAnimation handles the explicit-user value; the sparkline default-off
|
|
491
|
-
// behavior is applied below when no explicit value exists.
|
|
492
|
-
let rawAnimationSpec = ((overrides?.[breakpoint] as Record<string, unknown> | undefined)
|
|
493
|
-
?.animation ?? rawSpec.animation) as AnimationSpec | undefined;
|
|
494
|
-
if (rawAnimationSpec === undefined && chartSpec.display === 'sparkline') {
|
|
495
|
-
// Sparkline mode: animation defaults to false. User-explicit (top OR bp)
|
|
496
|
-
// already short-circuits this branch via userExplicit.animation.
|
|
497
|
-
rawAnimationSpec = false;
|
|
498
|
-
}
|
|
499
|
-
// Sparkline mode: when animation is on but the user didn't specify duration,
|
|
500
|
-
// bump to 1100ms so the line/area reveal feels paced rather than mechanical.
|
|
501
|
-
// The CSS override pairs this with an expo-out easing curve. AnimationConfig
|
|
502
|
-
// nests duration under `enter`, so we set it there.
|
|
503
|
-
if (
|
|
504
|
-
chartSpec.display === 'sparkline' &&
|
|
505
|
-
rawAnimationSpec !== false &&
|
|
506
|
-
rawAnimationSpec !== undefined
|
|
507
|
-
) {
|
|
508
|
-
const SPARK_DURATION = 1100;
|
|
509
|
-
if (rawAnimationSpec === true) {
|
|
510
|
-
rawAnimationSpec = { enter: { duration: SPARK_DURATION } } as AnimationSpec;
|
|
511
|
-
} else if (typeof rawAnimationSpec === 'object') {
|
|
512
|
-
const cfg = rawAnimationSpec as { enter?: unknown; annotationDelay?: number };
|
|
513
|
-
const enter = cfg.enter;
|
|
514
|
-
if (enter === undefined || enter === true) {
|
|
515
|
-
rawAnimationSpec = {
|
|
516
|
-
...cfg,
|
|
517
|
-
enter: { duration: SPARK_DURATION },
|
|
518
|
-
} as AnimationSpec;
|
|
519
|
-
} else if (
|
|
520
|
-
typeof enter === 'object' &&
|
|
521
|
-
enter !== null &&
|
|
522
|
-
(enter as { duration?: number }).duration === undefined
|
|
523
|
-
) {
|
|
524
|
-
rawAnimationSpec = {
|
|
525
|
-
...cfg,
|
|
526
|
-
enter: { ...(enter as object), duration: SPARK_DURATION },
|
|
527
|
-
} as AnimationSpec;
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
const resolvedAnimation = resolveAnimation(rawAnimationSpec);
|
|
532
|
-
|
|
533
|
-
// Crosshair: explicit user value at any level wins. Default is ON for line
|
|
534
|
-
// and area marks in full mode (the hover guideline + per-series tooltip is
|
|
535
|
-
// the standard interaction pattern for time-series charts). Sparkline mode
|
|
536
|
-
// defaults off. The value is plumbed through ChartLayout so the renderer
|
|
537
|
-
// doesn't need to re-inspect the raw spec.
|
|
538
|
-
const rawCrosshair = (bpForExplicit?.crosshair ?? rawSpec.crosshair) as boolean | undefined;
|
|
539
|
-
const isLineOrAreaMark = chartSpec.markType === 'line' || chartSpec.markType === 'area';
|
|
540
|
-
const crosshairDefault = chartSpec.display === 'sparkline' ? false : isLineOrAreaMark;
|
|
541
|
-
const crosshair = chartSpec.userExplicit.crosshair ? rawCrosshair === true : crosshairDefault;
|
|
542
|
-
|
|
543
|
-
// Watermark default-off in sparkline mode unless user-explicit.
|
|
544
|
-
if (chartSpec.display === 'sparkline' && !chartSpec.userExplicit.watermark) {
|
|
545
|
-
watermark = false;
|
|
546
|
-
chartSpec = { ...chartSpec, watermark: false };
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// Resolve theme: merge spec-level theme with options-level overrides
|
|
550
|
-
const mergedThemeConfig = options.theme
|
|
551
|
-
? { ...chartSpec.theme, ...options.theme }
|
|
552
|
-
: chartSpec.theme;
|
|
553
|
-
let theme: ResolvedTheme = resolveTheme(mergedThemeConfig);
|
|
554
|
-
if (options.darkMode) {
|
|
555
|
-
theme = adaptTheme(theme);
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// Sparkline mode: inject smart defaults that depend on the resolved theme
|
|
559
|
-
// (trend-aware color, area gradient, default endpoint dot, pill cornerRadius
|
|
560
|
-
// for bars). Each default backs off when the user has set the corresponding
|
|
561
|
-
// field explicitly. Must run AFTER theme resolution (color depends on theme)
|
|
562
|
-
// and BEFORE computeDimensions (which reads markDef.point for sparkline padding).
|
|
563
|
-
if (chartSpec.display === 'sparkline') {
|
|
564
|
-
chartSpec = applySparklineDefaults(chartSpec, theme);
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// ---------------------------------------------------------------------------
|
|
568
|
-
// Data filtering: move BEFORE layout plan so the plan measures real labels
|
|
569
|
-
// from the filtered data set. Legend retains all series via chartSpec
|
|
570
|
-
// (unfiltered); scales and marks use renderSpec (filtered).
|
|
571
|
-
// ---------------------------------------------------------------------------
|
|
572
|
-
let renderData = chartSpec.data;
|
|
573
|
-
|
|
574
|
-
// Filter hidden series: removed from rendering but kept in legend (dimmed in the adapter)
|
|
575
|
-
if (
|
|
576
|
-
chartSpec.hiddenSeries.length > 0 &&
|
|
577
|
-
chartSpec.encoding.color &&
|
|
578
|
-
'field' in chartSpec.encoding.color
|
|
579
|
-
) {
|
|
580
|
-
const colorField = chartSpec.encoding.color.field;
|
|
581
|
-
const hiddenSet = new Set(chartSpec.hiddenSeries);
|
|
582
|
-
renderData = renderData.filter((row) => !hiddenSet.has(String(row[colorField])));
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
// Filter clipped scale domains: when scale.clip is true, exclude rows outside the domain
|
|
586
|
-
renderData = filterClippedDomains(renderData, chartSpec.encoding);
|
|
587
|
-
|
|
588
|
-
// Build a filtered spec for scales and marks, keeping all other properties intact
|
|
589
|
-
let renderSpec = renderData !== chartSpec.data ? { ...chartSpec, data: renderData } : chartSpec;
|
|
590
|
-
|
|
591
|
-
// Lock the color scale domain to the unfiltered series list so palette
|
|
592
|
-
// assignments stay stable across legend toggles. Without this, hiding a
|
|
593
|
-
// series shrinks the ordinal scale's domain and shifts every remaining
|
|
594
|
-
// series down one palette index -- the visible lines would mismatch the
|
|
595
|
-
// legend swatches that still represent the original assignment.
|
|
596
|
-
// Skipped when the user already supplied an explicit domain.
|
|
597
|
-
const colorEnc = chartSpec.encoding.color;
|
|
598
|
-
if (
|
|
599
|
-
chartSpec.hiddenSeries.length > 0 &&
|
|
600
|
-
colorEnc &&
|
|
601
|
-
'field' in colorEnc &&
|
|
602
|
-
colorEnc.type !== 'quantitative' &&
|
|
603
|
-
colorEnc.scale?.domain == null
|
|
604
|
-
) {
|
|
605
|
-
const colorField = colorEnc.field;
|
|
606
|
-
const stableDomain = Array.from(new Set(chartSpec.data.map((row) => String(row[colorField]))));
|
|
607
|
-
renderSpec = {
|
|
608
|
-
...renderSpec,
|
|
609
|
-
encoding: {
|
|
610
|
-
...renderSpec.encoding,
|
|
611
|
-
color: {
|
|
612
|
-
...colorEnc,
|
|
613
|
-
scale: { ...(colorEnc.scale ?? {}), domain: stableDomain },
|
|
614
|
-
},
|
|
615
|
-
},
|
|
616
|
-
};
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
// ---------------------------------------------------------------------------
|
|
620
|
-
// Faceted compilation: when encoding.facet is present, take the faceted path
|
|
621
|
-
// ---------------------------------------------------------------------------
|
|
622
|
-
const facetChannel = renderSpec.encoding.facet;
|
|
623
|
-
if (facetChannel?.field) {
|
|
624
|
-
return compileFaceted(
|
|
625
|
-
chartSpec,
|
|
626
|
-
renderSpec,
|
|
627
|
-
facetChannel,
|
|
628
|
-
options,
|
|
629
|
-
theme,
|
|
630
|
-
strategy,
|
|
631
|
-
watermark,
|
|
632
|
-
resolvedAnimation,
|
|
633
|
-
crosshair,
|
|
634
|
-
spec,
|
|
635
|
-
optionsInput,
|
|
636
|
-
);
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// ---------------------------------------------------------------------------
|
|
640
|
-
// Layout plan: measure real labels, freeze gutter + chrome + legend content
|
|
641
|
-
// ---------------------------------------------------------------------------
|
|
642
|
-
const measure = createMeasureFn(options.measureText);
|
|
643
|
-
const plan = resolveLayoutPlan(
|
|
644
|
-
chartSpec,
|
|
645
|
-
renderSpec,
|
|
646
|
-
options,
|
|
647
|
-
theme,
|
|
648
|
-
strategy,
|
|
649
|
-
watermark,
|
|
650
|
-
measure,
|
|
651
|
-
);
|
|
652
|
-
|
|
653
|
-
// Compute dimensions (accounts for chrome + legend + responsive strategy).
|
|
654
|
-
// The plan provides measured leftGutter, xAxisExtent, and chrome so
|
|
655
|
-
// dimensions.ts skips its guess blocks.
|
|
656
|
-
const legendStub = placeLegend(
|
|
657
|
-
plan.legendContent,
|
|
658
|
-
{ x: 0, y: 0, width: options.width, height: options.height },
|
|
659
|
-
options.width,
|
|
660
|
-
theme,
|
|
661
|
-
plan.xAxisExtent,
|
|
662
|
-
);
|
|
663
|
-
const dims = computeDimensions(chartSpec, options, legendStub, theme, strategy, watermark, plan);
|
|
664
|
-
// A frozen area (set by compileLayer for leaf layers) overrides the computed
|
|
665
|
-
// one so all layers share the primary layout's coordinate space.
|
|
666
|
-
const chartArea = options.frozenChartArea ?? dims.chartArea;
|
|
667
|
-
|
|
668
|
-
// Place legend in its final position relative to the computed chart area.
|
|
669
|
-
// effectiveAxisGap lifts a top legend above the inline-tick overhang zone
|
|
670
|
-
// the margin stack reserved below it.
|
|
671
|
-
const finalLegend = placeLegend(
|
|
672
|
-
plan.legendContent,
|
|
673
|
-
chartArea,
|
|
674
|
-
options.width,
|
|
675
|
-
theme,
|
|
676
|
-
plan.xAxisExtent,
|
|
677
|
-
dims.effectiveAxisGap,
|
|
678
|
-
);
|
|
679
|
-
|
|
680
|
-
// Every legend on the chart. The color legend is primary (and stays in the
|
|
681
|
-
// singular `legend` slot); a size legend joins it when a quantitative `size`
|
|
682
|
-
// channel is encoded. Consumers that reserve space or hit-test must iterate
|
|
683
|
-
// this rather than read `legend` -- see ChartLayout.legends.
|
|
684
|
-
const sizeLegend = plan.sizeLegendContent
|
|
685
|
-
? placeSizeLegend(plan.sizeLegendContent, chartArea, theme, finalLegend)
|
|
686
|
-
: null;
|
|
687
|
-
const allLegends: LegendLayout[] = [finalLegend, ...(sizeLegend ? [sizeLegend] : [])];
|
|
688
|
-
|
|
689
|
-
// Inline y-axis labels: inset the x-scale range start by the widest tick
|
|
690
|
-
// label width so data marks start to the right of the labels. The plan
|
|
691
|
-
// already measured the real tick labels.
|
|
692
|
-
let scaleArea = chartArea;
|
|
693
|
-
if (plan.inlineYLabelInset > 0 && plan.inlineYLabelInset < chartArea.width) {
|
|
694
|
-
scaleArea = {
|
|
695
|
-
x: chartArea.x + plan.inlineYLabelInset,
|
|
696
|
-
y: chartArea.y,
|
|
697
|
-
width: chartArea.width - plan.inlineYLabelInset,
|
|
698
|
-
height: chartArea.height,
|
|
699
|
-
};
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
// Compute scales
|
|
703
|
-
const scales = computeScales(renderSpec, scaleArea, renderSpec.data);
|
|
704
|
-
|
|
705
|
-
// Update color scale to use theme palette (only when user hasn't provided an explicit range).
|
|
706
|
-
// When highlight is active, assign palette colors to highlighted series and mute the rest.
|
|
707
|
-
applyColorScaleRange(scales, renderSpec.encoding, theme, renderSpec.highlight);
|
|
708
|
-
|
|
709
|
-
// INVARIANT 3 -- post-hoc defaultColor: must run AFTER computeScales since resolution needs
|
|
710
|
-
// theme context. Do not move into computeScales (would require threading theme through).
|
|
711
|
-
// fill wins for bar/area/arc marks; stroke wins for line marks (the stroke IS the color).
|
|
712
|
-
scales.defaultColor =
|
|
713
|
-
chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
|
|
714
|
-
|
|
715
|
-
// Dev-mode WCAG contrast diagnostics. Host-gated via options.dev (never an
|
|
716
|
-
// env sniff; the engine is isomorphic). Advisory console.warn only.
|
|
717
|
-
if (options.dev) {
|
|
718
|
-
emitSpecWarnings(collectContrastWarnings(scales, chartSpec.markType, theme), options.onWarn);
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
// Axisless marks (arc, waffle, calendar, parliament) don't use axes or
|
|
722
|
-
// gridlines: arcs and parliament hemicycles are radial, waffles and calendars
|
|
723
|
-
// compute their own grid geometry.
|
|
724
|
-
const isRadial = isAxislessMark(chartSpec.markType);
|
|
725
|
-
|
|
726
|
-
// Compute axes (skip for radial charts).
|
|
727
|
-
// Sparkline mode skips axes by default unless the user explicitly opted into
|
|
728
|
-
// an axis on a specific channel.
|
|
729
|
-
const skipX = chartSpec.display === 'sparkline' && !chartSpec.userExplicit.xAxis;
|
|
730
|
-
const skipY = chartSpec.display === 'sparkline' && !chartSpec.userExplicit.yAxis;
|
|
731
|
-
const axes = isRadial
|
|
732
|
-
? { x: undefined, y: undefined }
|
|
733
|
-
: computeAxes(scales, chartArea, strategy, theme, options.measureText, {
|
|
734
|
-
data: renderSpec.data,
|
|
735
|
-
encoding: renderSpec.encoding as Encoding,
|
|
736
|
-
skipX,
|
|
737
|
-
skipY,
|
|
738
|
-
markType: chartSpec.markType,
|
|
739
|
-
totalWidth: options.width,
|
|
740
|
-
precomputedYTicks: plan.yTickValues.length > 0 ? plan.yTickValues : undefined,
|
|
741
|
-
});
|
|
742
|
-
|
|
743
|
-
// Intentional post-hoc mutation: axes must resolve before we know the x-axis extent.
|
|
744
|
-
const xAxisExtent = axes.x?.extent ?? 0;
|
|
745
|
-
dims.chrome.bottomAnchorY = chartArea.y + chartArea.height + xAxisExtent;
|
|
746
|
-
|
|
747
|
-
// INVARIANT 2 — computeGridlines mutates `axes` in place. Downstream consumers read
|
|
748
|
-
// axes.y.gridlines off the same object. Do not introduce a copy-on-write.
|
|
749
|
-
if (!isRadial) {
|
|
750
|
-
computeGridlines(axes, chartArea);
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
// Get chart renderer and compute marks (using filtered data).
|
|
754
|
-
const rendererKey = resolveRendererKey(
|
|
755
|
-
renderSpec.markType,
|
|
756
|
-
renderSpec.encoding,
|
|
757
|
-
renderSpec.markDef,
|
|
758
|
-
);
|
|
759
|
-
const renderer = getChartRenderer(rendererKey);
|
|
760
|
-
const marks: Mark[] = renderer
|
|
761
|
-
? renderer(renderSpec, scales, chartArea, strategy, theme, options.width)
|
|
762
|
-
: [];
|
|
763
|
-
|
|
764
|
-
// Opt-in per-series fill patterns (mark.fillPattern: 'auto'). Mutates
|
|
765
|
-
// filled marks in place; assignment is deterministic and theme-aware.
|
|
766
|
-
applyFillPatterns(marks, chartSpec.markDef, theme);
|
|
767
|
-
|
|
768
|
-
// Resolve "you draw it" geometry (pixel fromX, x-sample positions, target
|
|
769
|
-
// line color/key). Reads marks so it must run after they're computed.
|
|
770
|
-
const xFieldForYdi =
|
|
771
|
-
chartSpec.encoding.x && 'field' in chartSpec.encoding.x
|
|
772
|
-
? (chartSpec.encoding.x.field as string | undefined)
|
|
773
|
-
: undefined;
|
|
774
|
-
const youDrawIt = resolveYouDrawIt(chartSpec.youDrawIt, marks, scales, chartArea, xFieldForYdi);
|
|
775
|
-
|
|
776
|
-
// Compute the right-side endpoint labels column for multi-series line/area
|
|
777
|
-
// charts. Reads `mark.dataPoints` so it must run AFTER marks are computed.
|
|
778
|
-
// dimensions.ts already reserved the right margin via predictEndpointLabelsWidth.
|
|
779
|
-
const endpointLabels = computeEndpointLabels(
|
|
780
|
-
chartSpec,
|
|
781
|
-
marks,
|
|
782
|
-
theme,
|
|
783
|
-
chartArea,
|
|
784
|
-
strategy,
|
|
785
|
-
plan.endpointLabelsDemoted,
|
|
786
|
-
);
|
|
787
|
-
|
|
788
|
-
// Compute annotations from spec, passing legend + mark + brand bounds as obstacles
|
|
789
|
-
const obstacles: PlacementObstacle[] = [];
|
|
790
|
-
if (finalLegend.bounds.width > 0) {
|
|
791
|
-
obstacles.push({ ...finalLegend.bounds, kind: 'legend' });
|
|
792
|
-
}
|
|
793
|
-
obstacles.push(...computeMarkObstacles(marks, scales));
|
|
794
|
-
|
|
795
|
-
// Add visible data label bounds as obstacles so annotations avoid overlapping them
|
|
796
|
-
for (const mark of marks) {
|
|
797
|
-
if ('label' in mark && mark.label?.visible) {
|
|
798
|
-
obstacles.push({ ...computeLabelBounds(mark.label), kind: 'data-label' });
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
// Add brand watermark as an obstacle so annotations avoid overlapping it.
|
|
803
|
-
const watermarkRect = computeWatermarkObstacle(dims, watermark, axes, theme);
|
|
804
|
-
if (watermarkRect) obstacles.push({ ...watermarkRect, kind: 'watermark' });
|
|
805
|
-
|
|
806
|
-
// Add endpoint label bounds as obstacles
|
|
807
|
-
if (endpointLabels.entries.length > 0 && endpointLabels.bounds.width > 0) {
|
|
808
|
-
obstacles.push({ ...endpointLabels.bounds, kind: 'endpoint-label' });
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
const annotationMeasure: AnnotationMeasureTextFn = options.measureText
|
|
812
|
-
? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width
|
|
813
|
-
: heuristicMeasure;
|
|
814
|
-
|
|
815
|
-
let annotations: ResolvedAnnotation[] = computeAnnotations(chartSpec, {
|
|
816
|
-
scales,
|
|
817
|
-
chartArea,
|
|
818
|
-
strategy,
|
|
819
|
-
isDark: theme.isDark,
|
|
820
|
-
obstacles,
|
|
821
|
-
svg: { width: dims.total.width, height: dims.total.height },
|
|
822
|
-
measure: annotationMeasure,
|
|
823
|
-
fontFamily: theme.fonts.family,
|
|
824
|
-
autoThin: chartSpec.autoThin,
|
|
825
|
-
});
|
|
826
|
-
|
|
827
|
-
// Auto-thinning: demote overlapping text annotations to footnote markers.
|
|
828
|
-
// Pass the full spec annotations (not filtered to text-only) so index
|
|
829
|
-
// alignment with the resolved array is preserved.
|
|
830
|
-
let footnotes: import('@opendata-ai/openchart-core').AnnotationFootnote[] | undefined;
|
|
831
|
-
if (chartSpec.autoThin && annotations.length > 1) {
|
|
832
|
-
const result = thinAnnotations(
|
|
833
|
-
annotations,
|
|
834
|
-
chartSpec.annotations,
|
|
835
|
-
annotationMeasure,
|
|
836
|
-
chartArea,
|
|
837
|
-
{ x: 0, y: 0, width: dims.total.width, height: dims.total.height },
|
|
838
|
-
);
|
|
839
|
-
annotations = result.annotations;
|
|
840
|
-
if (result.footnotes.length > 0) {
|
|
841
|
-
footnotes = result.footnotes;
|
|
842
|
-
|
|
843
|
-
// The footnote list renders below the plot, but nothing reserved space for
|
|
844
|
-
// it — thinning only just now discovered how many lines there are. Recompile
|
|
845
|
-
// with that band carved out of the bottom margin so the list can't overrun
|
|
846
|
-
// the source row and the brand watermark.
|
|
847
|
-
//
|
|
848
|
-
// Reserving shrinks the plot, which can push a further label out of bounds
|
|
849
|
-
// and grow the list by one, so re-reserve until the count settles. The
|
|
850
|
-
// count only ever grows (a smaller plot never keeps more labels inline) and
|
|
851
|
-
// is bounded by the annotation count, so this terminates; the explicit cap
|
|
852
|
-
// is a belt-and-braces guard against a measurement quirk oscillating.
|
|
853
|
-
//
|
|
854
|
-
// A frozen area short-circuits all of this. `compileLayer` pins every
|
|
855
|
-
// leaf to the primary layout's coordinate space, and `chartArea` above
|
|
856
|
-
// takes `frozenChartArea` over anything `computeDimensions` produces --
|
|
857
|
-
// so a grown bottom margin is computed and then thrown away. The
|
|
858
|
-
// recursion would re-run the whole compile up to MAX_FOOTNOTE_PASSES
|
|
859
|
-
// times and return the identical layout. Skip it rather than pay for it.
|
|
860
|
-
const reserve = footnoteBandHeight(footnotes.length, theme);
|
|
861
|
-
const currentReserve = options.footnoteReserve ?? 0;
|
|
862
|
-
if (
|
|
863
|
-
!options.frozenChartArea &&
|
|
864
|
-
reserve > currentReserve &&
|
|
865
|
-
(options.footnotePass ?? 0) < MAX_FOOTNOTE_PASSES
|
|
866
|
-
) {
|
|
867
|
-
return compileChart(spec, {
|
|
868
|
-
...optionsInput,
|
|
869
|
-
footnoteReserve: reserve,
|
|
870
|
-
footnotePass: (options.footnotePass ?? 0) + 1,
|
|
871
|
-
});
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
// Compute tooltip descriptors from marks and encoding
|
|
877
|
-
const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
|
|
878
|
-
|
|
879
|
-
// Compute accessibility. An author-provided description (a11y.description,
|
|
880
|
-
// or top-level `description` folded in by the sugar expansion) replaces the
|
|
881
|
-
// auto-generated alt text; auto-generation stays the fallback.
|
|
882
|
-
const altText =
|
|
883
|
-
chartSpec.a11y?.description ??
|
|
884
|
-
generateAltText(
|
|
885
|
-
{
|
|
886
|
-
mark: chartSpec.markType,
|
|
887
|
-
data: chartSpec.data,
|
|
888
|
-
encoding: chartSpec.encoding,
|
|
889
|
-
chrome: chartSpec.chrome,
|
|
890
|
-
} as ChartSpec,
|
|
891
|
-
chartSpec.data,
|
|
892
|
-
);
|
|
893
|
-
const dataTableFallback = generateDataTable(
|
|
894
|
-
{
|
|
895
|
-
mark: chartSpec.markType,
|
|
896
|
-
data: chartSpec.data,
|
|
897
|
-
encoding: chartSpec.encoding,
|
|
898
|
-
} as ChartSpec,
|
|
899
|
-
chartSpec.data,
|
|
900
|
-
);
|
|
901
|
-
|
|
902
|
-
// Assign animationIndex for stagger ordering when animation is enabled
|
|
903
|
-
assignAnimationIndices(marks, resolvedAnimation);
|
|
904
|
-
|
|
905
|
-
const chrome = footnotes ? { ...dims.chrome, footnotes } : dims.chrome;
|
|
906
|
-
|
|
907
|
-
// Scale inversion anchors for edit-mode anchor drag (pixel -> data-space)
|
|
908
|
-
const xInvert = resolveScaleInvert(scales.x);
|
|
909
|
-
const yInvert = resolveScaleInvert(scales.y);
|
|
910
|
-
|
|
911
|
-
return {
|
|
912
|
-
area: chartArea,
|
|
913
|
-
chrome,
|
|
914
|
-
metrics: dims.metrics,
|
|
915
|
-
...(dims.seriesSearch ? { seriesSearch: dims.seriesSearch } : {}),
|
|
916
|
-
...(youDrawIt ? { youDrawIt } : {}),
|
|
917
|
-
axes: {
|
|
918
|
-
x: axes.x,
|
|
919
|
-
y: axes.y,
|
|
920
|
-
},
|
|
921
|
-
marks,
|
|
922
|
-
annotations,
|
|
923
|
-
legend: finalLegend,
|
|
924
|
-
legends: allLegends,
|
|
925
|
-
...(endpointLabels.entries.length > 0 ? { endpointLabels } : {}),
|
|
926
|
-
tooltipDescriptors,
|
|
927
|
-
a11y: {
|
|
928
|
-
altText,
|
|
929
|
-
dataTableFallback,
|
|
930
|
-
role: 'img',
|
|
931
|
-
keyboardNavigable: marks.length > 0,
|
|
932
|
-
...(chartSpec.a11y?.hidden ? { hidden: true } : {}),
|
|
933
|
-
},
|
|
934
|
-
theme,
|
|
935
|
-
dimensions: {
|
|
936
|
-
width: options.width,
|
|
937
|
-
height: options.height,
|
|
938
|
-
},
|
|
939
|
-
animation: resolvedAnimation,
|
|
940
|
-
watermark,
|
|
941
|
-
display: chartSpec.display,
|
|
942
|
-
crosshair,
|
|
943
|
-
measureText: options.measureText,
|
|
944
|
-
...(xInvert ? { xInvert } : {}),
|
|
945
|
-
...(yInvert ? { yInvert } : {}),
|
|
946
|
-
};
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
// ---------------------------------------------------------------------------
|
|
950
|
-
// Faceted (small-multiples) compilation
|
|
951
|
-
// ---------------------------------------------------------------------------
|
|
952
|
-
|
|
953
|
-
function compileFaceted(
|
|
954
|
-
chartSpec: NormalizedChartSpec,
|
|
955
|
-
renderSpec: NormalizedChartSpec,
|
|
956
|
-
facetChannel: FacetChannel,
|
|
957
|
-
options: CompileOptions,
|
|
958
|
-
theme: ResolvedTheme,
|
|
959
|
-
strategy: LayoutStrategy,
|
|
960
|
-
watermark: boolean,
|
|
961
|
-
resolvedAnimation: ResolvedAnimation | undefined,
|
|
962
|
-
crosshair: boolean,
|
|
963
|
-
rawSpec: unknown,
|
|
964
|
-
optionsInput: CompileOptions,
|
|
965
|
-
): ChartLayout {
|
|
966
|
-
const measure = createMeasureFn(options.measureText);
|
|
967
|
-
|
|
968
|
-
// Strip facet from encoding before passing to per-panel pipeline (panels
|
|
969
|
-
// don't know they're faceted -- they compile as regular single charts).
|
|
970
|
-
const panelEncoding = { ...renderSpec.encoding };
|
|
971
|
-
delete (panelEncoding as Record<string, unknown>).facet;
|
|
972
|
-
const panelSpec = { ...renderSpec, encoding: panelEncoding };
|
|
973
|
-
const panelChartSpec = { ...chartSpec, encoding: panelEncoding };
|
|
974
|
-
|
|
975
|
-
// Partition data by facet field
|
|
976
|
-
const groups = groupByField(renderSpec.data, facetChannel.field);
|
|
977
|
-
const facetValues = Array.from(groups.keys());
|
|
978
|
-
if (facetChannel.sort !== null) {
|
|
979
|
-
const dir = facetChannel.sort === 'descending' ? -1 : 1;
|
|
980
|
-
facetValues.sort((a, b) => dir * a.localeCompare(b));
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
// Compute figure-level chrome and dimensions using the full dataset
|
|
984
|
-
// (no facet channel so it compiles normally). We use a representative
|
|
985
|
-
// single-panel spec to get chrome + legend measurements.
|
|
986
|
-
const plan = resolveLayoutPlan(
|
|
987
|
-
panelChartSpec,
|
|
988
|
-
panelSpec,
|
|
989
|
-
options,
|
|
990
|
-
theme,
|
|
991
|
-
strategy,
|
|
992
|
-
watermark,
|
|
993
|
-
measure,
|
|
994
|
-
);
|
|
995
|
-
const legendStub = placeLegend(
|
|
996
|
-
plan.legendContent,
|
|
997
|
-
{ x: 0, y: 0, width: options.width, height: options.height },
|
|
998
|
-
options.width,
|
|
999
|
-
theme,
|
|
1000
|
-
plan.xAxisExtent,
|
|
1001
|
-
);
|
|
1002
|
-
const dims = computeDimensions(
|
|
1003
|
-
panelChartSpec,
|
|
1004
|
-
options,
|
|
1005
|
-
legendStub,
|
|
1006
|
-
theme,
|
|
1007
|
-
strategy,
|
|
1008
|
-
watermark,
|
|
1009
|
-
plan,
|
|
1010
|
-
);
|
|
1011
|
-
const chartArea = dims.chartArea;
|
|
1012
|
-
|
|
1013
|
-
// Same effectiveAxisGap threading as the regular (non-faceted) path.
|
|
1014
|
-
const finalLegend = placeLegend(
|
|
1015
|
-
plan.legendContent,
|
|
1016
|
-
chartArea,
|
|
1017
|
-
options.width,
|
|
1018
|
-
theme,
|
|
1019
|
-
plan.xAxisExtent,
|
|
1020
|
-
dims.effectiveAxisGap,
|
|
1021
|
-
);
|
|
1022
|
-
|
|
1023
|
-
// Estimate y-axis gutter width (used as left reservation for outer axes)
|
|
1024
|
-
const leftReservation = plan.leftGutter;
|
|
1025
|
-
// Estimate x-axis extent (used as bottom reservation for outer axes)
|
|
1026
|
-
const bottomReservation = plan.xAxisExtent;
|
|
1027
|
-
|
|
1028
|
-
// Compute facet grid geometry
|
|
1029
|
-
const grid = computeFacetGrid(facetValues, facetChannel.columns, chartArea, {
|
|
1030
|
-
left: leftReservation,
|
|
1031
|
-
bottom: bottomReservation,
|
|
1032
|
-
});
|
|
1033
|
-
|
|
1034
|
-
// If panels are too short, grow the compile height and restart (once).
|
|
1035
|
-
if (
|
|
1036
|
-
!optionsInput.facetHeightGrown &&
|
|
1037
|
-
grid.panels.length > 0 &&
|
|
1038
|
-
grid.panels[0].area.height < MIN_PANEL_HEIGHT
|
|
1039
|
-
) {
|
|
1040
|
-
const neededChartHeight = facetMinHeight(facetValues.length, grid.columns);
|
|
1041
|
-
const heightGrowth = neededChartHeight - chartArea.height;
|
|
1042
|
-
if (heightGrowth > 0) {
|
|
1043
|
-
return compileChart(rawSpec, {
|
|
1044
|
-
...optionsInput,
|
|
1045
|
-
height: options.height + heightGrowth,
|
|
1046
|
-
facetHeightGrown: true,
|
|
1047
|
-
});
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
// Resolve scale for determining if we use 'independent' or 'shared'
|
|
1052
|
-
const resolveConfig = chartSpec.resolve;
|
|
1053
|
-
const yResolve = resolveConfig?.scale?.y ?? 'shared';
|
|
1054
|
-
const xResolve = resolveConfig?.scale?.x ?? 'shared';
|
|
1055
|
-
|
|
1056
|
-
// Compute shared scale domains from the full dataset (when shared)
|
|
1057
|
-
const fullScales = computeScales(panelSpec, chartArea, renderSpec.data);
|
|
1058
|
-
const sharedXDomain =
|
|
1059
|
-
xResolve === 'shared' && fullScales.x ? fullScales.x.scale.domain() : undefined;
|
|
1060
|
-
const sharedYDomain =
|
|
1061
|
-
yResolve === 'shared' && fullScales.y ? fullScales.y.scale.domain() : undefined;
|
|
1062
|
-
|
|
1063
|
-
const isRadial = isAxislessMark(chartSpec.markType);
|
|
1064
|
-
const rendererKey = resolveRendererKey(
|
|
1065
|
-
renderSpec.markType,
|
|
1066
|
-
panelEncoding as Encoding,
|
|
1067
|
-
renderSpec.markDef,
|
|
1068
|
-
);
|
|
1069
|
-
const renderer = getChartRenderer(rendererKey);
|
|
1070
|
-
|
|
1071
|
-
const allMarks: Mark[] = [];
|
|
1072
|
-
const panelLayouts: FacetPanelLayout[] = [];
|
|
1073
|
-
// Footnotes pool across panels into one figure-level list (the renderer draws
|
|
1074
|
-
// a single list below the grid from `chrome.footnotes`), so the numbering is
|
|
1075
|
-
// continuous and each panel's markers index into it.
|
|
1076
|
-
const allFootnotes: import('@opendata-ai/openchart-core').AnnotationFootnote[] = [];
|
|
1077
|
-
const footnoteNumberBySpecIndex = new Map<number, number>();
|
|
1078
|
-
|
|
1079
|
-
for (const gridPanel of grid.panels) {
|
|
1080
|
-
const panelData = groups.get(gridPanel.key) ?? [];
|
|
1081
|
-
const panelSpecLocal = { ...panelSpec, data: panelData };
|
|
1082
|
-
|
|
1083
|
-
// Inject shared domains via scale.domain on the encoding channels
|
|
1084
|
-
let panelEnc = { ...panelEncoding } as Record<string, unknown>;
|
|
1085
|
-
if (sharedXDomain && panelEnc.x) {
|
|
1086
|
-
const xCh = panelEnc.x as Record<string, unknown>;
|
|
1087
|
-
panelEnc = {
|
|
1088
|
-
...panelEnc,
|
|
1089
|
-
x: { ...xCh, scale: { ...((xCh.scale as object) ?? {}), domain: sharedXDomain } },
|
|
1090
|
-
};
|
|
1091
|
-
}
|
|
1092
|
-
if (sharedYDomain && panelEnc.y) {
|
|
1093
|
-
const yCh = panelEnc.y as Record<string, unknown>;
|
|
1094
|
-
panelEnc = {
|
|
1095
|
-
...panelEnc,
|
|
1096
|
-
y: { ...yCh, scale: { ...((yCh.scale as object) ?? {}), domain: sharedYDomain } },
|
|
1097
|
-
};
|
|
1098
|
-
}
|
|
1099
|
-
const panelSpecWithDomains = {
|
|
1100
|
-
...panelSpecLocal,
|
|
1101
|
-
encoding: panelEnc as unknown as typeof panelSpec.encoding,
|
|
1102
|
-
};
|
|
1103
|
-
|
|
1104
|
-
// Compute scales for this panel's area
|
|
1105
|
-
const panelScales = computeScales(panelSpecWithDomains, gridPanel.area, panelData);
|
|
1106
|
-
applyColorScaleRange(panelScales, panelSpecWithDomains.encoding, theme, renderSpec.highlight);
|
|
1107
|
-
panelScales.defaultColor =
|
|
1108
|
-
chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
|
|
1109
|
-
|
|
1110
|
-
// Dev-mode contrast diagnostics: panels share one palette resolution, so
|
|
1111
|
-
// checking the first panel covers the figure without repeating warnings.
|
|
1112
|
-
if (options.dev && panelLayouts.length === 0) {
|
|
1113
|
-
emitSpecWarnings(
|
|
1114
|
-
collectContrastWarnings(panelScales, chartSpec.markType, theme),
|
|
1115
|
-
options.onWarn,
|
|
1116
|
-
);
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
const isLeftCol = gridPanel.col === 0;
|
|
1120
|
-
|
|
1121
|
-
const panelAxes = isRadial
|
|
1122
|
-
? { x: undefined, y: undefined }
|
|
1123
|
-
: computeAxes(panelScales, gridPanel.area, strategy, theme, options.measureText, {
|
|
1124
|
-
data: panelData,
|
|
1125
|
-
encoding: panelSpecWithDomains.encoding as Encoding,
|
|
1126
|
-
markType: chartSpec.markType,
|
|
1127
|
-
totalWidth: gridPanel.area.width,
|
|
1128
|
-
});
|
|
1129
|
-
|
|
1130
|
-
if (!isRadial) {
|
|
1131
|
-
computeGridlines(panelAxes, gridPanel.area);
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
// Strip y-axis tick labels from non-leftmost panels (gridlines stay).
|
|
1135
|
-
// X-axis labels show on every panel for readability.
|
|
1136
|
-
if (yResolve === 'shared' && !isLeftCol && panelAxes.y) {
|
|
1137
|
-
panelAxes.y = {
|
|
1138
|
-
...panelAxes.y,
|
|
1139
|
-
ticks: [],
|
|
1140
|
-
label: undefined,
|
|
1141
|
-
domainLine: false,
|
|
1142
|
-
tickMarks: false,
|
|
1143
|
-
};
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
// Compute marks for this panel
|
|
1147
|
-
const panelMarks: Mark[] = renderer
|
|
1148
|
-
? renderer(
|
|
1149
|
-
panelSpecWithDomains,
|
|
1150
|
-
panelScales,
|
|
1151
|
-
gridPanel.area,
|
|
1152
|
-
strategy,
|
|
1153
|
-
theme,
|
|
1154
|
-
gridPanel.area.width,
|
|
1155
|
-
)
|
|
1156
|
-
: [];
|
|
1157
|
-
|
|
1158
|
-
// Compute annotations for this panel
|
|
1159
|
-
const annotationMeasure: AnnotationMeasureTextFn = options.measureText
|
|
1160
|
-
? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width
|
|
1161
|
-
: heuristicMeasure;
|
|
1162
|
-
let panelAnnotations = computeAnnotations(panelSpecWithDomains, {
|
|
1163
|
-
scales: panelScales,
|
|
1164
|
-
chartArea: gridPanel.area,
|
|
1165
|
-
strategy,
|
|
1166
|
-
isDark: theme.isDark,
|
|
1167
|
-
obstacles: [],
|
|
1168
|
-
svg: { width: options.width, height: options.height },
|
|
1169
|
-
measure: annotationMeasure,
|
|
1170
|
-
fontFamily: theme.fonts.family,
|
|
1171
|
-
autoThin: panelSpecWithDomains.autoThin,
|
|
1172
|
-
});
|
|
1173
|
-
|
|
1174
|
-
// Auto-thinning, per panel. A panel is its own little chart: the box a label
|
|
1175
|
-
// must stay inside is the panel, not the figure. Thinning against the whole
|
|
1176
|
-
// SVG (what the single-chart path passes) would let a label from the left
|
|
1177
|
-
// column sprawl across its neighbour and still count as "fits".
|
|
1178
|
-
//
|
|
1179
|
-
// The spec's annotations resolve into *every* panel, so the same annotation
|
|
1180
|
-
// can demote in more than one. The footnote list is figure-level and its text
|
|
1181
|
-
// is per-spec-annotation, so number by spec index and reuse that number in
|
|
1182
|
-
// every panel that demoted it -- otherwise the list repeats itself once per
|
|
1183
|
-
// panel.
|
|
1184
|
-
if (chartSpec.autoThin && panelAnnotations.length > 1) {
|
|
1185
|
-
const thinned = thinAnnotations(
|
|
1186
|
-
panelAnnotations,
|
|
1187
|
-
chartSpec.annotations,
|
|
1188
|
-
annotationMeasure,
|
|
1189
|
-
gridPanel.area,
|
|
1190
|
-
gridPanel.area,
|
|
1191
|
-
);
|
|
1192
|
-
panelAnnotations = thinned.annotations;
|
|
1193
|
-
// `thinAnnotations` numbers footnotes 1..n *within the panel it was given*,
|
|
1194
|
-
// so restamp each demoted annotation with its figure-level number. Take the
|
|
1195
|
-
// text from `thinned.footnotes` (the 1-based panel-local index it just
|
|
1196
|
-
// assigned) rather than re-reading `label.text`: thinning owns that string,
|
|
1197
|
-
// and re-deriving it here would be a second producer to drift out of sync.
|
|
1198
|
-
//
|
|
1199
|
-
// Key on `specIndex`, NOT on position: with `resolve.scale.x: 'independent'`
|
|
1200
|
-
// each panel derives its own domain, so an annotation can resolve in one
|
|
1201
|
-
// panel and be dropped in another. Position i then means a different spec
|
|
1202
|
-
// annotation in each panel, and the markers point at other panels' text.
|
|
1203
|
-
for (const a of panelAnnotations) {
|
|
1204
|
-
const panelIndex = a.footnoteIndex;
|
|
1205
|
-
if (panelIndex == null || a.specIndex === undefined) continue;
|
|
1206
|
-
let num = footnoteNumberBySpecIndex.get(a.specIndex);
|
|
1207
|
-
if (num === undefined) {
|
|
1208
|
-
num = allFootnotes.length + 1;
|
|
1209
|
-
footnoteNumberBySpecIndex.set(a.specIndex, num);
|
|
1210
|
-
allFootnotes.push({
|
|
1211
|
-
index: num,
|
|
1212
|
-
text: thinned.footnotes[panelIndex - 1]?.text ?? '',
|
|
1213
|
-
});
|
|
1214
|
-
}
|
|
1215
|
-
a.footnoteIndex = num;
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
allMarks.push(...panelMarks);
|
|
1220
|
-
|
|
1221
|
-
panelLayouts.push({
|
|
1222
|
-
key: gridPanel.key,
|
|
1223
|
-
area: gridPanel.area,
|
|
1224
|
-
marks: panelMarks,
|
|
1225
|
-
axes: { x: panelAxes.x, y: panelAxes.y },
|
|
1226
|
-
annotations: panelAnnotations,
|
|
1227
|
-
header: {
|
|
1228
|
-
text: gridPanel.key,
|
|
1229
|
-
x: gridPanel.headerPos.x,
|
|
1230
|
-
y: gridPanel.headerPos.y,
|
|
1231
|
-
fontSize: theme.fonts.sizes.body,
|
|
1232
|
-
fontWeight: theme.fonts.weights.medium,
|
|
1233
|
-
},
|
|
1234
|
-
});
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
// The footnote list renders below the grid, but nothing reserved space for it
|
|
1238
|
-
// -- thinning only just now discovered how many lines there are. Recompile with
|
|
1239
|
-
// that band carved out of the bottom margin, same contract as the single-chart
|
|
1240
|
-
// path (see the matching block in compileChart). computeDimensions applies
|
|
1241
|
-
// options.footnoteReserve generically, so the facet grid shrinks with it.
|
|
1242
|
-
//
|
|
1243
|
-
// A frozen area short-circuits this, same as the single-chart path: compileLayer
|
|
1244
|
-
// pins every leaf to the primary layout's coordinate space, so the grown bottom
|
|
1245
|
-
// margin is computed and then thrown away. Recursing would re-run the whole
|
|
1246
|
-
// compile up to MAX_FOOTNOTE_PASSES times for an identical layout.
|
|
1247
|
-
if (allFootnotes.length > 0 && !options.frozenChartArea) {
|
|
1248
|
-
const reserve = footnoteBandHeight(allFootnotes.length, theme);
|
|
1249
|
-
const currentReserve = options.footnoteReserve ?? 0;
|
|
1250
|
-
if (reserve > currentReserve && (options.footnotePass ?? 0) < MAX_FOOTNOTE_PASSES) {
|
|
1251
|
-
return compileChart(rawSpec, {
|
|
1252
|
-
...optionsInput,
|
|
1253
|
-
footnoteReserve: reserve,
|
|
1254
|
-
footnotePass: (options.footnotePass ?? 0) + 1,
|
|
1255
|
-
});
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
// Compute tooltip descriptors from all marks across panels
|
|
1260
|
-
const tooltipDescriptors = computeTooltipDescriptors(panelChartSpec, allMarks);
|
|
1261
|
-
|
|
1262
|
-
// Opt-in per-series fill patterns across all panels at once so a series
|
|
1263
|
-
// gets the same pattern in every panel.
|
|
1264
|
-
applyFillPatterns(allMarks, chartSpec.markDef, theme);
|
|
1265
|
-
|
|
1266
|
-
// A11y: mention faceting in alt text. An author description is used
|
|
1267
|
-
// verbatim (the author already describes the whole figure).
|
|
1268
|
-
const panelCount = facetValues.length;
|
|
1269
|
-
const baseAltText = generateAltText(
|
|
1270
|
-
{
|
|
1271
|
-
mark: chartSpec.markType,
|
|
1272
|
-
data: chartSpec.data,
|
|
1273
|
-
encoding: chartSpec.encoding,
|
|
1274
|
-
chrome: chartSpec.chrome,
|
|
1275
|
-
} as ChartSpec,
|
|
1276
|
-
chartSpec.data,
|
|
1277
|
-
);
|
|
1278
|
-
const altText =
|
|
1279
|
-
chartSpec.a11y?.description ??
|
|
1280
|
-
`${baseAltText} Faceted into ${panelCount} panels by ${facetChannel.field}.`;
|
|
1281
|
-
|
|
1282
|
-
const dataTableFallback = generateDataTable(
|
|
1283
|
-
{
|
|
1284
|
-
mark: chartSpec.markType,
|
|
1285
|
-
data: chartSpec.data,
|
|
1286
|
-
encoding: chartSpec.encoding,
|
|
1287
|
-
} as ChartSpec,
|
|
1288
|
-
chartSpec.data,
|
|
1289
|
-
);
|
|
1290
|
-
|
|
1291
|
-
// Assign animation indices across all panels
|
|
1292
|
-
assignAnimationIndices(allMarks, resolvedAnimation);
|
|
1293
|
-
|
|
1294
|
-
// Figure-level axes are null (axes live in panels)
|
|
1295
|
-
// Figure-level marks are the union of all panel marks (for tooltip/keyboard nav)
|
|
1296
|
-
return {
|
|
1297
|
-
area: chartArea,
|
|
1298
|
-
chrome: allFootnotes.length > 0 ? { ...dims.chrome, footnotes: allFootnotes } : dims.chrome,
|
|
1299
|
-
metrics: dims.metrics,
|
|
1300
|
-
...(dims.seriesSearch ? { seriesSearch: dims.seriesSearch } : {}),
|
|
1301
|
-
axes: { x: undefined, y: undefined },
|
|
1302
|
-
marks: allMarks,
|
|
1303
|
-
annotations: [],
|
|
1304
|
-
legend: finalLegend,
|
|
1305
|
-
// Facets share one color legend across the grid; the size channel isn't
|
|
1306
|
-
// supported in facet specs, so there is no second legend to carry.
|
|
1307
|
-
legends: [finalLegend],
|
|
1308
|
-
tooltipDescriptors,
|
|
1309
|
-
a11y: {
|
|
1310
|
-
altText,
|
|
1311
|
-
dataTableFallback,
|
|
1312
|
-
role: 'img',
|
|
1313
|
-
keyboardNavigable: allMarks.length > 0,
|
|
1314
|
-
...(chartSpec.a11y?.hidden ? { hidden: true } : {}),
|
|
1315
|
-
},
|
|
1316
|
-
theme,
|
|
1317
|
-
dimensions: { width: options.width, height: options.height },
|
|
1318
|
-
animation: resolvedAnimation,
|
|
1319
|
-
watermark,
|
|
1320
|
-
display: chartSpec.display,
|
|
1321
|
-
crosshair,
|
|
1322
|
-
measureText: options.measureText,
|
|
1323
|
-
facet: {
|
|
1324
|
-
panels: panelLayouts,
|
|
1325
|
-
facetField: facetChannel.field,
|
|
1326
|
-
columns: grid.columns,
|
|
1327
|
-
sharedScales: yResolve === 'shared' && xResolve === 'shared',
|
|
1328
|
-
},
|
|
1329
|
-
};
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
// ---------------------------------------------------------------------------
|
|
1333
|
-
// Layer compilation
|
|
1334
|
-
// ---------------------------------------------------------------------------
|
|
1335
|
-
|
|
1336
|
-
export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLayout {
|
|
1337
|
-
// Expand VL-idiom sugar on the layer spec and every child up front. Leaf
|
|
1338
|
-
// compiles re-run the (idempotent) expansion after encoding inheritance;
|
|
1339
|
-
// warning triggers are stripped or stamped here so each warns exactly once.
|
|
1340
|
-
const sugarWarnings: string[] = [];
|
|
1341
|
-
const expanded = expandSpecSugar(
|
|
1342
|
-
spec as unknown as Record<string, unknown>,
|
|
1343
|
-
sugarWarnings,
|
|
1344
|
-
) as unknown as LayerSpec;
|
|
1345
|
-
emitSpecWarnings(sugarWarnings, options.onWarn);
|
|
1346
|
-
const resolvedOptions = applySpecSize(expanded, options);
|
|
1347
|
-
return compileLayerImpl(expanded, resolvedOptions, compileChart);
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
// ---------------------------------------------------------------------------
|
|
1351
|
-
// Table compilation
|
|
1352
|
-
// ---------------------------------------------------------------------------
|
|
1353
|
-
|
|
1354
|
-
/**
|
|
1355
|
-
* Compile a table spec into a TableLayout.
|
|
1356
|
-
*
|
|
1357
|
-
* Validates and normalizes the spec, resolves the theme, then delegates
|
|
1358
|
-
* to compileTableLayout for the full pipeline: column resolution, search,
|
|
1359
|
-
* sort, pagination, cell formatting, and visual enhancements.
|
|
1360
|
-
*
|
|
1361
|
-
* @param spec - Raw table spec.
|
|
1362
|
-
* @param options - Compile options with sort, search, pagination state.
|
|
1363
|
-
* @returns Fully resolved TableLayout.
|
|
1364
|
-
*/
|
|
1365
|
-
export function compileTable(spec: unknown, options: CompileTableOptions): TableLayout {
|
|
1366
|
-
const { spec: normalized } = compileSpec(spec);
|
|
1367
|
-
|
|
1368
|
-
const normType =
|
|
1369
|
-
'type' in normalized ? (normalized as unknown as Record<string, unknown>).type : undefined;
|
|
1370
|
-
if (normType !== 'table') {
|
|
1371
|
-
throw new Error(`compileTable received a non-table spec. Use compileChart instead.`);
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
const tableSpec = normalized as NormalizedTableSpec;
|
|
1375
|
-
|
|
1376
|
-
// Resolve theme: merge spec-level theme with options-level overrides
|
|
1377
|
-
const mergedThemeConfig = options.theme
|
|
1378
|
-
? { ...tableSpec.theme, ...options.theme }
|
|
1379
|
-
: tableSpec.theme;
|
|
1380
|
-
let theme: ResolvedTheme = resolveTheme(mergedThemeConfig);
|
|
1381
|
-
if (options.darkMode) {
|
|
1382
|
-
theme = adaptTheme(theme);
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
// Resolve watermark: spec-level wins, then options, then default true
|
|
1386
|
-
const rawWatermark = (spec as Record<string, unknown>).watermark;
|
|
1387
|
-
const watermark = rawWatermark !== undefined ? tableSpec.watermark : (options.watermark ?? true);
|
|
1388
|
-
|
|
1389
|
-
return compileTableLayout({ ...tableSpec, watermark }, options, theme);
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
// ---------------------------------------------------------------------------
|
|
1393
|
-
// Graph compilation
|
|
1394
|
-
// ---------------------------------------------------------------------------
|
|
1395
|
-
|
|
1396
|
-
/**
|
|
1397
|
-
* Compile a graph spec into a GraphCompilation.
|
|
1398
|
-
*
|
|
1399
|
-
* The graph pipeline resolves visual properties (size, color, stroke) for
|
|
1400
|
-
* nodes and edges, assigns communities, and builds legend/tooltip/a11y data.
|
|
1401
|
-
* Unlike charts, the output does NOT include x/y positions since the force
|
|
1402
|
-
* simulation in the adapter handles layout at runtime.
|
|
1403
|
-
*
|
|
1404
|
-
* @param spec - Raw graph spec (validated and normalized internally).
|
|
1405
|
-
* @param options - Compile options (width, height, theme, darkMode).
|
|
1406
|
-
* @returns GraphCompilation with resolved visual properties and simulation config.
|
|
1407
|
-
* @throws Error if spec is invalid or not a graph type.
|
|
1408
|
-
*/
|
|
1409
|
-
export function compileGraph(spec: unknown, options: CompileOptions): GraphCompilation {
|
|
1410
|
-
return compileGraphImpl(spec, options);
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
// ---------------------------------------------------------------------------
|
|
1414
|
-
// Sankey compilation
|
|
1415
|
-
// ---------------------------------------------------------------------------
|
|
1416
|
-
|
|
1417
|
-
/**
|
|
1418
|
-
* Compile a sankey spec into a SankeyLayout.
|
|
1419
|
-
*
|
|
1420
|
-
* Takes a raw sankey spec, validates, normalizes, resolves theme and chrome,
|
|
1421
|
-
* runs the d3-sankey layout algorithm, builds node/link marks with colors and
|
|
1422
|
-
* labels, and returns a SankeyLayout ready for rendering.
|
|
1423
|
-
*
|
|
1424
|
-
* @param spec - Raw sankey spec (validated and normalized internally).
|
|
1425
|
-
* @param options - Compile options (width, height, theme, darkMode).
|
|
1426
|
-
* @returns SankeyLayout with computed positions and visual properties.
|
|
1427
|
-
* @throws Error if spec is invalid or not a sankey type.
|
|
1428
|
-
*/
|
|
1429
|
-
export function compileSankey(
|
|
1430
|
-
spec: unknown,
|
|
1431
|
-
options: CompileOptions,
|
|
1432
|
-
): import('@opendata-ai/openchart-core').SankeyLayout {
|
|
1433
|
-
return compileSankeyImpl(spec, options);
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
// ---------------------------------------------------------------------------
|
|
1437
|
-
// TileMap compilation
|
|
1438
|
-
// ---------------------------------------------------------------------------
|
|
1439
|
-
|
|
1440
|
-
/**
|
|
1441
|
-
* Compile a tilemap spec into a TileMapLayout.
|
|
1442
|
-
*
|
|
1443
|
-
* Takes a raw tilemap spec, validates, normalizes, resolves theme and chrome,
|
|
1444
|
-
* computes tile positions, builds tile marks with colors and labels, and
|
|
1445
|
-
* returns a TileMapLayout ready for rendering.
|
|
1446
|
-
*
|
|
1447
|
-
* @param spec - Raw tilemap spec (validated and normalized internally).
|
|
1448
|
-
* @param options - Compile options (width, height, theme, darkMode).
|
|
1449
|
-
* @returns TileMapLayout with computed positions and visual properties.
|
|
1450
|
-
* @throws Error if spec is invalid or not a tilemap type.
|
|
1451
|
-
*/
|
|
1452
|
-
export function compileTileMap(
|
|
1453
|
-
spec: unknown,
|
|
1454
|
-
options: CompileOptions,
|
|
1455
|
-
): import('@opendata-ai/openchart-core').TileMapLayout {
|
|
1456
|
-
return compileTileMapImpl(spec, options);
|
|
1457
|
-
}
|
|
1458
|
-
|
|
1459
|
-
// ---------------------------------------------------------------------------
|
|
1460
|
-
// Map compilation
|
|
1461
|
-
// ---------------------------------------------------------------------------
|
|
1462
|
-
|
|
1463
|
-
/**
|
|
1464
|
-
* Compile a map spec into a MapLayout.
|
|
1465
|
-
*
|
|
1466
|
-
* Takes a raw map spec, validates, normalizes, resolves theme and chrome,
|
|
1467
|
-
* projects geo features, joins data, builds feature marks with color fills,
|
|
1468
|
-
* and returns a MapLayout ready for rendering.
|
|
1469
|
-
*
|
|
1470
|
-
* @param spec - Raw map spec (validated and normalized internally).
|
|
1471
|
-
* @param options - Compile options (width, height, theme, darkMode).
|
|
1472
|
-
* @returns MapLayout with computed positions and visual properties.
|
|
1473
|
-
* @throws Error if spec is invalid or not a map type.
|
|
1474
|
-
*/
|
|
1475
|
-
export function compileMap(
|
|
1476
|
-
spec: unknown,
|
|
1477
|
-
options: CompileOptions,
|
|
1478
|
-
): import('@opendata-ai/openchart-core').MapLayout {
|
|
1479
|
-
return compileMapImpl(spec, options);
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
|
-
// ---------------------------------------------------------------------------
|
|
1483
|
-
// BarList compilation
|
|
1484
|
-
// ---------------------------------------------------------------------------
|
|
1485
|
-
|
|
1486
|
-
/**
|
|
1487
|
-
* Compile a barlist spec into a BarListLayout.
|
|
1488
|
-
*
|
|
1489
|
-
* Takes a raw barlist spec, validates, normalizes, resolves theme and chrome,
|
|
1490
|
-
* computes row layout with proportional bars, builds tooltips, and returns
|
|
1491
|
-
* a BarListLayout ready for rendering.
|
|
1492
|
-
*
|
|
1493
|
-
* @param spec - Raw barlist spec (validated and normalized internally).
|
|
1494
|
-
* @param options - Compile options (width, height, theme, darkMode).
|
|
1495
|
-
* @returns BarListLayout with computed positions and visual properties.
|
|
1496
|
-
* @throws Error if spec is invalid or not a barlist type.
|
|
1497
|
-
*/
|
|
1498
|
-
export function compileBarList(
|
|
1499
|
-
spec: unknown,
|
|
1500
|
-
options: CompileOptions,
|
|
1501
|
-
): import('@opendata-ai/openchart-core').BarListLayout {
|
|
1502
|
-
return compileBarListImpl(spec, options);
|
|
1503
|
-
}
|