diff --git a/src/marks/box.js b/src/marks/box.js index 7d74db6972..a0b35b320c 100644 --- a/src/marks/box.js +++ b/src/marks/box.js @@ -1,4 +1,4 @@ -import {max, min, quantile} from "d3"; +import {max, min, quantile, quantileSorted} from "d3"; import {marks} from "../mark.js"; import {identity} from "../options.js"; import {groupX, groupY, groupZ} from "../transforms/group.js"; @@ -7,6 +7,8 @@ import {barX, barY} from "./bar.js"; import {dot} from "./dot.js"; import {ruleX, ruleY} from "./rule.js"; import {tickX, tickY} from "./tick.js"; +import {pointerX, pointerY} from "../interactions/pointer.js"; +import {tip as tipmark} from "./tip.js"; // Returns a composite mark for producing a horizontal box plot, applying the // necessary statistical transforms. The boxes are grouped by y, if present. @@ -21,6 +23,7 @@ export function boxX( strokeOpacity, strokeWidth = 2, sort, + tip, ...options } = {} ) { @@ -29,7 +32,8 @@ export function boxX( ruleY(data, group({x1: loqr1, x2: hiqr2}, {x, y, stroke, strokeOpacity, ...options})), barX(data, group({x1: "p25", x2: "p75"}, {x, y, fill, fillOpacity, ...options})), tickX(data, group({x: "p50"}, {x, y, stroke, strokeOpacity, strokeWidth, sort, ...options})), - dot(data, map({x: oqr}, {x, y, z: y, stroke, strokeOpacity, ...options})) + dot(data, map({x: oqr}, {x, y, z: y, stroke, strokeOpacity, ...options})), + tip && tipmark(data, pointerY(map({x: boxStats}, {x, y, z: y, ...options}))) ); } @@ -46,6 +50,7 @@ export function boxY( strokeOpacity, strokeWidth = 2, sort, + tip, ...options } = {} ) { @@ -54,7 +59,8 @@ export function boxY( ruleX(data, group({y1: loqr1, y2: hiqr2}, {x, y, stroke, strokeOpacity, ...options})), barY(data, group({y1: "p25", y2: "p75"}, {x, y, fill, fillOpacity, ...options})), tickY(data, group({y: "p50"}, {x, y, stroke, strokeOpacity, strokeWidth, sort, ...options})), - dot(data, map({y: oqr}, {x, y, z: x, stroke, strokeOpacity, ...options})) + dot(data, map({y: oqr}, {x, y, z: x, stroke, strokeOpacity, ...options})), + tip && tipmark(data, pointerX(map({y: boxStats}, {x, y, z: x, ...options}))) ); } @@ -82,3 +88,26 @@ function quartile1(values) { function quartile3(values) { return quantile(values, 0.75); } + +function boxStats(values) { + const V = Float64Array.from( + (function* (V) { + for (let v of V) if (v !== null && !isNaN((v = +v))) yield v; + })(values) + ).sort(); + const q1 = quantileSorted(V, 0.25); + const mi = quantileSorted(V, 0.5); + const q3 = quantileSorted(V, 0.75); + const lo = q1 * 2.5 - q3 * 1.5; + const loqr1 = V.find((d) => d >= lo); + const hi = q3 * 2.5 - q1 * 1.5; + let hiqr2; + for (let i = V.length - 1; i >= 0; --i) { + if (V[i] <= hi) { + hiqr2 = V[i]; + break; + } + } + const report = [q3, q1, mi, hiqr2, loqr1]; + return values.map((d) => (d !== null && (d < loqr1 || d > hiqr2) ? d : report.pop() ?? NaN)); +} diff --git a/test/output/boxplotFacetInterval.svg b/test/output/boxplotFacetInterval.svg index 473440c09a..018369f33e 100644 --- a/test/output/boxplotFacetInterval.svg +++ b/test/output/boxplotFacetInterval.svg @@ -596,4 +596,17 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/boxplotY.svg b/test/output/boxplotY.svg new file mode 100644 index 0000000000..eae07311eb --- /dev/null +++ b/test/output/boxplotY.svg @@ -0,0 +1,664 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 170 + + + 160 + + + 150 + + + 140 + + + 130 + + + 120 + + + 110 + + + 100 + + + 90 + + + 80 + + + 70 + + + 60 + + + 50 + + + 40 + + + 30 + + + + ← weight + + + + + + + + + + + + + + + + + + 1.3 + 1.4 + 1.5 + 1.6 + 1.7 + 1.8 + 1.9 + 2.0 + 2.1 + 2.2 + + + + ↑ height + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/boxplot.ts b/test/plots/boxplot.ts index 014db9c0ea..3010213d9e 100644 --- a/test/plots/boxplot.ts +++ b/test/plots/boxplot.ts @@ -17,7 +17,7 @@ export async function boxplotFacetInterval() { marks: [ Plot.boxX( olympians.filter((d) => d.height), - {x: "weight", fy: "height"} + {x: "weight", fy: "height", tip: true} ) ] }); @@ -40,3 +40,21 @@ export async function boxplotFacetNegativeInterval() { ] }); } + +export async function boxplotY() { + const olympians = await d3.csv("data/athletes.csv", d3.autoType); + return Plot.plot({ + fx: { + grid: true, + tickFormat: String, // for debugging + interval: 5, + reverse: true + }, + marks: [ + Plot.boxY( + olympians.filter((d) => d.height), + {fx: "weight", y: "height", tip: true} + ) + ] + }); +}