Skip to content
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

difference as a composite mark #1897

Merged
merged 3 commits into from
Oct 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions src/marks/difference.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ export interface DifferenceOptions extends MarkOptions, CurveOptions {
* The fill color when the primary value is greater than the secondary value;
* defaults to green.
*/
positiveColor?: string;
positiveColor?: ChannelValueSpec;

/**
* The fill color when the primary value is less than the secondary value;
* defaults to blue.
*/
negativeColor?: string;
negativeColor?: ChannelValueSpec;

/**
* The fill opacity; defaults to 1.
Expand Down
256 changes: 134 additions & 122 deletions src/marks/difference.js
Original file line number Diff line number Diff line change
@@ -1,143 +1,155 @@
import {area as shapeArea, line as shapeLine} from "d3";
import {area as shapeArea} from "d3";
import {create} from "../context.js";
import {maybeCurve} from "../curve.js";
import {Mark, withTip} from "../mark.js";
import {identity, indexOf, isColor, number} from "../options.js";
import {applyIndirectStyles, applyTransform, getClipId, groupIndex} from "../style.js";
import {identity, indexOf, column, valueof} from "../options.js";
import {basic as transform} from "../transforms/basic.js";
import {groupIndex, getClipId} from "../style.js";
import {marks} from "../mark.js";
import {area} from "./area.js";
import {lineY} from "./line.js";

const defaults = {
ariaLabel: "difference",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round",
strokeLinejoin: "round",
strokeMiterlimit: 1
};

function maybeColor(value) {
if (value == null) return "none";
if (!isColor(value)) throw new Error(`invalid color: ${value}`);
return value;
function renderArea(X, Y, y0, {curve}) {
return shapeArea()
.curve(curve)
.defined((i) => i >= 0) // TODO: ??
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The -1 is populated by the groupIndex helper, which is used by the area and line marks:

plot/src/style.js

Lines 265 to 271 in 7098bf3

// If any channel has an undefined value for this index, skip it.
for (const c of C) {
if (!defined(c[i])) {
if (Gg) Gg.push(-1);
continue out;
}
}

Suggested change
.defined((i) => i >= 0) // TODO: ??
.defined((i) => i >= 0)

.x((i) => X[i])
.y1((i) => Y[i])
.y0(y0);
}

class DifferenceY extends Mark {
constructor(data, options = {}) {
const {
export function differenceY(
data,
{
x = indexOf,
x1 = x,
x2 = x,
y = identity,
y1 = y,
y2 = y,
positiveColor = "#01ab63",
negativeColor = "#4269d0",
opacity = 1,
positiveOpacity = opacity,
negativeOpacity = opacity,
ariaLabel = "difference",
positiveAriaLabel = `positive ${ariaLabel}`,
negativeAriaLabel = `negative ${ariaLabel}`,
stroke,
strokeOpacity,
tip,
channels,
...options
} = {}
) {
// The positive area goes from the top (0) down to the reference value y2, and
// is clipped by an area going from y1 to the top (0). It computes the
// channels which are then reused by the negative area and the line.
const areaPositive = area(
data,
maybeDifferenceChannelsY({
x1,
y1,
x2,
y1,
y2,
curve,
tension,
positiveColor = "#01ab63",
negativeColor = "#4269d0",
opacity = 1,
positiveOpacity = opacity,
negativeOpacity = opacity
} = options;
super(
data,
{
x1: {value: x1, scale: "x"},
y1: {value: y1, scale: "y"},
x2: {value: x2 === x1 ? undefined : x2, scale: "x", optional: true},
y2: {value: y2 === y1 ? undefined : y2, scale: "y", optional: true}
},
options,
defaults
);
this.curve = maybeCurve(curve, tension);
this.positiveColor = maybeColor(positiveColor);
this.negativeColor = maybeColor(negativeColor);
this.positiveOpacity = number(positiveOpacity);
this.negativeOpacity = number(negativeOpacity);
}
filter(index) {
return index;
}
render(index, scales, channels, dimensions, context) {
const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels;
const {negativeColor, positiveColor, negativeOpacity, positiveOpacity} = this;
const {height} = dimensions;
const clipPositive = getClipId();
const clipNegative = getClipId();
return create("svg:g", context)
.call(applyIndirectStyles, this, dimensions, context)
.call(applyTransform, this, scales, 0, 0)
.call((g) =>
g
fill: positiveColor,
fillOpacity: positiveOpacity,
...options,
tip,
// todo render
render: function (index, scales, channels, dimensions, context, next) {
const wrapper = create("svg:g", context);
const clip = getClipId();
const {x1: X1, y1: Y1, x2: X2 = X1} = channels;
const {height} = dimensions;
wrapper
.append("clipPath")
.attr("id", clipPositive)
.attr("id", clip)
.selectAll()
.data(groupIndex(index, [X1, Y1], this, channels))
.enter()
.append("path")
.attr("d", renderArea(X1, Y1, height, this))
)
.call((g) =>
g
.attr("d", renderArea(X1, Y1, height, this));
const g = next(index, scales, {...channels, x1: X2, y1: new Float32Array(Y1.length)}, dimensions, context);
g.setAttribute("clip-path", `url(#${clip})`);
g.removeAttribute("aria-label");
wrapper.attr("aria-label", positiveAriaLabel);
wrapper.append(() => g);
return wrapper.node();
}
})
);

return marks(
areaPositive,

// The negative area goes from the bottom (height) up to the reference value
// y2, and is clipped by an area going from y1 to the top (0).
area(data, {
x1: [],
x2: [],
y1: [],
y2: [],
fill: negativeColor,
fillOpacity: negativeOpacity,
...options,
render: function (index, scales, channels, dimensions, context, next) {
const wrapper = create("svg:g", context);
const clip = getClipId();
const {values} = context.getMarkState(areaPositive);
const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2} = values;
const {height} = dimensions;
wrapper
.append("clipPath")
.attr("id", clipNegative)
.selectAll()
.data(groupIndex(index, [X1, Y1], this, channels))
.enter()
.append("path")
.attr("d", renderArea(X1, Y1, 0, this))
)
.call((g) =>
g
.selectAll()
.data(groupIndex(index, [X2, Y2], this, channels))
.enter()
.append("path")
.attr("fill", positiveColor)
.attr("fill-opacity", positiveOpacity)
.attr("stroke", "none")
.attr("clip-path", `url(#${clipPositive})`)
.attr("d", renderArea(X2, Y2, 0, this))
)
.call((g) =>
g
.selectAll()
.data(groupIndex(index, [X2, Y2], this, channels))
.enter()
.append("path")
.attr("fill", negativeColor)
.attr("fill-opacity", negativeOpacity)
.attr("stroke", "none")
.attr("clip-path", `url(#${clipNegative})`)
.attr("d", renderArea(X2, Y2, height, this))
)
.call((g) =>
g
.attr("id", clip)
.selectAll()
.data(groupIndex(index, [X1, Y1], this, channels))
.enter()
.append("path")
.attr("d", renderLine(X1, Y1, this))
)
.node();
}
}
.attr("d", renderArea(X1, Y1, 0, this));
const g = next(
index,
scales,
{...channels, x1: X2, y1: new Float32Array(Y1.length).fill(height), x2: X2, y2: Y2},
dimensions,
context
);
g.setAttribute("clip-path", `url(#${clip})`);
wrapper.append(() => g);
g.removeAttribute("aria-label");
wrapper.attr("aria-label", negativeAriaLabel);
return wrapper.node();
}
}),

function renderArea(X, Y, y0, {curve}) {
return shapeArea()
.curve(curve)
.defined((i) => i >= 0)
.x((i) => X[i])
.y1((i) => Y[i])
.y0(y0);
}

function renderLine(X, Y, {curve}) {
return shapeLine()
.curve(curve)
.defined((i) => i >= 0)
.x((i) => X[i])
.y((i) => Y[i]);
// reference line
lineY(data, {
x: [],
y: [],
render: function (index, scales, channels, dimensions, context, next) {
const {values} = context.getMarkState(areaPositive);
const {x1: X1, y1: Y1} = values;
return next(index, scales, {...channels, x: X1, y: Y1}, dimensions, context);
},
stroke,
strokeOpacity,
...options
})
);
}

export function differenceY(data, {x = indexOf, x1 = x, x2 = x, y = identity, y1 = y, y2 = y, ...options} = {}) {
return new DifferenceY(data, withTip({...options, x1, x2, y1, y2}, "x"));
// Adds the difference channels for the default tip mark. Materializes
// x1, y1 and y2.
function maybeDifferenceChannelsY({x1, y1, y2, ...options}) {
if (!options.tip) return {x1, y1, y2, ...options};
const [X1, setX] = column(x1);
const [Y1, setY1] = column(y1);
const [Y2, setY2] = column(y2);
const [D, setD] = column();
options = transform(options, function (data, facets) {
setX(valueof(data, x1));
const Y1 = setY1(valueof(data, y1));
const Y2 = setY2(valueof(data, y2));
setD(Float64Array.from(Y1, (y1, i) => y1 - Y2[i]));
return {data, facets};
});
return {x1: X1, y1: Y1, y2: Y2, channels: {x: X1, y1: true, y2: true, difference: D}, ...options};
}
14 changes: 11 additions & 3 deletions test/output/differenceFilterX.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 11 additions & 3 deletions test/output/differenceFilterY1.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 11 additions & 3 deletions test/output/differenceFilterY2.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 12 additions & 3 deletions test/output/differenceY.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 11 additions & 3 deletions test/output/differenceY1.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading