Skip to content

Stack improvements #110

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

Closed
wants to merge 16 commits into from
4 changes: 2 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ export {Line, line, lineX, lineY} from "./marks/line.js";
export {Link, link} from "./marks/link.js";
export {Rect, rect, rectX, rectY} from "./marks/rect.js";
export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js";
export {stackAreaX, stackAreaY, stackBarX, stackBarY} from "./marks/stack.js";
export {stackAreaX, stackAreaY, stackBarX, stackBarY, stackLineX, stackLineY} from "./marks/stack.js";
export {Text, text, textX, textY} from "./marks/text.js";
export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
export {bin1, bin2} from "./transforms/bin.js";
export {group1, group2} from "./transforms/group.js";
export {stackX, stackY} from "./transforms/stack.js";
export {stack, stackX, stackY} from "./transforms/stack.js";
43 changes: 39 additions & 4 deletions src/marks/stack.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,54 @@
import {stackX, stackY} from "../transforms/stack.js";
import {areaX, areaY} from "./area.js";
import {barX, barY} from "./bar.js";
import {line} from "./line.js";

export function stackAreaX(data, options) {
return areaX(...stackX(data, options));
return areaX(...stackX(data, {
z: options.fill || options.title || options.stroke,
...options
}));
}

export function stackAreaY(data, options) {
return areaY(...stackY(data, options));
return areaY(...stackY(data, {
z: options.fill || options.title || options.stroke,
...options
}));
}

export function stackBarX(data, options) {
return barX(...stackX(data, options));
return barX(...stackX(data, {
z: options.fill || options.title || options.stroke,
...options
}));
}

export function stackBarY(data, options) {
return barY(...stackY(data, options));
return barY(...stackY(data, {
z: options.fill || options.title || options.stroke,
...options
}));
}

export function stackLineX(data, {position, ...options}) {
[data, options] = stackY(data, {
z: options.title || options.stroke,
...options
});
options.x = position === "center" ? options.x
: position === "bottom" ? options.x1
: options.x2;
return line(data, options);
}

export function stackLineY(data, {position, ...options}) {
[data, options] = stackY(data, {
z: options.title || options.stroke,
...options
});
options.y = position === "center" ? options.y
: position === "bottom" ? options.y1
: options.y2;
return line(data, options);
}
226 changes: 197 additions & 29 deletions src/transforms/stack.js
Original file line number Diff line number Diff line change
@@ -1,38 +1,143 @@
import {InternMap} from "d3-array";
import {valueof} from "../mark";
import {InternMap, ascending, cumsum, descending, group, groupSort, maxIndex, range, rollup, sum} from "d3-array";
import {field, maybeSort, take, valueof} from "../mark.js";

// TODO configurable series order
export function stackX(data, {x, y, ...options}) {
const X = valueof(data, x);
const Y = valueof(data, y);
const n = X.length;
const X0 = new InternMap();
const X1 = new Float64Array(n);
const X2 = new Float64Array(n);
for (let i = 0; i < n; ++i) {
const k = Y[i];
const x1 = X1[i] = X0.has(k) ? X0.get(k) : 0;
const x2 = X2[i] = x1 + +X[i];
X0.set(k, isNaN(x2) ? x1 : x2);
}
return [data, {...options, x1: maybeLabel(X1, x), x2: X2, y: maybeLabel(Y, y)}];
}
const A = stack(data, {location: y, value: x, ...options});
{
const [data, {location, value, value1, value2, ...options}] = A;
return [data, {
y: location,
x: value,
x1: value1,
x2: value2,
...options
}];
}
}

// TODO configurable series order
export function stackY(data, {x, y, ...options}) {
const X = valueof(data, x);
const Y = valueof(data, y);
const n = X.length;
const Y0 = new InternMap();
const A = stack(data, {location: x, value: y, ...options});
{
const [data, {location, value, value1, value2, ...options}] = A;
return [data, {
x: location,
y: value,
y1: value1,
y2: value2,
...options
}];
}
}

export function stack(data, {
location,
value,
z,
rank,
reverse = ["descending", "reverse"].includes(rank),
offset,
sort,
...options
}) {
const X = valueof(data, location);
const Y = valueof(data, value);
const Z = valueof(data, z);
const R = maybeRank(rank, data, X, Y, Z);
const n = data.length;
const I = range(n);
const Y1 = new Float64Array(n);
const Y2 = 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);
}
return [data, {...options, x: maybeLabel(X, x), y1: maybeLabel(Y1, y), y2: Y2}];
sort = maybeSort(sort);

const transform = (data, facets) => {
for (const index of (facets === undefined ? [I] : facets)) {

if (sort) {
const facet = take(data, index);
const index0 = index.slice();
const sorted = sort(facet);
for (let k = 0; k < index.length; k++) index[k] = index0[facet.indexOf(sorted[k])];
}

const Yp = new InternMap();
const Yn = new InternMap();

const stacks = group(index, i => X[i]);

// rank sort
if (R) {
const a = reverse ? descending : ascending;
for (const [, stack] of stacks) stack.sort((i, j) => a(R[i], R[j]));
}

// stack
for (const [x, stack] of stacks) {
for (const i of stack) {
const v = +Y[i];
const [Y0, ceil, floor] = v < 0 ? [Yn, Y1, Y2] : [Yp, Y2, Y1];
const y1 = floor[i] = Y0.has(x) ? Y0.get(x) : 0;
const y2 = ceil[i] = y1 + +Y[i];
Y0.set(x, isNaN(y2) ? y1 : y2);
}
}

// offset
if (offset === "expand") {
for (const i of index) {
const x = X[i];
const floor = Yn.has(x) ? Yn.get(x) : 0;
const ceil = Yp.has(x) ? Yp.get(x) : 0;
const m = 1 / (ceil - floor || 1);
Y1[i] = m * (-floor + Y1[i]);
Y2[i] = m * (-floor + Y2[i]);
}
}
if (offset === "silhouette") {
for (const i of index) {
const x = X[i];
const floor = Yn.has(x) ? Yn.get(x) : 0;
const ceil = Yp.has(x) ? Yp.get(x) : 0;
const m = (ceil + floor) / 2;
Y1[i] -= m;
Y2[i] -= m;
}
}
if (offset === "wiggle") {
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;
}
}
}

return {index: facets === undefined ? I : facets, data};
};

return [data, {
...options,
transform,
location: maybeLabel(X, location),
value1: maybeLabel(Y1, value),
value2: Y2,
value: (_,i) => (Y1[i] + Y2[i]) / 2,
z
}];
}

// If x is a labeled value, propagate the label to the returned value array.
Expand All @@ -41,3 +146,66 @@ function maybeLabel(X, x) {
if (label !== undefined) X.label = label;
return X;
}

// well-known ranking strategies by series
function maybeRank(rank, data, X, Y, Z) {
if (rank == null) return [null];
// d3.stackOrderNone, sorts series by key, ascending
// d3.stackOrderReverse, sorts series by key, descending
if (rank === "key" || rank === "none" || rank === "reverse") {
return Z;
}
// d3.stackOrderAscending, sorts series by sum of value, ascending
if (rank === "sum" || rank === "ascending" || rank === "descending") {
const S = groupSort(range(data.length), g => sum(g, i => Y[i]), i => Z[i]);
return Z.map(z => S.indexOf(z));
}
// ranks items by value
if (rank === "value") {
return Y;
}
// d3.stackOrderAppearance, sorts series by x = argmax of value
if (rank === "appearance") {
const K = groupSort(
range(data.length),
v => X[v[maxIndex(v, i => Y[i])]],
i => Z[i]
);
return Z.map(z => K.indexOf(z));
}
// d3.stackOrderInsideOut, sorts series by x = argmax of value, then rearranges them
// inside out by alternating series according to the sign of a running divergence
// of their sums
if (rank === "insideOut") {
const K = groupSort(
range(data.length),
v => X[v[maxIndex(v, i => Y[i])]],
i => Z[i]
);
const sums = rollup(range(data.length), v => sum(v, i => Y[i]), i => Z[i]);
const order = [];
let diff = 0;
for (const k of K) {
if (diff < 0) {
diff += sums.get(k);
order.push(k);
} else {
diff -= sums.get(k);
order.unshift(k);
}
}
return Z.map(z => order.indexOf(z));
}
// any other string is a datum accessor
if (typeof rank === "string") {
return valueof(data, field(rank));
}
// rank can be an array of z (particularly useful with groupSort)
if (rank.indexOf) {
return Z.map(z => rank.indexOf(z));
}
// final case, a generic function
if (typeof rank === "function") {
return valueof(data, rank);
}
}
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