diff --git a/README.md b/README.md index f4bdbad1a6..43d9ebd103 100644 --- a/README.md +++ b/README.md @@ -1854,9 +1854,10 @@ The following **order** methods are supported: - *sum* - order series by their total value - *appearance* - order series by the position of their maximum value - *inside-out* - order the earliest-appearing series on the inside +- a named field or function of data - order data by priority - an array of *z* values -The **reverse** option reverses the effective order. For the *value* order, Plot.stackY uses the *y*-value while Plot.stackX uses the *x*-value. For the *appearance* order, Plot.stackY uses the *x*-position of the maximum *y*-value while Plot.stackX uses the *y*-position of the maximum *x*-value. If an array of *z* values are specified, they should enumerate the *z* values for all series in the desired order; this array is typically hard-coded or computed with [d3.groupSort](https://github.com/d3/d3-array/blob/main/README.md#groupSort). Note that the input order (null) and *value* order can produce crossing paths: unlike the other order methods, they do not guarantee a consistent series order across stacks. +The **reverse** option reverses the effective order. For the *value* order, Plot.stackY uses the *y*-value while Plot.stackX uses the *x*-value. For the *appearance* order, Plot.stackY uses the *x*-position of the maximum *y*-value while Plot.stackX uses the *y*-position of the maximum *x*-value. If an array of *z* values are specified, they should enumerate the *z* values for all series in the desired order; this array is typically hard-coded or computed with [d3.groupSort](https://github.com/d3/d3-array/blob/main/README.md#groupSort). Note that the input order (null) and *value* order can produce crossing paths: they do not guarantee a consistent series order across stacks. The stack transform supports diverging stacks: negative values are stacked below zero while positive values are stacked above zero. For Plot.stackY, the **y1** channel contains the value of lesser magnitude (closer to zero) while the **y2** channel contains the value of greater magnitude (farther from zero); the difference between the two corresponds to the input **y** channel value. For Plot.stackX, the same is true, except for **x1**, **x2**, and **x** respectively. @@ -1866,9 +1867,12 @@ After all values have been stacked from zero, an optional **offset** can be appl - *expand* (or *normalize*) - rescale each stack to fill [0, 1] - *center* (or *silhouette*) - align the centers of all stacks - *wiggle* - translate stacks to minimize apparent movement +- a function to be passed a nested index, and start, end, and *z* values If a given stack has zero total value, the *expand* offset will not adjust the stack’s position. Both the *center* and *wiggle* offsets ensure that the lowest element across stacks starts at zero for better default axes. The *wiggle* offset is recommended for streamgraphs, and if used, changes the default order to *inside-out*; see [Byron & Wattenberg](http://leebyron.com/streamgraph/). +If the offset is specified as a function, it will receive four arguments: an index of stacks nested by facet and then stack, an array of start values, an array of end values, and an array of *z* values. For stackX, the start and end values correspond to *x1* and *x2*, while for stackY, the start and end values correspond to *y1* and *y2*. The offset function is then responsible for mutating the arrays of start and end values, such as by subtracting a common offset for each of the indices that pertain to the same stack. + In addition to the **y1** and **y2** output channels, Plot.stackY computes a **y** output channel that represents the midpoint of **y1** and **y2**. Plot.stackX does the same for **x**. This can be used to position a label or a dot in the center of a stacked layer. The **x** and **y** output channels are lazy: they are only computed if needed by a downstream mark or transform. If two arguments are passed to the stack transform functions below, the stack-specific options (**offset**, **order**, and **reverse**) are pulled exclusively from the first *options* argument, while any channels (*e.g.*, **x**, **y**, and **z**) are pulled from second *options* argument. Options from the second argument that are not consumed by the stack transform will be passed through. Using two arguments is sometimes necessary is disambiguate the option recipient when chaining transforms. diff --git a/src/transforms/stack.js b/src/transforms/stack.js index 6f5ad1c080..ac17bd0154 100644 --- a/src/transforms/stack.js +++ b/src/transforms/stack.js @@ -108,6 +108,7 @@ function stack(x, y = () => 1, ky, {offset, order, reverse}, options) { function maybeOffset(offset) { if (offset == null) return; + if (typeof offset === "function") return offset; switch (`${offset}`.toLowerCase()) { case "expand": case "normalize": return offsetExpand; case "center": case "silhouette": return offsetCenter; diff --git a/test/data/README.md b/test/data/README.md index 42e2b43e69..21b6ad9cec 100644 --- a/test/data/README.md +++ b/test/data/README.md @@ -94,6 +94,10 @@ https://www.ncdc.noaa.gov/ IMDb/Todd W. Schneider https://data.world/data-society/the-simpsons-by-the-data +## survey.csv +Eitan Lees +https://talk.observablehq.com/t/diverging-stacked-bar-chart-in-plot/6028 + ## traffic.csv Moritz Klack https://observablehq.com/@moklick diff --git a/test/data/survey.csv b/test/data/survey.csv new file mode 100644 index 0000000000..657424e241 --- /dev/null +++ b/test/data/survey.csv @@ -0,0 +1,501 @@ +Question,ID,Response +Q1,0,Agree +Q1,1,Agree +Q1,2,Agree +Q1,3,Neutral +Q1,4,Strongly Disagree +Q1,5,Neutral +Q1,6,Strongly Disagree +Q1,7,Strongly Agree +Q1,8,Neutral +Q1,9,Neutral +Q1,10,Neutral +Q1,11,Agree +Q1,12,Strongly Agree +Q1,13,Strongly Agree +Q1,14,Neutral +Q1,15,Strongly Agree +Q1,16,Disagree +Q1,17,Agree +Q1,18,Agree +Q1,19,Neutral +Q1,20,Strongly Agree +Q1,21,Neutral +Q1,22,Strongly Agree +Q1,23,Neutral +Q1,24,Strongly Disagree +Q1,25,Neutral +Q1,26,Strongly Agree +Q1,27,Disagree +Q1,28,Strongly Agree +Q1,29,Agree +Q1,30,Neutral +Q1,31,Neutral +Q1,32,Strongly Agree +Q1,33,Strongly Agree +Q1,34,Agree +Q1,35,Disagree +Q1,36,Disagree +Q1,37,Neutral +Q1,38,Neutral +Q1,39,Agree +Q1,40,Strongly Agree +Q1,41,Disagree +Q1,42,Neutral +Q1,43,Neutral +Q1,44,Neutral +Q1,45,Neutral +Q1,46,Neutral +Q1,47,Strongly Disagree +Q1,48,Disagree +Q1,49,Strongly Disagree +Q1,50,Agree +Q1,51,Disagree +Q1,52,Neutral +Q1,53,Neutral +Q1,54,Disagree +Q1,55,Neutral +Q1,56,Strongly Disagree +Q1,57,Strongly Agree +Q1,58,Disagree +Q1,59,Neutral +Q1,60,Strongly Agree +Q1,61,Neutral +Q1,62,Strongly Agree +Q1,63,Disagree +Q1,64,Neutral +Q1,65,Neutral +Q1,66,Strongly Agree +Q1,67,Neutral +Q1,68,Neutral +Q1,69,Agree +Q1,70,Neutral +Q1,71,Neutral +Q1,72,Neutral +Q1,73,Strongly Agree +Q1,74,Agree +Q1,75,Neutral +Q1,76,Disagree +Q1,77,Agree +Q1,78,Disagree +Q1,79,Strongly Agree +Q1,80,Strongly Disagree +Q1,81,Agree +Q1,82,Agree +Q1,83,Agree +Q1,84,Disagree +Q1,85,Strongly Agree +Q1,86,Neutral +Q1,87,Disagree +Q1,88,Strongly Disagree +Q1,89,Strongly Disagree +Q1,90,Agree +Q1,91,Neutral +Q1,92,Neutral +Q1,93,Strongly Agree +Q1,94,Neutral +Q1,95,Strongly Agree +Q1,96,Neutral +Q1,97,Strongly Agree +Q1,98,Strongly Disagree +Q1,99,Disagree +Q1,100,Agree +Q2,0,Neutral +Q2,1,Agree +Q2,2,Agree +Q2,3,Agree +Q2,4,Neutral +Q2,5,Neutral +Q2,6,Neutral +Q2,7,Neutral +Q2,8,Agree +Q2,9,Neutral +Q2,10,Strongly Disagree +Q2,11,Agree +Q2,12,Strongly Agree +Q2,13,Agree +Q2,14,Neutral +Q2,15,Disagree +Q2,16,Neutral +Q2,17,Neutral +Q2,18,Agree +Q2,19,Strongly Agree +Q2,20,Strongly Agree +Q2,21,Agree +Q2,22,Strongly Agree +Q2,23,Neutral +Q2,24,Strongly Disagree +Q2,25,Neutral +Q2,26,Strongly Disagree +Q2,27,Disagree +Q2,28,Neutral +Q2,29,Neutral +Q2,30,Agree +Q2,31,Agree +Q2,32,Agree +Q2,33,Strongly Disagree +Q2,34,Strongly Agree +Q2,35,Agree +Q2,36,Strongly Disagree +Q2,37,Strongly Agree +Q2,38,Neutral +Q2,39,Strongly Agree +Q2,40,Disagree +Q2,41,Agree +Q2,42,Strongly Disagree +Q2,43,Strongly Disagree +Q2,44,Agree +Q2,45,Strongly Agree +Q2,46,Strongly Agree +Q2,47,Neutral +Q2,48,Neutral +Q2,49,Neutral +Q2,50,Strongly Disagree +Q2,51,Strongly Disagree +Q2,52,Agree +Q2,53,Agree +Q2,54,Neutral +Q2,55,Neutral +Q2,56,Agree +Q2,57,Strongly Disagree +Q2,58,Strongly Disagree +Q2,59,Neutral +Q2,60,Strongly Agree +Q2,61,Agree +Q2,62,Strongly Disagree +Q2,63,Agree +Q2,64,Agree +Q2,65,Agree +Q2,66,Agree +Q2,67,Neutral +Q2,68,Neutral +Q2,69,Strongly Disagree +Q2,70,Disagree +Q2,71,Strongly Disagree +Q2,72,Strongly Disagree +Q2,73,Strongly Disagree +Q2,74,Strongly Disagree +Q2,75,Strongly Disagree +Q2,76,Neutral +Q2,77,Strongly Agree +Q2,78,Neutral +Q2,79,Strongly Disagree +Q2,80,Agree +Q2,81,Agree +Q2,82,Strongly Agree +Q2,83,Agree +Q2,84,Strongly Disagree +Q2,85,Disagree +Q2,86,Strongly Disagree +Q2,87,Agree +Q2,88,Neutral +Q2,89,Strongly Disagree +Q2,90,Neutral +Q2,91,Agree +Q2,92,Strongly Disagree +Q2,93,Neutral +Q2,94,Neutral +Q3,0,Neutral +Q3,1,Agree +Q3,2,Disagree +Q3,3,Agree +Q3,4,Neutral +Q3,5,Agree +Q3,6,Neutral +Q3,7,Agree +Q3,8,Agree +Q3,9,Neutral +Q3,10,Neutral +Q3,11,Neutral +Q3,12,Neutral +Q3,13,Neutral +Q3,14,Strongly Disagree +Q3,15,Strongly Agree +Q3,16,Neutral +Q3,17,Neutral +Q3,18,Strongly Agree +Q3,19,Neutral +Q3,20,Agree +Q3,21,Neutral +Q3,22,Disagree +Q3,23,Neutral +Q3,24,Neutral +Q3,25,Strongly Disagree +Q3,26,Strongly Disagree +Q3,27,Agree +Q3,28,Disagree +Q3,29,Agree +Q3,30,Neutral +Q3,31,Neutral +Q3,32,Agree +Q3,33,Neutral +Q3,34,Strongly Agree +Q3,35,Neutral +Q3,36,Strongly Disagree +Q3,37,Strongly Agree +Q3,38,Neutral +Q3,39,Disagree +Q3,40,Neutral +Q3,41,Neutral +Q3,42,Agree +Q3,43,Neutral +Q3,44,Neutral +Q3,45,Neutral +Q3,46,Agree +Q3,47,Agree +Q3,48,Strongly Agree +Q3,49,Strongly Agree +Q3,50,Agree +Q3,51,Neutral +Q3,52,Neutral +Q3,53,Neutral +Q3,54,Disagree +Q3,55,Agree +Q3,56,Neutral +Q3,57,Neutral +Q3,58,Strongly Disagree +Q3,59,Agree +Q3,60,Strongly Disagree +Q3,61,Disagree +Q3,62,Neutral +Q3,63,Agree +Q3,64,Neutral +Q3,65,Neutral +Q3,66,Neutral +Q3,67,Disagree +Q3,68,Neutral +Q3,69,Strongly Agree +Q3,70,Neutral +Q3,71,Strongly Disagree +Q3,72,Neutral +Q3,73,Neutral +Q3,74,Neutral +Q3,75,Agree +Q3,76,Neutral +Q3,77,Agree +Q3,78,Strongly Agree +Q3,79,Strongly Agree +Q3,80,Agree +Q3,81,Neutral +Q3,82,Strongly Agree +Q3,83,Neutral +Q3,84,Strongly Agree +Q3,85,Agree +Q3,86,Neutral +Q3,87,Neutral +Q3,88,Strongly Agree +Q3,89,Agree +Q3,90,Neutral +Q3,91,Neutral +Q3,92,Neutral +Q3,93,Neutral +Q3,94,Neutral +Q3,95,Agree +Q3,96,Neutral +Q3,97,Strongly Agree +Q3,98,Strongly Agree +Q3,99,Strongly Agree +Q3,100,Strongly Disagree +Q3,101,Strongly Agree +Q3,102,Strongly Agree +Q4,0,Neutral +Q4,1,Disagree +Q4,2,Disagree +Q4,3,Neutral +Q4,4,Strongly Agree +Q4,5,Disagree +Q4,6,Disagree +Q4,7,Disagree +Q4,8,Neutral +Q4,9,Disagree +Q4,10,Disagree +Q4,11,Neutral +Q4,12,Neutral +Q4,13,Disagree +Q4,14,Neutral +Q4,15,Disagree +Q4,16,Neutral +Q4,17,Disagree +Q4,18,Neutral +Q4,19,Disagree +Q4,20,Neutral +Q4,21,Disagree +Q4,22,Strongly Disagree +Q4,23,Disagree +Q4,24,Strongly Agree +Q4,25,Neutral +Q4,26,Strongly Agree +Q4,27,Strongly Agree +Q4,28,Strongly Agree +Q4,29,Strongly Agree +Q4,30,Disagree +Q4,31,Agree +Q4,32,Neutral +Q4,33,Neutral +Q4,34,Disagree +Q4,35,Disagree +Q4,36,Neutral +Q4,37,Disagree +Q4,38,Disagree +Q4,39,Disagree +Q4,40,Agree +Q4,41,Disagree +Q4,42,Disagree +Q4,43,Disagree +Q4,44,Strongly Agree +Q4,45,Strongly Agree +Q4,46,Disagree +Q4,47,Disagree +Q4,48,Disagree +Q4,49,Disagree +Q4,50,Disagree +Q4,51,Disagree +Q4,52,Disagree +Q4,53,Neutral +Q4,54,Disagree +Q4,55,Strongly Disagree +Q4,56,Disagree +Q4,57,Disagree +Q4,58,Disagree +Q4,59,Strongly Disagree +Q4,60,Neutral +Q4,61,Disagree +Q4,62,Strongly Disagree +Q4,63,Strongly Agree +Q4,64,Strongly Agree +Q4,65,Neutral +Q4,66,Strongly Agree +Q4,67,Disagree +Q4,68,Disagree +Q4,69,Disagree +Q4,70,Agree +Q4,71,Strongly Agree +Q4,72,Strongly Agree +Q4,73,Strongly Disagree +Q4,74,Disagree +Q4,75,Neutral +Q4,76,Agree +Q4,77,Disagree +Q4,78,Neutral +Q4,79,Disagree +Q4,80,Disagree +Q4,81,Disagree +Q4,82,Strongly Agree +Q4,83,Strongly Disagree +Q4,84,Strongly Agree +Q4,85,Disagree +Q4,86,Strongly Agree +Q4,87,Disagree +Q4,88,Neutral +Q4,89,Neutral +Q4,90,Disagree +Q4,91,Disagree +Q4,92,Disagree +Q4,93,Strongly Agree +Q4,94,Agree +Q4,95,Neutral +Q4,96,Disagree +Q4,97,Disagree +Q4,98,Disagree +Q4,99,Disagree +Q5,0,Disagree +Q5,1,Neutral +Q5,2,Strongly Agree +Q5,3,Strongly Agree +Q5,4,Neutral +Q5,5,Strongly Disagree +Q5,6,Strongly Agree +Q5,7,Strongly Agree +Q5,8,Neutral +Q5,9,Strongly Disagree +Q5,10,Agree +Q5,11,Agree +Q5,12,Strongly Disagree +Q5,13,Strongly Disagree +Q5,14,Strongly Disagree +Q5,15,Disagree +Q5,16,Neutral +Q5,17,Strongly Disagree +Q5,18,Neutral +Q5,19,Neutral +Q5,20,Strongly Agree +Q5,21,Disagree +Q5,22,Agree +Q5,23,Strongly Agree +Q5,24,Strongly Agree +Q5,25,Strongly Agree +Q5,26,Strongly Agree +Q5,27,Neutral +Q5,28,Disagree +Q5,29,Strongly Agree +Q5,30,Neutral +Q5,31,Strongly Agree +Q5,32,Strongly Agree +Q5,33,Agree +Q5,34,Strongly Agree +Q5,35,Strongly Agree +Q5,36,Agree +Q5,37,Strongly Agree +Q5,38,Disagree +Q5,39,Strongly Agree +Q5,40,Strongly Agree +Q5,41,Agree +Q5,42,Strongly Agree +Q5,43,Neutral +Q5,44,Agree +Q5,45,Agree +Q5,46,Strongly Agree +Q5,47,Agree +Q5,48,Agree +Q5,49,Strongly Agree +Q5,50,Strongly Agree +Q5,51,Disagree +Q5,52,Neutral +Q5,53,Neutral +Q5,54,Strongly Agree +Q5,55,Disagree +Q5,56,Neutral +Q5,57,Neutral +Q5,58,Strongly Disagree +Q5,59,Disagree +Q5,60,Strongly Agree +Q5,61,Strongly Agree +Q5,62,Strongly Agree +Q5,63,Strongly Agree +Q5,64,Strongly Agree +Q5,65,Strongly Agree +Q5,66,Strongly Disagree +Q5,67,Strongly Agree +Q5,68,Strongly Agree +Q5,69,Agree +Q5,70,Strongly Agree +Q5,71,Agree +Q5,72,Strongly Agree +Q5,73,Agree +Q5,74,Strongly Agree +Q5,75,Agree +Q5,76,Strongly Agree +Q5,77,Neutral +Q5,78,Neutral +Q5,79,Disagree +Q5,80,Strongly Disagree +Q5,81,Agree +Q5,82,Neutral +Q5,83,Strongly Agree +Q5,84,Strongly Agree +Q5,85,Agree +Q5,86,Strongly Disagree +Q5,87,Neutral +Q5,88,Strongly Agree +Q5,89,Strongly Agree +Q5,90,Agree +Q5,91,Disagree +Q5,92,Neutral +Q5,93,Neutral +Q5,94,Neutral +Q5,95,Strongly Disagree +Q5,96,Disagree +Q5,97,Neutral +Q5,98,Strongly Agree +Q5,99,Strongly Agree +Q5,100,Disagree diff --git a/test/output/likertSurvey.html b/test/output/likertSurvey.html new file mode 100644 index 0000000000..1ab359c01f --- /dev/null +++ b/test/output/likertSurvey.html @@ -0,0 +1,121 @@ +
+
+ Strongly DisagreeDisagreeNeutralAgreeStrongly Agree +
+ + + + Q1 + + + Q2 + + + Q3 + + + Q4 + + + Q5 + Question + + + + 60 + + + 40 + + + 20 + + + 0 + + + 20 + + + 40 + + + 60 + ← more disagree · Number of responses · more agree → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index 8c147c5c04..5f08b36a12 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -77,6 +77,7 @@ export {default as letterFrequencyColumn} from "./letter-frequency-column.js"; export {default as letterFrequencyDot} from "./letter-frequency-dot.js"; export {default as letterFrequencyLollipop} from "./letter-frequency-lollipop.js"; export {default as letterFrequencyWheel} from "./letter-frequency-wheel.js"; +export {default as likertSurvey} from "./likert-survey.js"; export {default as logDegenerate} from "./log-degenerate.js"; export {default as metroInequality} from "./metro-inequality.js"; export {default as metroInequalityChange} from "./metro-inequality-change.js"; diff --git a/test/plots/likert-survey.js b/test/plots/likert-survey.js new file mode 100644 index 0000000000..2c9a4794b4 --- /dev/null +++ b/test/plots/likert-survey.js @@ -0,0 +1,50 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +function Likert(responses) { + const map = new Map(responses); + return { + order: Array.from(map.keys()), + offset(facetstacks, X1, X2, Z) { + for (const stacks of facetstacks) { + for (const stack of stacks) { + const k = d3.sum(stack, i => (X2[i] - X1[i]) * (1 - map.get(Z[i]))) / 2; + for (const i of stack) { + X1[i] -= k; + X2[i] -= k; + } + } + } + } + }; +} + +export default async function() { + const survey = await d3.csv("data/survey.csv"); + const {order, offset} = Likert([ + ["Strongly Disagree", -1], + ["Disagree", -1], + ["Neutral", 0], + ["Agree", 1], + ["Strongly Agree", 1] + ]); + return Plot.plot({ + x: { + tickFormat: Math.abs, + label: "← more disagree · Number of responses · more agree →", + labelAnchor: "center" + }, + y: { + tickSize: 0 + }, + color: { + legend: true, + domain: order, + scheme: "RdBu" + }, + marks: [ + Plot.barX(survey, Plot.groupY({x: "count"}, {y: "Question", fill: "Response", order, offset})), + Plot.ruleX([0]) + ] + }); +}