From 1880bff8aa016f413a7ed15c7d097bbde9fe4e10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 19 Oct 2023 12:01:14 +0200 Subject: [PATCH 1/3] difference as a composite mark --- src/marks/difference.d.ts | 4 +- src/marks/difference.js | 224 +++++++++++++--------------- test/output/differenceFilterX.svg | 14 +- test/output/differenceFilterY1.svg | 14 +- test/output/differenceFilterY2.svg | 14 +- test/output/differenceY.svg | 15 +- test/output/differenceY1.svg | 14 +- test/output/differenceYCurve.svg | 87 +++++++++++ test/output/differenceYVariable.svg | 84 +++++++++++ test/plots/difference.ts | 27 +++- 10 files changed, 355 insertions(+), 142 deletions(-) create mode 100644 test/output/differenceYCurve.svg create mode 100644 test/output/differenceYVariable.svg diff --git a/src/marks/difference.d.ts b/src/marks/difference.d.ts index 6afb7b672a..5af45a1339 100644 --- a/src/marks/difference.d.ts +++ b/src/marks/difference.d.ts @@ -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. diff --git a/src/marks/difference.js b/src/marks/difference.js index 55031e9bf1..c7f3612f51 100644 --- a/src/marks/difference.js +++ b/src/marks/difference.js @@ -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: ?? + .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}) + ); } diff --git a/test/output/differenceFilterX.svg b/test/output/differenceFilterX.svg index eff0e3a21f..02aff9b29c 100644 --- a/test/output/differenceFilterX.svg +++ b/test/output/differenceFilterX.svg @@ -51,15 +51,23 @@ 2017 2018 - + + + + + + - - + + + + + \ No newline at end of file diff --git a/test/output/differenceFilterY1.svg b/test/output/differenceFilterY1.svg index 2240273107..4e51b0d8d4 100644 --- a/test/output/differenceFilterY1.svg +++ b/test/output/differenceFilterY1.svg @@ -51,15 +51,23 @@ 2017 2018 - + + + + + + - - + + + + + \ No newline at end of file diff --git a/test/output/differenceFilterY2.svg b/test/output/differenceFilterY2.svg index 0f0f983c02..b517d1e6a1 100644 --- a/test/output/differenceFilterY2.svg +++ b/test/output/differenceFilterY2.svg @@ -51,15 +51,23 @@ 2017 2018 - + + + + + + - - + + + + + \ No newline at end of file diff --git a/test/output/differenceY.svg b/test/output/differenceY.svg index 56e6837996..4d24ddfeaf 100644 --- a/test/output/differenceY.svg +++ b/test/output/differenceY.svg @@ -51,15 +51,24 @@ 2017 2018 - + + + + + + - - + + + + + + \ No newline at end of file diff --git a/test/output/differenceY1.svg b/test/output/differenceY1.svg index 2fcdac90e6..d1d9aca701 100644 --- a/test/output/differenceY1.svg +++ b/test/output/differenceY1.svg @@ -57,15 +57,23 @@ 2017 2018 - + + + + + + - - + + + + + \ No newline at end of file diff --git a/test/output/differenceYCurve.svg b/test/output/differenceYCurve.svg new file mode 100644 index 0000000000..0bc0904e92 --- /dev/null +++ b/test/output/differenceYCurve.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + 0.96 + 0.97 + 0.98 + 0.99 + 1.00 + 1.01 + 1.02 + 1.03 + 1.04 + 1.05 + 1.06 + 1.07 + 1.08 + 1.09 + + + + + + + + + + + + + 11Aug + 18 + 25 + 1Sep + 8 + 15 + 22 + 29 + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/differenceYVariable.svg b/test/output/differenceYVariable.svg new file mode 100644 index 0000000000..6663640885 --- /dev/null +++ b/test/output/differenceYVariable.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + 1.0 + 1.2 + 1.4 + 1.6 + 1.8 + 2.0 + 2.2 + 2.4 + 2.6 + 2.8 + + + + + + + + + + 2014 + 2015 + 2016 + 2017 + 2018 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/difference.ts b/test/plots/difference.ts index 59f1e80d3d..351194b1ed 100644 --- a/test/plots/difference.ts +++ b/test/plots/difference.ts @@ -9,7 +9,32 @@ export async function differenceY() { const x = aapl.map((d) => d.Date); const y1 = aapl.map((d, i, data) => d.Close / data[0].Close); const y2 = goog.map((d, i, data) => d.Close / data[0].Close); - return Plot.differenceY(aapl, {x, y1, y2}).plot(); + return Plot.differenceY(aapl, {x, y1, y2, tip: true}).plot(); +} + +export async function differenceYCurve() { + const aapl = (await d3.csv("data/aapl.csv", d3.autoType)).slice(60, 100); + const goog = (await d3.csv("data/goog.csv", d3.autoType)).slice(60, 100); + const x = aapl.map((d) => d.Date); + const y1 = aapl.map((d, i, data) => d.Close / data[0].Close); + const y2 = goog.map((d, i, data) => d.Close / data[0].Close); + return Plot.differenceY(aapl, {x, y1, y2, curve: "cardinal", tension: 0.1}).plot(); +} + +export async function differenceYVariable() { + const aapl = await d3.csv("data/aapl.csv", d3.autoType); + const goog = await d3.csv("data/goog.csv", d3.autoType); + const x = aapl.map((d) => d.Date); + const y1 = aapl.map((d, i, data) => d.Close / data[0].Close); + const y2 = goog.map((d, i, data) => d.Close / data[0].Close); + return Plot.differenceY(aapl, { + x, + y1, + y2, + negativeColor: "#eee", + positiveColor: (d) => d.Date.getUTCFullYear(), + tip: true + }).plot(); } // Here we shift x2 forward to show year-over-year growth; to suppress the year From a93cc563bf5cc45818a81e57770d3c79bc76f800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 19 Oct 2023 12:23:18 +0200 Subject: [PATCH 2/3] difference tip --- src/marks/difference.js | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/marks/difference.js b/src/marks/difference.js index c7f3612f51..228f0b7a59 100644 --- a/src/marks/difference.js +++ b/src/marks/difference.js @@ -1,6 +1,7 @@ import {area as shapeArea} from "d3"; import {create} from "../context.js"; -import {identity, indexOf} from "../options.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"; @@ -114,6 +115,21 @@ export function differenceY( }), // reference line - lineY(data, {x: x1, y: y1, tip, channels: {...channels, y2}, ...options}) + lineY(data, maybeDifferenceChannelsY({x: x1, y: y1, y2, tip, ...options})) ); } + +// Adds the y2 and difference channels for the default tip mark. +function maybeDifferenceChannelsY(options) { + if (!options.tip) return options; + const [Y1, setY1] = column(options.y); + const [Y2, setY2] = column(options.y2); + const [D, setD] = column(); + options = transform(options, function (data, facets) { + const Y1 = setY1(valueof(data, options.y)); + const Y2 = setY2(valueof(data, options.y2)); + setD(Float64Array.from(Y1, (y1, i) => y1 - Y2[i])); + return {data, facets}; + }); + return {channels: {x: true, y: true, y2: Y2, difference: D}, ...options, y: Y1}; +} From c6c0be598f036b3996e00c681403d4bff3529424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 23 Oct 2023 21:06:21 +0200 Subject: [PATCH 3/3] reuse channels --- src/marks/difference.js | 68 +++++++++++++++++++----------- test/output/differenceYRandom.svg | 70 +++++++++++++++++++++++++++++++ test/plots/difference.ts | 7 ++++ 3 files changed, 121 insertions(+), 24 deletions(-) create mode 100644 test/output/differenceYRandom.svg diff --git a/src/marks/difference.js b/src/marks/difference.js index 228f0b7a59..d7f5f9b6bd 100644 --- a/src/marks/difference.js +++ b/src/marks/difference.js @@ -33,15 +33,19 @@ export function differenceY( ariaLabel = "difference", positiveAriaLabel = `positive ${ariaLabel}`, negativeAriaLabel = `negative ${ariaLabel}`, + stroke, + strokeOpacity, 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, { + // 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, x2, y1, @@ -49,6 +53,7 @@ export function differenceY( fill: positiveColor, fillOpacity: positiveOpacity, ...options, + tip, // todo render render: function (index, scales, channels, dimensions, context, next) { const wrapper = create("svg:g", context); @@ -70,22 +75,27 @@ export function differenceY( 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, + 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 {values} = context.getMarkState(areaPositive); + const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2} = values; const {height} = dimensions; wrapper .append("clipPath") @@ -98,11 +108,7 @@ export function differenceY( const g = next( index, scales, - { - ...channels, - x1: X2, - y1: new Float32Array(Y1.length).fill(height) - }, + {...channels, x1: X2, y1: new Float32Array(Y1.length).fill(height), x2: X2, y2: Y2}, dimensions, context ); @@ -115,21 +121,35 @@ export function differenceY( }), // reference line - lineY(data, maybeDifferenceChannelsY({x: x1, y: y1, y2, tip, ...options})) + 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 + }) ); } -// Adds the y2 and difference channels for the default tip mark. -function maybeDifferenceChannelsY(options) { - if (!options.tip) return options; - const [Y1, setY1] = column(options.y); - const [Y2, setY2] = column(options.y2); +// 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) { - const Y1 = setY1(valueof(data, options.y)); - const Y2 = setY2(valueof(data, options.y2)); + 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 {channels: {x: true, y: true, y2: Y2, difference: D}, ...options, y: Y1}; + return {x1: X1, y1: Y1, y2: Y2, channels: {x: X1, y1: true, y2: true, difference: D}, ...options}; } diff --git a/test/output/differenceYRandom.svg b/test/output/differenceYRandom.svg new file mode 100644 index 0000000000..a2989cbbc3 --- /dev/null +++ b/test/output/differenceYRandom.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + 1.5 + 2.0 + 2.5 + 3.0 + 3.5 + 4.0 + 4.5 + + + + + + + + + + + 0 + 10 + 20 + 30 + 40 + 50 + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/difference.ts b/test/plots/difference.ts index 351194b1ed..3209c854a1 100644 --- a/test/plots/difference.ts +++ b/test/plots/difference.ts @@ -12,6 +12,13 @@ export async function differenceY() { return Plot.differenceY(aapl, {x, y1, y2, tip: true}).plot(); } +export async function differenceYRandom() { + const random = d3.randomLcg(42); + let sum = 3; + const cumsum = () => (sum += random() - 0.5); + return Plot.differenceY({length: 60}, {y1: cumsum, y2: cumsum, curve: "natural", tip: true}).plot(); +} + export async function differenceYCurve() { const aapl = (await d3.csv("data/aapl.csv", d3.autoType)).slice(60, 100); const goog = (await d3.csv("data/goog.csv", d3.autoType)).slice(60, 100);