diff --git a/.eslintrc.json b/.eslintrc.json index 3b9bd641e4..48aa23da01 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -12,6 +12,7 @@ "rules": { "no-cond-assign": 0, "no-constant-condition": 0, + "no-sparse-arrays": 0, "no-unexpected-multiline": 0, "comma-dangle": ["error", "never"], "semi": [2, "always"] diff --git a/src/index.js b/src/index.js index 283568e6b3..5f646f3628 100644 --- a/src/index.js +++ b/src/index.js @@ -12,7 +12,7 @@ export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js"; export {Text, text, textX, textY} from "./marks/text.js"; export {TickX, TickY, tickX, tickY} from "./marks/tick.js"; export {bin, binX, binY, binXMid, binYMid, binR} from "./transforms/bin.js"; -export {group, groupX, groupY, groupR} from "./transforms/group.js"; +export {group, groupX, groupY, groupR, groupZ, groupZX, groupZY, groupZR} from "./transforms/group.js"; export {normalizeX, normalizeY} from "./transforms/normalize.js"; export {map, mapX, mapY} from "./transforms/map.js"; export {reduce, reduceX, reduceY} from "./transforms/reduce.js"; diff --git a/src/mark.js b/src/mark.js index bdc2792c0f..8791c6ccae 100644 --- a/src/mark.js +++ b/src/mark.js @@ -230,7 +230,9 @@ export function mid(x1, x2) { // This distinguishes between per-dimension options and a standalone value. export function maybeValue(value) { - return typeof value === "undefined" || (value && value.toString === objectToString) ? value : {value}; + return value === undefined || (value && + value.toString === objectToString && + typeof value.transform !== "function") ? value : {value}; } function compose(t1, t2) { diff --git a/src/transforms/group.js b/src/transforms/group.js index 7dd4dd9911..695a3f88eb 100644 --- a/src/transforms/group.js +++ b/src/transforms/group.js @@ -2,111 +2,67 @@ import {group as grouper, sort, sum, InternSet} from "d3"; import {defined, firstof} from "../defined.js"; import {valueof, maybeColor, maybeTransform, maybeValue, maybeLazyChannel, lazyChannel, first, identity, take, maybeTuple, labelof} from "../mark.js"; -// Group on y, z, fill, or stroke, if any, then group on x. -export function groupX({x, y, out = y == null ? "y" : "fill", ...options} = {}) { - const [transform, X, l] = group1(x, "y", {y, ...options}); - return {x: X, ...transform, [out]: l}; +// Group on {z, fill, stroke}. +export function groupZ({out = "fill", ...options} = {}) { + const [transform, L] = group2(null, null, options); + return {...transform, [out]: L}; } -// Group on x, z, fill, or stroke, if any, then group on y. -export function groupY({y, x, out = x == null ? "x" : "fill", ...options} = {}) { - const [transform, Y, l] = group1(y, "x", {x, ...options}); - return {y: Y, ...transform, [out]: l}; +export function groupZX(options) { + return groupZ({...options, out: "x"}); } -// Group on z, fill, or stroke, if any. -export function groupR(options) { - return group({...options, out: "r"}); +export function groupZY(options) { + return groupZ({...options, out: "y"}); +} + +export function groupZR(options) { + return groupZ({...options, out: "r"}); +} + +// Group on {z, fill, stroke}, then on x (optionally). +export function groupX({x = identity, out = "y", ...options} = {}) { + const [transform, L, X] = group2(x, null, options); + return {...transform, x: X, [out]: L}; +} + +// Group on {z, fill, stroke}, then on y (optionally). +export function groupY({y = identity, out = "x", ...options} = {}) { + const [transform, L,, Y] = group2(null, y, options); + return {...transform, y: Y, [out]: L}; } +// Group on {z, fill, stroke}, then on x and y (optionally). export function group({x, y, out = "fill", ...options} = {}) { - const [transform, X, Y, L] = group2(x, y, options); - return {x: X, y: Y, ...transform, [out]: L}; + ([x, y] = maybeTuple(x, y)); + const [transform, L, X, Y] = group2(x, y, options); + return {...transform, x: X, y: Y, [out]: L}; } -function group1(x = identity, key, {[key]: k, weight, domain, normalize, z, fill, stroke, ...options} = {}) { - const m = normalize === true || normalize === "z" ? 100 : +normalize; - const [X, setX] = lazyChannel(x); - const [L, setL] = lazyChannel(`${labelof(weight, "Frequency")}${m === 100 ? " (%)" : ""}`); - const [vfill] = maybeColor(fill); - const [vstroke] = maybeColor(stroke); - const [BK, setBK] = maybeLazyChannel(k); - const [BZ, setBZ] = maybeLazyChannel(z); - const [BF = fill, setBF] = maybeLazyChannel(vfill); - const [BS = stroke, setBS] = maybeLazyChannel(vstroke); - const defined = maybeDomain(domain); - return [ - { - ...key && {[key]: BK}, - z: BZ, - fill: BF, - stroke: BS, - ...options, - transform: maybeTransform(options, (data, facets) => { - const X = valueof(data, x); - const K = valueof(data, k); - const Z = valueof(data, z); - const F = valueof(data, vfill); - const S = valueof(data, vstroke); - const W = valueof(data, weight); - const groupFacets = []; - const groupData = []; - const BX = setX([]); - const L = setL([]); - const G = firstof(K, Z, F, S); - const BK = K && setBK([]); - const BZ = Z && setBZ([]); - const BF = F && setBF([]); - const BS = S && setBS([]); - let n = W ? sum(W) : data.length; - let i = 0; - for (const facet of facets) { - const groupFacet = []; - for (const I of G ? grouper(facet, i => G[i]).values() : [facet]) { - if (normalize === "z") n = W ? sum(I, i => W[i]) : I.length; - for (const [x, f] of sort(grouper(I, i => X[i]), first)) { - if (!defined(x)) continue; - const l = W ? sum(f, i => W[i]) : f.length; - groupFacet.push(i++); - groupData.push(take(data, f)); - BX.push(x); - L.push(m ? l * m / n : l); - if (K) BK.push(K[f[0]]); - if (Z) BZ.push(Z[f[0]]); - if (F) BF.push(F[f[0]]); - if (S) BS.push(S[f[0]]); - } - } - groupFacets.push(groupFacet); - } - return {data: groupData, facets: groupFacets}; - }) - }, - X, - L - ]; +export function groupR(options) { + return group({...options, out: "r"}); } function group2(xv, yv, {z, fill, stroke, weight, domain, normalize, ...options} = {}) { let {value: x, domain: xdomain} = {domain, ...maybeValue(xv)}; let {value: y, domain: ydomain} = {domain, ...maybeValue(yv)}; ([x, y] = maybeTuple(x, y)); - const m = normalize === true || normalize === "z" ? 100 : +normalize; - const [X, setX] = lazyChannel(x); - const [Y, setY] = lazyChannel(y); - const [L, setL] = lazyChannel(`${labelof(weight, "Frequency")}${m === 100 ? " (%)" : ""}`); - const [Z, setZ] = maybeLazyChannel(z); + const m = maybeNormalize(normalize); + const [BL, setBL] = lazyChannel(`${labelof(weight, "Frequency")}${m === 100 ? " (%)" : ""}`); + const [BX, setBX] = maybeLazyChannel(x); + const [BY, setBY] = maybeLazyChannel(y); + const [BZ, setBZ] = maybeLazyChannel(z); const [vfill] = maybeColor(fill); const [vstroke] = maybeColor(stroke); - const [F = fill, setF] = maybeLazyChannel(vfill); - const [S = stroke, setS] = maybeLazyChannel(vstroke); - const xdefined = maybeDomain(xdomain); - const ydefined = maybeDomain(ydomain); + const [BF = fill, setBF] = maybeLazyChannel(vfill); + const [BS = stroke, setBS] = maybeLazyChannel(vstroke); + const xdefined = BX && maybeDomain(xdomain); + const ydefined = BY && maybeDomain(ydomain); return [ { - z: Z, - fill: F, - stroke: S, + z: BZ, + fill: BF, + stroke: BS, ...options, transform: maybeTransform(options, (data, facets) => { const X = valueof(data, x); @@ -118,28 +74,27 @@ function group2(xv, yv, {z, fill, stroke, weight, domain, normalize, ...options} const groupFacets = []; const groupData = []; const G = firstof(Z, F, S); - const BX = setX([]); - const BY = setY([]); - const BL = setL([]); - const BZ = Z && setZ([]); - const BF = F && setF([]); - const BS = S && setS([]); + const BL = setBL([]); + const BX = X && setBX([]); + const BY = Y && setBY([]); + const BZ = Z && setBZ([]); + const BF = F && setBF([]); + const BS = S && setBS([]); let n = W ? sum(W) : data.length; let i = 0; for (const facet of facets) { const groupFacet = []; + if (normalize === "facet") n = W ? sum(facet, i => W[i]) : facet.length; for (const I of G ? grouper(facet, i => G[i]).values() : [facet]) { if (normalize === "z") n = W ? sum(I, i => W[i]) : I.length; - for (const [y, fy] of sort(grouper(I, i => Y[i]), first)) { - if (!ydefined(y)) continue; - for (const [x, f] of sort(grouper(fy, i => X[i]), first)) { - if (!xdefined(x)) continue; + for (const [y, fy] of Y ? sort(grouper(I, i => Y[i]), first).filter(ydefined) : [[, I]]) { + for (const [x, f] of X ? sort(grouper(fy, i => X[i]), first).filter(xdefined) : [[, fy]]) { const l = W ? sum(f, i => W[i]) : f.length; groupFacet.push(i++); groupData.push(take(data, f)); - BX.push(x); - BY.push(y); BL.push(m ? l * m / n : l); + if (X) BX.push(x); + if (Y) BY.push(y); if (Z) BZ.push(Z[f[0]]); if (F) BF.push(F[f[0]]); if (S) BS.push(S[f[0]]); @@ -151,9 +106,9 @@ function group2(xv, yv, {z, fill, stroke, weight, domain, normalize, ...options} return {data: groupData, facets: groupFacets}; }) }, - X, - Y, - L + BL, + BX, + BY ]; } @@ -161,5 +116,15 @@ function maybeDomain(domain) { if (domain === undefined) return defined; if (domain === null) return () => false; domain = new InternSet(domain); - return value => domain.has(value); + return ([key]) => domain.has(key); +} + +function maybeNormalize(normalize) { + if (!normalize) return; + if (normalize === true) return 100; + if (typeof normalize === "number") return normalize; + switch ((normalize + "").toLowerCase()) { + case "facet": case "z": return 100; + } + throw new Error("invalid normalize"); } diff --git a/test/output/penguinSpeciesGroup.svg b/test/output/penguinSpeciesGroup.svg new file mode 100644 index 0000000000..a159847956 --- /dev/null +++ b/test/output/penguinSpeciesGroup.svg @@ -0,0 +1,57 @@ + + + + + 0 + + + + 10 + + + + 20 + + + + 30 + + + + 40 + + + + 50 + + + + 60 + + + + 70 + + + + 80 + + + + 90 + + + + 100 + Frequency (%) → + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index 0665482814..130cf515bc 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -51,6 +51,7 @@ export {default as penguinMassSex} from "./penguin-mass-sex.js"; export {default as penguinMassSexSpecies} from "./penguin-mass-sex-species.js"; export {default as penguinMassSpecies} from "./penguin-mass-species.js"; export {default as penguinSexMassCulmenSpecies} from "./penguin-sex-mass-culmen-species.js"; +export {default as penguinSpeciesGroup} from "./penguin-species-group.js"; export {default as penguinSpeciesIsland} from "./penguin-species-island.js"; export {default as policeDeaths} from "./police-deaths.js"; export {default as policeDeathsBar} from "./police-deaths-bar.js"; diff --git a/test/plots/penguin-species-group.js b/test/plots/penguin-species-group.js new file mode 100644 index 0000000000..d6b8fdc034 --- /dev/null +++ b/test/plots/penguin-species-group.js @@ -0,0 +1,15 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + x: { + grid: true + }, + marks: [ + Plot.barX(penguins, Plot.stackX(Plot.groupZX({fill: "species", normalize: true}))), + Plot.ruleX([0, 100]) + ] + }); +}