insomni-plot 0.1.0-alpha.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/LICENSE.md +674 -0
- package/README.md +81 -0
- package/dist/core.d.mts +340 -0
- package/dist/core.mjs +1047 -0
- package/dist/index.d.mts +3426 -0
- package/dist/index.mjs +12762 -0
- package/dist/interactions-DEFL_F4E.mjs +5395 -0
- package/dist/range-presets-CzECsu3V.d.mts +1523 -0
- package/package.json +34 -0
- package/src/annotations.d.ts +121 -0
- package/src/annotations.ts +438 -0
- package/src/axis.d.ts +184 -0
- package/src/axis.test.ts +131 -0
- package/src/axis.ts +765 -0
- package/src/colorbar.d.ts +69 -0
- package/src/colorbar.ts +294 -0
- package/src/colors.d.ts +57 -0
- package/src/colors.test.ts +28 -0
- package/src/colors.ts +486 -0
- package/src/core.ts +299 -0
- package/src/format.d.ts +54 -0
- package/src/format.ts +138 -0
- package/src/grammar/accessibility.d.ts +147 -0
- package/src/grammar/accessibility.test.ts +199 -0
- package/src/grammar/accessibility.ts +443 -0
- package/src/grammar/aes.d.ts +35 -0
- package/src/grammar/aes.test.ts +75 -0
- package/src/grammar/aes.ts +120 -0
- package/src/grammar/annotations.d.ts +86 -0
- package/src/grammar/annotations.test.ts +68 -0
- package/src/grammar/annotations.ts +336 -0
- package/src/grammar/attach-brush.d.ts +44 -0
- package/src/grammar/attach-brush.test.ts +214 -0
- package/src/grammar/attach-brush.ts +111 -0
- package/src/grammar/attach-presets.d.ts +33 -0
- package/src/grammar/attach-presets.test.ts +106 -0
- package/src/grammar/attach-presets.ts +215 -0
- package/src/grammar/chart.d.ts +952 -0
- package/src/grammar/chart.test.ts +118 -0
- package/src/grammar/chart.ts +1172 -0
- package/src/grammar/color-utils.d.ts +29 -0
- package/src/grammar/color-utils.test.ts +53 -0
- package/src/grammar/color-utils.ts +66 -0
- package/src/grammar/constants.d.ts +45 -0
- package/src/grammar/constants.ts +61 -0
- package/src/grammar/coord.d.ts +183 -0
- package/src/grammar/coord.test.ts +355 -0
- package/src/grammar/coord.ts +619 -0
- package/src/grammar/data/pivot.d.ts +57 -0
- package/src/grammar/data/pivot.ts +107 -0
- package/src/grammar/emphasis-driver.d.ts +69 -0
- package/src/grammar/emphasis-driver.test.ts +199 -0
- package/src/grammar/emphasis-driver.ts +205 -0
- package/src/grammar/equality.d.ts +3 -0
- package/src/grammar/equality.ts +40 -0
- package/src/grammar/facet.d.ts +63 -0
- package/src/grammar/facet.test.ts +60 -0
- package/src/grammar/facet.ts +175 -0
- package/src/grammar/geoms/_categorical.d.ts +94 -0
- package/src/grammar/geoms/_categorical.ts +0 -0
- package/src/grammar/geoms/_distribution.d.ts +52 -0
- package/src/grammar/geoms/_distribution.ts +125 -0
- package/src/grammar/geoms/_mark.d.ts +69 -0
- package/src/grammar/geoms/_mark.ts +136 -0
- package/src/grammar/geoms/_shape.d.ts +41 -0
- package/src/grammar/geoms/_shape.ts +74 -0
- package/src/grammar/geoms/aggregate.d.ts +95 -0
- package/src/grammar/geoms/aggregate.test.ts +554 -0
- package/src/grammar/geoms/aggregate.ts +840 -0
- package/src/grammar/geoms/area.d.ts +32 -0
- package/src/grammar/geoms/area.test.ts +165 -0
- package/src/grammar/geoms/area.ts +578 -0
- package/src/grammar/geoms/band.d.ts +27 -0
- package/src/grammar/geoms/band.test.ts +57 -0
- package/src/grammar/geoms/band.ts +126 -0
- package/src/grammar/geoms/bar.d.ts +56 -0
- package/src/grammar/geoms/bar.test.ts +367 -0
- package/src/grammar/geoms/bar.ts +1054 -0
- package/src/grammar/geoms/boxplot.d.ts +129 -0
- package/src/grammar/geoms/boxplot.test.ts +299 -0
- package/src/grammar/geoms/boxplot.ts +834 -0
- package/src/grammar/geoms/connected-scatter.d.ts +27 -0
- package/src/grammar/geoms/connected-scatter.test.ts +157 -0
- package/src/grammar/geoms/connected-scatter.ts +63 -0
- package/src/grammar/geoms/emphasis.d.ts +76 -0
- package/src/grammar/geoms/emphasis.test.ts +135 -0
- package/src/grammar/geoms/emphasis.ts +162 -0
- package/src/grammar/geoms/histogram.d.ts +75 -0
- package/src/grammar/geoms/histogram.test.ts +262 -0
- package/src/grammar/geoms/histogram.ts +740 -0
- package/src/grammar/geoms/index.d.ts +20 -0
- package/src/grammar/geoms/index.ts +77 -0
- package/src/grammar/geoms/interval.d.ts +31 -0
- package/src/grammar/geoms/interval.test.ts +154 -0
- package/src/grammar/geoms/interval.ts +342 -0
- package/src/grammar/geoms/line.d.ts +38 -0
- package/src/grammar/geoms/line.test.ts +247 -0
- package/src/grammar/geoms/line.ts +659 -0
- package/src/grammar/geoms/point.d.ts +57 -0
- package/src/grammar/geoms/point.test.ts +163 -0
- package/src/grammar/geoms/point.ts +545 -0
- package/src/grammar/geoms/polar.test.ts +216 -0
- package/src/grammar/geoms/ribbon.d.ts +21 -0
- package/src/grammar/geoms/ribbon.test.ts +170 -0
- package/src/grammar/geoms/ribbon.ts +87 -0
- package/src/grammar/geoms/ridgeline.d.ts +89 -0
- package/src/grammar/geoms/ridgeline.test.ts +247 -0
- package/src/grammar/geoms/ridgeline.ts +1164 -0
- package/src/grammar/geoms/rolling.d.ts +43 -0
- package/src/grammar/geoms/rolling.test.ts +217 -0
- package/src/grammar/geoms/rolling.ts +387 -0
- package/src/grammar/geoms/rug.d.ts +28 -0
- package/src/grammar/geoms/rug.test.ts +126 -0
- package/src/grammar/geoms/rug.ts +214 -0
- package/src/grammar/geoms/rule.d.ts +23 -0
- package/src/grammar/geoms/rule.test.ts +69 -0
- package/src/grammar/geoms/rule.ts +212 -0
- package/src/grammar/geoms/smooth.d.ts +54 -0
- package/src/grammar/geoms/smooth.test.ts +78 -0
- package/src/grammar/geoms/smooth.ts +337 -0
- package/src/grammar/geoms/text.d.ts +29 -0
- package/src/grammar/geoms/text.test.ts +64 -0
- package/src/grammar/geoms/text.ts +234 -0
- package/src/grammar/geoms/tile.d.ts +61 -0
- package/src/grammar/geoms/tile.test.ts +157 -0
- package/src/grammar/geoms/tile.ts +621 -0
- package/src/grammar/geoms/types.d.ts +319 -0
- package/src/grammar/geoms/types.ts +362 -0
- package/src/grammar/geoms/violin.d.ts +85 -0
- package/src/grammar/geoms/violin.test.ts +187 -0
- package/src/grammar/geoms/violin.ts +672 -0
- package/src/grammar/index.d.ts +22 -0
- package/src/grammar/index.ts +269 -0
- package/src/grammar/interactions/_disposable.d.ts +5 -0
- package/src/grammar/interactions/_disposable.ts +23 -0
- package/src/grammar/interactions/_z.d.ts +4 -0
- package/src/grammar/interactions/_z.ts +16 -0
- package/src/grammar/interactions/brush-selection.test.ts +262 -0
- package/src/grammar/interactions/brush.d.ts +63 -0
- package/src/grammar/interactions/brush.test.ts +483 -0
- package/src/grammar/interactions/brush.ts +452 -0
- package/src/grammar/interactions/crosshair.d.ts +19 -0
- package/src/grammar/interactions/crosshair.test.ts +127 -0
- package/src/grammar/interactions/crosshair.ts +76 -0
- package/src/grammar/interactions/hit-layer.d.ts +64 -0
- package/src/grammar/interactions/hit-layer.ts +246 -0
- package/src/grammar/interactions/legend.d.ts +19 -0
- package/src/grammar/interactions/legend.ts +101 -0
- package/src/grammar/interactions/menu.d.ts +93 -0
- package/src/grammar/interactions/menu.test.ts +373 -0
- package/src/grammar/interactions/menu.ts +342 -0
- package/src/grammar/interactions/selection.d.ts +25 -0
- package/src/grammar/interactions/selection.test.ts +289 -0
- package/src/grammar/interactions/selection.ts +142 -0
- package/src/grammar/interactions/series-readout.d.ts +91 -0
- package/src/grammar/interactions/series-readout.test.ts +668 -0
- package/src/grammar/interactions/series-readout.ts +422 -0
- package/src/grammar/interactions/series-snap.d.ts +70 -0
- package/src/grammar/interactions/series-snap.test.ts +214 -0
- package/src/grammar/interactions/series-snap.ts +218 -0
- package/src/grammar/interactions/tooltip-axis.test.ts +176 -0
- package/src/grammar/interactions/tooltip-touch.browser.test.ts +49 -0
- package/src/grammar/interactions/tooltip-touch.test.ts +161 -0
- package/src/grammar/interactions/tooltip.d.ts +140 -0
- package/src/grammar/interactions/tooltip.test.ts +406 -0
- package/src/grammar/interactions/tooltip.ts +622 -0
- package/src/grammar/interactions/transitions.d.ts +34 -0
- package/src/grammar/interactions/transitions.test.ts +172 -0
- package/src/grammar/interactions/transitions.ts +160 -0
- package/src/grammar/layout.d.ts +68 -0
- package/src/grammar/layout.ts +186 -0
- package/src/grammar/legend-merge.test.ts +332 -0
- package/src/grammar/mount.d.ts +78 -0
- package/src/grammar/mount.test.ts +479 -0
- package/src/grammar/mount.ts +2112 -0
- package/src/grammar/palettes.d.ts +54 -0
- package/src/grammar/palettes.test.ts +80 -0
- package/src/grammar/palettes.ts +167 -0
- package/src/grammar/pan-zoom.test.ts +398 -0
- package/src/grammar/phylo.d.ts +65 -0
- package/src/grammar/phylo.test.ts +59 -0
- package/src/grammar/phylo.ts +112 -0
- package/src/grammar/pipeline.auto-ticks.test.ts +40 -0
- package/src/grammar/pipeline.d.ts +158 -0
- package/src/grammar/pipeline.test.ts +463 -0
- package/src/grammar/pipeline.ts +1233 -0
- package/src/grammar/profiling.d.ts +8 -0
- package/src/grammar/profiling.ts +24 -0
- package/src/grammar/scales.d.ts +188 -0
- package/src/grammar/scales.test.ts +181 -0
- package/src/grammar/scales.ts +800 -0
- package/src/grammar/svg.d.ts +3 -0
- package/src/grammar/svg.ts +39 -0
- package/src/grammar/theme.d.ts +261 -0
- package/src/grammar/theme.test.ts +105 -0
- package/src/grammar/theme.ts +490 -0
- package/src/heatmap/cpu.ts +109 -0
- package/src/heatmap/gpu.ts +565 -0
- package/src/heatmap/types.ts +177 -0
- package/src/heatmap.browser.test.ts +308 -0
- package/src/heatmap.test.ts +320 -0
- package/src/heatmap.ts +123 -0
- package/src/index.d.ts +1 -0
- package/src/index.ts +8 -0
- package/src/interactions.d.ts +48 -0
- package/src/interactions.test.ts +226 -0
- package/src/interactions.ts +394 -0
- package/src/layout/box.d.ts +48 -0
- package/src/layout/box.test.ts +107 -0
- package/src/layout/box.ts +143 -0
- package/src/legend.d.ts +115 -0
- package/src/legend.ts +422 -0
- package/src/marks/curve.d.ts +43 -0
- package/src/marks/curve.ts +244 -0
- package/src/marks/stack.d.ts +53 -0
- package/src/marks/stack.ts +184 -0
- package/src/marks.d.ts +273 -0
- package/src/marks.test.ts +541 -0
- package/src/marks.ts +1292 -0
- package/src/navigator.test.ts +174 -0
- package/src/navigator.ts +393 -0
- package/src/range-presets.d.ts +113 -0
- package/src/range-presets.test.ts +345 -0
- package/src/range-presets.ts +349 -0
- package/src/scales.d.ts +98 -0
- package/src/scales.test.ts +103 -0
- package/src/scales.ts +695 -0
- package/src/stats/index.d.ts +200 -0
- package/src/stats/index.test.ts +349 -0
- package/src/stats/index.ts +740 -0
- package/src/stats/regression.d.ts +38 -0
- package/src/stats/regression.test.ts +56 -0
- package/src/stats/regression.ts +396 -0
- package/src/stats/rolling-window.d.ts +55 -0
- package/src/stats/rolling-window.test.ts +237 -0
- package/src/stats/rolling-window.ts +256 -0
- package/src/test-setup.ts +19 -0
- package/src/viewport/axis-state.d.ts +72 -0
- package/src/viewport/axis-state.ts +476 -0
- package/src/viewport.d.ts +170 -0
- package/src/viewport.test.ts +363 -0
- package/src/viewport.ts +510 -0
|
@@ -0,0 +1,1233 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Chart compile pipeline
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Pure function: takes a frozen ChartConfig + data + three target Layers and
|
|
5
|
+
// emits axes, marks, title, and legend. Used by both the WebGPU mount loop
|
|
6
|
+
// (mount.ts) and the static SVG export (svg.ts).
|
|
7
|
+
|
|
8
|
+
import type { Color, Frame, GlyphAtlas, Layer, Padding } from "insomni";
|
|
9
|
+
import { truncateToWidth, type AxisOptions } from "../axis.ts";
|
|
10
|
+
import type { ContinuousPalette } from "../colors.ts";
|
|
11
|
+
import { colorBar } from "../colorbar.ts";
|
|
12
|
+
import { placeable, stack, type Placeable } from "../layout/box.ts";
|
|
13
|
+
import { renderAnnotations, type AnnotationSpec } from "./annotations.ts";
|
|
14
|
+
import {
|
|
15
|
+
computeFacetLayout,
|
|
16
|
+
groupForFacet,
|
|
17
|
+
renderFacetStrip,
|
|
18
|
+
type FacetPanel,
|
|
19
|
+
type FacetSpec,
|
|
20
|
+
} from "./facet.ts";
|
|
21
|
+
import {
|
|
22
|
+
legend as legendBuilder,
|
|
23
|
+
pointSwatch,
|
|
24
|
+
type LegendBuilder,
|
|
25
|
+
type PointSwatchSpec,
|
|
26
|
+
type SwatchSpec,
|
|
27
|
+
} from "../legend.ts";
|
|
28
|
+
import { resolveAes, type Aes, type ResolvedAes } from "./aes.ts";
|
|
29
|
+
import type {
|
|
30
|
+
AxesSpec,
|
|
31
|
+
AxisSpec,
|
|
32
|
+
LegendInsidePosition,
|
|
33
|
+
LegendMergeChannel,
|
|
34
|
+
LegendSpec,
|
|
35
|
+
TitleSpec,
|
|
36
|
+
} from "./chart.ts";
|
|
37
|
+
import { frameBoundCoord, type Coord } from "./coord.ts";
|
|
38
|
+
import type { GrammarTransitions } from "./interactions/transitions.ts";
|
|
39
|
+
import { DEFAULT_CHART_HEIGHT, DEFAULT_CHART_WIDTH, LAYOUT_GAP } from "./constants.ts";
|
|
40
|
+
import type { Geom, PositionScaleHint, RangeHints, ScaleBundle } from "./geoms/index.ts";
|
|
41
|
+
import { geomEmphasisBase } from "./geoms/emphasis.ts";
|
|
42
|
+
import { computeLayout, type SlotMap } from "./layout.ts";
|
|
43
|
+
import { isSignal, type Signal } from "insomni/reactivity";
|
|
44
|
+
import {
|
|
45
|
+
buildAlphaScale,
|
|
46
|
+
buildBorderStyleScale,
|
|
47
|
+
buildCategoricalColorScale,
|
|
48
|
+
buildColorScale,
|
|
49
|
+
buildOverlayGlyphScale,
|
|
50
|
+
buildPositionScale,
|
|
51
|
+
buildShapeScale,
|
|
52
|
+
buildSizeScale,
|
|
53
|
+
numericExtent,
|
|
54
|
+
type AlphaScaleOptions,
|
|
55
|
+
type BorderStyleScaleOptions,
|
|
56
|
+
type ColorScaleOptions,
|
|
57
|
+
type NumericPositionScaleOptions,
|
|
58
|
+
type OverlayGlyphScaleOptions,
|
|
59
|
+
type PositionScaleOptions,
|
|
60
|
+
type ShapeScaleOptions,
|
|
61
|
+
type SizeScaleOptions,
|
|
62
|
+
} from "./scales.ts";
|
|
63
|
+
import { type Theme } from "./theme.ts";
|
|
64
|
+
import { resolveTextColor } from "./accessibility.ts";
|
|
65
|
+
import { arraysEqual } from "./equality.ts";
|
|
66
|
+
|
|
67
|
+
export interface ChartConfig<T> {
|
|
68
|
+
data: readonly T[] | Signal<readonly T[]>;
|
|
69
|
+
width: number | undefined;
|
|
70
|
+
height: number | undefined;
|
|
71
|
+
background: Color | undefined;
|
|
72
|
+
padding: Padding;
|
|
73
|
+
framePadding: { top: number; right: number; bottom: number; left: number };
|
|
74
|
+
device: GPUDevice | undefined;
|
|
75
|
+
externalAtlas: GlyphAtlas | undefined;
|
|
76
|
+
theme: Theme;
|
|
77
|
+
layers: readonly Geom<T>[];
|
|
78
|
+
axes: AxesSpec;
|
|
79
|
+
titles: TitleSpec;
|
|
80
|
+
legend: LegendSpec;
|
|
81
|
+
scaleOverrides: {
|
|
82
|
+
x?: PositionScaleOptions;
|
|
83
|
+
y?: PositionScaleOptions;
|
|
84
|
+
color?: ColorScaleOptions<unknown>;
|
|
85
|
+
size?: SizeScaleOptions;
|
|
86
|
+
alpha?: AlphaScaleOptions;
|
|
87
|
+
shape?: ShapeScaleOptions;
|
|
88
|
+
borderStyle?: BorderStyleScaleOptions;
|
|
89
|
+
overlayGlyph?: OverlayGlyphScaleOptions;
|
|
90
|
+
};
|
|
91
|
+
annotations: readonly AnnotationSpec[];
|
|
92
|
+
facet?: FacetSpec<T>;
|
|
93
|
+
/**
|
|
94
|
+
* Coordinate system. Defaults to `coordCartesian()`. Threaded into
|
|
95
|
+
* `CompileContext.coord` and used to dispatch axis rendering.
|
|
96
|
+
*/
|
|
97
|
+
coord: Coord;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface PipelineOutput<T = unknown> {
|
|
101
|
+
scales: ScaleBundle;
|
|
102
|
+
/** Hit-test contributions from any geom that implements `compileHitTest`. */
|
|
103
|
+
hitTests: import("./geoms/types.ts").CompiledHitTest<T>[];
|
|
104
|
+
/**
|
|
105
|
+
* Hover-focus decorators from geoms that implement `hoverDecoration` (point).
|
|
106
|
+
* The mount draws these into the overlay layer on hover — a cheap path that
|
|
107
|
+
* avoids recompiling the baked marks. Empty for geoms whose hover treatment
|
|
108
|
+
* is global (dim-others), which the mount handles via a marks re-bake.
|
|
109
|
+
* Faceted charts collect one decorator per (panel, geom), each closing over
|
|
110
|
+
* its panel's rows (P6-T3).
|
|
111
|
+
*/
|
|
112
|
+
hoverDecorators: import("./geoms/types.ts").GeomHoverDecorator[];
|
|
113
|
+
/**
|
|
114
|
+
* Emphasis-key resolvers from dim-participating geoms (P5-T3). The mount maps
|
|
115
|
+
* an active hover hit to the geom's namespaced focused key and drives the
|
|
116
|
+
* core's animated GPU dim. Empty for charts with no dim geom. Faceted charts
|
|
117
|
+
* collect one resolver per (panel, geom) with a disjoint per-panel key band
|
|
118
|
+
* (P6-T3), so hovering one panel never dims another.
|
|
119
|
+
*/
|
|
120
|
+
emphasisResolvers: import("./geoms/emphasis.ts").EmphasisResolver[];
|
|
121
|
+
/**
|
|
122
|
+
* Inset chart frame after outer padding — the area available to axes, slots,
|
|
123
|
+
* and the plot panel. Used to clip the hud layer so overlay marks (e.g. tip
|
|
124
|
+
* labels) don't bleed into the outer padding region.
|
|
125
|
+
*/
|
|
126
|
+
outerFrame: Frame;
|
|
127
|
+
/**
|
|
128
|
+
* Plot drawing region in absolute element-CSS pixels (top-left of the plot
|
|
129
|
+
* frame, matching `Frame.topLeft`). For faceted charts this is the union
|
|
130
|
+
* of all panel frames — wide enough to enclose every panel — used by the
|
|
131
|
+
* crosshair to clip its guide line to the marks region.
|
|
132
|
+
*/
|
|
133
|
+
plotFrame: Frame;
|
|
134
|
+
/**
|
|
135
|
+
* Position-scale range insets in CSS px (`framePadding` + per-geom
|
|
136
|
+
* `prepareRange` reservations). The position scales' pixel range is
|
|
137
|
+
* `[reserve.left, plotFrame.width - reserve.right]` (X) and
|
|
138
|
+
* `[plotFrame.height - reserve.bottom, reserve.top]` (Y). The mount feeds
|
|
139
|
+
* this into the data viewport via `setFrame(plotFrame, reserve)` so
|
|
140
|
+
* `dataToScreen` matches where marks render.
|
|
141
|
+
*/
|
|
142
|
+
reserve: {
|
|
143
|
+
readonly left: number;
|
|
144
|
+
readonly right: number;
|
|
145
|
+
readonly top: number;
|
|
146
|
+
readonly bottom: number;
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* Per-panel plot frames for faceted charts (one entry per panel, in render
|
|
150
|
+
* order). Empty / undefined for non-faceted charts. Used by the crosshair
|
|
151
|
+
* to clip its guide line to the active panel rather than the chart-wide
|
|
152
|
+
* union frame. Coordinates match `plotFrame` (absolute element-CSS px).
|
|
153
|
+
*/
|
|
154
|
+
panelFrames?: readonly Frame[];
|
|
155
|
+
/** Categorical legend builder and its placed origin (absolute CSS px). */
|
|
156
|
+
legend?: {
|
|
157
|
+
builder: import("../legend.ts").LegendBuilder;
|
|
158
|
+
origin: { x: number; y: number };
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function currentData<T>(data: readonly T[] | Signal<readonly T[]>): readonly T[] {
|
|
163
|
+
return isSignal<readonly T[]>(data) ? data.get() : data;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Optional tail bundle for {@link runPipeline}. Required core args
|
|
168
|
+
* (`config`, `data`, layers, `atlas`) stay positional; everything that varies
|
|
169
|
+
* per-frame lives here so call sites don't drift when fields are added.
|
|
170
|
+
*/
|
|
171
|
+
export interface RunPipelineOptions<T> {
|
|
172
|
+
hovered?: import("./geoms/types.ts").HoveredHit | null;
|
|
173
|
+
selected?: readonly import("./geoms/types.ts").HoveredHit[];
|
|
174
|
+
hidden?: ReadonlySet<string>;
|
|
175
|
+
legendDimAlpha?: number;
|
|
176
|
+
transitions?: GrammarTransitions;
|
|
177
|
+
transitionKey?: (datum: T, index: number) => string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function runPipeline<T>(
|
|
181
|
+
config: ChartConfig<T>,
|
|
182
|
+
data: readonly T[],
|
|
183
|
+
axisLayer: Layer,
|
|
184
|
+
marksLayer: Layer,
|
|
185
|
+
hudLayer: Layer,
|
|
186
|
+
atlas: GlyphAtlas | undefined,
|
|
187
|
+
options: RunPipelineOptions<T> = {},
|
|
188
|
+
): PipelineOutput<T> {
|
|
189
|
+
const { hovered = null, selected, hidden, legendDimAlpha, transitions, transitionKey } = options;
|
|
190
|
+
const { theme, axes, titles, legend, layers } = config;
|
|
191
|
+
axisLayer.clear();
|
|
192
|
+
marksLayer.clear();
|
|
193
|
+
hudLayer.clear();
|
|
194
|
+
|
|
195
|
+
// ---- 1. Resolve channel aesthetics across all layers ------------------
|
|
196
|
+
// We need samples for axis pre-measurement and union domains for scales.
|
|
197
|
+
type ChannelData = { aes: ResolvedAes<T, unknown>; values: unknown[] };
|
|
198
|
+
const xColumns: ChannelData[] = [];
|
|
199
|
+
const yColumns: ChannelData[] = [];
|
|
200
|
+
const colorColumns: ChannelData[] = [];
|
|
201
|
+
const sizeColumns: ChannelData[] = [];
|
|
202
|
+
const alphaColumns: ChannelData[] = [];
|
|
203
|
+
const shapeColumns: ChannelData[] = [];
|
|
204
|
+
const borderStyleColumns: ChannelData[] = [];
|
|
205
|
+
const overlayGlyphColumns: ChannelData[] = [];
|
|
206
|
+
const colorLayers: { aes: ResolvedAes<T, unknown>; geom: Geom<T> }[] = [];
|
|
207
|
+
let xHint: PositionScaleHint | undefined;
|
|
208
|
+
let yHint: PositionScaleHint | undefined;
|
|
209
|
+
// When a geom declares array-shaped x or y (multi-series, e.g. stacked bar),
|
|
210
|
+
// the keys themselves are the color domain — used to drive the auto-legend
|
|
211
|
+
// and the geom's series fills.
|
|
212
|
+
let implicitSeries: { keys: readonly string[]; geom: Geom<T> } | undefined;
|
|
213
|
+
|
|
214
|
+
// Resolve the position channel for one axis. Single-key columns become a
|
|
215
|
+
// regular accessor; array-of-keys columns sum across the named columns
|
|
216
|
+
// (stacked layout) and seed `implicitSeries` for the auto-legend.
|
|
217
|
+
function extractPositionColumn(
|
|
218
|
+
raw: unknown,
|
|
219
|
+
geom: Geom<T>,
|
|
220
|
+
hasExplicitColor: boolean,
|
|
221
|
+
): ChannelData {
|
|
222
|
+
if (Array.isArray(raw)) {
|
|
223
|
+
const keys = raw as readonly string[];
|
|
224
|
+
const totals = rowSums(data, keys);
|
|
225
|
+
const aes: ResolvedAes<T, unknown> = {
|
|
226
|
+
kind: "accessor",
|
|
227
|
+
fn: ((_d: T, i: number) => totals[i]) as (d: T, i: number) => unknown,
|
|
228
|
+
};
|
|
229
|
+
// Multi-series array x/y has an implicit color domain = keys so the
|
|
230
|
+
// auto-legend can label the series. Skip if the user wired their own
|
|
231
|
+
// explicit color channel.
|
|
232
|
+
if (!hasExplicitColor && !implicitSeries) {
|
|
233
|
+
implicitSeries = { keys: [...keys], geom };
|
|
234
|
+
}
|
|
235
|
+
return { aes, values: totals };
|
|
236
|
+
}
|
|
237
|
+
const aes = resolveAes<T, unknown>(raw as Aes<T, unknown>);
|
|
238
|
+
return { aes, values: data.map((d, i) => aes.fn(d, i)) };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (const geom of layers) {
|
|
242
|
+
const c = geom.channels;
|
|
243
|
+
const hints = geom.scaleHints;
|
|
244
|
+
if (hints?.x) xHint = mergeHint(xHint, hints.x);
|
|
245
|
+
if (hints?.y) yHint = mergeHint(yHint, hints.y);
|
|
246
|
+
const prepared = geom.prepareDomain?.(data);
|
|
247
|
+
if (prepared?.x) xHint = mergeHint(xHint, prepared.x);
|
|
248
|
+
if (prepared?.y) yHint = mergeHint(yHint, prepared.y);
|
|
249
|
+
const hasExplicitColor = c.color !== undefined;
|
|
250
|
+
if (c.x !== undefined) {
|
|
251
|
+
xColumns.push(extractPositionColumn(c.x, geom, hasExplicitColor));
|
|
252
|
+
}
|
|
253
|
+
if (c.y !== undefined) {
|
|
254
|
+
yColumns.push(extractPositionColumn(c.y, geom, hasExplicitColor));
|
|
255
|
+
}
|
|
256
|
+
if (c.color !== undefined) {
|
|
257
|
+
const aes = resolveAes<T, unknown>(c.color as Aes<T, unknown>);
|
|
258
|
+
colorColumns.push({ aes, values: data.map((d, i) => aes.fn(d, i)) });
|
|
259
|
+
colorLayers.push({ aes, geom });
|
|
260
|
+
}
|
|
261
|
+
if (c.size !== undefined) {
|
|
262
|
+
const aes = resolveAes<T, number>(c.size as Aes<T, number>);
|
|
263
|
+
sizeColumns.push({
|
|
264
|
+
aes: aes as ResolvedAes<T, unknown>,
|
|
265
|
+
values: data.map((d, i) => aes.fn(d, i)),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
if (c.alpha !== undefined) {
|
|
269
|
+
const aes = resolveAes<T, number>(c.alpha as Aes<T, number>);
|
|
270
|
+
alphaColumns.push({
|
|
271
|
+
aes: aes as ResolvedAes<T, unknown>,
|
|
272
|
+
values: data.map((d, i) => aes.fn(d, i)),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
if (c.shape !== undefined) {
|
|
276
|
+
const aes = resolveAes<T, unknown>(c.shape as Aes<T, unknown>);
|
|
277
|
+
shapeColumns.push({ aes, values: data.map((d, i) => aes.fn(d, i)) });
|
|
278
|
+
}
|
|
279
|
+
if (c.borderStyle !== undefined) {
|
|
280
|
+
const aes = resolveAes<T, unknown>(c.borderStyle as Aes<T, unknown>);
|
|
281
|
+
borderStyleColumns.push({ aes, values: data.map((d, i) => aes.fn(d, i)) });
|
|
282
|
+
}
|
|
283
|
+
if (c.overlayGlyph !== undefined) {
|
|
284
|
+
const aes = resolveAes<T, unknown>(c.overlayGlyph as Aes<T, unknown>);
|
|
285
|
+
overlayGlyphColumns.push({ aes, values: data.map((d, i) => aes.fn(d, i)) });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const xSample = (xColumns[0]?.values ?? []) as readonly (number | string | Date)[];
|
|
290
|
+
const ySample = (yColumns[0]?.values ?? []) as readonly (number | string | Date)[];
|
|
291
|
+
|
|
292
|
+
// ---- 2. Build non-position scales (pre-layout) ------------------------
|
|
293
|
+
// Color/size/alpha/shape scales don't depend on plot pixels — only x/y do.
|
|
294
|
+
// Building them now lets us construct the *real* legend Placeable and
|
|
295
|
+
// measure it for the layout slot, instead of guessing pixel reservations.
|
|
296
|
+
function buildNonPositionScales() {
|
|
297
|
+
const colorScale = colorColumns[0]
|
|
298
|
+
? buildColorScale(colorColumns[0].aes, data, theme, config.scaleOverrides.color)
|
|
299
|
+
: implicitSeries
|
|
300
|
+
? buildCategoricalColorScale(implicitSeries.keys, theme, config.scaleOverrides.color)
|
|
301
|
+
: undefined;
|
|
302
|
+
const sizeScale = sizeColumns[0]
|
|
303
|
+
? buildSizeScale(
|
|
304
|
+
sizeColumns[0].aes as ResolvedAes<T, number>,
|
|
305
|
+
data,
|
|
306
|
+
config.scaleOverrides.size,
|
|
307
|
+
)
|
|
308
|
+
: undefined;
|
|
309
|
+
const alphaScale = alphaColumns[0]
|
|
310
|
+
? buildAlphaScale(
|
|
311
|
+
alphaColumns[0].aes as ResolvedAes<T, number>,
|
|
312
|
+
data,
|
|
313
|
+
config.scaleOverrides.alpha,
|
|
314
|
+
)
|
|
315
|
+
: undefined;
|
|
316
|
+
const shapeScale = shapeColumns[0]
|
|
317
|
+
? buildShapeScale(shapeColumns[0].aes, data, config.scaleOverrides.shape)
|
|
318
|
+
: undefined;
|
|
319
|
+
const borderStyleScale = borderStyleColumns[0]
|
|
320
|
+
? buildBorderStyleScale(borderStyleColumns[0].aes, data, config.scaleOverrides.borderStyle)
|
|
321
|
+
: undefined;
|
|
322
|
+
const overlayGlyphScale = overlayGlyphColumns[0]
|
|
323
|
+
? buildOverlayGlyphScale(overlayGlyphColumns[0].aes, data, config.scaleOverrides.overlayGlyph)
|
|
324
|
+
: undefined;
|
|
325
|
+
return { colorScale, sizeScale, alphaScale, shapeScale, borderStyleScale, overlayGlyphScale };
|
|
326
|
+
}
|
|
327
|
+
const { colorScale, sizeScale, alphaScale, shapeScale, borderStyleScale, overlayGlyphScale } =
|
|
328
|
+
buildNonPositionScales();
|
|
329
|
+
|
|
330
|
+
// ---- 3. Build legend + title Placeables; compute slot reservations ----
|
|
331
|
+
const xAxisOptions = makeAxisOptions(axes.x, theme, atlas);
|
|
332
|
+
const yAxisOptions = makeAxisOptions(axes.y, theme, atlas);
|
|
333
|
+
|
|
334
|
+
const titleResolved = resolveTitleEntry(titles.title);
|
|
335
|
+
const subtitleResolved = resolveTitleEntry(titles.subtitle);
|
|
336
|
+
const titleBlock =
|
|
337
|
+
atlas && (titleResolved || subtitleResolved)
|
|
338
|
+
? buildTitleBlock(atlas, theme, titleResolved, subtitleResolved)
|
|
339
|
+
: undefined;
|
|
340
|
+
|
|
341
|
+
const hasLegend = colorLayers.length > 0 || implicitSeries !== undefined;
|
|
342
|
+
const isContinuousLegend =
|
|
343
|
+
colorScale !== undefined &&
|
|
344
|
+
(colorScale.type === "continuous" || colorScale.type === "diverging");
|
|
345
|
+
const showLegend =
|
|
346
|
+
atlas !== undefined && legend.show !== false && hasLegend && colorScale !== undefined;
|
|
347
|
+
|
|
348
|
+
const inside = isInsidePosition(legend.position) ? legend.position : undefined;
|
|
349
|
+
const outerLegendPosition: "top" | "right" | "bottom" | "left" | undefined = inside
|
|
350
|
+
? undefined
|
|
351
|
+
: ((legend.position as "top" | "right" | "bottom" | "left" | undefined) ??
|
|
352
|
+
(isContinuousLegend ? "right" : "top"));
|
|
353
|
+
|
|
354
|
+
const colorBarLength = legend.length ?? 160;
|
|
355
|
+
const colorBarThickness = legend.thickness ?? 12;
|
|
356
|
+
|
|
357
|
+
const legendBuilt: LegendBuilder | undefined = showLegend
|
|
358
|
+
? buildLegend({
|
|
359
|
+
atlas: atlas!,
|
|
360
|
+
theme,
|
|
361
|
+
colorScale,
|
|
362
|
+
colorLayers,
|
|
363
|
+
implicitSeries,
|
|
364
|
+
legendSpec: legend,
|
|
365
|
+
orientation:
|
|
366
|
+
outerLegendPosition === "right" || outerLegendPosition === "left"
|
|
367
|
+
? "vertical"
|
|
368
|
+
: "horizontal",
|
|
369
|
+
colorBarLength,
|
|
370
|
+
colorBarThickness,
|
|
371
|
+
hidden,
|
|
372
|
+
dimAlpha: legendDimAlpha,
|
|
373
|
+
scales: {
|
|
374
|
+
color: colorScale,
|
|
375
|
+
size: sizeScale,
|
|
376
|
+
alpha: alphaScale,
|
|
377
|
+
shape: shapeScale,
|
|
378
|
+
borderStyle: borderStyleScale,
|
|
379
|
+
overlayGlyph: overlayGlyphScale,
|
|
380
|
+
},
|
|
381
|
+
})
|
|
382
|
+
: undefined;
|
|
383
|
+
|
|
384
|
+
// Slot reservations: top slot may stack title + legend (when legend on top);
|
|
385
|
+
// side legends consume right/left slot; bottom legend consumes bottom slot.
|
|
386
|
+
// Inside-plot legends consume no slot.
|
|
387
|
+
const titleSize = titleBlock?.measure();
|
|
388
|
+
const legendSize = legendBuilt?.measure();
|
|
389
|
+
|
|
390
|
+
const slots: SlotMap = {};
|
|
391
|
+
if (outerLegendPosition === "top" && legendSize) {
|
|
392
|
+
const totalH =
|
|
393
|
+
(titleSize?.height ?? 0) + (titleSize ? LAYOUT_GAP.titleToLegend : 0) + legendSize.height;
|
|
394
|
+
const totalW = Math.max(titleSize?.width ?? 0, legendSize.width);
|
|
395
|
+
slots.top = { width: totalW, height: totalH };
|
|
396
|
+
} else if (titleSize) {
|
|
397
|
+
slots.top = { width: titleSize.width, height: titleSize.height };
|
|
398
|
+
}
|
|
399
|
+
if (outerLegendPosition === "right" && legendSize) {
|
|
400
|
+
slots.right = { width: legendSize.width, height: legendSize.height };
|
|
401
|
+
}
|
|
402
|
+
if (outerLegendPosition === "bottom" && legendSize) {
|
|
403
|
+
slots.bottom = { width: legendSize.width, height: legendSize.height };
|
|
404
|
+
}
|
|
405
|
+
if (outerLegendPosition === "left" && legendSize) {
|
|
406
|
+
slots.left = { width: legendSize.width, height: legendSize.height };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const layout = computeLayout({
|
|
410
|
+
width: config.width ?? DEFAULT_CHART_WIDTH,
|
|
411
|
+
height: config.height ?? DEFAULT_CHART_HEIGHT,
|
|
412
|
+
padding: config.padding,
|
|
413
|
+
theme,
|
|
414
|
+
atlas,
|
|
415
|
+
xSample,
|
|
416
|
+
ySample,
|
|
417
|
+
xAxisOptions: xAxisOptions as AxisOptions<unknown>,
|
|
418
|
+
yAxisOptions: yAxisOptions as AxisOptions<unknown>,
|
|
419
|
+
slots,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const plotFrame = layout.plot;
|
|
423
|
+
|
|
424
|
+
// ---- 2b. Collect range reservations (post-layout) --------------------
|
|
425
|
+
// Geoms whose visual extent overshoots their data footprint — ridgeline
|
|
426
|
+
// rows that rise into the row above — request pixel reservations here so
|
|
427
|
+
// the band/numeric scale built next sits inside a shrunken range and the
|
|
428
|
+
// axis ticks follow.
|
|
429
|
+
let rangeHints: RangeHints = {};
|
|
430
|
+
for (const geom of layers) {
|
|
431
|
+
const r = geom.prepareRange?.({
|
|
432
|
+
data,
|
|
433
|
+
plot: plotFrame,
|
|
434
|
+
scaleOptions: { x: config.scaleOverrides.x, y: config.scaleOverrides.y },
|
|
435
|
+
theme,
|
|
436
|
+
atlas,
|
|
437
|
+
});
|
|
438
|
+
if (r) rangeHints = mergeRangeHints(rangeHints, r);
|
|
439
|
+
}
|
|
440
|
+
const fp = config.framePadding;
|
|
441
|
+
const reserveLeft = (rangeHints.left ?? 0) + fp.left;
|
|
442
|
+
const reserveRight = (rangeHints.right ?? 0) + fp.right;
|
|
443
|
+
const reserveTop = (rangeHints.top ?? 0) + fp.top;
|
|
444
|
+
const reserveBottom = (rangeHints.bottom ?? 0) + fp.bottom;
|
|
445
|
+
const xRange: [number, number] = [reserveLeft, plotFrame.width - reserveRight];
|
|
446
|
+
const yRange: [number, number] = [plotFrame.height - reserveBottom, reserveTop];
|
|
447
|
+
|
|
448
|
+
// ---- 4. Build position scales (need plot pixels) ---------------------
|
|
449
|
+
const xScale = xColumns[0]
|
|
450
|
+
? buildPositionScale(
|
|
451
|
+
xColumns[0].aes,
|
|
452
|
+
data,
|
|
453
|
+
xRange,
|
|
454
|
+
applyHint(xColumns[0].values, config.scaleOverrides.x, xHint),
|
|
455
|
+
)
|
|
456
|
+
: buildPositionScale(
|
|
457
|
+
resolveAes<T, unknown>(((_d: T, i: number) => i) as Aes<T, unknown>),
|
|
458
|
+
data,
|
|
459
|
+
xRange,
|
|
460
|
+
);
|
|
461
|
+
const yScale = yColumns[0]
|
|
462
|
+
? buildPositionScale(
|
|
463
|
+
yColumns[0].aes,
|
|
464
|
+
data,
|
|
465
|
+
yRange,
|
|
466
|
+
applyHint(yColumns[0].values, config.scaleOverrides.y, yHint),
|
|
467
|
+
)
|
|
468
|
+
: buildPositionScale(
|
|
469
|
+
resolveAes<T, unknown>(((_d: T, i: number) => i) as Aes<T, unknown>),
|
|
470
|
+
data,
|
|
471
|
+
yRange,
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
const scales: ScaleBundle = {
|
|
475
|
+
x: xScale,
|
|
476
|
+
y: yScale,
|
|
477
|
+
color: colorScale,
|
|
478
|
+
size: sizeScale,
|
|
479
|
+
alpha: alphaScale,
|
|
480
|
+
shape: shapeScale,
|
|
481
|
+
borderStyle: borderStyleScale,
|
|
482
|
+
overlayGlyph: overlayGlyphScale,
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// ---- 4. Render axes + marks ------------------------------------------
|
|
486
|
+
const hitTests: import("./geoms/types.ts").CompiledHitTest<T>[] = [];
|
|
487
|
+
const hoverDecorators: import("./geoms/types.ts").GeomHoverDecorator[] = [];
|
|
488
|
+
const emphasisResolvers: import("./geoms/emphasis.ts").EmphasisResolver[] = [];
|
|
489
|
+
const panelFrames: Frame[] = [];
|
|
490
|
+
if (config.facet) {
|
|
491
|
+
renderFacets({
|
|
492
|
+
facet: config.facet,
|
|
493
|
+
data,
|
|
494
|
+
layers,
|
|
495
|
+
plotFrame,
|
|
496
|
+
sharedScales: scales,
|
|
497
|
+
theme,
|
|
498
|
+
atlas,
|
|
499
|
+
coord: config.coord,
|
|
500
|
+
xColumns,
|
|
501
|
+
yColumns,
|
|
502
|
+
xAxisOptions: xAxisOptions as AxisOptions<unknown>,
|
|
503
|
+
yAxisOptions: yAxisOptions as AxisOptions<unknown>,
|
|
504
|
+
axisLayer,
|
|
505
|
+
marksLayer,
|
|
506
|
+
hudLayer,
|
|
507
|
+
reserve: { left: reserveLeft, right: reserveRight, top: reserveTop, bottom: reserveBottom },
|
|
508
|
+
hitTests,
|
|
509
|
+
hoverDecorators,
|
|
510
|
+
emphasisResolvers,
|
|
511
|
+
hovered,
|
|
512
|
+
selected,
|
|
513
|
+
hidden,
|
|
514
|
+
transitions,
|
|
515
|
+
transitionKey,
|
|
516
|
+
panelFrames,
|
|
517
|
+
});
|
|
518
|
+
} else {
|
|
519
|
+
renderSinglePanel();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function renderSinglePanel() {
|
|
523
|
+
// Stateful coords (polar) need the plot frame before any `project` or
|
|
524
|
+
// axis call. Cartesian's bindFrame is a no-op.
|
|
525
|
+
config.coord.bindFrame(plotFrame);
|
|
526
|
+
config.coord.renderAxes({
|
|
527
|
+
axisLayer,
|
|
528
|
+
scales,
|
|
529
|
+
plotFrame,
|
|
530
|
+
hasX: xColumns[0] !== undefined,
|
|
531
|
+
hasY: yColumns[0] !== undefined,
|
|
532
|
+
xAxisOptions: xAxisOptions as AxisOptions<unknown>,
|
|
533
|
+
yAxisOptions: yAxisOptions as AxisOptions<unknown>,
|
|
534
|
+
atlas,
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
for (let gi = 0; gi < layers.length; gi++) {
|
|
538
|
+
const geom = layers[gi]!;
|
|
539
|
+
const activeTransition = transitions?.contextFor(gi);
|
|
540
|
+
const ctx = {
|
|
541
|
+
data,
|
|
542
|
+
scales,
|
|
543
|
+
plot: plotFrame,
|
|
544
|
+
theme,
|
|
545
|
+
atlas,
|
|
546
|
+
coord: config.coord,
|
|
547
|
+
hovered,
|
|
548
|
+
selected,
|
|
549
|
+
hidden,
|
|
550
|
+
transitionKey,
|
|
551
|
+
activeTransition,
|
|
552
|
+
// Disjoint per-geom emphasis-key band (P5-T3) for GPU hover dim.
|
|
553
|
+
emphasisBase: geomEmphasisBase(gi),
|
|
554
|
+
};
|
|
555
|
+
const builders = geom.compile(ctx);
|
|
556
|
+
const target = geom.overlay ? hudLayer : marksLayer;
|
|
557
|
+
for (const b of builders) b.addTo(target);
|
|
558
|
+
// Capture frame for transitions (always; storeFrame decides when to update stable store).
|
|
559
|
+
if (transitions && geom.captureFrame) {
|
|
560
|
+
const frame = geom.captureFrame(ctx);
|
|
561
|
+
if (frame) transitions.storeFrame(gi, frame);
|
|
562
|
+
}
|
|
563
|
+
const hits = geom.compileHitTest?.(ctx);
|
|
564
|
+
if (hits) hitTests.push(hits);
|
|
565
|
+
const deco = geom.hoverDecoration?.(ctx);
|
|
566
|
+
if (deco) hoverDecorators.push(deco);
|
|
567
|
+
const emphRes = geom.emphasisResolution?.(ctx);
|
|
568
|
+
if (emphRes) emphasisResolvers.push(emphRes);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ---- 6. Title + legend -----------------------------------------------
|
|
573
|
+
// Title block draws at the top slot's left edge. When the legend is also
|
|
574
|
+
// top-positioned, it draws right-aligned below the title within the same
|
|
575
|
+
// slot — preserving the historical "title left + legend right" layout.
|
|
576
|
+
if (titleBlock) {
|
|
577
|
+
const topAnchor = layout.slots.top ?? { x: layout.outer.x, y: layout.outer.y };
|
|
578
|
+
titleBlock.addTo(hudLayer, { x: topAnchor.x, y: topAnchor.y });
|
|
579
|
+
}
|
|
580
|
+
let legendOriginX: number | undefined;
|
|
581
|
+
let legendOriginY: number | undefined;
|
|
582
|
+
|
|
583
|
+
if (legendBuilt && legendSize) {
|
|
584
|
+
const { originX, originY } = placeLegend(legendSize);
|
|
585
|
+
legendOriginX = originX;
|
|
586
|
+
legendOriginY = originY;
|
|
587
|
+
legendBuilt.addTo(hudLayer, { x: originX, y: originY });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function placeLegend(m: { width: number; height: number }): {
|
|
591
|
+
originX: number;
|
|
592
|
+
originY: number;
|
|
593
|
+
} {
|
|
594
|
+
if (inside) {
|
|
595
|
+
// Inside-plot: x/y are normalized 0..1 in plot frame; justify chooses
|
|
596
|
+
// which corner of the legend bbox lands on that point.
|
|
597
|
+
const jx = inside.justify?.x ?? "start";
|
|
598
|
+
const jy = inside.justify?.y ?? "start";
|
|
599
|
+
const px = plotFrame.x + plotFrame.width * inside.inside.x;
|
|
600
|
+
const py = plotFrame.y + plotFrame.height * inside.inside.y;
|
|
601
|
+
return {
|
|
602
|
+
originX: px - (jx === "end" ? m.width : jx === "center" ? m.width / 2 : 0),
|
|
603
|
+
originY: py - (jy === "end" ? m.height : jy === "center" ? m.height / 2 : 0),
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
if (outerLegendPosition === "top") {
|
|
607
|
+
const top = layout.slots.top!;
|
|
608
|
+
// Right-aligned within top slot. Title lives at top-left; legend at
|
|
609
|
+
// top-right, vertically offset by titleHeight + gap if title present.
|
|
610
|
+
const yOffset = titleSize !== undefined ? titleSize.height + LAYOUT_GAP.titleToLegend : 0;
|
|
611
|
+
return { originX: top.x + top.width - m.width, originY: top.y + yOffset };
|
|
612
|
+
}
|
|
613
|
+
if (outerLegendPosition === "right") {
|
|
614
|
+
const r = layout.slots.right!;
|
|
615
|
+
return { originX: r.x, originY: r.y + Math.max(0, (plotFrame.height - m.height) / 2) };
|
|
616
|
+
}
|
|
617
|
+
if (outerLegendPosition === "left") {
|
|
618
|
+
const l = layout.slots.left!;
|
|
619
|
+
return { originX: l.x, originY: l.y + Math.max(0, (plotFrame.height - m.height) / 2) };
|
|
620
|
+
}
|
|
621
|
+
const b = layout.slots.bottom!;
|
|
622
|
+
return { originX: b.x + b.width / 2 - m.width / 2, originY: b.y };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ---- 7. Free-form annotations ----------------------------------------
|
|
626
|
+
if (config.annotations.length > 0) {
|
|
627
|
+
renderAnnotations(config.annotations, scales, plotFrame, theme, hudLayer);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
scales,
|
|
632
|
+
hitTests,
|
|
633
|
+
hoverDecorators,
|
|
634
|
+
emphasisResolvers,
|
|
635
|
+
outerFrame: layout.outer,
|
|
636
|
+
plotFrame,
|
|
637
|
+
reserve: { left: reserveLeft, right: reserveRight, top: reserveTop, bottom: reserveBottom },
|
|
638
|
+
panelFrames: panelFrames.length > 0 ? panelFrames : undefined,
|
|
639
|
+
legend:
|
|
640
|
+
legendBuilt && legendOriginX !== undefined && legendOriginY !== undefined
|
|
641
|
+
? {
|
|
642
|
+
builder: legendBuilt,
|
|
643
|
+
origin: { x: legendOriginX, y: legendOriginY },
|
|
644
|
+
}
|
|
645
|
+
: undefined,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ---------------------------------------------------------------------------
|
|
650
|
+
// Helpers
|
|
651
|
+
// ---------------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
interface FacetRenderArgs<T> {
|
|
654
|
+
facet: FacetSpec<T>;
|
|
655
|
+
data: readonly T[];
|
|
656
|
+
layers: readonly Geom<T>[];
|
|
657
|
+
plotFrame: import("insomni").Frame;
|
|
658
|
+
sharedScales: ScaleBundle;
|
|
659
|
+
theme: Theme;
|
|
660
|
+
atlas: GlyphAtlas | undefined;
|
|
661
|
+
coord: Coord;
|
|
662
|
+
xColumns: { aes: ResolvedAes<T, unknown>; values: unknown[] }[];
|
|
663
|
+
yColumns: { aes: ResolvedAes<T, unknown>; values: unknown[] }[];
|
|
664
|
+
xAxisOptions: AxisOptions<unknown>;
|
|
665
|
+
yAxisOptions: AxisOptions<unknown>;
|
|
666
|
+
axisLayer: Layer;
|
|
667
|
+
marksLayer: Layer;
|
|
668
|
+
hudLayer: Layer;
|
|
669
|
+
reserve: { left: number; right: number; top: number; bottom: number };
|
|
670
|
+
hitTests: import("./geoms/types.ts").CompiledHitTest<T>[];
|
|
671
|
+
hoverDecorators: import("./geoms/types.ts").GeomHoverDecorator[];
|
|
672
|
+
emphasisResolvers: import("./geoms/emphasis.ts").EmphasisResolver[];
|
|
673
|
+
hovered: import("./geoms/types.ts").HoveredHit | null;
|
|
674
|
+
selected: readonly import("./geoms/types.ts").HoveredHit[] | undefined;
|
|
675
|
+
hidden: ReadonlySet<string> | undefined;
|
|
676
|
+
transitions: GrammarTransitions | undefined;
|
|
677
|
+
transitionKey: ((datum: T, index: number) => string) | undefined;
|
|
678
|
+
panelFrames: Frame[];
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Faceted render — one panel per group key. With `scales: "fixed"` (default)
|
|
683
|
+
* each panel reuses the chart-wide x/y domain but maps it onto the panel's
|
|
684
|
+
* local pixel range, so axes line up across panels. With `scales: "free"`
|
|
685
|
+
* each panel computes its own domain from its rows.
|
|
686
|
+
*/
|
|
687
|
+
function renderFacets<T>(args: FacetRenderArgs<T>): void {
|
|
688
|
+
const groups = groupForFacet(args.facet, args.data);
|
|
689
|
+
if (groups.length === 0) return;
|
|
690
|
+
const layout = computeFacetLayout(args.facet, groups, args.plotFrame);
|
|
691
|
+
const scaleMode = args.facet.scales ?? "fixed";
|
|
692
|
+
|
|
693
|
+
// Emphasis-key namespacing across panels (P6-T3). Keys are global per frame
|
|
694
|
+
// (one emphasis uniform), so every (panel, geom) pair needs a DISJOINT band or
|
|
695
|
+
// hovering panel A would dim panel B's same-index geom. We flatten the 2-D
|
|
696
|
+
// (panelIndex, gi) coordinate into a single effective geom index
|
|
697
|
+
// `panelIndex * geomCount + gi` and feed it through the same `geomEmphasisBase`
|
|
698
|
+
// band formula the non-faceted path uses — no new constants, no per-panel
|
|
699
|
+
// stride. With `geomCount` panels × geoms this can reach the existing
|
|
700
|
+
// `EMPHASIS_MAX_GEOM_INDEX` ceiling, which `geomEmphasisBase` already guards.
|
|
701
|
+
const geomCount = args.layers.length;
|
|
702
|
+
|
|
703
|
+
for (let panelIndex = 0; panelIndex < layout.panels.length; panelIndex++) {
|
|
704
|
+
const panel = layout.panels[panelIndex]!;
|
|
705
|
+
args.panelFrames.push(panel.frame);
|
|
706
|
+
renderFacetStrip(
|
|
707
|
+
args.hudLayer,
|
|
708
|
+
panel as FacetPanel<unknown>,
|
|
709
|
+
args.facet as FacetSpec<unknown>,
|
|
710
|
+
args.theme,
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
const panelData = panel.rows;
|
|
714
|
+
const panelXRange: [number, number] = [
|
|
715
|
+
args.reserve.left,
|
|
716
|
+
panel.frame.width - args.reserve.right,
|
|
717
|
+
];
|
|
718
|
+
const panelYRange: [number, number] = [
|
|
719
|
+
panel.frame.height - args.reserve.bottom,
|
|
720
|
+
args.reserve.top,
|
|
721
|
+
];
|
|
722
|
+
|
|
723
|
+
let panelX = args.sharedScales.x;
|
|
724
|
+
let panelY = args.sharedScales.y;
|
|
725
|
+
if (args.xColumns[0]) {
|
|
726
|
+
// "fixed": pin the inferred domain to the chart-wide one. "free": let
|
|
727
|
+
// each panel compute its own domain from its rows.
|
|
728
|
+
panelX = buildPositionScale(
|
|
729
|
+
args.xColumns[0].aes,
|
|
730
|
+
scaleMode === "fixed" ? args.data : (panelData as readonly T[]),
|
|
731
|
+
panelXRange,
|
|
732
|
+
scaleMode === "fixed" ? shareDomain(panelX) : undefined,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
if (args.yColumns[0]) {
|
|
736
|
+
panelY = buildPositionScale(
|
|
737
|
+
args.yColumns[0].aes,
|
|
738
|
+
scaleMode === "fixed" ? args.data : (panelData as readonly T[]),
|
|
739
|
+
panelYRange,
|
|
740
|
+
scaleMode === "fixed" ? shareDomain(panelY) : undefined,
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const panelScales: ScaleBundle = {
|
|
745
|
+
...args.sharedScales,
|
|
746
|
+
x: panelX,
|
|
747
|
+
y: panelY,
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
// Per-panel coord pinned to this panel's frame. The geoms' deferred
|
|
751
|
+
// `hoverDecoration` closures capture `ctx.coord` and run at hover time —
|
|
752
|
+
// long after the loop has advanced — so they MUST see a panel-stable frame,
|
|
753
|
+
// not the shared coord left bound to the last panel. `frameBoundCoord`
|
|
754
|
+
// re-binds the underlying coord to `panel.frame` on every frame-dependent
|
|
755
|
+
// call (Cartesian no-ops). Synchronous compile/axes use the same instance.
|
|
756
|
+
const panelCoord = frameBoundCoord(args.coord, panel.frame);
|
|
757
|
+
panelCoord.renderAxes({
|
|
758
|
+
axisLayer: args.axisLayer,
|
|
759
|
+
scales: panelScales,
|
|
760
|
+
plotFrame: panel.frame,
|
|
761
|
+
hasX: args.xColumns[0] !== undefined && panel.isBottomRow,
|
|
762
|
+
hasY: args.yColumns[0] !== undefined && panel.isLeftCol,
|
|
763
|
+
xAxisOptions: args.xAxisOptions,
|
|
764
|
+
yAxisOptions: args.yAxisOptions,
|
|
765
|
+
atlas: args.atlas,
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
for (let gi = 0; gi < args.layers.length; gi++) {
|
|
769
|
+
const geom = args.layers[gi]!;
|
|
770
|
+
const activeTransition = args.transitions?.contextFor(gi, panel.key);
|
|
771
|
+
// Flatten (panel, geom) → one effective index so each panel's geom gets a
|
|
772
|
+
// private emphasis band. The resolver/decorator we collect below close over
|
|
773
|
+
// this panel's `data` (panel.rows), and faceted hits carry that same sliced
|
|
774
|
+
// array (CompiledHitTest.data = ctx.data), so the mount's `r.data === hit.data`
|
|
775
|
+
// match stays correct per panel.
|
|
776
|
+
const effectiveGi = panelIndex * geomCount + gi;
|
|
777
|
+
const ctx = {
|
|
778
|
+
data: panelData,
|
|
779
|
+
scales: panelScales,
|
|
780
|
+
plot: panel.frame,
|
|
781
|
+
theme: args.theme,
|
|
782
|
+
atlas: args.atlas,
|
|
783
|
+
coord: panelCoord,
|
|
784
|
+
hovered: args.hovered,
|
|
785
|
+
selected: args.selected,
|
|
786
|
+
hidden: args.hidden,
|
|
787
|
+
transitionKey: args.transitionKey,
|
|
788
|
+
activeTransition,
|
|
789
|
+
emphasisBase: geomEmphasisBase(effectiveGi),
|
|
790
|
+
};
|
|
791
|
+
const builders = geom.compile(ctx);
|
|
792
|
+
const target = geom.overlay ? args.hudLayer : args.marksLayer;
|
|
793
|
+
for (const b of builders) b.addTo(target);
|
|
794
|
+
if (args.transitions && geom.captureFrame) {
|
|
795
|
+
const frame = geom.captureFrame(ctx);
|
|
796
|
+
if (frame) args.transitions.storeFrame(gi, frame, panel.key);
|
|
797
|
+
}
|
|
798
|
+
const hits = geom.compileHitTest?.(ctx);
|
|
799
|
+
if (hits) args.hitTests.push(hits);
|
|
800
|
+
const deco = geom.hoverDecoration?.(ctx);
|
|
801
|
+
if (deco) args.hoverDecorators.push(deco);
|
|
802
|
+
const emphRes = geom.emphasisResolution?.(ctx);
|
|
803
|
+
if (emphRes) args.emphasisResolvers.push(emphRes);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Extract the existing scale's domain into a `PositionScaleOptions` that pins
|
|
810
|
+
* a new scale to the same input range — used to keep faceted panels aligned
|
|
811
|
+
* under `scales: "fixed"`.
|
|
812
|
+
*
|
|
813
|
+
* Exported for unit testing — not part of the public grammar surface.
|
|
814
|
+
*/
|
|
815
|
+
export function shareDomain(scale: ScaleBundle["x"]): PositionScaleOptions {
|
|
816
|
+
const dom = (scale.axisScale as { domain: readonly unknown[] }).domain;
|
|
817
|
+
if (scale.type === "band") {
|
|
818
|
+
return { type: "band", domain: dom as readonly string[] };
|
|
819
|
+
}
|
|
820
|
+
if (scale.type === "time") {
|
|
821
|
+
return { type: "time", domain: dom as readonly [Date, Date] };
|
|
822
|
+
}
|
|
823
|
+
if (scale.type === "log") {
|
|
824
|
+
return { type: "log", domain: dom as readonly [number, number] };
|
|
825
|
+
}
|
|
826
|
+
return {
|
|
827
|
+
type: scale.type === "sqrt" ? "sqrt" : "linear",
|
|
828
|
+
domain: dom as readonly [number, number],
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function rowSums<T>(data: readonly T[], keys: readonly string[]): number[] {
|
|
833
|
+
const out = Array.from<number>({ length: data.length });
|
|
834
|
+
for (let i = 0; i < data.length; i++) {
|
|
835
|
+
const d = data[i] as Record<string, number>;
|
|
836
|
+
let s = 0;
|
|
837
|
+
for (const k of keys) s += d[k] ?? 0;
|
|
838
|
+
out[i] = s;
|
|
839
|
+
}
|
|
840
|
+
return out;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function mergeRangeHints(a: RangeHints, b: RangeHints): RangeHints {
|
|
844
|
+
return {
|
|
845
|
+
top: Math.max(a.top ?? 0, b.top ?? 0) || undefined,
|
|
846
|
+
bottom: Math.max(a.bottom ?? 0, b.bottom ?? 0) || undefined,
|
|
847
|
+
left: Math.max(a.left ?? 0, b.left ?? 0) || undefined,
|
|
848
|
+
right: Math.max(a.right ?? 0, b.right ?? 0) || undefined,
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function mergeHint(a: PositionScaleHint | undefined, b: PositionScaleHint): PositionScaleHint {
|
|
853
|
+
if (!a) return b;
|
|
854
|
+
let extend: readonly [number, number] | undefined;
|
|
855
|
+
if (a.extend && b.extend) {
|
|
856
|
+
extend = [Math.min(a.extend[0], b.extend[0]), Math.max(a.extend[1], b.extend[1])];
|
|
857
|
+
} else {
|
|
858
|
+
extend = a.extend ?? b.extend;
|
|
859
|
+
}
|
|
860
|
+
return {
|
|
861
|
+
domain: a.domain ?? b.domain,
|
|
862
|
+
includeZero: a.includeZero || b.includeZero,
|
|
863
|
+
includeOne: a.includeOne || b.includeOne,
|
|
864
|
+
extend,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function applyHint(
|
|
869
|
+
values: readonly unknown[],
|
|
870
|
+
override: PositionScaleOptions | undefined,
|
|
871
|
+
hint: PositionScaleHint | undefined,
|
|
872
|
+
): PositionScaleOptions | undefined {
|
|
873
|
+
if (!hint) return override;
|
|
874
|
+
if (override?.domain !== undefined) return override;
|
|
875
|
+
if (override?.type === "band" || override?.type === "time") return override;
|
|
876
|
+
|
|
877
|
+
if (hint.domain) {
|
|
878
|
+
// The `type === "time" | "band"` early-returns above leave only numeric
|
|
879
|
+
// scales here, so the hint's numeric domain is valid for the result.
|
|
880
|
+
return { ...override, domain: [hint.domain[0], hint.domain[1]] } as NumericPositionScaleOptions;
|
|
881
|
+
}
|
|
882
|
+
if (!hint.includeZero && !hint.includeOne && !hint.extend) return override;
|
|
883
|
+
|
|
884
|
+
let [lo, hi] = numericExtent(values);
|
|
885
|
+
// numericExtent returns [0, 1] for non-numeric input — preserve the original
|
|
886
|
+
// override in that case rather than fabricating a domain from nothing.
|
|
887
|
+
if (!values.some((v) => typeof v === "number" && Number.isFinite(v))) {
|
|
888
|
+
return override;
|
|
889
|
+
}
|
|
890
|
+
if (hint.includeZero) {
|
|
891
|
+
if (0 < lo) lo = 0;
|
|
892
|
+
if (0 > hi) hi = 0;
|
|
893
|
+
}
|
|
894
|
+
if (hint.includeOne) {
|
|
895
|
+
if (1 < lo) lo = 1;
|
|
896
|
+
if (1 > hi) hi = 1;
|
|
897
|
+
}
|
|
898
|
+
if (hint.extend) {
|
|
899
|
+
if (hint.extend[0] < lo) lo = hint.extend[0];
|
|
900
|
+
if (hint.extend[1] > hi) hi = hint.extend[1];
|
|
901
|
+
}
|
|
902
|
+
if (lo === hi) hi = lo + 1;
|
|
903
|
+
return { ...override, domain: [lo, hi] } as NumericPositionScaleOptions;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function resolveTitleEntry(
|
|
907
|
+
entry: string | { text: string; maxWidth?: number } | undefined,
|
|
908
|
+
): { text: string; maxWidth: number | undefined } | null {
|
|
909
|
+
if (!entry) return null;
|
|
910
|
+
if (typeof entry === "string") {
|
|
911
|
+
return entry.length > 0 ? { text: entry, maxWidth: undefined } : null;
|
|
912
|
+
}
|
|
913
|
+
return entry.text.length > 0 ? { text: entry.text, maxWidth: entry.maxWidth } : null;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Recover the original `ContinuousPalette` from a built color scale. We don't
|
|
918
|
+
* stash it on the scale object today, so reconstruct from the override or the
|
|
919
|
+
* theme. Diverging scales fall back to the theme's diverging palette.
|
|
920
|
+
*/
|
|
921
|
+
function continuousPaletteFromScale(scale: { type: string }, theme: Theme): ContinuousPalette {
|
|
922
|
+
const base = scale.type === "diverging" ? theme.palettes.diverging : theme.palettes.continuous;
|
|
923
|
+
return base.blendSpace === theme.paletteBlendSpace
|
|
924
|
+
? base
|
|
925
|
+
: base.withBlendSpace(theme.paletteBlendSpace);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** @internal Exported for unit tests only. */
|
|
929
|
+
export function makeAxisOptions(
|
|
930
|
+
spec: AxisSpec | undefined,
|
|
931
|
+
theme: Theme,
|
|
932
|
+
atlas: GlyphAtlas | undefined,
|
|
933
|
+
): AxisOptions<unknown> {
|
|
934
|
+
const labelColor = resolveTextColor(theme.axis.labelColor, theme.background, theme, {
|
|
935
|
+
fontSizePx: theme.axis.labelFontSize,
|
|
936
|
+
site: "axis-label",
|
|
937
|
+
});
|
|
938
|
+
const titleColor = resolveTextColor(theme.axis.titleColor, theme.background, theme, {
|
|
939
|
+
fontSizePx: theme.axis.titleFontSize,
|
|
940
|
+
bold: true,
|
|
941
|
+
site: "axis-title",
|
|
942
|
+
});
|
|
943
|
+
return {
|
|
944
|
+
atlas,
|
|
945
|
+
ticks: spec?.ticks ?? "auto",
|
|
946
|
+
title: spec?.title,
|
|
947
|
+
format: spec?.format as (v: unknown) => string,
|
|
948
|
+
gridLines: spec?.gridLines ?? true,
|
|
949
|
+
axisLine: spec?.axisLine ?? true,
|
|
950
|
+
label: spec?.label,
|
|
951
|
+
labelCollision: spec?.labelCollision ?? "auto",
|
|
952
|
+
labelFontSize: theme.axis.labelFontSize,
|
|
953
|
+
labelColor,
|
|
954
|
+
tickColor: theme.axis.color,
|
|
955
|
+
axisLineColor: theme.axis.color,
|
|
956
|
+
gridColor: theme.axis.gridColor,
|
|
957
|
+
titleFontSize: theme.axis.titleFontSize,
|
|
958
|
+
titleColor,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function isInsidePosition(p: LegendSpec["position"]): p is LegendInsidePosition {
|
|
963
|
+
return typeof p === "object" && p !== null && "inside" in p;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Build the chart title block (title + subtitle) as a single Placeable. The
|
|
968
|
+
* block is left-aligned and stacked vertically; either child may be absent.
|
|
969
|
+
*/
|
|
970
|
+
function buildTitleBlock(
|
|
971
|
+
atlas: GlyphAtlas,
|
|
972
|
+
theme: Theme,
|
|
973
|
+
title: { text: string; maxWidth: number | undefined } | null,
|
|
974
|
+
subtitle: { text: string; maxWidth: number | undefined } | null,
|
|
975
|
+
): Placeable | undefined {
|
|
976
|
+
const items: Placeable[] = [];
|
|
977
|
+
if (title) {
|
|
978
|
+
const text = truncateToWidth(atlas, title.text, title.maxWidth, {
|
|
979
|
+
fontSize: theme.title.fontSize,
|
|
980
|
+
fontWeight: theme.title.fontWeight,
|
|
981
|
+
});
|
|
982
|
+
const w = atlas.measureText(text, {
|
|
983
|
+
fontSize: theme.title.fontSize,
|
|
984
|
+
fontWeight: theme.title.fontWeight,
|
|
985
|
+
simple: true,
|
|
986
|
+
}).width;
|
|
987
|
+
const titleColor = resolveTextColor(theme.title.color, theme.background, theme, {
|
|
988
|
+
fontSizePx: theme.title.fontSize,
|
|
989
|
+
bold: true,
|
|
990
|
+
site: "title",
|
|
991
|
+
});
|
|
992
|
+
items.push(
|
|
993
|
+
placeable({ width: w, height: theme.title.fontSize }, (layer, o) => {
|
|
994
|
+
layer.pushText({
|
|
995
|
+
simple: true,
|
|
996
|
+
text,
|
|
997
|
+
x: o.x,
|
|
998
|
+
y: o.y,
|
|
999
|
+
fontSize: theme.title.fontSize,
|
|
1000
|
+
color: titleColor,
|
|
1001
|
+
});
|
|
1002
|
+
}),
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
if (subtitle) {
|
|
1006
|
+
const text = truncateToWidth(atlas, subtitle.text, subtitle.maxWidth, {
|
|
1007
|
+
fontSize: theme.subtitle.fontSize,
|
|
1008
|
+
});
|
|
1009
|
+
const w = atlas.measureText(text, { fontSize: theme.subtitle.fontSize, simple: true }).width;
|
|
1010
|
+
const subColor = resolveTextColor(theme.subtitle.color, theme.background, theme, {
|
|
1011
|
+
fontSizePx: theme.subtitle.fontSize,
|
|
1012
|
+
site: "subtitle",
|
|
1013
|
+
});
|
|
1014
|
+
items.push(
|
|
1015
|
+
placeable({ width: w, height: theme.subtitle.fontSize }, (layer, o) => {
|
|
1016
|
+
layer.pushText({
|
|
1017
|
+
simple: true,
|
|
1018
|
+
text,
|
|
1019
|
+
x: o.x,
|
|
1020
|
+
y: o.y,
|
|
1021
|
+
fontSize: theme.subtitle.fontSize,
|
|
1022
|
+
color: subColor,
|
|
1023
|
+
});
|
|
1024
|
+
}),
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
if (items.length === 0) return undefined;
|
|
1028
|
+
return stack(items, { direction: "vertical", align: "start", gap: LAYOUT_GAP.subtitleGap });
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
type LegendChannelScales = Omit<ScaleBundle, "x" | "y">;
|
|
1032
|
+
|
|
1033
|
+
interface BuildLegendArgs<T> {
|
|
1034
|
+
atlas: GlyphAtlas;
|
|
1035
|
+
theme: Theme;
|
|
1036
|
+
colorScale: NonNullable<ScaleBundle["color"]>;
|
|
1037
|
+
colorLayers: { aes: ResolvedAes<T, unknown>; geom: Geom<T> }[];
|
|
1038
|
+
implicitSeries: { keys: readonly string[]; geom: Geom<T> } | undefined;
|
|
1039
|
+
legendSpec: LegendSpec;
|
|
1040
|
+
orientation: "horizontal" | "vertical";
|
|
1041
|
+
colorBarLength: number;
|
|
1042
|
+
colorBarThickness: number;
|
|
1043
|
+
hidden: ReadonlySet<string> | undefined;
|
|
1044
|
+
dimAlpha: number | undefined;
|
|
1045
|
+
scales: LegendChannelScales;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Build one point-style swatch for a merged legend entry. Resolves each
|
|
1050
|
+
* listed channel's scale at `value` and composes the result into a single
|
|
1051
|
+
* `PointSwatchSpec`. Channels listed in `mergeSet` but lacking an active
|
|
1052
|
+
* scale are silently skipped — the merge declaration is the consumer's
|
|
1053
|
+
* assertion that the channels exist *if* they're configured.
|
|
1054
|
+
*/
|
|
1055
|
+
export function composeMergedPointSwatch(
|
|
1056
|
+
value: unknown,
|
|
1057
|
+
color: Color,
|
|
1058
|
+
scales: LegendChannelScales,
|
|
1059
|
+
mergeSet: ReadonlySet<LegendMergeChannel>,
|
|
1060
|
+
theme: Theme,
|
|
1061
|
+
): PointSwatchSpec {
|
|
1062
|
+
const swatch: PointSwatchSpec = {
|
|
1063
|
+
kind: "point",
|
|
1064
|
+
fill: color,
|
|
1065
|
+
radius: 5,
|
|
1066
|
+
strokeWidth: theme.marks.pointStrokeWidth,
|
|
1067
|
+
};
|
|
1068
|
+
if (mergeSet.has("shape") && scales.shape) {
|
|
1069
|
+
swatch.shape = scales.shape.fn(value);
|
|
1070
|
+
}
|
|
1071
|
+
if (mergeSet.has("borderStyle") && scales.borderStyle) {
|
|
1072
|
+
swatch.borderStyle = scales.borderStyle.fn(value);
|
|
1073
|
+
if (
|
|
1074
|
+
swatch.borderStyle === "open" ||
|
|
1075
|
+
swatch.borderStyle === "dashed" ||
|
|
1076
|
+
swatch.borderStyle === "dotted"
|
|
1077
|
+
) {
|
|
1078
|
+
swatch.stroke = color;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (mergeSet.has("overlayGlyph") && scales.overlayGlyph) {
|
|
1082
|
+
const og = scales.overlayGlyph.fn(value);
|
|
1083
|
+
if (og) swatch.overlayGlyph = og;
|
|
1084
|
+
}
|
|
1085
|
+
if (mergeSet.has("size") && scales.size) {
|
|
1086
|
+
// Size encodes a *numeric* field, so a categorical merge can't tie a
|
|
1087
|
+
// distinct size to each domain entry. Sample at the domain midpoint so
|
|
1088
|
+
// every row gets the same representative radius — communicates "size is
|
|
1089
|
+
// also encoded by this field" without pretending each category has its
|
|
1090
|
+
// own size. Cap to a sensible legend swatch radius.
|
|
1091
|
+
const [d0, d1] = scales.size.domain;
|
|
1092
|
+
const r = scales.size.fn((d0 + d1) / 2) as number;
|
|
1093
|
+
swatch.radius = Math.min(Math.max(r, 3), 9);
|
|
1094
|
+
}
|
|
1095
|
+
return swatch;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Detect which non-color channels share the color scale's categorical domain,
|
|
1100
|
+
* so the legend can fold them into one row per entry without the consumer
|
|
1101
|
+
* spelling out `merge: [...]`. A channel auto-merges when its scale exists AND
|
|
1102
|
+
* its `.domain` is structurally equal to the color domain. `size` is numeric
|
|
1103
|
+
* and never auto-merges; opt in via `legendSpec.merge` if you want it.
|
|
1104
|
+
*
|
|
1105
|
+
* Exported for testing.
|
|
1106
|
+
*/
|
|
1107
|
+
export function autoMergeChannels(
|
|
1108
|
+
colorDomain: readonly unknown[],
|
|
1109
|
+
scales: LegendChannelScales,
|
|
1110
|
+
): LegendMergeChannel[] {
|
|
1111
|
+
const out: LegendMergeChannel[] = [];
|
|
1112
|
+
// An empty color domain has no entries to merge into; equality with an
|
|
1113
|
+
// empty channel domain is meaningless and would produce phantom merges.
|
|
1114
|
+
if (colorDomain.length === 0) return out;
|
|
1115
|
+
if (
|
|
1116
|
+
scales.shape &&
|
|
1117
|
+
scales.shape.domain.length > 0 &&
|
|
1118
|
+
arraysEqual(colorDomain, scales.shape.domain)
|
|
1119
|
+
) {
|
|
1120
|
+
out.push("shape");
|
|
1121
|
+
}
|
|
1122
|
+
if (
|
|
1123
|
+
scales.borderStyle &&
|
|
1124
|
+
scales.borderStyle.domain.length > 0 &&
|
|
1125
|
+
arraysEqual(colorDomain, scales.borderStyle.domain)
|
|
1126
|
+
) {
|
|
1127
|
+
out.push("borderStyle");
|
|
1128
|
+
}
|
|
1129
|
+
if (
|
|
1130
|
+
scales.overlayGlyph &&
|
|
1131
|
+
scales.overlayGlyph.domain.length > 0 &&
|
|
1132
|
+
arraysEqual(colorDomain, scales.overlayGlyph.domain)
|
|
1133
|
+
) {
|
|
1134
|
+
out.push("overlayGlyph");
|
|
1135
|
+
}
|
|
1136
|
+
return out;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Build the appropriate legend builder (categorical `legend()` vs continuous
|
|
1141
|
+
* `colorBar()`) from a finalized color scale. Returned as a `Placeable` —
|
|
1142
|
+
* the chart pipeline measures it before layout to size the slot exactly.
|
|
1143
|
+
*/
|
|
1144
|
+
function buildLegend<T>(args: BuildLegendArgs<T>): LegendBuilder {
|
|
1145
|
+
const {
|
|
1146
|
+
atlas,
|
|
1147
|
+
theme,
|
|
1148
|
+
colorScale,
|
|
1149
|
+
colorLayers,
|
|
1150
|
+
implicitSeries,
|
|
1151
|
+
legendSpec,
|
|
1152
|
+
orientation,
|
|
1153
|
+
hidden,
|
|
1154
|
+
dimAlpha,
|
|
1155
|
+
} = args;
|
|
1156
|
+
const isContinuous = colorScale.type === "continuous" || colorScale.type === "diverging";
|
|
1157
|
+
if (isContinuous) {
|
|
1158
|
+
const stashed = colorScale.palette;
|
|
1159
|
+
const palette: ContinuousPalette =
|
|
1160
|
+
stashed && stashed.kind === "continuous"
|
|
1161
|
+
? stashed
|
|
1162
|
+
: continuousPaletteFromScale(colorScale, theme);
|
|
1163
|
+
const domain = colorScale.domain as readonly [unknown, unknown];
|
|
1164
|
+
const d0 = Number(domain[0]);
|
|
1165
|
+
const d1 = Number(domain[1]);
|
|
1166
|
+
const numericDomain: readonly [number, number] = [
|
|
1167
|
+
Number.isFinite(d0) ? d0 : 0,
|
|
1168
|
+
Number.isFinite(d1) ? d1 : 0,
|
|
1169
|
+
];
|
|
1170
|
+
const legendLabelColor = resolveTextColor(theme.legend.labelColor, theme.background, theme, {
|
|
1171
|
+
fontSizePx: theme.legend.fontSize,
|
|
1172
|
+
site: "legend-label",
|
|
1173
|
+
});
|
|
1174
|
+
return colorBar({
|
|
1175
|
+
palette,
|
|
1176
|
+
domain: numericDomain,
|
|
1177
|
+
orientation,
|
|
1178
|
+
length: args.colorBarLength,
|
|
1179
|
+
thickness: args.colorBarThickness,
|
|
1180
|
+
ticks: legendSpec.ticks,
|
|
1181
|
+
tickValues: legendSpec.tickValues,
|
|
1182
|
+
minorTicks: legendSpec.minorTicks,
|
|
1183
|
+
labelStep: legendSpec.labelStep,
|
|
1184
|
+
format: legendSpec.format,
|
|
1185
|
+
atlas,
|
|
1186
|
+
fontSize: theme.legend.fontSize,
|
|
1187
|
+
labelColor: legendLabelColor,
|
|
1188
|
+
title: legendSpec.title,
|
|
1189
|
+
titleFontSize: theme.legend.fontSize,
|
|
1190
|
+
titleColor: legendLabelColor,
|
|
1191
|
+
// The color scale already applied `theme.paletteBlendSpace` to
|
|
1192
|
+
// `palette` upstream, so this override only fires when the legend
|
|
1193
|
+
// spec wants a *different* space than the chart's color encoding.
|
|
1194
|
+
blendSpace: legendSpec.blendSpace,
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
const swatchGeom = (colorLayers[0]?.geom ?? implicitSeries?.geom)!;
|
|
1198
|
+
const mergeSet = new Set<LegendMergeChannel>(
|
|
1199
|
+
legendSpec.merge ?? autoMergeChannels(colorScale.domain, args.scales),
|
|
1200
|
+
);
|
|
1201
|
+
const mergesPointStyling =
|
|
1202
|
+
mergeSet.has("shape") ||
|
|
1203
|
+
mergeSet.has("borderStyle") ||
|
|
1204
|
+
mergeSet.has("overlayGlyph") ||
|
|
1205
|
+
mergeSet.has("size");
|
|
1206
|
+
const swatchFor: (value: unknown, color: Color) => SwatchSpec = mergesPointStyling
|
|
1207
|
+
? (value, color) => composeMergedPointSwatch(value, color, args.scales, mergeSet, theme)
|
|
1208
|
+
: swatchGeom.legendSwatch
|
|
1209
|
+
? (_v, c) => swatchGeom.legendSwatch!(c, theme)
|
|
1210
|
+
: (_v, c) => pointSwatch({ fill: c, radius: 5 });
|
|
1211
|
+
const catLabelColor = resolveTextColor(theme.legend.labelColor, theme.background, theme, {
|
|
1212
|
+
fontSizePx: theme.legend.fontSize,
|
|
1213
|
+
site: "legend-label",
|
|
1214
|
+
});
|
|
1215
|
+
return legendBuilder(
|
|
1216
|
+
colorScale.domain.map((value) => ({
|
|
1217
|
+
label: String(value),
|
|
1218
|
+
swatch: swatchFor(value, colorScale.fn(value)),
|
|
1219
|
+
hidden: hidden?.has(String(value)),
|
|
1220
|
+
})),
|
|
1221
|
+
{
|
|
1222
|
+
atlas,
|
|
1223
|
+
orientation,
|
|
1224
|
+
fontSize: theme.legend.fontSize,
|
|
1225
|
+
labelColor: catLabelColor,
|
|
1226
|
+
entryGap: theme.legend.entryGap,
|
|
1227
|
+
swatchGap: theme.legend.swatchGap,
|
|
1228
|
+
title: legendSpec.title,
|
|
1229
|
+
titleColor: catLabelColor,
|
|
1230
|
+
dimAlpha,
|
|
1231
|
+
},
|
|
1232
|
+
);
|
|
1233
|
+
}
|