Skip to content

difference as a composite mark #1897

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 3 commits into from
Oct 24, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
difference as a composite mark
  • Loading branch information
Fil committed Oct 19, 2023
commit 1880bff8aa016f413a7ed15c7d097bbde9fe4e10
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
224 changes: 100 additions & 124 deletions src/marks/difference.js
Original file line number Diff line number Diff line change
@@ -1,143 +1,119 @@
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} from "../options.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}`,
tip,
channels,
...options
} = {}
) {
return marks(
// 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).
area(data, {
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,
// 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();
}
}),

// 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 {x1: X1, y1: Y1, x2: X2 = X1} = channels;
const {height} = dimensions;
wrapper
.append("clipPath")
.attr("id", clipNegative)
.attr("id", clip)
.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
.selectAll()
.data(groupIndex(index, [X1, Y1], this, channels))
.enter()
.append("path")
.attr("d", renderLine(X1, Y1, this))
)
.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]);
}
.attr("d", renderArea(X1, Y1, 0, this));
const g = next(
index,
scales,
{
...channels,
x1: X2,
y1: new Float32Array(Y1.length).fill(height)
},
dimensions,
context
);
g.setAttribute("clip-path", `url(#${clip})`);
wrapper.append(() => g);
g.removeAttribute("aria-label");
wrapper.attr("aria-label", negativeAriaLabel);
return wrapper.node();
}
}),

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"));
// reference line
lineY(data, {x: x1, y: y1, tip, channels: {...channels, y2}, ...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