@graphysdk/geom-boxplot 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,17 @@
1
+ # License
2
+
3
+ This software is available under any of the following licenses, at your option,
4
+ depending on which one's conditions you meet:
5
+
6
+ - **PolyForm Noncommercial 1.0.0** — for noncommercial use (personal,
7
+ charitable, educational, research, government).
8
+ See [licenses/PolyForm-NonCommercial-1.0.0.md](licenses/PolyForm-NonCommercial-1.0.0.md)
9
+
10
+ - **PolyForm Small Business 1.0.0** — free commercial use for organizations
11
+ under the size thresholds defined in the license.
12
+ See [licenses/PolyForm-Small-Business-1.0.0.md](licenses/PolyForm-Small-Business-1.0.0.md)
13
+
14
+ - **PolyForm Free Trial 1.0.0** — a 32-day evaluation for anyone else.
15
+ See [licenses/PolyForm-Free-Trial-1.0.0.md](licenses/PolyForm-Free-Trial-1.0.0.md)
16
+
17
+ For commercial use beyond these terms, contact [hello@graphy.dev](mailto:hello@graphy.dev) or visit [https://graphy.dev](https://graphy.dev).
package/README.md CHANGED
@@ -1,3 +1,59 @@
1
- # Temporary Holding Version
1
+ # @graphysdk/geom-boxplot
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > **Beta.** This geom is on a `0.x` version line: a minor release may change its aesthetics, params, style parts or look. Use a caret range (`^0.1.0`) to take only patches.
4
+
5
+ A boxplot geom for the Graphy charting SDK. It summarises the values of each category as a box from the first to the third quartile with a line at the median, whiskers out to the furthest values within 1.5 interquartile ranges of the box, and each value beyond them as an outlier.
6
+
7
+ Learn more at [graphy.dev](https://graphy.dev).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @graphysdk/geom-boxplot @graphysdk/react
13
+ ```
14
+
15
+ Requires React 19. Its only peer is `@graphysdk/react`.
16
+
17
+ ## Usage
18
+
19
+ Data is raw, one row per value; the geom computes the summary. Map the category to `x`, the value to `y`, and a group to `color` to draw boxes side by side:
20
+
21
+ ```tsx
22
+ import { createGraphyKit, GraphRenderer } from '@graphysdk/react';
23
+ import { boxplot } from '@graphysdk/geom-boxplot';
24
+
25
+ const kit = createGraphyKit({ plugins: [boxplot] });
26
+
27
+ const spec = kit.pipe(
28
+ kit.createSpec({ x: 'service', y: 'ms' }),
29
+ kit.geom.boxplot(),
30
+ kit.scale.x.discrete(),
31
+ kit.scale.y.continuous()
32
+ );
33
+
34
+ export function Latencies({ data }) {
35
+ return (
36
+ <kit.GraphProvider spec={spec} data={data}>
37
+ <GraphRenderer />
38
+ </kit.GraphProvider>
39
+ );
40
+ }
41
+ ```
42
+
43
+ `kit.geom.boxplot({ params: { extent: 'min-max' } })` runs the whiskers to the extremes instead, with no outliers.
44
+
45
+ Style the boxes with `kit.style.geom.boxplot()`, and the rest of the mark with `kit.style.geom.boxplot.median()`, `.whisker()` and `.outlier()`.
46
+
47
+ ## Docs
48
+
49
+ - [Boxplot](https://docs.graphy.dev/sdk-next/graph-types/boxplot)
50
+
51
+ ## Feedback
52
+
53
+ This geom is released early to hear what works. Tell us on [Discord](https://discord.gg/yGmYCjkSr).
54
+
55
+ ## Licence
56
+
57
+ This package is available under PolyForm Noncommercial, Small Business, or a 32-day Free Trial. Free Trial is local or internal evaluation only — it does not cover a public page. See [what you can do under the trial](https://docs.graphy.dev/licence/evaluation).
58
+
59
+ For a commercial licence, [talk to us](https://graphy.dev/license).
@@ -0,0 +1,189 @@
1
+ import { AnchorContext, AnchorPosition, GeomCompileResult, GeomCompilerInput, GeomMappingValidationInput, GeomObservationPart, GeomStylePart, GeomStyleTarget, Observation, UserInputIssue, Geom } from '@graphysdk/react';
2
+ import { WhiskerExtent } from './utils/summarise-box';
3
+ import { BoxplotStat } from './boxplot.stat';
4
+ export interface BoxplotGeomParams {
5
+ /**
6
+ * How far past the quartiles a whisker may reach, in interquartile ranges: `1.5` (Tukey's) by default. A
7
+ * value beyond it is drawn as an outlier. `'min-max'` runs the whiskers to the extremes, with no outliers.
8
+ */
9
+ extent?: WhiskerExtent;
10
+ /**
11
+ * The fraction of its band a category's boxes spread across, in `(0, 1]`: `0.6` by default, so neighbours
12
+ * keep a gap. Dodged, the groups share it.
13
+ */
14
+ width?: number;
15
+ /** Whether each value past a whisker is drawn: `true` by default. Hidden, it holds no room on the value axis. */
16
+ outliers?: boolean;
17
+ /** Whether each box is notched at its median's rough 95% interval, so two boxes' medians compare at a glance. */
18
+ notch?: boolean;
19
+ /** Whether each box marks its mean. */
20
+ showMean?: boolean;
21
+ /**
22
+ * Whether each box's width follows the root of how many values it summarises, against the layer's largest,
23
+ * so a box drawn from few values reads as thinner evidence.
24
+ */
25
+ varwidth?: boolean;
26
+ }
27
+ /** The observations paint the shared geom vocabulary on top of these. */
28
+ declare const BOX_VOCABULARY: {
29
+ readonly strokeWidth: "pixels";
30
+ };
31
+ declare const LINE_VOCABULARY: {
32
+ readonly stroke: "color";
33
+ readonly alpha: "unitInterval";
34
+ readonly strokeWidth: "pixels";
35
+ readonly dashArray: "dashArray";
36
+ };
37
+ declare const DOT_VOCABULARY: {
38
+ readonly fill: "color";
39
+ readonly fillAlpha: "unitInterval";
40
+ readonly stroke: "color";
41
+ readonly alpha: "unitInterval";
42
+ readonly size: "pixels";
43
+ readonly strokeWidth: "pixels";
44
+ };
45
+ /** Named so the published types reach the observations and every part through `@graphysdk/react`, the package's one peer. */
46
+ interface BoxplotStyleTarget extends GeomStyleTarget {
47
+ observation: GeomObservationPart<typeof BOX_VOCABULARY>;
48
+ parts: {
49
+ median: GeomStylePart<typeof LINE_VOCABULARY>;
50
+ whisker: GeomStylePart<typeof LINE_VOCABULARY>;
51
+ cap: GeomStylePart<typeof LINE_VOCABULARY>;
52
+ outlier: GeomStylePart<typeof DOT_VOCABULARY>;
53
+ mean: GeomStylePart<typeof DOT_VOCABULARY>;
54
+ };
55
+ }
56
+ /**
57
+ * A boxplot: one box per category and group, from the first to the third quartile with a line at the median,
58
+ * whiskers out to the furthest values within reach of the box, and every value beyond them as an outlier.
59
+ *
60
+ * By default data is long and raw, one row per value: `x` is the category, `y` the value and `color` the
61
+ * group, and the `boxplot` stat summarises it. Under `stat: 'identity'` the data is already summarised, one
62
+ * row per box: the author maps `lowerWhisker`, `q1`, `median`, `q3` and `upperWhisker` (and, if they have
63
+ * them, `outlier`, `mean`, `notchLower`, `notchUpper` and `count`). A category holding several groups dodges
64
+ * their boxes across its band, as a grouped bar does. A horizontal boxplot is `coord.flip()`.
65
+ */
66
+ export declare class BoxplotGeom extends Geom<BoxplotGeomParams> {
67
+ readonly type: "boxplot";
68
+ readonly defaultParams: BoxplotGeomParams;
69
+ readonly defaultStat: BoxplotStat;
70
+ readonly derivedVariables: ("boxplotQ1" | "boxplotMedian" | "boxplotQ3" | "boxplotLowerWhisker" | "boxplotUpperWhisker" | "boxplotCount" | "boxplotMean" | "boxplotNotchLower" | "boxplotNotchUpper" | "boxplotRelativeWidth" | "boxplotOutlier")[];
71
+ /**
72
+ * The whiskers are the value interval, so they drive the value axis. The median, quartiles and outliers are
73
+ * scaled on the same axis.
74
+ */
75
+ readonly positionRoles: readonly [{
76
+ readonly axis: "x";
77
+ readonly role: "point";
78
+ readonly valueKind: "value";
79
+ }, {
80
+ readonly axis: "y";
81
+ readonly role: "min";
82
+ readonly valueKind: "value";
83
+ readonly aes: "lowerWhisker";
84
+ }, {
85
+ readonly axis: "y";
86
+ readonly role: "max";
87
+ readonly valueKind: "value";
88
+ readonly aes: "upperWhisker";
89
+ }, {
90
+ readonly axis: "y";
91
+ readonly role: "scalar";
92
+ readonly valueKind: "value";
93
+ readonly aes: "q1";
94
+ }, {
95
+ readonly axis: "y";
96
+ readonly role: "scalar";
97
+ readonly valueKind: "value";
98
+ readonly aes: "median";
99
+ }, {
100
+ readonly axis: "y";
101
+ readonly role: "scalar";
102
+ readonly valueKind: "value";
103
+ readonly aes: "q3";
104
+ }, {
105
+ readonly axis: "y";
106
+ readonly role: "scalar";
107
+ readonly valueKind: "value";
108
+ readonly aes: "outlier";
109
+ }, {
110
+ readonly axis: "y";
111
+ readonly role: "scalar";
112
+ readonly valueKind: "value";
113
+ readonly aes: "mean";
114
+ }, {
115
+ readonly axis: "y";
116
+ readonly role: "scalar";
117
+ readonly valueKind: "value";
118
+ readonly aes: "notchLower";
119
+ }, {
120
+ readonly axis: "y";
121
+ readonly role: "scalar";
122
+ readonly valueKind: "value";
123
+ readonly aes: "notchUpper";
124
+ }];
125
+ /** `y` is the raw values the stat summarises; `count` how many values a box summarises, which `varwidth` reads. */
126
+ readonly aesthetics: readonly [{
127
+ readonly kind: "visual";
128
+ readonly name: "color";
129
+ }, {
130
+ readonly kind: "data";
131
+ readonly name: "y";
132
+ }, {
133
+ readonly kind: "data";
134
+ readonly name: "count";
135
+ }];
136
+ readonly defaultPosition = "dodge";
137
+ /** A stacked box would start on the one below it, and its quartiles would read as lengths they are not. */
138
+ readonly supportedPositions: readonly ["identity", "dodge"];
139
+ readonly supportedCoordTypes: readonly ["cartesian", "flip"];
140
+ /** The value axis fits the data: a box encodes where values sit, not a length from zero. */
141
+ readonly scaleConstraints: {
142
+ discreteMainAxis: boolean;
143
+ };
144
+ readonly legend: {
145
+ suppressWhenSingleItem: boolean;
146
+ };
147
+ /**
148
+ * A hovered box is one unit, from its lower whisker to its upper across its share of the band, so a dodged
149
+ * group's box answers for itself. An outlier is a rect of no extent at its value on its box's share, so it
150
+ * answers once the cursor is nearer it than the box.
151
+ */
152
+ readonly spatialKind = "rects";
153
+ readonly highlightStrategy: "observation-rerender";
154
+ /**
155
+ * A box's tooltip reads its summary top to bottom, and an outlier's its own value, each formatted like the
156
+ * value axis. The two kinds of observation carry none of each other's fields, so each drops the other's rows.
157
+ */
158
+ readonly tooltip: {
159
+ key: string;
160
+ aes: string;
161
+ optional: boolean;
162
+ }[];
163
+ readonly styleTarget: BoxplotStyleTarget;
164
+ /** The boxes spread across `width` of their band, so an axis annotation aligned to an edge lands where they end. */
165
+ readonly resolveBandFraction: Geom['resolveBandFraction'];
166
+ /** A pinned number or a difference arrow on a box measures its median, where the box is read. */
167
+ readonly resolveValueSource: Geom['resolveValueSource'];
168
+ /** Substitutes an unreadable `extent` or `width` and reports it, so the same spec can't diverge across renderers. */
169
+ resolveParams(options: Parameters<Geom['resolveParams']>[0]): Record<string, unknown>;
170
+ /**
171
+ * Under the `boxplot` stat the summary comes from the raw values of `y`, so a mapped summary aesthetic would
172
+ * be overwritten. Under any other stat the author maps the summary, and a box needs its quartiles and median;
173
+ * a stat that computes `y` would leave nothing to draw a box from.
174
+ */
175
+ readonly validateMapping: ({ mapping, computedVariables, }: GeomMappingValidationInput) => readonly UserInputIssue[];
176
+ /**
177
+ * Where the editor raises its annotation trigger and where an annotation lands: on a box's median at the
178
+ * centre of the box, which is where a box is read, or on an outlier itself.
179
+ */
180
+ readonly resolveAnchorPosition: (observation: Observation, { coordSystem }: AnchorContext) => AnchorPosition | null;
181
+ compile({ data, mapping, params }: GeomCompilerInput): GeomCompileResult;
182
+ private resolveExtent;
183
+ /**
184
+ * Above `1` the boxes would overlap the next category's, so it clamps to the full band; at or below `0`
185
+ * nothing is drawable, so the default takes over.
186
+ */
187
+ private resolveWidth;
188
+ }
189
+ export {};
@@ -0,0 +1,15 @@
1
+ import { StatCompileResult, StatCompilerInput, Stat } from '@graphysdk/react';
2
+ /** Every aesthetic the summary provides, so a layer under this stat maps none of them itself. */
3
+ export declare const BOXPLOT_STAT_AESTHETICS: ReadonlySet<string>;
4
+ /**
5
+ * Summarises the raw values of `y` into one observation per box and one per outlier. A box is a category of
6
+ * `x` and a group, split by the same aesthetics the layer groups on (`group`, or else its discrete ones such
7
+ * as `color`), as the `count` stat splits: the stat runs before the layer is grouped.
8
+ *
9
+ * It reads the whisker `extent` from the layer's params, where the author sets it on the geom.
10
+ */
11
+ export declare class BoxplotStat extends Stat {
12
+ readonly type: "boxplot";
13
+ readonly computedVariables: ReadonlySet<string>;
14
+ protected computeStat({ data, mapping, params }: StatCompilerInput): StatCompileResult;
15
+ }
@@ -0,0 +1,24 @@
1
+ /** How far past the quartiles a whisker may reach, in interquartile ranges, or to the extremes (`'min-max'`). */
2
+ export type WhiskerExtent = number | 'min-max';
3
+ /** Tukey's reach: a value more than one and a half interquartile ranges past the box is an outlier. */
4
+ export declare const DEFAULT_EXTENT = 1.5;
5
+ export declare const isWhiskerExtent: (value: unknown) => value is WhiskerExtent;
6
+ /** The five-number summary one box draws, with the extras it may show. A value past a whisker is an outlier. */
7
+ export interface BoxSummary {
8
+ lowerWhisker: number;
9
+ q1: number;
10
+ median: number;
11
+ q3: number;
12
+ upperWhisker: number;
13
+ count: number;
14
+ mean: number;
15
+ /** The median's rough 95% interval (McGill, Tukey and Larsen): two boxes whose notches miss differ in median. */
16
+ notchLower: number;
17
+ notchUpper: number;
18
+ }
19
+ /**
20
+ * Summarises one box's values: the quartiles by linear interpolation between order statistics (the method
21
+ * R, NumPy and d3 default to), and Tukey whiskers that reach the furthest value inside `extent` interquartile
22
+ * ranges of the box. `null` when there is nothing to summarise.
23
+ */
24
+ export declare const summariseBox: (values: readonly number[], extent: WhiskerExtent) => BoxSummary | null;
package/dist/index.cjs ADDED
@@ -0,0 +1 @@
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});let s=require("@graphysdk/react"),W=require("react"),f=require("react/jsx-runtime");var x={q1:"boxplotQ1",median:"boxplotMedian",q3:"boxplotQ3",lowerWhisker:"boxplotLowerWhisker",upperWhisker:"boxplotUpperWhisker",count:"boxplotCount",mean:"boxplotMean",notchLower:"boxplotNotchLower",notchUpper:"boxplotNotchUpper",relativeWidth:"boxplotRelativeWidth",outlier:"boxplotOutlier"},_=e=>(e.start+e.end)/2,H=(e,t)=>{const r=t==="x"?(0,s.getXMin)(e):(0,s.getYMin)(e),o=t==="x"?(0,s.getXMax)(e):(0,s.getYMax)(e),a=t==="x"?(0,s.getYMin)(e):(0,s.getXMin)(e),n=t==="x"?(0,s.getYMax)(e):(0,s.getXMax)(e),l=(0,s.getScaledAesthetic)(e,"q1"),u=(0,s.getScaledAesthetic)(e,"median"),c=(0,s.getScaledAesthetic)(e,"q3");if(r===null||o===null||a===null||n===null||u===null||l===null||c===null)return null;const i=E(e,"notchLower"),d=E(e,"notchUpper"),[m,k]=ne(r,o,e);return{observation:e,start:m,end:k,lowerWhisker:a,q1:l,median:u,q3:c,upperWhisker:n,mean:E(e,"mean"),notch:i===null||d===null?null:{lower:i,upper:d}}},F=(e,t)=>{const r=t==="x"?(0,s.getXMin)(e):(0,s.getYMin)(e),o=t==="x"?(0,s.getXMax)(e):(0,s.getYMax)(e),a=(0,s.getScaledAesthetic)(e,"outlier");return r===null||o===null||a===null?null:{observation:e,band:_({start:r,end:o}),value:a}},S=(e,t)=>{const r=[],o=[];for(const a of e){const n=H(a,t);if(n!==null){r.push(n);continue}const l=F(a,t);l!==null&&o.push(l)}return{boxes:r,outliers:o}},E=(e,t)=>(0,s.getScaledAesthetic)(e,t)??null,ne=(e,t,r)=>{const o=r[x.relativeWidth];if(typeof o!="number")return[e,t];const a=(e+t)/2,n=(t-e)*o/2;return[a-n,a+n]},b=1.5,G=e=>e==="min-max"||typeof e=="number"&&Number.isFinite(e)&&e>=0,se=1.58,le=(e,t)=>{if(e.length===0)return null;const r=[...e].sort((m,k)=>m-k),o=B(r,.25),a=B(r,.5),n=B(r,.75),l=t==="min-max"?1/0:t*(n-o),u=o-l,c=n+l,i=r.filter(m=>m>=u&&m<=c),d=se*(n-o)/Math.sqrt(e.length);return{lowerWhisker:Math.min(i[0]??o,o),q1:o,median:a,q3:n,upperWhisker:Math.max(i[i.length-1]??n,n),count:e.length,mean:r.reduce((m,k)=>m+k,0)/r.length,notchLower:a-d,notchUpper:a+d}},B=(e,t)=>{const r=(e.length-1)*t,o=Math.floor(r),a=e[o]??0;return a+((e[o+1]??a)-a)*(r-o)},Y={q1:x.q1,median:x.median,q3:x.q3,lowerWhisker:x.lowerWhisker,upperWhisker:x.upperWhisker,count:x.count,outlier:x.outlier},z=new Set([...Object.keys(Y),"mean","notchLower","notchUpper"]),ie=class extends s.Stat{constructor(...e){super(...e),this.type="boxplot",this.computedVariables=z}computeStat({data:e,mapping:t,params:r}){const o=(0,s.readVariableName)(t.x),a=(0,s.readVariableName)(t.y);if(o===null||a===null||!e.hasVariable(o)||!e.hasVariable(a))return{data:e,mapping:{}};if(e.size()>0&&e.getType(a)!=="numeric"){const l=e.getType(a);throw new s.UserInputError({code:"INCOMPATIBLE_TYPE",message:`'boxplot' stat requires a numeric 'y' (got '${l}' for variable '${a}')`,context:{aesthetic:"y",variableName:a,variableType:l},suggestion:"Map the raw values to 'y', or summarised columns under stat 'identity'."})}const n=r?.extent;return{data:ue({data:e,xVariable:o,yVariable:a,groupVariables:(0,s.getGroupVariables)(e,t).filter(l=>l!==o),extent:G(n)?n:b}),mapping:Y}}},ue=({data:e,xVariable:t,yVariable:r,groupVariables:o,extent:a})=>{const n=ce(e,t,r,o),l=e.getVariableNames(),u=new Map(l.map(p=>[p,e.getValues(p)])),c=new Map(l.map(p=>[p,[]])),i={q1:[],median:[],q3:[],lowerWhisker:[],upperWhisker:[],count:[],mean:[],notchLower:[],notchUpper:[],outlier:[]},d=[];for(const p of n){const v=le(p.values,a);if(v===null)continue;for(const h of l)c.get(h)?.push(he(u.get(h)??[],p.indices));i.q1.push(v.q1),i.median.push(v.median),i.q3.push(v.q3),i.lowerWhisker.push(v.lowerWhisker),i.upperWhisker.push(v.upperWhisker),i.count.push(v.count),i.mean.push(v.mean),i.notchLower.push(v.notchLower),i.notchUpper.push(v.notchUpper),i.outlier.push(null);const w={low:v.lowerWhisker,high:v.upperWhisker};p.indices.forEach((h,y)=>{const M=p.values[y];M!==void 0&&(M<w.low||M>w.high)&&d.push({index:h,value:M})})}for(const{index:p,value:v}of d){for(const w of l)c.get(w)?.push(u.get(w)?.[p]??null);i.q1.push(null),i.median.push(null),i.q3.push(null),i.lowerWhisker.push(null),i.upperWhisker.push(null),i.count.push(null),i.mean.push(null),i.notchLower.push(null),i.notchUpper.push(null),i.outlier.push(v)}const m=e.getValueFormat(r),k=Object.fromEntries(l.map(p=>[p,{type:e.getType(p),values:c.get(p)??[],valueFormat:e.getValueFormat(p)}]));for(const[p,v]of Object.entries(i)){const w=x[p];k[w]=p==="count"?{type:"numeric",values:v}:{type:"numeric",values:v,valueFormat:m}}return new s.Dataset(k)},ce=(e,t,r,o)=>{const a=[t,...o].map(u=>e.getValues(u)),n=e.getValues(r,{type:"numeric"}),l=new Map;return n.forEach((u,c)=>{if(u===null||!Number.isFinite(u))return;const i=a.map(m=>N(m[c]??null)).join("\0"),d=l.get(i)??{indices:[],values:[]};d.indices.push(c),d.values.push(u),l.set(i,d)}),[...l.values()]},he=(e,t)=>{const r=e[t[0]??-1]??null,o=N(r);return t.every(a=>N(e[a]??null)===o)?r:null},N=e=>e===null?"\0null":e instanceof Date?`date:${e.getTime()}`:`${typeof e}:${String(e)}`,Q=.6,de=1.5,pe=.3,me=2,A=1.5,ve=6,ge=1.5,fe=7,ke=1.5,ye=["q1","median","q3"],xe={strokeWidth:"pixels"},q={stroke:"color",alpha:"unitInterval",strokeWidth:"pixels",dashArray:"dashArray"},D={fill:"color",fillAlpha:"unitInterval",stroke:"color",alpha:"unitInterval",size:"pixels",strokeWidth:"pixels"},Z=class extends s.Geom{constructor(...e){super(...e),this.type="boxplot",this.defaultParams={extent:b,outliers:!0,notch:!1,showMean:!1,varwidth:!1},this.defaultStat=new ie,this.derivedVariables=Object.values(x),this.positionRoles=[{axis:"x",role:"point",valueKind:"value"},{axis:"y",role:"min",valueKind:"value",aes:"lowerWhisker"},{axis:"y",role:"max",valueKind:"value",aes:"upperWhisker"},{axis:"y",role:"scalar",valueKind:"value",aes:"q1"},{axis:"y",role:"scalar",valueKind:"value",aes:"median"},{axis:"y",role:"scalar",valueKind:"value",aes:"q3"},{axis:"y",role:"scalar",valueKind:"value",aes:"outlier"},{axis:"y",role:"scalar",valueKind:"value",aes:"mean"},{axis:"y",role:"scalar",valueKind:"value",aes:"notchLower"},{axis:"y",role:"scalar",valueKind:"value",aes:"notchUpper"}],this.aesthetics=[{kind:"visual",name:"color"},{kind:"data",name:"y"},{kind:"data",name:"count"}],this.defaultPosition="dodge",this.supportedPositions=["identity","dodge"],this.supportedCoordTypes=["cartesian","flip"],this.scaleConstraints={discreteMainAxis:!0},this.legend={suppressWhenSingleItem:!0},this.spatialKind="rects",this.highlightStrategy="observation-rerender",this.tooltip=[{key:"Upper whisker",aes:"upperWhisker",optional:!0},{key:"Q3",aes:"q3",optional:!0},{key:"Median",aes:"median",optional:!0},{key:"Q1",aes:"q1",optional:!0},{key:"Lower whisker",aes:"lowerWhisker",optional:!0},{key:"Count",aes:"count",optional:!0},{key:"Mean",aes:"mean",optional:!0},{key:"Outlier",aes:"outlier",optional:!0}],this.styleTarget=(0,s.defineGeomStyleTarget)({observation:{vocabulary:xe,aesthetics:{fill:"color",stroke:"color"},rest:{fill:(0,s.token)("geom"),fillAlpha:pe,stroke:(0,s.token)("geom"),strokeWidth:de}},parts:{median:{vocabulary:q,aesthetics:{stroke:"color"},rest:{stroke:(0,s.token)("geom"),alpha:1,strokeWidth:me,dashArray:[]}},whisker:{vocabulary:q,aesthetics:{stroke:"color"},rest:{stroke:(0,s.token)("geom"),alpha:1,strokeWidth:A,dashArray:[]}},cap:{vocabulary:q,aesthetics:{stroke:"color"},rest:{stroke:(0,s.token)("geom"),alpha:1,strokeWidth:A,dashArray:[]}},outlier:{vocabulary:D,aesthetics:{fill:"color",stroke:"color"},rest:{fill:(0,s.token)("geom"),fillAlpha:0,stroke:(0,s.token)("geom"),alpha:1,size:ve,strokeWidth:ge}},mean:{vocabulary:D,aesthetics:{fill:"color"},rest:{fill:(0,s.token)("geom"),fillAlpha:1,stroke:(0,s.token)("graphBackground"),alpha:1,size:fe,strokeWidth:ke}}}}),this.resolveBandFraction=t=>R(t),this.resolveValueSource=t=>t.median??{variable:x.median},this.validateMapping=({mapping:t,computedVariables:r})=>r.has("y")?[{code:"CONFLICTING_STAT_MAPPING",message:"boxplot geom draws a summary, not a single 'y'; use the 'boxplot' stat or 'identity'"}]:r.has("median")?[...[...z].filter(o=>t[o]!==void 0).map(o=>({code:"CONFLICTING_STAT_MAPPING",message:`boxplot stat computes '${o}' from 'y'; remove its mapping, or set the layer's stat to 'identity'`})),...t.y===void 0?[{code:"MISSING_AESTHETIC",message:"boxplot stat summarises 'y'; map it to the raw values"}]:[]]:ye.filter(o=>t[o]===void 0).map(o=>({code:"MISSING_AESTHETIC",message:`boxplot geom requires '${o}' when its data is already summarised`,context:{aesthetic:o}})),this.resolveAnchorPosition=(t,{coordSystem:r})=>{const o=(0,s.readMainAxis)(r),a=we(t,o);return a===null?null:o==="x"?{x:a.band,y:a.value}:{x:a.value,y:a.band}}}resolveParams(e){const{extent:t,width:r,...o}=super.resolveParams(e);return{...o,extent:this.resolveExtent(t,e.diagnostics),...this.resolveWidth(r,e.diagnostics)}}compile({data:e,mapping:t,params:r}){const o=P(e,t.lowerWhisker),a=P(e,t.upperWhisker);if(o===null||a===null)return{data:e,mapping:{}};const{outliers:n,notch:l,showMean:u,varwidth:c}=r,i=P(e,t.outlier),d=n===!1&&i!==null?Se(e,i):e,m=P(d,t.count),k=R(r)/2;return{data:d.addVariable(x.relativeWidth,"numeric",c===!0&&m!==null?Me(d,m):We(d)).addVariable(s.POSITION_VARIABLES.yMin,"numeric",K(d,o,i)).addVariable(s.POSITION_VARIABLES.yMax,"numeric",K(d,a,i)).addConstantVariable(s.POSITION_VARIABLES.xMin,"numeric",-k).addConstantVariable(s.POSITION_VARIABLES.xMax,"numeric",k),mapping:{...t.y===void 0&&{y:t.median},...u===!0&&t.mean===void 0&&d.hasVariable(x.mean)&&{mean:x.mean},...l===!0&&t.notchLower===void 0&&d.hasVariable(x.notchLower)&&{notchLower:x.notchLower,notchUpper:x.notchUpper}}}}resolveExtent(e,t){return G(e)?e:(t?.addWarning({code:"INVALID_GEOM_PARAM",message:`Boxplot extent must be a non-negative number or 'min-max', got ${String(e)}; falling back to ${b}.`,context:{geom:this.type,param:"extent",expected:"extent >= 0 or 'min-max'",actual:String(e)},suggestion:"Set params.extent to a number of interquartile ranges, or 'min-max'."}),b)}resolveWidth(e,t){if(e===void 0)return{};const r=typeof e=="number"&&Number.isFinite(e)?e:null;if(r!==null&&r>0&&r<=1)return{width:r};const o=r!==null&&r>1?1:void 0;return t?.addWarning({code:"INVALID_GEOM_PARAM",message:`Boxplot width must be a fraction in (0, 1], got ${String(e)}; falling back to ${o??Q}.`,context:{geom:this.type,param:"width",expected:"0 < width <= 1",actual:r??String(e)},suggestion:"Set params.width to a fraction of the category band in (0, 1]."}),o!==void 0?{width:o}:{}}},we=(e,t)=>{const r=H(e,t);if(r!==null)return{band:_(r),value:r.median};const o=F(e,t);return o===null?null:{band:o.band,value:o.value}},R=({width:e})=>e??Q,Me=(e,t)=>{const r=e.getValues(t,{type:"numeric"}),o=Math.max(0,...r.map(a=>a??0));return r.map(a=>a===null||o===0?null:Math.sqrt(a/o))},We=e=>Array.from({length:e.size()},()=>null),K=(e,t,r)=>{const o=r===null?[]:e.getValues(r,{type:"numeric"});return e.getValues(t,{type:"numeric"}).map((a,n)=>a??o[n]??null)},Se=(e,t)=>{const r=e.getValues(t,{type:"numeric"});return e.take(r.flatMap((o,a)=>o===null?[a]:[]))},P=(e,t)=>{const r=(0,s.readVariableName)(t);return r!==null&&e.hasVariable(r)?r:null},Oe=5e-4,Ie=.5,Pe=.5,J=(e,t)=>{const r=(e.q1+e.q3)/2,o=Math.max(Math.abs(e.q3-e.q1),Oe)/2,a=g(t,e.start,r-o),n=g(t,e.end,r+o),[l,u]=U(a.x,n.x),[c,i]=U(a.y,n.y);return{x:l,y:c,width:u-l,height:i-c}},ee=(e,t)=>{if(e.notch===null)return null;const{lower:r,upper:o}=e.notch,[a,n]=re(e);return[g(t,e.start,e.q1),g(t,e.start,r),g(t,a,e.median),g(t,e.start,o),g(t,e.start,e.q3),g(t,e.end,e.q3),g(t,e.end,o),g(t,n,e.median),g(t,e.end,r),g(t,e.end,e.q1)]},be=(e,t)=>{const[r,o]=e.notch===null?[e.start,e.end]:re(e);return{from:g(t,r,e.median),to:g(t,o,e.median)}},te=(e,t)=>e.mean===null?null:g(t,_(e),e.mean),re=e=>{const t=(e.end-e.start)*(1-Pe)/2;return[e.start+t,e.end-t]},oe=(e,t)=>{const r=_(e),o=(e.end-e.start)*Ie/2;return[{side:"lower",quartile:e.q1,reach:e.lowerWhisker},{side:"upper",quartile:e.q3,reach:e.upperWhisker}].filter(({quartile:a,reach:n})=>a!==n).map(({side:a,quartile:n,reach:l})=>({side:a,stem:{from:g(t,r,n),to:g(t,r,l)},cap:{from:g(t,r-o,l),to:g(t,r+o,l)}}))},g=(e,t,r)=>{const o=(0,s.composePoint)(e,{main:t,cross:r});return{x:(0,s.toViewBoxX)(o.x),y:(0,s.toViewBoxY)(o.y)}},U=(e,t)=>e<=t?[e,t]:[t,e],I=(e,t)=>({x:e.x*t.width,y:e.y*t.height}),V=(e,t)=>`${e.map((r,o)=>{const{x:a,y:n}=I(r,t);return`${o===0?"M":"L"}${a},${n}`}).join("")}Z`,_e=(e,t)=>V([{x:e.x,y:e.y},{x:e.x+e.width,y:e.y},{x:e.x+e.width,y:e.y+e.height},{x:e.x,y:e.y+e.height}],t),Te=(e,t)=>{const r=I(e.from,t),o=I(e.to,t);return`M${r.x},${r.y}L${o.x},${o.y}`},Ee=({layer:e,coordSystem:t,panelRect:r,styleReaders:o})=>{const a=(0,s.readMainAxis)(t),{boxes:n,outliers:l}=S(e.data,a),u=n.map(h=>{const y=ee(h,a);return y!==null?{kind:"region",pathData:V(y,r)}:{kind:"region",pathData:_e(J(h,a),r)}}),c=o.part("whisker"),i=o.part("cap"),d=n.flatMap(h=>oe(h,a).flatMap(y=>[{segment:y.stem,readers:c},{segment:y.cap,readers:i}].flatMap(({segment:M,readers:T})=>{const O=T?.get("strokeWidth",h.observation)??0;return O>0?[{kind:"stroke",pathData:Te(M,r),width:O}]:[]}))),m=o.part("outlier"),k=m===void 0?[]:l.map(h=>({center:I(g(a,h.band,h.value),r),size:j(m,h.observation)})),p=o.part("mean"),v=p===void 0?[]:n.flatMap(h=>{const y=te(h,a);return y===null?[]:[{center:I(y,r),size:j(p,h.observation)}]}),w=(0,s.buildDotsEditOutlineShapes)([...k,...v]);return[...d,...u,...w]},j=(e,t)=>(e.get("size",t)??0)+(e.get("strokeWidth",t)??0),L=e=>{const t=(0,s.createStableKeyGenerator)(e.data,e.mapping,e.id),r=(0,s.readVariableName)(e.mapping.outlier),o=a=>`${t(a.observation)}-outlier-${String(r===null?a.value:a.observation[r])}`;return{readBoxKey:t,readOutlierKeys:a=>{const n=new Map;return a.map(l=>{const u=o(l),c=n.get(u)??0;return n.set(u,c+1),c===0?u:`${u}-${c}`})}}},Be=(e,t,r)=>{const o=e.data.getVariableNames(),a=l=>JSON.stringify(o.map(u=>l.observation[u]??null)),n=new Set(r.map(a));return t.filter(l=>n.has(a(l)))},ae=(e,t,r)=>{const o=new Set(t.map(a=>r(a.observation)));return e.filter(a=>o.has(r(a.observation)))},C=({boxes:e,outliers:t,mainAxis:r,panelSize:o,source:a,styleReaders:n,keys:l,isHovered:u=!1,shouldAnimateTransitions:c=!1})=>{const i=u?"hovered":void 0,d=n.part("whisker"),m=n.part("cap"),k=n.part("median"),p=n.part("mean"),v=n.part("outlier"),w=l.readOutlierKeys(t);return(0,f.jsxs)(f.Fragment,{children:[e.flatMap(h=>oe(h,r).flatMap(y=>[{piece:"stem",testId:"boxplot-whisker",readers:d},{piece:"cap",testId:"boxplot-cap",readers:m}].map(({piece:M,testId:T,readers:O})=>O===void 0?null:(0,f.jsx)($,{testId:T,segment:y[M],observation:h.observation,readers:O,state:i,shouldAnimateTransitions:c},`${l.readBoxKey(h.observation)}-${y.side}-${M}`)))),e.map(h=>(0,f.jsx)(qe,{box:h,mainAxis:r,panelSize:o,source:a,styleReaders:n,state:i,shouldAnimateTransitions:c},l.readBoxKey(h.observation))),k&&e.map(h=>(0,f.jsx)($,{testId:"boxplot-median",segment:be(h,r),observation:h.observation,readers:k,state:i,shouldAnimateTransitions:c},l.readBoxKey(h.observation))),p&&e.map(h=>{const y=te(h,r);return y===null?null:(0,f.jsx)(X,{testId:"boxplot-mean",centre:y,observation:h.observation,readers:p,state:i,shouldAnimateTransitions:c},l.readBoxKey(h.observation))}),v&&t.map((h,y)=>(0,f.jsx)(X,{testId:"boxplot-outlier",centre:g(r,h.band,h.value),observation:h.observation,readers:v,state:i,shouldAnimateTransitions:c},w[y]))]})},$=({testId:e,segment:t,observation:r,readers:o,state:a,shouldAnimateTransitions:n})=>{const l=o.get("strokeWidth",r,a)??0,u=o.get("stroke",r,a);return(0,f.jsx)(s.GeomLine,{"data-testid":e,x1:(0,s.toPercent)(t.from.x),y1:(0,s.toPercent)(t.from.y),x2:(0,s.toPercent)(t.to.x),y2:(0,s.toPercent)(t.to.y),stroke:u===void 0?void 0:(0,s.toPaintColor)(u),strokeOpacity:o.get("alpha",r,a),strokeWidth:l,strokeDasharray:(0,s.toStrokeDasharray)(o.get("dashArray",r,a),l),shouldAnimateTransitions:n})},qe=({box:e,mainAxis:t,panelSize:r,source:o,styleReaders:a,state:n,shouldAnimateTransitions:l})=>{const u=a.get("fill",e.observation,n),c=a.get("stroke",e.observation,n),i={fill:u===void 0?void 0:(0,s.toPaintColor)(u),opacity:a.get("alpha",e.observation,n),fillOpacity:a.get("fillAlpha",e.observation,n),stroke:c===void 0?void 0:(0,s.toPaintColor)(c),strokeOpacity:a.get("strokeAlpha",e.observation,n),strokeWidth:a.get("strokeWidth",e.observation,n),shouldAnimateTransitions:l},d=ee(e,t);if(d!==null)return(0,f.jsx)(s.GeomPath,{"data-testid":"boxplot-box",d:V(d,r),source:o,...i});const m=J(e,t);return(0,f.jsx)(s.GeomRect,{"data-testid":"boxplot-box",x:(0,s.toPercent)(m.x),y:(0,s.toPercent)(m.y),width:(0,s.toPercent)(m.width),height:(0,s.toPercent)(m.height),...i})},X=({testId:e,centre:t,observation:r,readers:o,state:a,shouldAnimateTransitions:n})=>{const l=o.get("fill",r,a),u=o.get("stroke",r,a);return(0,f.jsx)(s.GeomCircle,{"data-testid":e,cx:(0,s.toPercent)(t.x),cy:(0,s.toPercent)(t.y),r:(o.get("size",r,a)??0)/2,fill:l===void 0?void 0:(0,s.toPaintColor)(l),opacity:o.get("alpha",r,a),fillOpacity:o.get("fillAlpha",r,a),stroke:u===void 0?void 0:(0,s.toPaintColor)(u),strokeWidth:o.get("strokeWidth",r,a),shouldAnimateTransitions:n})},Ne=({layer:e,sourceLayer:t,mainAxis:r,panelSize:o,styleReaders:a})=>{const n=(0,W.useMemo)(()=>L(t),[t]),l=(0,W.useMemo)(()=>S(t.data,r).outliers,[t.data,r]),{boxes:u,outliers:c}=(0,W.useMemo)(()=>{const i=S(e.data,r),d=new Set(Be(t,l,i.outliers)),m=new Set(ae(l,i.boxes,n.readBoxKey)),k=l.filter(p=>m.has(p)||d.has(p));return{boxes:i.boxes,outliers:k}},[e.data,r,t,l,n]);return(0,f.jsx)(C,{boxes:u,outliers:c,mainAxis:r,panelSize:o,source:t,styleReaders:a,keys:n})},Ve=({layer:e,primary:t,mainAxis:r,panelSize:o,styleReaders:a})=>{const n=(0,W.useMemo)(()=>S(e.data,r).outliers,[e.data,r]),l=(0,W.useMemo)(()=>L(e),[e]),{boxes:u,outliers:c}=S([t.observation],r);return(0,f.jsx)("g",{"data-testid":"hovered-boxes",children:(0,f.jsx)(C,{boxes:u,outliers:u.length>0?ae(n,u,l.readBoxKey):c,mainAxis:r,panelSize:o,source:e,styleReaders:a,keys:l,isHovered:!0})})},Le=({layer:e,mainAxis:t,panelSize:r,styleReaders:o,shouldAnimateTransitions:a,intro:n})=>{const{boxes:l,outliers:u}=(0,W.useMemo)(()=>S(e.data,t),[e.data,t]),c=(0,f.jsx)(C,{boxes:l,outliers:u,mainAxis:t,panelSize:r,source:e,styleReaders:o,keys:(0,W.useMemo)(()=>L(e),[e]),shouldAnimateTransitions:a});return n==null?c:(0,f.jsx)(s.IntroFade,{intro:{type:"fade",layerId:n.layerId,durationSeconds:n.durationSeconds},children:c})},Ce=(0,s.defineGeomRenderer)(new Z,{coord:"cartesian",swatchShape:"square",guideMode:"band",render:({layer:e,coordSystem:t,panelRect:r,styleReaders:o,shouldAnimateTransitions:a,intro:n})=>(0,f.jsx)(Le,{layer:e,mainAxis:(0,s.readMainAxis)(t),panelSize:r,styleReaders:o,shouldAnimateTransitions:a,intro:n}),renderHighlight:({layer:e,sourceLayer:t,coordSystem:r,panelRect:o,styleReaders:a})=>(0,f.jsx)(Ne,{layer:e,sourceLayer:t,mainAxis:(0,s.readMainAxis)(r),panelSize:o,styleReaders:a}),renderHover:({layer:e,primary:t,coordSystem:r,panelRect:o,styleReaders:a})=>(0,f.jsx)(Ve,{layer:e,primary:t,mainAxis:(0,s.readMainAxis)(r),panelSize:o,styleReaders:a}),renderHoverCompanions:()=>null,getEditOutlineShapes:Ee});exports.BOXPLOT_COLUMNS=x;exports.BoxplotGeom=Z;exports.boxplot=Ce;
@@ -0,0 +1,3 @@
1
+ export { BoxplotGeom } from './engine/boxplot.geom';
2
+ export { boxplot } from './renderer/boxplot';
3
+ export { BOXPLOT_COLUMNS } from './utils/boxplot-columns';