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,129 @@
|
|
|
1
|
+
import type { Color } from "insomni";
|
|
2
|
+
import { type QuantileMethod, type WhiskerRule } from "../../stats/index.ts";
|
|
3
|
+
import { type PointShapeKind } from "../../marks.ts";
|
|
4
|
+
import type { Aes } from "../aes.ts";
|
|
5
|
+
import type { Geom } from "./types.ts";
|
|
6
|
+
import { type CategoricalLayout } from "./_categorical.ts";
|
|
7
|
+
export type PointsMode = "auto" | "always" | "none";
|
|
8
|
+
export interface BoxplotChannels<T> {
|
|
9
|
+
/** Categorical band axis. */
|
|
10
|
+
x: Aes<T, string | number | Date>;
|
|
11
|
+
/**
|
|
12
|
+
* Numeric value axis (the distribution to summarize) when vertical. For
|
|
13
|
+
* horizontal layouts (`orientation: "x"`) this carries the band category, so
|
|
14
|
+
* a string is accepted; the value axis then lives on `x`.
|
|
15
|
+
*/
|
|
16
|
+
y: Aes<T, string | number | Date>;
|
|
17
|
+
/**
|
|
18
|
+
* Optional grouping key. When supplied (and distinct from `x`) the layer
|
|
19
|
+
* dodges side-by-side by this channel within each band.
|
|
20
|
+
*/
|
|
21
|
+
color?: Aes<T, unknown>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Mean-overlay configuration. `true` → default red dot; `false` (default) →
|
|
25
|
+
* no overlay; object → fully customised.
|
|
26
|
+
*/
|
|
27
|
+
export interface MeanMarkerOptions {
|
|
28
|
+
shape?: PointShapeKind;
|
|
29
|
+
/** Radius in pixels. Default `3.5`. */
|
|
30
|
+
radius?: number;
|
|
31
|
+
fill?: Color;
|
|
32
|
+
stroke?: Color;
|
|
33
|
+
strokeWidth?: number;
|
|
34
|
+
}
|
|
35
|
+
export interface BoxplotOptions {
|
|
36
|
+
/** Auto-detected from scale types when omitted (band axis = category axis). */
|
|
37
|
+
orientation?: "x" | "y";
|
|
38
|
+
/**
|
|
39
|
+
* Box width as a fraction of the bandwidth (or grouped inner bandwidth, when
|
|
40
|
+
* dodging). Default `0.6`.
|
|
41
|
+
*/
|
|
42
|
+
width?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Scale box widths by `√(n / nMax)` so groups with more samples render
|
|
45
|
+
* wider — matches ggplot's `varwidth = TRUE`. Default `false`.
|
|
46
|
+
*/
|
|
47
|
+
varwidth?: boolean;
|
|
48
|
+
/** Whisker rule. Default `1.5` (Tukey). Pass `"minmax"` for no outliers. */
|
|
49
|
+
whisker?: WhiskerRule;
|
|
50
|
+
/** Quantile interpolation method. Default `"type-7"` (R default). */
|
|
51
|
+
quantile?: QuantileMethod;
|
|
52
|
+
/**
|
|
53
|
+
* Render notches around the median at `± 1.58 · IQR / √n` (McGill 1978's
|
|
54
|
+
* 95% CI). Useful as an informal significance test: non-overlapping
|
|
55
|
+
* notches between groups suggest medians differ. Default `false`.
|
|
56
|
+
*/
|
|
57
|
+
notched?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Notch indent as a fraction of the box width (how far the sides pull in
|
|
60
|
+
* at the median). Default `0.5`.
|
|
61
|
+
*/
|
|
62
|
+
notchWidth?: number;
|
|
63
|
+
/**
|
|
64
|
+
* When to overlay raw jittered points.
|
|
65
|
+
*
|
|
66
|
+
* - `"auto"` (default) — only when `n < pointsThreshold` for the group.
|
|
67
|
+
* Rationale: small samples are easy to mislead with a box; show the dots.
|
|
68
|
+
* - `"always"` — always overlay.
|
|
69
|
+
* - `"none"` — never overlay (still draws outliers unless `outliers: false`).
|
|
70
|
+
*/
|
|
71
|
+
points?: PointsMode;
|
|
72
|
+
/** Threshold for `points: "auto"`. Default `10`. */
|
|
73
|
+
pointsThreshold?: number;
|
|
74
|
+
/** Jitter spread, fraction of the per-box width. Default `0.5`. */
|
|
75
|
+
pointJitter?: number;
|
|
76
|
+
/** Radius of overlay & outlier points. Default `2.5`. */
|
|
77
|
+
pointRadius?: number;
|
|
78
|
+
/** Override fill for jittered overlay points. Default `alphaize(stroke, 0.5)`. */
|
|
79
|
+
pointFill?: Color;
|
|
80
|
+
/** Override fill for outlier points. Default = stroke. */
|
|
81
|
+
outlierFill?: Color;
|
|
82
|
+
/** Outlier point radius. Default = `pointRadius`. */
|
|
83
|
+
outlierRadius?: number;
|
|
84
|
+
/** Seed for deterministic jitter. Default `1`. */
|
|
85
|
+
jitterSeed?: number;
|
|
86
|
+
/** Render outlier dots beyond the whiskers. Default `true`. */
|
|
87
|
+
outliers?: boolean;
|
|
88
|
+
/** Constant fill when no `color` channel. Default theme palette[0]. */
|
|
89
|
+
fill?: Color;
|
|
90
|
+
/**
|
|
91
|
+
* Override the theme's fill alpha (use `0` for outline-only boxes, e.g.
|
|
92
|
+
* to match ggplot's bottom-middle "no-fill" look). When unset the layer
|
|
93
|
+
* uses `theme.marks.fillAlpha`.
|
|
94
|
+
*/
|
|
95
|
+
fillAlpha?: number;
|
|
96
|
+
/** Box stroke color. Default theme text color (light line on dark fill). */
|
|
97
|
+
stroke?: Color;
|
|
98
|
+
/** Box stroke width. Default `1`. */
|
|
99
|
+
strokeWidth?: number;
|
|
100
|
+
/** Median line color. Defaults to `stroke`. */
|
|
101
|
+
medianStroke?: Color;
|
|
102
|
+
/** Median line stroke width. Default `2`. */
|
|
103
|
+
medianStrokeWidth?: number;
|
|
104
|
+
/** Whisker line stroke width. Default = `strokeWidth`. */
|
|
105
|
+
whiskerStrokeWidth?: number;
|
|
106
|
+
/** Whisker cap width as a fraction of box width. Default `0.5`. */
|
|
107
|
+
capWidth?: number;
|
|
108
|
+
/**
|
|
109
|
+
* Mean overlay. `true` for the default red dot; `false` (default) to omit;
|
|
110
|
+
* pass an object to customize shape, radius, fill, stroke. The mean is
|
|
111
|
+
* **not** part of the box geometry — it's an annotation on top.
|
|
112
|
+
*/
|
|
113
|
+
mean?: boolean | MeanMarkerOptions;
|
|
114
|
+
/** Inner-band padding for grouped (color-split) layout. Default `0.05`. */
|
|
115
|
+
groupPadding?: number;
|
|
116
|
+
/**
|
|
117
|
+
* Render an `n=<count>` label per band-axis category, just outside the
|
|
118
|
+
* plot frame. Counts are summed across dodge groups. Default `false`.
|
|
119
|
+
*/
|
|
120
|
+
showCounts?: boolean;
|
|
121
|
+
/** Pixel offset of count labels from plot frame. Default `28` (vertical) / `32` (horizontal). */
|
|
122
|
+
countsOffset?: number;
|
|
123
|
+
countsFontSize?: number;
|
|
124
|
+
countsColor?: Color;
|
|
125
|
+
/** Display label for legend. */
|
|
126
|
+
label?: string;
|
|
127
|
+
}
|
|
128
|
+
export declare function boxplot<T>(channels: BoxplotChannels<T>, options?: BoxplotOptions): Geom<T>;
|
|
129
|
+
export type { CategoricalLayout };
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { createFrame } from "insomni";
|
|
3
|
+
import { describe, expect, test } from "vite-plus/test";
|
|
4
|
+
import { plot } from "../chart.ts";
|
|
5
|
+
import { resolveAes } from "../aes.ts";
|
|
6
|
+
import { buildPositionScale, type ScaleBundle } from "../scales.ts";
|
|
7
|
+
import { themeDefault } from "../theme.ts";
|
|
8
|
+
import { boxplot } from "./boxplot.ts";
|
|
9
|
+
import { emphasisKeyFor, geomEmphasisBase } from "./emphasis.ts";
|
|
10
|
+
import type { CompileContext } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
// A fake Layer that records the emphasisKey of every primitive pushed, across
|
|
13
|
+
// all the push kinds a box composites (rect / polyline / circle / polygon).
|
|
14
|
+
function makeKeyCapture() {
|
|
15
|
+
const keys: (number | undefined)[] = [];
|
|
16
|
+
const record = (s: { emphasisKey?: number }) => keys.push(s.emphasisKey);
|
|
17
|
+
const layer = {
|
|
18
|
+
pushRect: record,
|
|
19
|
+
pushCircle: record,
|
|
20
|
+
pushEllipse: record,
|
|
21
|
+
pushSegment: record,
|
|
22
|
+
pushLine: record,
|
|
23
|
+
pushPolyline: record,
|
|
24
|
+
pushPolygon: record,
|
|
25
|
+
pushText: () => layer,
|
|
26
|
+
pushString: () => layer,
|
|
27
|
+
};
|
|
28
|
+
return { layer, keys };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Obs {
|
|
32
|
+
group: string;
|
|
33
|
+
value: number;
|
|
34
|
+
color?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ============ Fixtures ============
|
|
38
|
+
const fixture = {
|
|
39
|
+
// Three categories, ~10 observations each, seeded so tests are deterministic.
|
|
40
|
+
threeGroups: (): Obs[] => {
|
|
41
|
+
const out: Obs[] = [];
|
|
42
|
+
const seeds = { A: 0.1, B: 0.5, C: 0.9 };
|
|
43
|
+
let r = 0;
|
|
44
|
+
const rand = () => {
|
|
45
|
+
r = (r * 9301 + 49297) % 233280;
|
|
46
|
+
return r / 233280;
|
|
47
|
+
};
|
|
48
|
+
for (const [name, mu] of Object.entries(seeds)) {
|
|
49
|
+
for (let i = 0; i < 12; i++) {
|
|
50
|
+
out.push({ group: name, value: mu + (rand() - 0.5) * 0.3 });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
},
|
|
55
|
+
withOutliers: (): Obs[] =>
|
|
56
|
+
[1, 2, 2, 3, 3, 3, 4, 4, 5, 12, 18].map((v) => ({ group: "A", value: v })),
|
|
57
|
+
smallSample: (): Obs[] => [1, 2, 3].map((v) => ({ group: "A", value: v })),
|
|
58
|
+
dodged: (): Obs[] => {
|
|
59
|
+
const out: Obs[] = [];
|
|
60
|
+
for (const grp of ["A", "B"]) {
|
|
61
|
+
for (const col of ["x", "y"]) {
|
|
62
|
+
for (let i = 0; i < 10; i++) {
|
|
63
|
+
out.push({ group: grp, color: col, value: i + (col === "x" ? 0 : 5) });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ============ Tests ============
|
|
72
|
+
describe("boxplot()", () => {
|
|
73
|
+
test("renders to SVG with a band x-axis and numeric y", () => {
|
|
74
|
+
const svg = plot({ data: fixture.threeGroups(), width: 400, height: 300 })
|
|
75
|
+
.layer(boxplot({ x: "group", y: "value" }))
|
|
76
|
+
.toSVG();
|
|
77
|
+
expect(svg).toBeInstanceOf(SVGSVGElement);
|
|
78
|
+
// Three groups, no outliers expected with this jitter range.
|
|
79
|
+
// We assert the SVG produced something non-trivial by checking the body dim.
|
|
80
|
+
expect(svg.getAttribute("width")).toBe("400");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("renders outlier points beyond 1.5*IQR by default", () => {
|
|
84
|
+
const svg = plot({ data: fixture.withOutliers(), width: 320, height: 240 })
|
|
85
|
+
.layer(boxplot({ x: "group", y: "value" }))
|
|
86
|
+
.toSVG();
|
|
87
|
+
expect(svg).toBeInstanceOf(SVGSVGElement);
|
|
88
|
+
// Two outliers (12, 18) → at least 2 circles. SVG renderer represents
|
|
89
|
+
// circles as <circle> nodes.
|
|
90
|
+
const circles = svg.querySelectorAll("circle");
|
|
91
|
+
expect(circles.length).toBeGreaterThanOrEqual(2);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("'minmax' whisker rule emits no outlier circles", () => {
|
|
95
|
+
const svg = plot({ data: fixture.withOutliers(), width: 320, height: 240 })
|
|
96
|
+
.layer(boxplot({ x: "group", y: "value" }, { whisker: "minmax", points: "none" }))
|
|
97
|
+
.toSVG();
|
|
98
|
+
const circles = svg.querySelectorAll("circle");
|
|
99
|
+
expect(circles.length).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("auto-overlays jittered points when n < threshold", () => {
|
|
103
|
+
const svg = plot({ data: fixture.smallSample(), width: 320, height: 240 })
|
|
104
|
+
.layer(boxplot({ x: "group", y: "value" }, { points: "auto", pointsThreshold: 10 }))
|
|
105
|
+
.toSVG();
|
|
106
|
+
const circles = svg.querySelectorAll("circle");
|
|
107
|
+
expect(circles.length).toBeGreaterThanOrEqual(3);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("dodges by color channel without throwing", () => {
|
|
111
|
+
const svg = plot({ data: fixture.dodged(), width: 480, height: 320 })
|
|
112
|
+
.layer(boxplot({ x: "group", y: "value", color: "color" }))
|
|
113
|
+
.toSVG();
|
|
114
|
+
expect(svg).toBeInstanceOf(SVGSVGElement);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("does not throw on n=1 or all-equal samples", () => {
|
|
118
|
+
expect(() =>
|
|
119
|
+
plot({ data: [{ group: "A", value: 5 }] as Obs[] })
|
|
120
|
+
.layer(boxplot({ x: "group", y: "value" }))
|
|
121
|
+
.toSVG({ width: 200, height: 200 }),
|
|
122
|
+
).not.toThrow();
|
|
123
|
+
expect(() =>
|
|
124
|
+
plot({ data: [1, 1, 1, 1].map((v) => ({ group: "A", value: v })) as Obs[] })
|
|
125
|
+
.layer(boxplot({ x: "group", y: "value" }))
|
|
126
|
+
.toSVG({ width: 200, height: 200 }),
|
|
127
|
+
).not.toThrow();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("notched boxes emit polygon nodes (one per group)", () => {
|
|
131
|
+
const svg = plot({ data: fixture.threeGroups(), width: 400, height: 300 })
|
|
132
|
+
.layer(boxplot({ x: "group", y: "value" }, { notched: true, points: "none" }))
|
|
133
|
+
.toSVG();
|
|
134
|
+
// Notched bodies are rendered as polygons; un-notched would be <rect>.
|
|
135
|
+
const polygons = svg.querySelectorAll("polygon");
|
|
136
|
+
const rects = svg.querySelectorAll("rect");
|
|
137
|
+
expect(polygons.length).toBeGreaterThanOrEqual(3);
|
|
138
|
+
// The 3 box bodies should now be polygons rather than rects (rects may
|
|
139
|
+
// still appear from background panel etc, but at least 3 polygons exist).
|
|
140
|
+
expect(polygons.length).toBeGreaterThan(0);
|
|
141
|
+
expect(rects.length).toBeGreaterThanOrEqual(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("mean: true overlays one additional point per group", () => {
|
|
145
|
+
const data = fixture.threeGroups();
|
|
146
|
+
const baseSvg = plot({ data, width: 320, height: 240 })
|
|
147
|
+
.layer(boxplot({ x: "group", y: "value" }, { points: "none", outliers: false }))
|
|
148
|
+
.toSVG();
|
|
149
|
+
const meanSvg = plot({ data, width: 320, height: 240 })
|
|
150
|
+
.layer(boxplot({ x: "group", y: "value" }, { points: "none", outliers: false, mean: true }))
|
|
151
|
+
.toSVG();
|
|
152
|
+
const baseCircles = baseSvg.querySelectorAll("circle").length;
|
|
153
|
+
const meanCircles = meanSvg.querySelectorAll("circle").length;
|
|
154
|
+
expect(meanCircles - baseCircles).toBe(3);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("mean accepts a custom shape and the renderer doesn't throw", () => {
|
|
158
|
+
expect(() =>
|
|
159
|
+
plot({ data: fixture.threeGroups() })
|
|
160
|
+
.layer(
|
|
161
|
+
boxplot(
|
|
162
|
+
{ x: "group", y: "value" },
|
|
163
|
+
{ mean: { shape: "diamond", radius: 4 }, points: "none" },
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
.toSVG({ width: 320, height: 240 }),
|
|
167
|
+
).not.toThrow();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("showCounts does not throw and renders without an atlas", () => {
|
|
171
|
+
// SVG export without a device has no glyph atlas, so the count labels are
|
|
172
|
+
// skipped — but the geom must remain robust. (The full label render is
|
|
173
|
+
// exercised in the live `plot-box` example.)
|
|
174
|
+
expect(() =>
|
|
175
|
+
plot({ data: fixture.threeGroups(), width: 400, height: 300 })
|
|
176
|
+
.layer(boxplot({ x: "group", y: "value" }, { showCounts: true, points: "none" }))
|
|
177
|
+
.toSVG(),
|
|
178
|
+
).not.toThrow();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("varwidth doesn't throw and stays valid for unequal group sizes", () => {
|
|
182
|
+
const data: Obs[] = [];
|
|
183
|
+
for (let i = 0; i < 30; i++) data.push({ group: "A", value: Math.sin(i) });
|
|
184
|
+
for (let i = 0; i < 5; i++) data.push({ group: "B", value: Math.cos(i) });
|
|
185
|
+
expect(() =>
|
|
186
|
+
plot({ data, width: 320, height: 240 })
|
|
187
|
+
.layer(boxplot({ x: "group", y: "value" }, { varwidth: true, points: "none" }))
|
|
188
|
+
.toSVG(),
|
|
189
|
+
).not.toThrow();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("compileHitTest emits one hit per non-empty bucket at (bandCenter, median)", () => {
|
|
193
|
+
const data = fixture.threeGroups();
|
|
194
|
+
const xAes = resolveAes<Obs, unknown>("group");
|
|
195
|
+
const yAes = resolveAes<Obs, unknown>("value");
|
|
196
|
+
const xScale = buildPositionScale(xAes, data, [0, 300], { type: "band", padding: 0 });
|
|
197
|
+
const yScale = buildPositionScale(yAes, data, [200, 0]);
|
|
198
|
+
const scales: ScaleBundle = { x: xScale, y: yScale };
|
|
199
|
+
const ctx: CompileContext<Obs> = {
|
|
200
|
+
data,
|
|
201
|
+
scales,
|
|
202
|
+
plot: createFrame({ x: 50, y: 30, width: 300, height: 200 }),
|
|
203
|
+
theme: themeDefault,
|
|
204
|
+
atlas: undefined,
|
|
205
|
+
};
|
|
206
|
+
const geom = boxplot<Obs>({ x: "group", y: "value" });
|
|
207
|
+
const hits = geom.compileHitTest!(ctx)!;
|
|
208
|
+
expect(hits.geomKind).toBe("boxplot");
|
|
209
|
+
expect(hits.dataIndex.length).toBe(3);
|
|
210
|
+
// Each hit's seriesKey should match the category when not dodging.
|
|
211
|
+
expect(new Set(hits.seriesKey)).toEqual(new Set(["A", "B", "C"]));
|
|
212
|
+
// Hit positions should fall inside the plot frame.
|
|
213
|
+
for (let i = 0; i < hits.dataIndex.length; i++) {
|
|
214
|
+
expect(hits.positions[i * 2]).toBeGreaterThanOrEqual(50);
|
|
215
|
+
expect(hits.positions[i * 2]).toBeLessThanOrEqual(50 + 300);
|
|
216
|
+
expect(hits.positions[i * 2 + 1]).toBeGreaterThanOrEqual(30);
|
|
217
|
+
expect(hits.positions[i * 2 + 1]).toBeLessThanOrEqual(30 + 200);
|
|
218
|
+
}
|
|
219
|
+
// Channels expose median + category synth accessors.
|
|
220
|
+
const yAesSynth = hits.channels.y!;
|
|
221
|
+
expect(typeof yAesSynth.fn(data[0]!, 0)).toBe("number");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("fillAlpha: 0 produces outline-only boxes", () => {
|
|
225
|
+
expect(() =>
|
|
226
|
+
plot({ data: fixture.threeGroups(), width: 320, height: 240 })
|
|
227
|
+
.layer(boxplot({ x: "group", y: "value" }, { fillAlpha: 0, points: "none" }))
|
|
228
|
+
.toSVG(),
|
|
229
|
+
).not.toThrow();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("boxplot() — GPU hover emphasis (P5-T3 Gap 1)", () => {
|
|
234
|
+
function makeCtx(data: Obs[], emphasis = true): CompileContext<Obs> {
|
|
235
|
+
const xAes = resolveAes<Obs, unknown>("group");
|
|
236
|
+
const yAes = resolveAes<Obs, unknown>("value");
|
|
237
|
+
const xScale = buildPositionScale(xAes, data, [0, 300], { type: "band", padding: 0 });
|
|
238
|
+
const yScale = buildPositionScale(yAes, data, [200, 0]);
|
|
239
|
+
const scales: ScaleBundle = { x: xScale, y: yScale };
|
|
240
|
+
return {
|
|
241
|
+
data,
|
|
242
|
+
scales,
|
|
243
|
+
plot: createFrame({ x: 50, y: 30, width: 300, height: 200 }),
|
|
244
|
+
theme: themeDefault,
|
|
245
|
+
atlas: undefined,
|
|
246
|
+
emphasisBase: emphasis ? geomEmphasisBase(0) : undefined,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
test("tagging present + every primitive of a single box shares ONE key", () => {
|
|
251
|
+
// smallSample is one bucket ("A") with raw-point overlay, outliers, mean:
|
|
252
|
+
// exercises rect + median polyline + whisker polylines + circle dots.
|
|
253
|
+
const data = fixture.smallSample();
|
|
254
|
+
const geom = boxplot<Obs>({ x: "group", y: "value" }, { mean: true, points: "always" });
|
|
255
|
+
const { layer, keys } = makeKeyCapture();
|
|
256
|
+
for (const b of geom.compile(makeCtx(data))) b.addTo(layer as never);
|
|
257
|
+
const nonzero = keys.filter((k): k is number => k !== undefined && k >= 1);
|
|
258
|
+
expect(nonzero.length).toBeGreaterThan(0); // tagging present
|
|
259
|
+
// Whole-entity invariant: a single bucket → exactly one distinct key, the
|
|
260
|
+
// resolver's key for bucketIndex 0.
|
|
261
|
+
const expected = emphasisKeyFor(geomEmphasisBase(0), 0);
|
|
262
|
+
expect(new Set(nonzero)).toEqual(new Set([expected]));
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("tagging is stable across hover (compile no longer reads ctx.hovered)", () => {
|
|
266
|
+
const data = fixture.threeGroups();
|
|
267
|
+
const geom = boxplot<Obs>({ x: "group", y: "value" }, { points: "none" });
|
|
268
|
+
const cap1 = makeKeyCapture();
|
|
269
|
+
for (const b of geom.compile(makeCtx(data))) b.addTo(cap1.layer as never);
|
|
270
|
+
const cap2 = makeKeyCapture();
|
|
271
|
+
for (const b of geom.compile({
|
|
272
|
+
...makeCtx(data),
|
|
273
|
+
hovered: { geomKind: "boxplot", dataIndex: 1, data, x: 0, y: 0 },
|
|
274
|
+
}))
|
|
275
|
+
b.addTo(cap2.layer as never);
|
|
276
|
+
expect(cap2.keys).toEqual(cap1.keys);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("emphasisResolution maps a hit's dataIndex (bucketIndex) to the tagged key", () => {
|
|
280
|
+
const data = fixture.threeGroups();
|
|
281
|
+
const geom = boxplot<Obs>({ x: "group", y: "value" });
|
|
282
|
+
const res = geom.emphasisResolution!(makeCtx(data))!;
|
|
283
|
+
expect(res.geomKind).toBe("boxplot");
|
|
284
|
+
expect(res.resolve({ geomKind: "boxplot", dataIndex: 2, data, x: 0, y: 0 })).toBe(
|
|
285
|
+
emphasisKeyFor(geomEmphasisBase(0), 2),
|
|
286
|
+
);
|
|
287
|
+
// A hit from another geom is not ours.
|
|
288
|
+
expect(res.resolve({ geomKind: "violin", dataIndex: 0, data, x: 0, y: 0 })).toBeNull();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("no emphasisBase (SSR/SVG) → boxes untagged, no resolver key", () => {
|
|
292
|
+
const data = fixture.threeGroups();
|
|
293
|
+
const geom = boxplot<Obs>({ x: "group", y: "value" }, { points: "none" });
|
|
294
|
+
const { layer, keys } = makeKeyCapture();
|
|
295
|
+
for (const b of geom.compile(makeCtx(data, false))) b.addTo(layer as never);
|
|
296
|
+
expect(keys.every((k) => k === undefined)).toBe(true);
|
|
297
|
+
expect(geom.emphasisResolution!(makeCtx(data, false))).toBeNull();
|
|
298
|
+
});
|
|
299
|
+
});
|