diff --git a/docs/transforms/bin.md b/docs/transforms/bin.md index 99cbb83aa2..ea3195ab7a 100644 --- a/docs/transforms/bin.md +++ b/docs/transforms/bin.md @@ -192,7 +192,7 @@ Plot.plot({ ``` ::: -The bin transform works with Plot’s [faceting system](../features/facets.md), partitioning bins by facet. Below, we compare the weight distributions of athletes within each sport using the *proportion-facet* reducer. Sports are sorted by median weight: gymnasts tend to be the lightest, and basketball players the heaviest. +The bin transform works with Plot’s [faceting system](../features/facets.md), partitioning bins by facet. Below, we compare the weight distributions of athletes within each sport using the *density* reducer. Sports are sorted by median weight: gymnasts tend to be the lightest, and basketball players the heaviest. :::plot defer ```js-vue @@ -202,7 +202,7 @@ Plot.plot({ x: {grid: true}, fy: {domain: d3.groupSort(olympians.filter((d) => d.weight), (g) => d3.median(g, (d) => d.weight), (d) => d.sport)}, color: {scheme: "{{$dark ? "turbo" : "YlGnBu"}}"}, - marks: [Plot.rect(olympians, Plot.binX({fill: "proportion-facet"}, {x: "weight", fy: "sport", inset: 0.5}))] + marks: [Plot.rect(olympians, Plot.binX({fill: "density"}, {x: "weight", fy: "sport", inset: 0.5}))] }) ``` ::: @@ -253,6 +253,7 @@ The following named reducers are supported: * *first* - the first value, in input order * *last* - the last value, in input order * *count* - the number of elements (frequency) +* *density* – the count or sum normalized by series (*z*) * *distinct* - the number of distinct values * *sum* - the sum of values * *proportion* - the sum proportional to the overall total (weighted frequency) diff --git a/docs/transforms/group.md b/docs/transforms/group.md index ebfcc3b0bd..39d87f24e6 100644 --- a/docs/transforms/group.md +++ b/docs/transforms/group.md @@ -354,6 +354,7 @@ The following named reducers are supported: * *first* - the first value, in input order * *last* - the last value, in input order * *count* - the number of elements (frequency) +the count or sum normalized by series (*z*) * *sum* - the sum of values * *proportion* - the sum proportional to the overall total (weighted frequency) * *proportion-facet* - the sum proportional to the facet total diff --git a/docs/transforms/hexbin.md b/docs/transforms/hexbin.md index 91198eaf64..5702d61625 100644 --- a/docs/transforms/hexbin.md +++ b/docs/transforms/hexbin.md @@ -183,6 +183,7 @@ The following named reducers are supported: * *first* - the first value, in input order * *last* - the last value, in input order * *count* - the number of elements (frequency) +the count or sum normalized by series (*z*) * *distinct* - the number of distinct values * *sum* - the sum of values * *proportion* - the sum proportional to the overall total (weighted frequency) diff --git a/src/reducer.d.ts b/src/reducer.d.ts index a7b8f1ca28..45bd0bf51d 100644 --- a/src/reducer.d.ts +++ b/src/reducer.d.ts @@ -13,6 +13,7 @@ export type ReducerPercentile = * - *first* - the first value, in input order * - *last* - the last value, in input order * - *count* - the number of elements (frequency) + * - *density* – the count or sum normalized by series (*z*) * - *distinct* - the number of distinct values * - *sum* - the sum of values * - *proportion* - the sum proportional to the overall total (weighted frequency) @@ -36,6 +37,7 @@ export type ReducerName = | "last" | "identity" | "count" + | "density" | "distinct" | "sum" | "proportion" diff --git a/src/transforms/bin.js b/src/transforms/bin.js index 16abb9d883..32e861bda8 100644 --- a/src/transforms/bin.js +++ b/src/transforms/bin.js @@ -179,6 +179,7 @@ function binn( if (sort) sort.scope("facet", facet); if (filter) filter.scope("facet", facet); for (const [f, I] of maybeGroup(facet, G)) { + for (const o of outputs) o.scope("group", I); for (const [k, g] of maybeGroup(I, K)) { for (const [b, extent] of bin(g)) { if (G) extent.z = f; diff --git a/src/transforms/group.js b/src/transforms/group.js index 07ae348359..a665f0ea5b 100644 --- a/src/transforms/group.js +++ b/src/transforms/group.js @@ -131,6 +131,7 @@ function groupn( if (sort) sort.scope("facet", facet); if (filter) filter.scope("facet", facet); for (const [f, I] of maybeGroup(facet, G)) { + for (const o of outputs) o.scope("group", I); for (const [y, gg] of maybeGroup(I, Y)) { for (const [x, g] of maybeGroup(gg, X)) { const extent = {data}; @@ -248,6 +249,8 @@ export function maybeReduce(reduce, value, fallback = invalidReduce) { return reduceIdentity; case "count": return reduceCount; + case "density": + return reduceDensity; case "distinct": return reduceDistinct; case "sum": @@ -405,6 +408,18 @@ export const reduceCount = { } }; +export const reduceDensity = { + label: "Density", + scope: "group", + reduceIndex(I, V, context, extent) { + if (context === undefined) return I.length; + let proportion = I.length / context; + if ("y2" in extent && !("x2" in extent)) proportion /= extent.y2 - extent.y1; + else if ("x2" in extent && !("y2" in extent)) proportion /= extent.x2 - extent.x1; + return proportion; + } +}; + const reduceDistinct = { label: "Distinct", reduceIndex(I, X) { diff --git a/src/transforms/hexbin.js b/src/transforms/hexbin.js index 54e409d0aa..07ce6a3738 100644 --- a/src/transforms/hexbin.js +++ b/src/transforms/hexbin.js @@ -64,6 +64,7 @@ export function hexbin(outputs = {fill: "count"}, {binWidth, ...options} = {}) { const binFacet = []; for (const o of outputs) o.scope("facet", facet); for (const [f, I] of maybeGroup(facet, G)) { + for (const o of outputs) o.scope("group", I); for (const {index: b, extent} of hbin(data, I, X, Y, binWidth)) { binFacet.push(++i); BX.push(extent.x); diff --git a/test/output/densityReducer.html b/test/output/densityReducer.html new file mode 100644 index 0000000000..dae6e10890 --- /dev/null +++ b/test/output/densityReducer.html @@ -0,0 +1,178 @@ +
+
+ + + μ = 0 + + μ = 3 +
+ + + + σ = 1 + + + σ = 2 + + + + + + + 0.0 + 0.1 + 0.2 + 0.3 + 0.4 + + + 0.0 + 0.1 + 0.2 + 0.3 + 0.4 + + + + + + + −6 + −4 + −2 + 0 + 2 + 4 + 6 + 8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/plots/athletes-sport-weight.ts b/test/plots/athletes-sport-weight.ts index 8f008a19a0..780e822ab6 100644 --- a/test/plots/athletes-sport-weight.ts +++ b/test/plots/athletes-sport-weight.ts @@ -8,7 +8,7 @@ export async function athletesSportWeight() { grid: true, color: {scheme: "YlGnBu", zero: true}, marks: [ - Plot.barX(athletes, Plot.binX({fill: "proportion-facet"}, {x: "weight", fy: "sport", thresholds: 60})), + Plot.barX(athletes, Plot.binX({fill: "density"}, {x: "weight", fy: "sport", thresholds: 60})), Plot.frame({anchor: "bottom", facetAnchor: "bottom"}) ] }); diff --git a/test/plots/density-reducer.ts b/test/plots/density-reducer.ts new file mode 100644 index 0000000000..fd6e890da8 --- /dev/null +++ b/test/plots/density-reducer.ts @@ -0,0 +1,46 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +const pdf_normal = (x, mu = 0, sigma = 1) => + Math.exp(-0.5 * Math.pow((x - mu) / sigma, 2)) / (sigma * Math.sqrt(2 * Math.PI)); + +const densities = d3 + .range(-6, 10, 0.1) + .map((x) => [0, 3].map((mu) => [1, 2].map((sigma) => ({x, mu, sigma, rho: pdf_normal(x, mu, sigma)})))) + .flat(3); + +const n_pts = 100000; + +const mus = Array.from({length: n_pts}, d3.randomBernoulli.source(d3.randomLcg(42))(0.2)).map((x) => 3 * x); +const sigmas = Array.from({length: n_pts}, d3.randomBernoulli.source(d3.randomLcg(43))(0.3)).map((x) => 1 + x); +const standardNormals = Array.from({length: n_pts}, d3.randomNormal.source(d3.randomLcg(44))(0, 1)).map( + (x, i) => x * sigmas[i] + mus[i] +); + +const pts = standardNormals.map((value, i) => ({mu: mus[i], sigma: sigmas[i], value})); + +export async function densityReducer() { + return Plot.plot({ + marks: [ + Plot.areaY( + pts, + Plot.binX( + {y2: "density"}, + { + x: "value", + fill: (x) => `μ = ${x.mu}`, + opacity: 0.5, + fy: (x) => `σ = ${x.sigma}`, + interval: 0.2, + curve: "step" + } + ) + ), + Plot.line(densities, {x: "x", y: "rho", stroke: (x) => `μ = ${x.mu}`, fy: (x) => `σ = ${x.sigma}`}), + Plot.ruleY([0]) + ], + fy: {label: null}, + color: {legend: true, type: "categorical"}, + grid: true + }); +} diff --git a/test/plots/hexbin-r.ts b/test/plots/hexbin-r.ts index 5f56775a36..f7e7e3ea75 100644 --- a/test/plots/hexbin-r.ts +++ b/test/plots/hexbin-r.ts @@ -17,7 +17,7 @@ export async function hexbinR() { marks: [ Plot.frame(), Plot.hexgrid(), - Plot.dot(penguins, Plot.hexbin({title: "count", r: "count", fill: "proportion-facet"}, xy)) + Plot.dot(penguins, Plot.hexbin({title: "count", r: "count", fill: "density"}, xy)) ] }); } diff --git a/test/plots/index.ts b/test/plots/index.ts index 27efed9822..a9f31fab93 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -65,6 +65,7 @@ export * from "./d3-survey-2015-comfort.js"; export * from "./d3-survey-2015-why.js"; export * from "./darker-dodge.js"; export * from "./decathlon.js"; +export * from "./density-reducer.js"; export * from "./diamonds-boxplot.js"; export * from "./diamonds-carat-price-dots.js"; export * from "./diamonds-carat-price.js";