From d72edee399d380c66528c8d95538a048bbe820ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sun, 27 Aug 2023 15:24:31 +0200 Subject: [PATCH 1/3] fix box plot tips we have 5 elements to report + the outliers when there are less than 5 values, a choice must be made. The "report" array is in order of priority (highest priority last, since we use Array.pop). This seems to work well with 1, 2, 3, 4, and 5 values. I wonder if q1 and q3 make sense when there are < 5 values, by the way, and we should probably censor them in the visible mark too? --- src/marks/box.js | 35 +- test/output/boxplotFacetInterval.svg | 13 + test/output/boxplotY.svg | 664 +++++++++++++++++++++++++++ test/plots/boxplot.ts | 20 +- 4 files changed, 728 insertions(+), 4 deletions(-) create mode 100644 test/output/boxplotY.svg diff --git a/src/marks/box.js b/src/marks/box.js index 7d74db6972..4e33b7dbbb 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, pointerX(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, pointerY(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 < 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} + ) + ] + }); +} From 364cf7862333bc1329ce489181e70fd5aeac15c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 30 Aug 2023 16:54:30 +0200 Subject: [PATCH 2/3] fix pointer --- src/marks/box.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/marks/box.js b/src/marks/box.js index 4e33b7dbbb..1474ce8a71 100644 --- a/src/marks/box.js +++ b/src/marks/box.js @@ -33,7 +33,7 @@ export function boxX( 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})), - tip && tipmark(data, pointerX(map({x: boxStats}, {x, y, z: y, ...options}))) + tip && tipmark(data, pointerY(map({x: boxStats}, {x, y, z: y, ...options}))) ); } @@ -60,7 +60,7 @@ export function boxY( 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})), - tip && tipmark(data, pointerY(map({y: boxStats}, {x, y, z: x, ...options}))) + tip && tipmark(data, pointerX(map({y: boxStats}, {x, y, z: x, ...options}))) ); } From ea290caf63bb9faa3904ec84a88a43bef57eb798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 30 Aug 2023 16:54:40 +0200 Subject: [PATCH 3/3] skip null --- src/marks/box.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/marks/box.js b/src/marks/box.js index 1474ce8a71..a0b35b320c 100644 --- a/src/marks/box.js +++ b/src/marks/box.js @@ -109,5 +109,5 @@ function boxStats(values) { } } const report = [q3, q1, mi, hiqr2, loqr1]; - return values.map((d) => (d < loqr1 || d > hiqr2 ? d : report.pop() ?? NaN)); + return values.map((d) => (d !== null && (d < loqr1 || d > hiqr2) ? d : report.pop() ?? NaN)); }