Skip to content

stacks #177

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 37 commits into from
Mar 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
9deabf1
stacks, rebased on https://github.com/observablehq/plot/pull/176
Fil Mar 1, 2021
110d829
formatting
mbostock Mar 1, 2021
ce6baf0
fix data type
mbostock Mar 1, 2021
487cb7d
formatting
mbostock Mar 1, 2021
1686443
formatting
mbostock Mar 1, 2021
301951a
stack[XY][12]
mbostock Mar 1, 2021
4dac6a9
[xy]2 is outer channel
mbostock Mar 1, 2021
ba41398
reduce policeDeaths
mbostock Mar 1, 2021
f65cd66
deduplicate
mbostock Mar 1, 2021
b65c2bb
tiny fix
mbostock Mar 1, 2021
5b00526
[XY]Mid
mbostock Mar 1, 2021
b9d82b1
{rank: null, reverse: true} ranks data in reverse input order
Fil Mar 1, 2021
c3a41a7
simplify
mbostock Mar 2, 2021
731af43
simplify
mbostock Mar 2, 2021
0986788
One-dimensional stack
Fil Mar 2, 2021
58e67ab
cleanup: we can retire the "key", "none", "ascending", "descending", …
Fil Mar 2, 2021
8d092c9
no rank-dependent default reverse
mbostock Mar 2, 2021
d8a9e96
normalize to undefined
mbostock Mar 2, 2021
a7ca1a7
Revert "normalize to undefined"
mbostock Mar 2, 2021
04d2269
remove sort option
mbostock Mar 2, 2021
33f2396
simplify
mbostock Mar 2, 2021
9ea24a8
optimize Z.map(z => S.indexOf(z))
Fil Mar 2, 2021
c286f07
insideOut ↦ inside-out
mbostock Mar 2, 2021
c658747
redesign musicRevenue
mbostock Mar 2, 2021
c0e5d9b
cleaner
mbostock Mar 2, 2021
00e38ad
rank cleanup
mbostock Mar 2, 2021
352d300
rank cleanup
mbostock Mar 2, 2021
8220a3e
optimize
mbostock Mar 2, 2021
991f5f6
optimize
mbostock Mar 2, 2021
eba2d96
use {a,de}scendingDefined
mbostock Mar 2, 2021
d894a75
adopt InternMap
mbostock Mar 2, 2021
9c835a0
refactor stack transform
mbostock Mar 3, 2021
7a15bda
standard z inheritance
mbostock Mar 3, 2021
928656b
cleaner
mbostock Mar 3, 2021
cd8281f
tweak learningPoverty example
mbostock Mar 3, 2021
4792fac
tweak learningPoverty example
mbostock Mar 3, 2021
98ca3d0
tweak learningPoverty example
mbostock Mar 3, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/defined.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {ascending} from "d3-array";
import {ascending, descending} from "d3-array";

export function defined(x) {
return x != null && !Number.isNaN(x);
Expand All @@ -8,6 +8,10 @@ export function ascendingDefined(a, b) {
return defined(b) - defined(a) || ascending(a, b);
}

export function descendingDefined(a, b) {
return defined(b) - defined(a) || descending(a, b);
}

export function nonempty(x) {
return x != null && (x + "") !== "";
}
Expand Down
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
export {bin, binX, binY, binR} from "./transforms/bin.js";
export {group, groupX, groupY} from "./transforms/group.js";
export {movingAverage} from "./transforms/movingAverage.js";
export {stackX, stackY} from "./transforms/stack.js";
export {stackX, stackX1, stackX2, stackXMid, stackY, stackY1, stackY2, stackYMid} from "./transforms/stack.js";
239 changes: 221 additions & 18 deletions src/transforms/stack.js
Original file line number Diff line number Diff line change
@@ -1,38 +1,88 @@
import {InternMap} from "d3-array";
import {valueof} from "../mark.js";
import {InternMap, ascending, cumsum, group, groupSort, greatest, rollup, sum} from "d3-array";
import {field, maybeColor, range, valueof} from "../mark.js";

export function stackX({x, y, ...options}) {
const [transform, Y, x1, x2] = stack(y, x);
const [transform, Y, x1, x2] = stack(y, x, options);
return {...options, transform, y: Y, x1, x2};
}

export function stackX1({x, y, ...options}) {
const [transform, Y, X] = stack(y, x, options);
return {...options, transform, y: Y, x: X};
}

export function stackX2({x, y, ...options}) {
const [transform, Y,, X] = stack(y, x, options);
return {...options, transform, y: Y, x: X};
}

export function stackXMid({x, y, ...options}) {
const [transform, Y, X1, X2] = stack(y, x, options);
return {...options, transform, y: Y, x: mid(X1, X2)};
}

export function stackY({x, y, ...options}) {
const [transform, X, y1, y2] = stack(x, y);
const [transform, X, y1, y2] = stack(x, y, options);
return {...options, transform, x: X, y1, y2};
}

// TODO configurable series order
function stack(x, y) {
export function stackY1({x, y, ...options}) {
const [transform, X, Y] = stack(x, y, options);
return {...options, transform, x: X, y: Y};
}

export function stackY2({x, y, ...options}) {
const [transform, X,, Y] = stack(x, y, options);
return {...options, transform, x: X, y: Y};
}

export function stackYMid({x, y, ...options}) {
const [transform, X, Y1, Y2] = stack(x, y, options);
return {...options, transform, x: X, y: mid(Y1, Y2)};
}

function stack(x, y = () => 1, {
z,
fill,
stroke,
offset,
order,
reverse
}) {
if (z === undefined && ([fill] = maybeColor(fill), fill != null)) z = fill;
if (z === undefined && ([stroke] = maybeColor(stroke), stroke != null)) z = stroke;
const [X, setX] = lazyChannel(x);
const [Y1, setY1] = lazyChannel(y);
const [Y2, setY2] = lazyChannel(y);
offset = maybeOffset(offset);
order = order === undefined && offset === offsetWiggle ? orderInsideOut : maybeOrder(order, offset);
return [
data => {
const X = setX(valueof(data, x));
(data, facets) => {
const I = range(data);
const X = x == null ? [] : setX(valueof(data, x));
const Y = valueof(data, y);
const n = X.length;
const Y0 = new InternMap();
const Z = valueof(data, z);
const n = data.length;
const Y1 = setY1(new Float64Array(n));
const Y2 = setY2(new Float64Array(n));
for (let i = 0; i < n; ++i) {
const k = X[i];
const y1 = Y1[i] = Y0.has(k) ? Y0.get(k) : 0;
const y2 = Y2[i] = y1 + +Y[i];
Y0.set(k, isNaN(y2) ? y1 : y2);
for (const index of facets === undefined ? [I] : facets) {
const stacks = Array.from(group(index, i => X[i]).values());
if (order) applyOrder(stacks, order(data, I, X, Y, Z));
for (const stack of stacks) {
let yn = 0, yp = 0;
if (reverse) stack.reverse();
for (const i of stack) {
const y = +Y[i];
if (y < 0) yn = Y2[i] = (Y1[i] = yn) + y;
else if (y > 0) yp = Y2[i] = (Y1[i] = yp) + y;
else Y2[i] = Y1[i] = yp; // NaN or zero
}
}
if (offset) offset(stacks, Y1, Y2, Z);
}
return data;
return {index: facets === undefined ? I : facets, data};
},
X,
x == null ? x : X,
Y1,
Y2
];
Expand All @@ -45,11 +95,164 @@ function lazyChannel(source) {
let value;
return [
{
transform() { return value; },
transform: () => value,
label: typeof source === "string" ? source
: source ? source.label
: undefined
},
v => value = v
];
}

// Assuming that both x1 and x2 and lazy channels (per above), this derives a
// new a channel that’s the average of the two, and which inherits the channel
// label (if any).
function mid(x1, x2) {
return {
transform() {
const X1 = x1.transform();
const X2 = x2.transform();
return Float64Array.from(X1, (_, i) => (X1[i] + X2[i]) / 2);
},
label: x1.label
};
}

function maybeOffset(offset) {
if (offset == null) return;
switch ((offset + "").toLowerCase()) {
case "expand": return offsetExpand;
case "silhouette": return offsetSilhouette;
case "wiggle": return offsetWiggle;
}
throw new Error(`unknown offset: ${offset}`);
}

// Given a single stack, returns the minimum and maximum values from the given
// Y2 column. Note that this relies on Y2 always being the outer column for
// diverging values.
function extent(stack, Y2) {
let min = 0, max = 0;
for (const i of stack) {
const y = Y2[i];
if (y < min) min = y;
if (y > max) max = y;
}
return [min, max];
}

function offsetExpand(stacks, Y1, Y2) {
for (const stack of stacks) {
const [yn, yp] = extent(stack, Y2);
for (const i of stack) {
const m = 1 / (yp - yn || 1);
Y1[i] = m * (Y1[i] - yn);
Y2[i] = m * (Y2[i] - yn);
}
}
}

function offsetSilhouette(stacks, Y1, Y2) {
for (const stack of stacks) {
const [yn, yp] = extent(stack, Y2);
for (const i of stack) {
const m = (yp + yn) / 2;
Y1[i] -= m;
Y2[i] -= m;
}
}
}

function offsetWiggle(stacks, Y1, Y2, Z) {
const prev = new InternMap();
let y = 0;
for (const stack of stacks) {
let j = -1;
const Fi = stack.map(i => Math.abs(Y2[i] - Y1[i]));
const Df = stack.map(i => {
j = Z ? Z[i] : ++j;
const value = Y2[i] - Y1[i];
const diff = prev.has(j) ? value - prev.get(j) : 0;
prev.set(j, value);
return diff;
});
const Cf1 = [0, ...cumsum(Df)];
for (const i of stack) {
Y1[i] += y;
Y2[i] += y;
}
const s1 = sum(Fi);
if (s1) y -= sum(Fi, (d, i) => (Df[i] / 2 + Cf1[i]) * d) / s1;
}
}

function maybeOrder(order) {
if (order == null) return;
if (typeof order === "string") {
switch (order.toLowerCase()) {
case "sum": return orderSum;
case "value": return orderY;
case "appearance": return orderAppearance;
case "inside-out": return orderInsideOut;
}
return orderFunction(field(order));
}
if (typeof order === "function") return orderFunction(order);
return orderZDomain(order);
}

// by sum of value (a.k.a. “ascending”)
function orderSum(data, I, X, Y, Z) {
return orderZ(Z, groupSort(I, I => sum(I, i => Y[i]), i => Z[i]));
}

// by value
function orderY(data, I, X, Y) {
return Y;
}

// by x = argmax of value
function orderAppearance(data, I, X, Y, Z) {
return orderZ(Z, groupSort(I, I => X[greatest(I, i => Y[i])], i => Z[i]));
}

// by x = argmax of value, but rearranged inside-out by alternating series
// according to the sign of a running divergence of sums
function orderInsideOut(data, I, X, Y, Z) {
const K = groupSort(I, I => X[greatest(I, i => Y[i])], i => Z[i]);
const sums = rollup(I, I => sum(I, i => Y[i]), i => Z[i]);
const Kp = [], Kn = [];
let s = 0;
for (const k of K) {
if (s < 0) {
s += sums.get(k);
Kp.push(k);
} else {
s -= sums.get(k);
Kn.push(k);
}
}
return orderZ(Z, Kn.reverse().concat(Kp));
}

function orderFunction(f) {
return data => valueof(data, f);
}

function orderZDomain(domain) {
return (data, I, X, Y, Z) => orderZ(Z, domain);
}

// Given an explicit ordering of distinct values in z, returns a parallel column
// O that can be used with applyOrder to sort stacks. Note that this is a series
// order: it will be consistent across stacks.
function orderZ(Z, domain) {
domain = new InternMap(domain.map((d, i) => [d, i]));
return Z.map(z => domain.get(z));
}

function applyOrder(stacks, O) {
for (const stack of stacks) {
stack.sort((i, j) => ascending(O[i], O[j]));
}
}
79 changes: 79 additions & 0 deletions test/data/caltrain.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
station,time,line,type,orientation,hours,minutes
Palo Alto,5:01am,101N,N,N,5,01
Palo Alto,8:01pm,191N,N,N,20,01
Palo Alto,9:01pm,193N,N,N,21,01
Palo Alto,10:01pm,195N,N,N,22,01
Palo Alto,11:01pm,197N,N,N,23,01
Palo Alto,8:01am,216L,L,S,8,01
Palo Alto,9:01am,226L,L,S,9,01
Palo Alto,5:01pm,264L,L,S,17,01
Palo Alto,6:02pm,274L,L,S,18,02
Palo Alto,10:03am,134N,N,S,10,03
Palo Alto,11:03am,138N,N,S,11,03
Palo Alto,12:03pm,142N,N,S,12,03
Palo Alto,1:03pm,146N,N,S,13,03
Palo Alto,2:03pm,150N,N,S,14,03
Palo Alto,3:03pm,154N,N,S,15,03
Palo Alto,4:03pm,158N,N,S,16,03
Palo Alto,6:05am,305B,B,N,6,05
Palo Alto,7:05am,313B,B,N,7,05
Palo Alto,8:05am,323B,B,N,8,05
Palo Alto,5:06pm,369B,B,N,17,06
Palo Alto,6:06pm,379B,B,N,18,06
Palo Alto,7:10pm,287L,L,N,19,10
Palo Alto,9:11am,233L,L,N,9,11
Palo Alto,10:11am,237L,L,N,10,11
Palo Alto,3:11pm,257L,L,N,15,11
Palo Alto,5:12pm,368B,B,S,17,12
Palo Alto,6:12pm,378B,B,S,18,12
Palo Alto,7:12pm,386B,B,S,19,12
Palo Alto,7:16am,215L,L,N,7,16
Palo Alto,8:16am,225L,L,N,8,16
Palo Alto,4:16pm,261L,L,N,16,16
Palo Alto,5:16pm,267L,L,N,17,16
Palo Alto,6:16pm,277L,L,N,18,16
Palo Alto,7:18am,208L,L,S,7,18
Palo Alto,8:18am,218L,L,S,8,18
Palo Alto,9:18am,228L,L,S,9,18
Palo Alto,7:21pm,189N,N,N,19,21
Palo Alto,6:21am,104N,N,S,6,21
Palo Alto,6:23am,309B,B,N,6,23
Palo Alto,7:23am,319B,B,N,7,23
Palo Alto,8:23am,329B,B,N,8,23
Palo Alto,4:24pm,263L,L,N,16,24
Palo Alto,5:24pm,271L,L,N,17,24
Palo Alto,6:24pm,281L,L,N,18,24
Palo Alto,10:25am,236L,L,S,10,25
Palo Alto,3:25pm,256L,L,S,15,25
Palo Alto,4:25pm,260L,L,S,16,25
Palo Alto,7:26am,210L,L,S,7,26
Palo Alto,8:26am,220L,L,S,8,26
Palo Alto,9:26am,230L,L,S,9,26
Palo Alto,8:26pm,190N,N,S,20,26
Palo Alto,5:36am,103N,N,N,5,36
Palo Alto,6:36am,207L,L,N,6,36
Palo Alto,7:36am,217L,L,N,7,36
Palo Alto,8:36am,227L,L,N,8,36
Palo Alto,9:36pm,192N,N,S,21,36
Palo Alto,10:36pm,194N,N,S,22,36
Palo Alto,11:36pm,196N,N,S,23,36
Palo Alto,3:38pm,159N,N,N,15,38
Palo Alto,5:38pm,270L,L,S,17,38
Palo Alto,6:38pm,280L,L,S,18,38
Palo Alto,7:38pm,288L,L,S,19,38
Palo Alto,9:41am,135N,N,N,9,41
Palo Alto,10:41am,139N,N,N,10,41
Palo Alto,11:41am,143N,N,N,11,41
Palo Alto,12:41pm,147N,N,N,12,41
Palo Alto,1:41pm,151N,N,N,13,41
Palo Alto,2:41pm,155N,N,N,14,41
Palo Alto,4:44pm,362B,B,S,16,44
Palo Alto,5:49pm,372B,B,S,17,49
Palo Alto,6:49pm,382B,B,S,18,49
Palo Alto,5:51am,102N,N,S,5,51
Palo Alto,7:51am,314B,B,S,7,51
Palo Alto,8:51am,324B,B,S,8,51
Palo Alto,5:54pm,275L,L,N,17,54
Palo Alto,6:54pm,285L,L,N,18,54
Palo Alto,6:57am,206L,L,S,6,57
Palo Alto,12:57am,198N,N,S,24,57
Loading