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,1164 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// ridgeline geom
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// One distribution per category, drawn as a closed silhouette that rises off
|
|
5
|
+
// a row baseline. Adjacent rows are allowed to overlap (the iconic "joyplot"
|
|
6
|
+
// look) via the `overlap` factor. Two density estimators:
|
|
7
|
+
//
|
|
8
|
+
// - `geom: "kde"` (default) — Gaussian/Epanechnikov kernel density,
|
|
9
|
+
// evaluated on a fixed grid (shared internals with `violin`).
|
|
10
|
+
// - `geom: "histogram"` — per-row binning with bins shared across rows so
|
|
11
|
+
// columns align.
|
|
12
|
+
//
|
|
13
|
+
// Two fill modes:
|
|
14
|
+
//
|
|
15
|
+
// - `fillMode: "solid"` (default) — palette per category (or chart's color
|
|
16
|
+
// scale when a `color` channel is mapped).
|
|
17
|
+
// - `fillMode: "gradient"` — value-axis-mapped color ramp; reproduces the
|
|
18
|
+
// "Lincoln NE temperatures" look. The ramp is rendered as a sequence of
|
|
19
|
+
// adjacent quads sharing vertices with the KDE/bin grid so there are no
|
|
20
|
+
// visible seams; the silhouette stroke is drawn afterwards as a single
|
|
21
|
+
// polyline.
|
|
22
|
+
//
|
|
23
|
+
// Inner annotations (`inner: "median" | "quartile" | "mean"`) and the row
|
|
24
|
+
// baseline are layered on top of the fill.
|
|
25
|
+
|
|
26
|
+
import type { Color, Layer, Vec2 } from "insomni";
|
|
27
|
+
import { pushFilledPolygon } from "../../marks.ts";
|
|
28
|
+
import {
|
|
29
|
+
binBreaks,
|
|
30
|
+
binWithBreaks,
|
|
31
|
+
boxStats,
|
|
32
|
+
histogramMeasureValue,
|
|
33
|
+
type BinClosed,
|
|
34
|
+
type BinResult,
|
|
35
|
+
type BinRule,
|
|
36
|
+
type BoxStats,
|
|
37
|
+
type HistogramMeasure,
|
|
38
|
+
type KdeBandwidth,
|
|
39
|
+
type KdeKernel,
|
|
40
|
+
type KdeResult,
|
|
41
|
+
kde as kdeFn,
|
|
42
|
+
type QuantileMethod,
|
|
43
|
+
type WhiskerRule,
|
|
44
|
+
} from "../../stats/index.ts";
|
|
45
|
+
import { bandScale, type ContinuousScale } from "../../scales.ts";
|
|
46
|
+
import { resolveAes, type Aes } from "../aes.ts";
|
|
47
|
+
import { withAlpha } from "../color-utils.ts";
|
|
48
|
+
import { DEFAULT_OVERLAP } from "../constants.ts";
|
|
49
|
+
import { selectChannels, wrapMark } from "./_mark.ts";
|
|
50
|
+
import { emphasisContext } from "./emphasis.ts";
|
|
51
|
+
import type {
|
|
52
|
+
CompileContext,
|
|
53
|
+
CompiledHitTest,
|
|
54
|
+
Geom,
|
|
55
|
+
PositionScaleHint,
|
|
56
|
+
PrepareRangeContext,
|
|
57
|
+
RangeHints,
|
|
58
|
+
ResolvedChannelMap,
|
|
59
|
+
} from "./types.ts";
|
|
60
|
+
import {
|
|
61
|
+
bucketBandCenter,
|
|
62
|
+
countsLabelMark,
|
|
63
|
+
prepareCategoricalLayout,
|
|
64
|
+
synthAes,
|
|
65
|
+
type CategoricalBucket,
|
|
66
|
+
type CategoricalLayout,
|
|
67
|
+
} from "./_categorical.ts";
|
|
68
|
+
import {
|
|
69
|
+
computeGroupedKde,
|
|
70
|
+
densityWidthFn,
|
|
71
|
+
kdeValueExtent,
|
|
72
|
+
type DensityScale,
|
|
73
|
+
type GroupedKdeResult,
|
|
74
|
+
} from "./_distribution.ts";
|
|
75
|
+
|
|
76
|
+
export type RidgelineGeom = "kde" | "histogram";
|
|
77
|
+
export type RidgelineScale = DensityScale;
|
|
78
|
+
export type RidgelineInner = "none" | "median" | "quartile" | "mean";
|
|
79
|
+
export type RidgelineFillMode = "solid" | "gradient";
|
|
80
|
+
|
|
81
|
+
export interface RidgelineChannels<T> {
|
|
82
|
+
/** Numeric value being distributed. Provide one of x or y; the other is the row category. */
|
|
83
|
+
x: Aes<T, string | number>;
|
|
84
|
+
y: Aes<T, string | number>;
|
|
85
|
+
/** Optional categorical group key. Drives palette fill (chart color scale). */
|
|
86
|
+
color?: Aes<T, unknown>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface RidgelineOptions {
|
|
90
|
+
/** Auto-detected from scale types when omitted. The default reproduces the common "rows on y, values on x" layout. */
|
|
91
|
+
orientation?: "x" | "y";
|
|
92
|
+
|
|
93
|
+
/** Density estimator. Default `"kde"`. */
|
|
94
|
+
geom?: RidgelineGeom;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* How tall a ridge can be relative to its band-cell spacing. `1` = no
|
|
98
|
+
* overlap (each ridge stays inside its row); `>1` allows overlap into
|
|
99
|
+
* neighbours. Default `2.5`.
|
|
100
|
+
*/
|
|
101
|
+
overlap?: number;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Across-group height normalization (same semantics as `violin`'s `scale`).
|
|
105
|
+
*
|
|
106
|
+
* - `"width"` (default) — each row uses its full allotted height.
|
|
107
|
+
* - `"area"` — heights normalized by the global max density.
|
|
108
|
+
* - `"count"` — heights scaled by sample size on top of `"area"`.
|
|
109
|
+
*/
|
|
110
|
+
scale?: RidgelineScale;
|
|
111
|
+
|
|
112
|
+
// KDE pass-throughs (only when `geom === "kde"`)
|
|
113
|
+
bandwidth?: KdeBandwidth;
|
|
114
|
+
gridSize?: number;
|
|
115
|
+
kernel?: KdeKernel;
|
|
116
|
+
trim?: boolean;
|
|
117
|
+
|
|
118
|
+
// Histogram pass-throughs (only when `geom === "histogram"`)
|
|
119
|
+
bins?: number;
|
|
120
|
+
binwidth?: number;
|
|
121
|
+
breaks?: readonly number[];
|
|
122
|
+
rule?: BinRule;
|
|
123
|
+
measure?: HistogramMeasure;
|
|
124
|
+
closed?: BinClosed;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Solid fill (default) uses the chart color scale per category, or the
|
|
128
|
+
* theme's categorical palette if no `color` channel is mapped. Gradient
|
|
129
|
+
* fill ignores the category and applies a value-axis-mapped color ramp,
|
|
130
|
+
* reproducing the "Lincoln NE temperatures" look.
|
|
131
|
+
*/
|
|
132
|
+
fillMode?: RidgelineFillMode;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Color ramp for `fillMode: "gradient"`. Receives `t ∈ [0, 1]` mapped from
|
|
136
|
+
* the value-axis range. Default = `theme.palettes.continuous`.
|
|
137
|
+
*/
|
|
138
|
+
gradient?: (t01: number) => Color;
|
|
139
|
+
|
|
140
|
+
/** Per-row inner annotation. Default `"none"`. */
|
|
141
|
+
inner?: RidgelineInner;
|
|
142
|
+
innerStroke?: Color;
|
|
143
|
+
innerStrokeWidth?: number;
|
|
144
|
+
/** Radius of the median/mean dot when `inner` includes a point. Default `3`. */
|
|
145
|
+
innerDotRadius?: number;
|
|
146
|
+
|
|
147
|
+
/** Draw a thin rule along each row's baseline. Default `true`. */
|
|
148
|
+
baseline?: boolean;
|
|
149
|
+
baselineStroke?: Color;
|
|
150
|
+
baselineWidth?: number;
|
|
151
|
+
|
|
152
|
+
/** `n=<count>` per row, anchored inline at the row baseline by default. */
|
|
153
|
+
showCounts?: boolean;
|
|
154
|
+
countsAnchor?: "outside" | "inline";
|
|
155
|
+
/** Pixel offset for the counts label. Default 4 (inline) / 28 (outside, vertical) / 32 (outside, horizontal). */
|
|
156
|
+
countsOffset?: number;
|
|
157
|
+
countsFontSize?: number;
|
|
158
|
+
countsColor?: Color;
|
|
159
|
+
|
|
160
|
+
/** Solid fill for the silhouette. Default theme palette[0] (overridden by chart color scale when `color` is set). */
|
|
161
|
+
fill?: Color;
|
|
162
|
+
fillAlpha?: number;
|
|
163
|
+
/** Silhouette outline. Default theme text color. */
|
|
164
|
+
stroke?: Color;
|
|
165
|
+
strokeWidth?: number;
|
|
166
|
+
/** Inner-band padding when dodging via `color`. Default `0.05`. */
|
|
167
|
+
groupPadding?: number;
|
|
168
|
+
|
|
169
|
+
// Box-stat options used by inner annotations (only when `inner !== "none"`).
|
|
170
|
+
whisker?: WhiskerRule;
|
|
171
|
+
quantile?: QuantileMethod;
|
|
172
|
+
|
|
173
|
+
label?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface KdeRow {
|
|
177
|
+
bucket: CategoricalBucket;
|
|
178
|
+
/** Bucket index in `layout.buckets`; used to match against `ctx.hovered`. */
|
|
179
|
+
bucketIndex: number;
|
|
180
|
+
/** Center on the band axis (in pixel space). */
|
|
181
|
+
bandCenter: number;
|
|
182
|
+
kde: KdeResult;
|
|
183
|
+
maxDensity: number;
|
|
184
|
+
n: number;
|
|
185
|
+
stats: BoxStats;
|
|
186
|
+
fill: Color;
|
|
187
|
+
strokeWidth: number;
|
|
188
|
+
/**
|
|
189
|
+
* GPU emphasis key (P5-T3) written onto EVERY primitive of this row (ridge
|
|
190
|
+
* fill + silhouette, baseline, inner annotations) so the whole row dims as one
|
|
191
|
+
* unit. Ordinal = `bucketIndex` (the hit `dataIndex`). `undefined` when off.
|
|
192
|
+
*/
|
|
193
|
+
emphasisKey?: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface HistRow {
|
|
197
|
+
bucket: CategoricalBucket;
|
|
198
|
+
bucketIndex: number;
|
|
199
|
+
bandCenter: number;
|
|
200
|
+
bins: BinResult[];
|
|
201
|
+
/** Per-bin measured value (count / density / proportion). */
|
|
202
|
+
values: number[];
|
|
203
|
+
/** Max value across `values` (post-measure). */
|
|
204
|
+
maxValue: number;
|
|
205
|
+
n: number;
|
|
206
|
+
stats: BoxStats | null;
|
|
207
|
+
/** See {@link KdeRow.emphasisKey}. */
|
|
208
|
+
emphasisKey?: number;
|
|
209
|
+
fill: Color;
|
|
210
|
+
strokeWidth: number;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function ridgeline<T>(
|
|
214
|
+
channels: RidgelineChannels<T>,
|
|
215
|
+
options: RidgelineOptions = {},
|
|
216
|
+
): Geom<T> {
|
|
217
|
+
const orientationOpt = options.orientation;
|
|
218
|
+
const geomMode: RidgelineGeom = options.geom ?? "kde";
|
|
219
|
+
|
|
220
|
+
// Shared between compile() and compileHitTest() within a single render —
|
|
221
|
+
// both receive the same `ctx` instance from pipeline.ts. WeakMap keyed by
|
|
222
|
+
// ctx so the cache invalidates naturally each frame (new ctx) without
|
|
223
|
+
// retaining stale layouts.
|
|
224
|
+
const layoutCache = new WeakMap<CompileContext<T>, CategoricalLayout>();
|
|
225
|
+
const getLayout = (ctx: CompileContext<T>): CategoricalLayout => {
|
|
226
|
+
const cached = layoutCache.get(ctx);
|
|
227
|
+
if (cached) return cached;
|
|
228
|
+
const layout = prepareCategoricalLayout(
|
|
229
|
+
ctx,
|
|
230
|
+
// Cast — the categorical helper accepts `string | number | Date` for
|
|
231
|
+
// the band axis; ridgeline's surface is the same minus Date.
|
|
232
|
+
channels as unknown as {
|
|
233
|
+
x: Aes<T, string | number | Date>;
|
|
234
|
+
y: Aes<T, number>;
|
|
235
|
+
color?: Aes<T, unknown>;
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
orientation: options.orientation,
|
|
239
|
+
groupPadding: options.groupPadding,
|
|
240
|
+
fill: options.fill,
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
layoutCache.set(ctx, layout);
|
|
244
|
+
return layout;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
kind: "ridgeline",
|
|
249
|
+
channels: { x: channels.x, y: channels.y, color: channels.color },
|
|
250
|
+
label: options.label,
|
|
251
|
+
|
|
252
|
+
prepareDomain(data) {
|
|
253
|
+
// Run a tiny per-bucket KDE / bin pass to learn the value-axis extent
|
|
254
|
+
// so the chart's value scale unions in any smoothed tails / bin edges.
|
|
255
|
+
// Convention (matches violin / _categorical): `orientation` names the
|
|
256
|
+
// axis the **value** lives on. `"x"` → categories on y, ridges rise
|
|
257
|
+
// upward (the default look). `"y"` → categories on x, ridges rise
|
|
258
|
+
// rightward.
|
|
259
|
+
let orientation: "x" | "y" = orientationOpt ?? "x";
|
|
260
|
+
if (!orientationOpt && data.length > 0) {
|
|
261
|
+
const xAes0 = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
|
|
262
|
+
const yAes0 = resolveAes<T, unknown>(channels.y as Aes<T, unknown>);
|
|
263
|
+
const xSample = xAes0.fn(data[0]!, 0);
|
|
264
|
+
const ySample = yAes0.fn(data[0]!, 0);
|
|
265
|
+
if (typeof xSample === "string") orientation = "y";
|
|
266
|
+
else if (typeof ySample === "string") orientation = "x";
|
|
267
|
+
}
|
|
268
|
+
const { categoryChannel, valueChannel } = selectChannels(orientation, channels);
|
|
269
|
+
const valueAes = resolveAes<T, number>(valueChannel as Aes<T, number>);
|
|
270
|
+
const bandAes = resolveAes<T, unknown>(categoryChannel as Aes<T, unknown>);
|
|
271
|
+
const buckets = new Map<string, number[]>();
|
|
272
|
+
for (let i = 0; i < data.length; i++) {
|
|
273
|
+
const datum = data[i]!;
|
|
274
|
+
const v = valueAes.fn(datum, i);
|
|
275
|
+
if (!Number.isFinite(v)) continue;
|
|
276
|
+
const key = String(bandAes.fn(datum, i));
|
|
277
|
+
let arr = buckets.get(key);
|
|
278
|
+
if (!arr) {
|
|
279
|
+
arr = [];
|
|
280
|
+
buckets.set(key, arr);
|
|
281
|
+
}
|
|
282
|
+
arr.push(v);
|
|
283
|
+
}
|
|
284
|
+
let lo = Number.POSITIVE_INFINITY;
|
|
285
|
+
let hi = Number.NEGATIVE_INFINITY;
|
|
286
|
+
if (geomMode === "kde") {
|
|
287
|
+
const extent = kdeValueExtent(buckets.values(), {
|
|
288
|
+
bandwidth: options.bandwidth ?? "silverman",
|
|
289
|
+
gridSize: options.gridSize ?? 64,
|
|
290
|
+
kernel: options.kernel ?? "gaussian",
|
|
291
|
+
trim: options.trim ?? true,
|
|
292
|
+
});
|
|
293
|
+
if (extent) {
|
|
294
|
+
lo = extent[0];
|
|
295
|
+
hi = extent[1];
|
|
296
|
+
}
|
|
297
|
+
} else {
|
|
298
|
+
// Shared bin breaks across all groups so columns align.
|
|
299
|
+
const flat: number[] = [];
|
|
300
|
+
for (const values of buckets.values()) for (const v of values) flat.push(v);
|
|
301
|
+
const breaks = binBreaks(flat, {
|
|
302
|
+
bins: options.bins,
|
|
303
|
+
binwidth: options.binwidth,
|
|
304
|
+
breaks: options.breaks,
|
|
305
|
+
rule: options.rule,
|
|
306
|
+
});
|
|
307
|
+
if (breaks.length >= 2) {
|
|
308
|
+
lo = breaks[0]!;
|
|
309
|
+
hi = breaks[breaks.length - 1]!;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return undefined;
|
|
313
|
+
const valueHint: PositionScaleHint = { extend: [lo, hi] };
|
|
314
|
+
return orientation === "y" ? { y: valueHint } : { x: valueHint };
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
prepareRange(ctx: PrepareRangeContext<T>): RangeHints | undefined {
|
|
318
|
+
// Ridgelines rise off their baseline by `overlap × cellSize`, which
|
|
319
|
+
// routinely overshoots the topmost row's headroom (and the plot frame
|
|
320
|
+
// above it). Compute the worst row's overshoot at the *uncompressed*
|
|
321
|
+
// band scale, then ask the chart to reserve that much canvas — the
|
|
322
|
+
// band scale gets shrunk by k = H/(H+overflow) and after compression
|
|
323
|
+
// every row exactly fits.
|
|
324
|
+
const { data, plot, scaleOptions } = ctx;
|
|
325
|
+
if (data.length === 0) return undefined;
|
|
326
|
+
|
|
327
|
+
let orientation: "x" | "y" = orientationOpt ?? "x";
|
|
328
|
+
if (!orientationOpt) {
|
|
329
|
+
const xAes0 = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
|
|
330
|
+
const yAes0 = resolveAes<T, unknown>(channels.y as Aes<T, unknown>);
|
|
331
|
+
const xSample = xAes0.fn(data[0]!, 0);
|
|
332
|
+
const ySample = yAes0.fn(data[0]!, 0);
|
|
333
|
+
if (typeof xSample === "string") orientation = "y";
|
|
334
|
+
else if (typeof ySample === "string") orientation = "x";
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const { categoryChannel, valueChannel } = selectChannels(orientation, channels);
|
|
338
|
+
const valueAes = resolveAes<T, number>(valueChannel as Aes<T, number>);
|
|
339
|
+
const bandAes = resolveAes<T, unknown>(categoryChannel as Aes<T, unknown>);
|
|
340
|
+
|
|
341
|
+
const order: string[] = [];
|
|
342
|
+
const buckets = new Map<string, number[]>();
|
|
343
|
+
for (let i = 0; i < data.length; i++) {
|
|
344
|
+
const datum = data[i]!;
|
|
345
|
+
const v = valueAes.fn(datum, i);
|
|
346
|
+
if (!Number.isFinite(v)) continue;
|
|
347
|
+
const key = String(bandAes.fn(datum, i));
|
|
348
|
+
let arr = buckets.get(key);
|
|
349
|
+
if (!arr) {
|
|
350
|
+
arr = [];
|
|
351
|
+
buckets.set(key, arr);
|
|
352
|
+
order.push(key);
|
|
353
|
+
}
|
|
354
|
+
arr.push(v);
|
|
355
|
+
}
|
|
356
|
+
if (buckets.size === 0) return undefined;
|
|
357
|
+
|
|
358
|
+
// Build a temp band scale matching what the chart will build on the
|
|
359
|
+
// un-reserved range, so we can read true bandCenters for each bucket.
|
|
360
|
+
const bandSide = orientation === "y" ? "x" : "y";
|
|
361
|
+
const bandOpts = scaleOptions[bandSide];
|
|
362
|
+
const explicitDomain = bandOpts && bandOpts.type === "band" ? bandOpts.domain : undefined;
|
|
363
|
+
const domain = explicitDomain ?? order;
|
|
364
|
+
if (domain.length === 0) return undefined;
|
|
365
|
+
const padding = bandOpts?.padding ?? 0.1;
|
|
366
|
+
const H = orientation === "x" ? plot.height : plot.width;
|
|
367
|
+
const range: [number, number] = orientation === "x" ? [H, 0] : [0, H];
|
|
368
|
+
const tempBand = bandScale<string>(domain, range, { padding });
|
|
369
|
+
const cellSize = tempBand.bandwidth();
|
|
370
|
+
if (cellSize <= 0) return undefined;
|
|
371
|
+
|
|
372
|
+
const overlap = options.overlap ?? DEFAULT_OVERLAP;
|
|
373
|
+
const rowHeightPx = cellSize * overlap;
|
|
374
|
+
const scaleMode: RidgelineScale = options.scale ?? "width";
|
|
375
|
+
|
|
376
|
+
let maxOverflow = 0;
|
|
377
|
+
const consider = (key: string, alpha: number) => {
|
|
378
|
+
const bandStart = tempBand(key);
|
|
379
|
+
if (Number.isNaN(bandStart)) return;
|
|
380
|
+
const bandCenter = bandStart + cellSize / 2;
|
|
381
|
+
const headroom = orientation === "x" ? bandCenter : H - bandCenter;
|
|
382
|
+
const overflow = alpha * rowHeightPx - headroom;
|
|
383
|
+
if (overflow > maxOverflow) maxOverflow = overflow;
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
if (geomMode === "kde") {
|
|
387
|
+
const kdeOpts = {
|
|
388
|
+
bandwidth: options.bandwidth ?? "silverman",
|
|
389
|
+
gridSize: options.gridSize ?? 64,
|
|
390
|
+
kernel: options.kernel ?? "gaussian",
|
|
391
|
+
trim: options.trim ?? true,
|
|
392
|
+
} as const;
|
|
393
|
+
const peaks: { key: string; maxDensity: number; n: number }[] = [];
|
|
394
|
+
let globalRef = 0;
|
|
395
|
+
for (const [key, values] of buckets) {
|
|
396
|
+
const k = kdeFn(values, kdeOpts);
|
|
397
|
+
if (!k) continue;
|
|
398
|
+
let max = 0;
|
|
399
|
+
for (const y of k.y) if (y > max) max = y;
|
|
400
|
+
if (max <= 0) continue;
|
|
401
|
+
const scaled = scaleMode === "count" ? max * values.length : max;
|
|
402
|
+
if (scaled > globalRef) globalRef = scaled;
|
|
403
|
+
peaks.push({ key, maxDensity: max, n: values.length });
|
|
404
|
+
}
|
|
405
|
+
if (globalRef <= 0) return undefined;
|
|
406
|
+
for (const p of peaks) {
|
|
407
|
+
const alpha =
|
|
408
|
+
scaleMode === "width"
|
|
409
|
+
? 1
|
|
410
|
+
: scaleMode === "area"
|
|
411
|
+
? p.maxDensity / globalRef
|
|
412
|
+
: (p.maxDensity * p.n) / globalRef;
|
|
413
|
+
consider(p.key, alpha);
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
const flat: number[] = [];
|
|
417
|
+
for (const values of buckets.values()) for (const v of values) flat.push(v);
|
|
418
|
+
const breaks = binBreaks(flat, {
|
|
419
|
+
bins: options.bins,
|
|
420
|
+
binwidth: options.binwidth,
|
|
421
|
+
breaks: options.breaks,
|
|
422
|
+
rule: options.rule,
|
|
423
|
+
});
|
|
424
|
+
if (breaks.length < 2) return undefined;
|
|
425
|
+
const closed: BinClosed = options.closed ?? "left";
|
|
426
|
+
const measure: HistogramMeasure = options.measure ?? "count";
|
|
427
|
+
|
|
428
|
+
const peaks: { key: string; maxValue: number; n: number }[] = [];
|
|
429
|
+
let globalRef = 0;
|
|
430
|
+
for (const [key, values] of buckets) {
|
|
431
|
+
const bins = binWithBreaks(values, breaks, closed);
|
|
432
|
+
let n = 0;
|
|
433
|
+
for (const b of bins) n += b.count;
|
|
434
|
+
if (n === 0) continue;
|
|
435
|
+
let max = 0;
|
|
436
|
+
for (const b of bins) {
|
|
437
|
+
const v = histogramMeasureValue(b, n, measure);
|
|
438
|
+
if (v > max) max = v;
|
|
439
|
+
}
|
|
440
|
+
if (max <= 0) continue;
|
|
441
|
+
const scaled = scaleMode === "count" ? max * n : max;
|
|
442
|
+
if (scaled > globalRef) globalRef = scaled;
|
|
443
|
+
peaks.push({ key, maxValue: max, n });
|
|
444
|
+
}
|
|
445
|
+
if (globalRef <= 0) return undefined;
|
|
446
|
+
for (const p of peaks) {
|
|
447
|
+
const alpha =
|
|
448
|
+
scaleMode === "width"
|
|
449
|
+
? 1
|
|
450
|
+
: scaleMode === "area"
|
|
451
|
+
? p.maxValue / globalRef
|
|
452
|
+
: (p.maxValue * p.n) / globalRef;
|
|
453
|
+
consider(p.key, alpha);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (maxOverflow <= 0) return undefined;
|
|
458
|
+
const reserved = (maxOverflow * H) / (H + maxOverflow);
|
|
459
|
+
return orientation === "x" ? { top: reserved } : { right: reserved };
|
|
460
|
+
},
|
|
461
|
+
|
|
462
|
+
compile(ctx: CompileContext<T>) {
|
|
463
|
+
const { plot, theme, atlas } = ctx;
|
|
464
|
+
const layout = getLayout(ctx);
|
|
465
|
+
const { orientation, valueAxis, cellSize, ox, oy, buckets } = layout;
|
|
466
|
+
|
|
467
|
+
// Hover dim rides the core's GPU emphasis uniform (P5-T3). Each ridge is a
|
|
468
|
+
// `pushPolygon` KDE fill, which now carries an emphasis key (added this
|
|
469
|
+
// change), so a whole row is tagged as ONE entity: the SAME key on the
|
|
470
|
+
// ridge fill + silhouette, the baseline, and the inner annotations. Ordinal
|
|
471
|
+
// = `bucketIndex` (the hit `dataIndex`), so the mount's emphasisResolution
|
|
472
|
+
// maps a hover hit to this key with no recompile. The compile-time
|
|
473
|
+
// `dimFor`/`strokeWidthFor` stay identity — the dim is GPU-side now.
|
|
474
|
+
const emph = emphasisContext(ctx, "ridgeline");
|
|
475
|
+
const dimFor = (_bucketIndex: number): number => 1;
|
|
476
|
+
const strokeWidthFor = (_bucketIndex: number, base: number): number => base;
|
|
477
|
+
|
|
478
|
+
const overlap = options.overlap ?? DEFAULT_OVERLAP;
|
|
479
|
+
const rowHeightPx = cellSize * overlap;
|
|
480
|
+
|
|
481
|
+
const scaleMode: RidgelineScale = options.scale ?? "width";
|
|
482
|
+
const fillMode: RidgelineFillMode = options.fillMode ?? "solid";
|
|
483
|
+
// Ridgelines rely on z-order occlusion between overlapping rows, so the
|
|
484
|
+
// default fill is opaque. Theme alpha is bypassed; pass `fillAlpha`
|
|
485
|
+
// explicitly to opt into translucency.
|
|
486
|
+
const fillAlpha = options.fillAlpha ?? 1;
|
|
487
|
+
|
|
488
|
+
const stroke: Color = options.stroke ?? theme.text.color;
|
|
489
|
+
const strokeWidth = options.strokeWidth ?? 1;
|
|
490
|
+
|
|
491
|
+
const baseline = options.baseline ?? true;
|
|
492
|
+
const baselineStroke: Color = options.baselineStroke ?? stroke;
|
|
493
|
+
const baselineWidth = options.baselineWidth ?? 1;
|
|
494
|
+
|
|
495
|
+
const innerKind: RidgelineInner = options.inner ?? "none";
|
|
496
|
+
const innerStroke: Color = options.innerStroke ?? stroke;
|
|
497
|
+
const innerStrokeWidth = options.innerStrokeWidth ?? 1;
|
|
498
|
+
const innerDotRadius = options.innerDotRadius ?? 3;
|
|
499
|
+
|
|
500
|
+
const showCounts = options.showCounts ?? false;
|
|
501
|
+
const countsAnchor = options.countsAnchor ?? "inline";
|
|
502
|
+
const countsFontSize = options.countsFontSize ?? theme.marks.labelFontSize;
|
|
503
|
+
const countsColor: Color = options.countsColor ?? theme.text.color;
|
|
504
|
+
const countsOffset =
|
|
505
|
+
options.countsOffset ?? (countsAnchor === "inline" ? 4 : orientation === "y" ? 28 : 32);
|
|
506
|
+
|
|
507
|
+
// Resolve fill per bucket. Solid mode delegates to the categorical
|
|
508
|
+
// layout's resolver (then `fillAlpha` is forced via `withAlpha`).
|
|
509
|
+
// Gradient mode ignores the bucket and samples a value-axis-mapped ramp.
|
|
510
|
+
const gradientPalette = options.gradient ?? ((t: number) => theme.palettes.continuous(t));
|
|
511
|
+
const gradientFor = (xValue: number): Color => {
|
|
512
|
+
const [d0, d1] = valueAxis.domain;
|
|
513
|
+
const t = d0 === d1 ? 0.5 : (xValue - d0) / (d1 - d0);
|
|
514
|
+
const c = gradientPalette(t < 0 ? 0 : t > 1 ? 1 : t);
|
|
515
|
+
return withAlpha(c, fillAlpha);
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
// Joyplot z-order: each ridge rises off its baseline and intrudes into
|
|
519
|
+
// the adjacent row. The row being intruded INTO must paint on top for the
|
|
520
|
+
// overlap to read correctly. Submission order is the stacking order (v3:
|
|
521
|
+
// the last-submitted shape paints on top), so the row whose ridge is
|
|
522
|
+
// overlapped is appended last:
|
|
523
|
+
// `"x"` ridges rise upward → draw bottom-to-top (descending bandCenter);
|
|
524
|
+
// `"y"` ridges grow rightward → draw left-to-right (ascending bandCenter).
|
|
525
|
+
const zOrder = (a: { bandCenter: number }, b: { bandCenter: number }) =>
|
|
526
|
+
orientation === "x" ? b.bandCenter - a.bandCenter : a.bandCenter - b.bandCenter;
|
|
527
|
+
|
|
528
|
+
const builders: { length: number; addTo: (l: Layer) => Layer }[] = [];
|
|
529
|
+
|
|
530
|
+
if (geomMode === "kde") {
|
|
531
|
+
const kdeOpts = {
|
|
532
|
+
bandwidth: options.bandwidth ?? "silverman",
|
|
533
|
+
gridSize: options.gridSize ?? 64,
|
|
534
|
+
kernel: options.kernel ?? "gaussian",
|
|
535
|
+
trim: options.trim ?? true,
|
|
536
|
+
} as const;
|
|
537
|
+
const { groups, globalMaxDensity } = computeGroupedKde(buckets, {
|
|
538
|
+
kde: kdeOpts,
|
|
539
|
+
box: {
|
|
540
|
+
whisker: options.whisker ?? 1.5,
|
|
541
|
+
quantile: options.quantile ?? "type-7",
|
|
542
|
+
},
|
|
543
|
+
scale: scaleMode,
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
const rows: KdeRow[] = [];
|
|
547
|
+
for (const g of groups) {
|
|
548
|
+
const bandCenter = bucketBandCenter(layout, g.bucket);
|
|
549
|
+
if (Number.isNaN(bandCenter)) continue;
|
|
550
|
+
const bucketIndex = buckets.indexOf(g.bucket);
|
|
551
|
+
const dim = dimFor(bucketIndex);
|
|
552
|
+
rows.push({
|
|
553
|
+
bucket: g.bucket,
|
|
554
|
+
bucketIndex,
|
|
555
|
+
bandCenter,
|
|
556
|
+
kde: g.kde,
|
|
557
|
+
maxDensity: g.maxDensity,
|
|
558
|
+
n: g.n,
|
|
559
|
+
stats: g.stats,
|
|
560
|
+
fill: withAlpha(layout.resolveFill(g.bucket), fillAlpha * dim),
|
|
561
|
+
strokeWidth: strokeWidthFor(bucketIndex, strokeWidth),
|
|
562
|
+
emphasisKey: emph?.keyFor(bucketIndex),
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
rows.sort(zOrder);
|
|
566
|
+
|
|
567
|
+
builders.push({
|
|
568
|
+
length: rows.length,
|
|
569
|
+
addTo(layer: Layer) {
|
|
570
|
+
for (const row of rows) {
|
|
571
|
+
const widthFor = densityWidthFn(
|
|
572
|
+
row as GroupedKdeResult,
|
|
573
|
+
globalMaxDensity,
|
|
574
|
+
scaleMode,
|
|
575
|
+
rowHeightPx,
|
|
576
|
+
);
|
|
577
|
+
drawKdeRow(layer, row, {
|
|
578
|
+
orientation,
|
|
579
|
+
valueAxis,
|
|
580
|
+
ox,
|
|
581
|
+
oy,
|
|
582
|
+
widthFor,
|
|
583
|
+
fill: row.fill,
|
|
584
|
+
stroke,
|
|
585
|
+
strokeWidth: row.strokeWidth,
|
|
586
|
+
fillMode,
|
|
587
|
+
gradientFor,
|
|
588
|
+
emphasisKey: row.emphasisKey,
|
|
589
|
+
});
|
|
590
|
+
if (baseline) {
|
|
591
|
+
drawBaseline(layer, row.bandCenter, valueAxis, {
|
|
592
|
+
orientation,
|
|
593
|
+
ox,
|
|
594
|
+
oy,
|
|
595
|
+
stroke: baselineStroke,
|
|
596
|
+
width: baselineWidth,
|
|
597
|
+
emphasisKey: row.emphasisKey,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
if (innerKind !== "none") {
|
|
601
|
+
drawInner(layer, row.bandCenter, row.stats, valueAxis, {
|
|
602
|
+
orientation,
|
|
603
|
+
ox,
|
|
604
|
+
oy,
|
|
605
|
+
kind: innerKind,
|
|
606
|
+
stroke: innerStroke,
|
|
607
|
+
strokeWidth: innerStrokeWidth,
|
|
608
|
+
dotRadius: innerDotRadius,
|
|
609
|
+
emphasisKey: row.emphasisKey,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return layer;
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
} else {
|
|
617
|
+
// Histogram mode — shared breaks across all rows.
|
|
618
|
+
const allValues: number[] = [];
|
|
619
|
+
for (const b of buckets) for (const v of b.values) allValues.push(v);
|
|
620
|
+
const breaks = binBreaks(allValues, {
|
|
621
|
+
bins: options.bins,
|
|
622
|
+
binwidth: options.binwidth,
|
|
623
|
+
breaks: options.breaks,
|
|
624
|
+
rule: options.rule,
|
|
625
|
+
});
|
|
626
|
+
if (breaks.length < 2) return [];
|
|
627
|
+
|
|
628
|
+
const closed: BinClosed = options.closed ?? "left";
|
|
629
|
+
const measure: HistogramMeasure = options.measure ?? "count";
|
|
630
|
+
|
|
631
|
+
const measureValue = (b: BinResult, n: number): number =>
|
|
632
|
+
histogramMeasureValue(b, n, measure);
|
|
633
|
+
|
|
634
|
+
const rows: HistRow[] = [];
|
|
635
|
+
let globalMax = 0;
|
|
636
|
+
let globalMaxScaled = 0;
|
|
637
|
+
for (const bucket of buckets) {
|
|
638
|
+
const bandCenter = bucketBandCenter(layout, bucket);
|
|
639
|
+
if (Number.isNaN(bandCenter)) continue;
|
|
640
|
+
const bins = binWithBreaks(bucket.values, breaks, closed);
|
|
641
|
+
let n = 0;
|
|
642
|
+
for (const b of bins) n += b.count;
|
|
643
|
+
if (n === 0) continue;
|
|
644
|
+
const values = bins.map((b) => measureValue(b, n));
|
|
645
|
+
let max = 0;
|
|
646
|
+
for (const v of values) if (v > max) max = v;
|
|
647
|
+
if (max > globalMax) globalMax = max;
|
|
648
|
+
const scaled = scaleMode === "count" ? max * n : max;
|
|
649
|
+
if (scaled > globalMaxScaled) globalMaxScaled = scaled;
|
|
650
|
+
const stats = boxStats(bucket.values, {
|
|
651
|
+
whisker: options.whisker ?? 1.5,
|
|
652
|
+
quantile: options.quantile ?? "type-7",
|
|
653
|
+
});
|
|
654
|
+
const bucketIndex = buckets.indexOf(bucket);
|
|
655
|
+
const dim = dimFor(bucketIndex);
|
|
656
|
+
rows.push({
|
|
657
|
+
bucket,
|
|
658
|
+
bucketIndex,
|
|
659
|
+
bandCenter,
|
|
660
|
+
bins,
|
|
661
|
+
values,
|
|
662
|
+
maxValue: max,
|
|
663
|
+
n,
|
|
664
|
+
stats,
|
|
665
|
+
fill: withAlpha(layout.resolveFill(bucket), fillAlpha * dim),
|
|
666
|
+
strokeWidth: strokeWidthFor(bucketIndex, strokeWidth),
|
|
667
|
+
emphasisKey: emph?.keyFor(bucketIndex),
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
rows.sort(zOrder);
|
|
671
|
+
|
|
672
|
+
builders.push({
|
|
673
|
+
length: rows.length,
|
|
674
|
+
addTo(layer: Layer) {
|
|
675
|
+
for (const row of rows) {
|
|
676
|
+
const widthFor = (() => {
|
|
677
|
+
if (scaleMode === "width") {
|
|
678
|
+
if (row.maxValue <= 0) return () => 0;
|
|
679
|
+
const k = rowHeightPx / row.maxValue;
|
|
680
|
+
return (v: number) => v * k;
|
|
681
|
+
}
|
|
682
|
+
const ref = globalMaxScaled > 0 ? globalMaxScaled : 1;
|
|
683
|
+
if (scaleMode === "count") {
|
|
684
|
+
const k = (rowHeightPx * row.n) / ref;
|
|
685
|
+
return (v: number) => v * k;
|
|
686
|
+
}
|
|
687
|
+
const k = rowHeightPx / ref;
|
|
688
|
+
return (v: number) => v * k;
|
|
689
|
+
})();
|
|
690
|
+
|
|
691
|
+
drawHistogramRow(layer, row, {
|
|
692
|
+
orientation,
|
|
693
|
+
valueAxis,
|
|
694
|
+
ox,
|
|
695
|
+
oy,
|
|
696
|
+
widthFor,
|
|
697
|
+
fill: row.fill,
|
|
698
|
+
stroke,
|
|
699
|
+
strokeWidth: row.strokeWidth,
|
|
700
|
+
fillMode,
|
|
701
|
+
gradientFor,
|
|
702
|
+
emphasisKey: row.emphasisKey,
|
|
703
|
+
});
|
|
704
|
+
if (baseline) {
|
|
705
|
+
drawBaseline(layer, row.bandCenter, valueAxis, {
|
|
706
|
+
orientation,
|
|
707
|
+
ox,
|
|
708
|
+
oy,
|
|
709
|
+
stroke: baselineStroke,
|
|
710
|
+
width: baselineWidth,
|
|
711
|
+
emphasisKey: row.emphasisKey,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
if (innerKind !== "none" && row.stats) {
|
|
715
|
+
drawInner(layer, row.bandCenter, row.stats, valueAxis, {
|
|
716
|
+
orientation,
|
|
717
|
+
ox,
|
|
718
|
+
oy,
|
|
719
|
+
kind: innerKind,
|
|
720
|
+
stroke: innerStroke,
|
|
721
|
+
strokeWidth: innerStrokeWidth,
|
|
722
|
+
dotRadius: innerDotRadius,
|
|
723
|
+
emphasisKey: row.emphasisKey,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return layer;
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (showCounts && atlas) {
|
|
733
|
+
const labelMark = countsLabelMark(
|
|
734
|
+
layout,
|
|
735
|
+
{
|
|
736
|
+
offset: countsOffset,
|
|
737
|
+
fontSize: countsFontSize,
|
|
738
|
+
color: countsColor,
|
|
739
|
+
anchor: countsAnchor,
|
|
740
|
+
},
|
|
741
|
+
plot.width,
|
|
742
|
+
plot.height,
|
|
743
|
+
);
|
|
744
|
+
builders.push(wrapMark(labelMark, plot.topLeft, layout.buckets.length));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return builders;
|
|
748
|
+
},
|
|
749
|
+
emphasisResolution(ctx) {
|
|
750
|
+
// Ordinal = the hit's `dataIndex`, which compileHitTest sets to the
|
|
751
|
+
// pre-filter `bucketIndex` — the same value `compile` tags each row with.
|
|
752
|
+
return emphasisContext(ctx, "ridgeline")?.resolver() ?? null;
|
|
753
|
+
},
|
|
754
|
+
compileHitTest(ctx: CompileContext<T>): CompiledHitTest<T> | null {
|
|
755
|
+
const { data } = ctx;
|
|
756
|
+
const layout = getLayout(ctx);
|
|
757
|
+
const { orientation, valueAxis, cellSize, ox, oy, buckets, dodging } = layout;
|
|
758
|
+
|
|
759
|
+
// Whole-row hit regions: each bucket claims its full band slot across
|
|
760
|
+
// the entire value axis. The result is "anywhere on the row" hover,
|
|
761
|
+
// which matches the painted ridge silhouette far better than the
|
|
762
|
+
// single-point fallback (median × bandCenter) ever could.
|
|
763
|
+
const [vDom0, vDom1] = valueAxis.domain;
|
|
764
|
+
const valueLo = valueAxis(vDom0);
|
|
765
|
+
const valueHi = valueAxis(vDom1);
|
|
766
|
+
const valueStart = Math.min(valueLo, valueHi);
|
|
767
|
+
const valueSpan = Math.abs(valueHi - valueLo);
|
|
768
|
+
|
|
769
|
+
const positions = new Float32Array(buckets.length * 2);
|
|
770
|
+
const rects = new Float32Array(buckets.length * 4);
|
|
771
|
+
const seriesKey: (string | undefined)[] = Array.from({ length: buckets.length });
|
|
772
|
+
const medians: number[] = Array.from({ length: buckets.length });
|
|
773
|
+
const counts: number[] = Array.from({ length: buckets.length });
|
|
774
|
+
const categories: string[] = Array.from({ length: buckets.length });
|
|
775
|
+
const groupKeys: (string | undefined)[] = Array.from({ length: buckets.length });
|
|
776
|
+
// Pre-filter bucket index per emitted hit: the hovered row references the
|
|
777
|
+
// same `buckets.indexOf(bucket)` the compile path uses. Using a post-filter
|
|
778
|
+
// `n` here would silently desync whenever a bucket was skipped (no stats /
|
|
779
|
+
// off-band).
|
|
780
|
+
const hitBucketIndex: number[] = Array.from({ length: buckets.length });
|
|
781
|
+
let n = 0;
|
|
782
|
+
for (let bi = 0; bi < buckets.length; bi++) {
|
|
783
|
+
const bucket = buckets[bi]!;
|
|
784
|
+
const stats = boxStats(bucket.values, {
|
|
785
|
+
whisker: options.whisker ?? 1.5,
|
|
786
|
+
quantile: options.quantile ?? "type-7",
|
|
787
|
+
});
|
|
788
|
+
if (!stats) continue;
|
|
789
|
+
const bandCenter = bucketBandCenter(layout, bucket);
|
|
790
|
+
if (!Number.isFinite(bandCenter)) continue;
|
|
791
|
+
const medianPx = valueAxis(stats.median);
|
|
792
|
+
if (!Number.isFinite(medianPx)) continue;
|
|
793
|
+
// For canonical orientation "x" (rows on y, value on x), hit at
|
|
794
|
+
// (medianPx, bandCenter). For "y" the axes swap.
|
|
795
|
+
const bandStart = bandCenter - cellSize / 2;
|
|
796
|
+
if (orientation === "x") {
|
|
797
|
+
positions[n * 2] = ox + medianPx;
|
|
798
|
+
positions[n * 2 + 1] = oy + bandCenter;
|
|
799
|
+
rects[n * 4] = ox + valueStart;
|
|
800
|
+
rects[n * 4 + 1] = oy + bandStart;
|
|
801
|
+
rects[n * 4 + 2] = valueSpan;
|
|
802
|
+
rects[n * 4 + 3] = cellSize;
|
|
803
|
+
} else {
|
|
804
|
+
positions[n * 2] = ox + bandCenter;
|
|
805
|
+
positions[n * 2 + 1] = oy + medianPx;
|
|
806
|
+
rects[n * 4] = ox + bandStart;
|
|
807
|
+
rects[n * 4 + 1] = oy + valueStart;
|
|
808
|
+
rects[n * 4 + 2] = cellSize;
|
|
809
|
+
rects[n * 4 + 3] = valueSpan;
|
|
810
|
+
}
|
|
811
|
+
// `seriesKey` is consumed by the hit-layer keyed on slot index `n`
|
|
812
|
+
// (it reads `seriesKey[hitIndex]`), so it stays at `n`. The synth
|
|
813
|
+
// channel accessors below look up by `dataIndex[hitIndex]` (=
|
|
814
|
+
// pre-filter bucket index), so those side arrays write at `bi`.
|
|
815
|
+
seriesKey[n] = dodging ? bucket.groupKey : bucket.category;
|
|
816
|
+
medians[bi] = stats.median;
|
|
817
|
+
counts[bi] = stats.n;
|
|
818
|
+
categories[bi] = bucket.category;
|
|
819
|
+
groupKeys[bi] = bucket.groupKey;
|
|
820
|
+
hitBucketIndex[n] = bi;
|
|
821
|
+
n++;
|
|
822
|
+
}
|
|
823
|
+
if (n === 0) return null;
|
|
824
|
+
|
|
825
|
+
const dataIndex = new Int32Array(n);
|
|
826
|
+
for (let i = 0; i < n; i++) dataIndex[i] = hitBucketIndex[i]!;
|
|
827
|
+
|
|
828
|
+
const channelsMap: ResolvedChannelMap<T> = {
|
|
829
|
+
x: synthAes(orientation === "x" ? "median" : "category", (_d, idx) =>
|
|
830
|
+
orientation === "x" ? medians[idx] : categories[idx],
|
|
831
|
+
),
|
|
832
|
+
y: synthAes(orientation === "x" ? "category" : "median", (_d, idx) =>
|
|
833
|
+
orientation === "x" ? categories[idx] : medians[idx],
|
|
834
|
+
),
|
|
835
|
+
color: dodging ? synthAes("group", (_d, idx) => groupKeys[idx] ?? "") : undefined,
|
|
836
|
+
size: synthAes("n", (_d, idx) => counts[idx]),
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
return {
|
|
840
|
+
geomKind: "ridgeline",
|
|
841
|
+
label: options.label,
|
|
842
|
+
positions: positions.subarray(0, n * 2),
|
|
843
|
+
rects: rects.subarray(0, n * 4),
|
|
844
|
+
dataIndex,
|
|
845
|
+
seriesKey: seriesKey.slice(0, n),
|
|
846
|
+
// Region hits cover the band slot exactly; pickRadius is the
|
|
847
|
+
// fallback used for tooltip anchoring and brush queries.
|
|
848
|
+
pickRadius: Math.max(layout.cellSize / 2, 8),
|
|
849
|
+
channels: channelsMap,
|
|
850
|
+
data,
|
|
851
|
+
};
|
|
852
|
+
},
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// ---------------------------------------------------------------------------
|
|
857
|
+
// KDE row drawing
|
|
858
|
+
// ---------------------------------------------------------------------------
|
|
859
|
+
|
|
860
|
+
interface DrawKdeCtx {
|
|
861
|
+
orientation: "x" | "y";
|
|
862
|
+
valueAxis: ContinuousScale;
|
|
863
|
+
ox: number;
|
|
864
|
+
oy: number;
|
|
865
|
+
widthFor: (density: number) => number;
|
|
866
|
+
fill: Color;
|
|
867
|
+
stroke: Color;
|
|
868
|
+
strokeWidth: number;
|
|
869
|
+
fillMode: RidgelineFillMode;
|
|
870
|
+
gradientFor: (xValue: number) => Color;
|
|
871
|
+
emphasisKey?: number;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function drawKdeRow(layer: Layer, row: KdeRow, c: DrawKdeCtx) {
|
|
875
|
+
const k = row.kde;
|
|
876
|
+
const n = k.x.length;
|
|
877
|
+
if (n < 2) return;
|
|
878
|
+
const baselinePx = row.bandCenter;
|
|
879
|
+
|
|
880
|
+
// Project KDE samples to (value-axis pixel, density-pixel-offset). With the
|
|
881
|
+
// shared `orientation` convention ("x" → value on x; "y" → value on y), the
|
|
882
|
+
// density extends perpendicular to the value axis. For orientation "x"
|
|
883
|
+
// (the canonical ridgeline look) it pushes upward (-y); for orientation "y"
|
|
884
|
+
// it pushes rightward (+x).
|
|
885
|
+
const valuePx = Array.from<number>({ length: n });
|
|
886
|
+
const topOffset = Array.from<number>({ length: n });
|
|
887
|
+
for (let i = 0; i < n; i++) {
|
|
888
|
+
valuePx[i] = c.valueAxis(k.x[i]!);
|
|
889
|
+
topOffset[i] = c.widthFor(k.y[i]!);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const project = (i: number, top: boolean): Vec2 => {
|
|
893
|
+
if (c.orientation === "x") {
|
|
894
|
+
return {
|
|
895
|
+
x: c.ox + valuePx[i]!,
|
|
896
|
+
y: c.oy + (top ? baselinePx - topOffset[i]! : baselinePx),
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
return {
|
|
900
|
+
x: c.ox + (top ? baselinePx + topOffset[i]! : baselinePx),
|
|
901
|
+
y: c.oy + valuePx[i]!,
|
|
902
|
+
};
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
if (c.fillMode === "gradient") {
|
|
906
|
+
for (let i = 0; i < n - 1; i++) {
|
|
907
|
+
const xMid = (k.x[i]! + k.x[i + 1]!) / 2;
|
|
908
|
+
const fill = c.gradientFor(xMid);
|
|
909
|
+
const quad: Vec2[] = [
|
|
910
|
+
project(i, false),
|
|
911
|
+
project(i, true),
|
|
912
|
+
project(i + 1, true),
|
|
913
|
+
project(i + 1, false),
|
|
914
|
+
];
|
|
915
|
+
layer.pushPolygon({ points: quad, fill, emphasisKey: c.emphasisKey });
|
|
916
|
+
}
|
|
917
|
+
if (c.strokeWidth > 0) {
|
|
918
|
+
const outline: Vec2[] = Array.from({ length: n });
|
|
919
|
+
for (let i = 0; i < n; i++) outline[i] = project(i, true);
|
|
920
|
+
layer.pushPolyline({
|
|
921
|
+
points: outline,
|
|
922
|
+
color: c.stroke,
|
|
923
|
+
width: c.strokeWidth,
|
|
924
|
+
emphasisKey: c.emphasisKey,
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const points: Vec2[] = Array.from({ length: n * 2 });
|
|
931
|
+
for (let i = 0; i < n; i++) points[i] = project(i, true);
|
|
932
|
+
for (let i = 0; i < n; i++) points[n + i] = project(n - 1 - i, false);
|
|
933
|
+
pushFilledPolygon(layer, points, {
|
|
934
|
+
fill: c.fill,
|
|
935
|
+
stroke: c.stroke,
|
|
936
|
+
strokeWidth: c.strokeWidth,
|
|
937
|
+
emphasisKey: c.emphasisKey,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// ---------------------------------------------------------------------------
|
|
942
|
+
// Histogram row drawing
|
|
943
|
+
// ---------------------------------------------------------------------------
|
|
944
|
+
|
|
945
|
+
interface DrawHistCtx {
|
|
946
|
+
orientation: "x" | "y";
|
|
947
|
+
valueAxis: ContinuousScale;
|
|
948
|
+
ox: number;
|
|
949
|
+
oy: number;
|
|
950
|
+
widthFor: (value: number) => number;
|
|
951
|
+
fill: Color;
|
|
952
|
+
stroke: Color;
|
|
953
|
+
strokeWidth: number;
|
|
954
|
+
fillMode: RidgelineFillMode;
|
|
955
|
+
gradientFor: (xValue: number) => Color;
|
|
956
|
+
emphasisKey?: number;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function drawHistogramRow(layer: Layer, row: HistRow, c: DrawHistCtx) {
|
|
960
|
+
const baselinePx = row.bandCenter;
|
|
961
|
+
const k = row.bins.length;
|
|
962
|
+
if (k === 0) return;
|
|
963
|
+
|
|
964
|
+
const projectBin = (binEdgeValuePx: number, top: number, atTop: boolean): Vec2 => {
|
|
965
|
+
if (c.orientation === "x") {
|
|
966
|
+
return {
|
|
967
|
+
x: c.ox + binEdgeValuePx,
|
|
968
|
+
y: c.oy + (atTop ? baselinePx - top : baselinePx),
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
return {
|
|
972
|
+
x: c.ox + (atTop ? baselinePx + top : baselinePx),
|
|
973
|
+
y: c.oy + binEdgeValuePx,
|
|
974
|
+
};
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
if (c.fillMode === "gradient") {
|
|
978
|
+
for (let i = 0; i < k; i++) {
|
|
979
|
+
const bin = row.bins[i]!;
|
|
980
|
+
const top = c.widthFor(row.values[i]!);
|
|
981
|
+
if (top <= 0) continue;
|
|
982
|
+
const xMid = (bin.x0 + bin.x1) / 2;
|
|
983
|
+
const fill = c.gradientFor(xMid);
|
|
984
|
+
const x0Px = c.valueAxis(bin.x0);
|
|
985
|
+
const x1Px = c.valueAxis(bin.x1);
|
|
986
|
+
const quad: Vec2[] = [
|
|
987
|
+
projectBin(x0Px, top, false),
|
|
988
|
+
projectBin(x0Px, top, true),
|
|
989
|
+
projectBin(x1Px, top, true),
|
|
990
|
+
projectBin(x1Px, top, false),
|
|
991
|
+
];
|
|
992
|
+
layer.pushPolygon({ points: quad, fill, emphasisKey: c.emphasisKey });
|
|
993
|
+
}
|
|
994
|
+
if (c.strokeWidth > 0) {
|
|
995
|
+
const outline = histogramOutline(row, c, /* closeToBaseline */ false);
|
|
996
|
+
if (outline.length >= 2) {
|
|
997
|
+
layer.pushPolyline({
|
|
998
|
+
points: outline,
|
|
999
|
+
color: c.stroke,
|
|
1000
|
+
width: c.strokeWidth,
|
|
1001
|
+
emphasisKey: c.emphasisKey,
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const closed = histogramOutline(row, c, /* closeToBaseline */ true);
|
|
1009
|
+
if (closed.length >= 3) {
|
|
1010
|
+
pushFilledPolygon(layer, closed, {
|
|
1011
|
+
fill: c.fill,
|
|
1012
|
+
stroke: c.stroke,
|
|
1013
|
+
strokeWidth: c.strokeWidth,
|
|
1014
|
+
emphasisKey: c.emphasisKey,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Build the stepped silhouette of a histogram row.
|
|
1021
|
+
*
|
|
1022
|
+
* - `closeToBaseline: true` returns a closed polygon (top edge + baseline
|
|
1023
|
+
* closure), suitable for `pushPolygon` solid fill.
|
|
1024
|
+
* - `closeToBaseline: false` returns just the top edge — used for the
|
|
1025
|
+
* stroke pass over a gradient-filled row.
|
|
1026
|
+
*/
|
|
1027
|
+
function histogramOutline(row: HistRow, c: DrawHistCtx, closeToBaseline: boolean): Vec2[] {
|
|
1028
|
+
const baselinePx = row.bandCenter;
|
|
1029
|
+
const k = row.bins.length;
|
|
1030
|
+
const points: Vec2[] = [];
|
|
1031
|
+
for (let i = 0; i < k; i++) {
|
|
1032
|
+
const bin = row.bins[i]!;
|
|
1033
|
+
const top = c.widthFor(row.values[i]!);
|
|
1034
|
+
const x0Px = c.valueAxis(bin.x0);
|
|
1035
|
+
const x1Px = c.valueAxis(bin.x1);
|
|
1036
|
+
if (c.orientation === "x") {
|
|
1037
|
+
points.push({ x: c.ox + x0Px, y: c.oy + baselinePx - top });
|
|
1038
|
+
points.push({ x: c.ox + x1Px, y: c.oy + baselinePx - top });
|
|
1039
|
+
} else {
|
|
1040
|
+
points.push({ x: c.ox + baselinePx + top, y: c.oy + x0Px });
|
|
1041
|
+
points.push({ x: c.ox + baselinePx + top, y: c.oy + x1Px });
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
if (closeToBaseline && points.length > 0) {
|
|
1045
|
+
const lastEdge = c.valueAxis(row.bins[k - 1]!.x1);
|
|
1046
|
+
const firstEdge = c.valueAxis(row.bins[0]!.x0);
|
|
1047
|
+
if (c.orientation === "x") {
|
|
1048
|
+
points.push({ x: c.ox + lastEdge, y: c.oy + baselinePx });
|
|
1049
|
+
points.push({ x: c.ox + firstEdge, y: c.oy + baselinePx });
|
|
1050
|
+
} else {
|
|
1051
|
+
points.push({ x: c.ox + baselinePx, y: c.oy + lastEdge });
|
|
1052
|
+
points.push({ x: c.ox + baselinePx, y: c.oy + firstEdge });
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
return points;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// ---------------------------------------------------------------------------
|
|
1059
|
+
// Baseline + inner annotations
|
|
1060
|
+
// ---------------------------------------------------------------------------
|
|
1061
|
+
|
|
1062
|
+
interface BaselineCtx {
|
|
1063
|
+
orientation: "x" | "y";
|
|
1064
|
+
ox: number;
|
|
1065
|
+
oy: number;
|
|
1066
|
+
stroke: Color;
|
|
1067
|
+
width: number;
|
|
1068
|
+
emphasisKey?: number;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function drawBaseline(
|
|
1072
|
+
layer: Layer,
|
|
1073
|
+
bandCenter: number,
|
|
1074
|
+
valueAxis: ContinuousScale,
|
|
1075
|
+
c: BaselineCtx,
|
|
1076
|
+
) {
|
|
1077
|
+
const [r0, r1] = valueAxis.range;
|
|
1078
|
+
if (c.orientation === "x") {
|
|
1079
|
+
// Value on x → baseline is horizontal at the row's y position.
|
|
1080
|
+
layer.pushPolyline({
|
|
1081
|
+
points: [
|
|
1082
|
+
{ x: c.ox + r0, y: c.oy + bandCenter },
|
|
1083
|
+
{ x: c.ox + r1, y: c.oy + bandCenter },
|
|
1084
|
+
],
|
|
1085
|
+
color: c.stroke,
|
|
1086
|
+
width: c.width,
|
|
1087
|
+
emphasisKey: c.emphasisKey,
|
|
1088
|
+
});
|
|
1089
|
+
} else {
|
|
1090
|
+
// Value on y → baseline is vertical at the column's x position.
|
|
1091
|
+
layer.pushPolyline({
|
|
1092
|
+
points: [
|
|
1093
|
+
{ x: c.ox + bandCenter, y: c.oy + r0 },
|
|
1094
|
+
{ x: c.ox + bandCenter, y: c.oy + r1 },
|
|
1095
|
+
],
|
|
1096
|
+
color: c.stroke,
|
|
1097
|
+
width: c.width,
|
|
1098
|
+
emphasisKey: c.emphasisKey,
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
interface InnerCtx {
|
|
1104
|
+
orientation: "x" | "y";
|
|
1105
|
+
ox: number;
|
|
1106
|
+
oy: number;
|
|
1107
|
+
kind: RidgelineInner;
|
|
1108
|
+
stroke: Color;
|
|
1109
|
+
strokeWidth: number;
|
|
1110
|
+
dotRadius: number;
|
|
1111
|
+
emphasisKey?: number;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function drawInner(
|
|
1115
|
+
layer: Layer,
|
|
1116
|
+
bandCenter: number,
|
|
1117
|
+
stats: BoxStats,
|
|
1118
|
+
valueAxis: ContinuousScale,
|
|
1119
|
+
c: InnerCtx,
|
|
1120
|
+
) {
|
|
1121
|
+
if (c.kind === "median") {
|
|
1122
|
+
drawDot(layer, valueAxis(stats.median), bandCenter, c.dotRadius, c);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
if (c.kind === "mean") {
|
|
1126
|
+
drawDot(layer, valueAxis(stats.mean), bandCenter, c.dotRadius, c);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
if (c.kind === "quartile") {
|
|
1130
|
+
const q1Px = valueAxis(stats.q1);
|
|
1131
|
+
const q3Px = valueAxis(stats.q3);
|
|
1132
|
+
if (c.orientation === "x") {
|
|
1133
|
+
layer.pushPolyline({
|
|
1134
|
+
points: [
|
|
1135
|
+
{ x: c.ox + q1Px, y: c.oy + bandCenter },
|
|
1136
|
+
{ x: c.ox + q3Px, y: c.oy + bandCenter },
|
|
1137
|
+
],
|
|
1138
|
+
color: c.stroke,
|
|
1139
|
+
width: c.strokeWidth + 1,
|
|
1140
|
+
emphasisKey: c.emphasisKey,
|
|
1141
|
+
});
|
|
1142
|
+
} else {
|
|
1143
|
+
layer.pushPolyline({
|
|
1144
|
+
points: [
|
|
1145
|
+
{ x: c.ox + bandCenter, y: c.oy + q1Px },
|
|
1146
|
+
{ x: c.ox + bandCenter, y: c.oy + q3Px },
|
|
1147
|
+
],
|
|
1148
|
+
color: c.stroke,
|
|
1149
|
+
width: c.strokeWidth + 1,
|
|
1150
|
+
emphasisKey: c.emphasisKey,
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
drawDot(layer, valueAxis(stats.median), bandCenter, c.dotRadius, c);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function drawDot(layer: Layer, valuePx: number, bandCenter: number, radius: number, c: InnerCtx) {
|
|
1158
|
+
const cx = c.orientation === "x" ? c.ox + valuePx : c.ox + bandCenter;
|
|
1159
|
+
const cy = c.orientation === "x" ? c.oy + bandCenter : c.oy + valuePx;
|
|
1160
|
+
layer.pushCircle({ cx, cy, radius, fill: c.stroke, emphasisKey: c.emphasisKey });
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Re-export for tests.
|
|
1164
|
+
export type { CategoricalLayout };
|