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 mark and shift transform #1896

Merged
merged 55 commits into from
Nov 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
ecf9138
difference mark
mbostock Oct 17, 2023
64e3f62
fix filtering; opacity options
mbostock Oct 17, 2023
4ac221f
remove unused import
mbostock Oct 17, 2023
6b56c80
withTip; don’t duplicate channels
mbostock Oct 18, 2023
1880bff
difference as a composite mark
Fil Oct 19, 2023
a93cc56
difference tip
Fil Oct 19, 2023
c6c0be5
reuse channels
Fil Oct 23, 2023
a347333
more composite marks
mbostock Oct 23, 2023
b5ccc92
apply clip as render transform
mbostock Oct 23, 2023
43275f8
consolidate code
mbostock Oct 23, 2023
dea1cf3
aria labels
mbostock Oct 23, 2023
cc32a9b
organize imports
mbostock Oct 23, 2023
0326a33
fix differenceY1 test
mbostock Oct 23, 2023
b893638
update tests
mbostock Oct 23, 2023
376c079
better defaults
mbostock Oct 23, 2023
f1cd4ed
handle ChannelValueSpec
mbostock Oct 24, 2023
3544a98
update test
mbostock Oct 24, 2023
0775c7e
memoTuple
mbostock Oct 24, 2023
79ceefe
checkpoint docs
mbostock Oct 24, 2023
9e8a7ff
fix differenceY1 test
mbostock Oct 24, 2023
6952832
tip fixes
mbostock Oct 24, 2023
b18c780
**positiveOpacity**, **negativeOpacity** default to **fillOpacity**; …
Fil Oct 24, 2023
56cbe36
positiveFill
Fil Oct 24, 2023
6aa5488
another test
mbostock Oct 24, 2023
23dd51f
positiveFillOpacity & fix test
Fil Oct 25, 2023
fea7c77
swap [xy][12]; default y1 = 0
mbostock Oct 26, 2023
790b449
Merge branch 'main' into mbostock/difference
mbostock Oct 28, 2023
0b2eea1
shift option
mbostock Oct 28, 2023
6585322
another difference example
mbostock Oct 28, 2023
e170c8c
z
Fil Oct 30, 2023
24531b9
simpler marks (no need for two differences)
Fil Oct 30, 2023
db54a68
inferScaleOrder
mbostock Oct 30, 2023
a6a88da
Merge branch 'mbostock/difference' into fil/difference
Fil Oct 31, 2023
354d22f
simpler chart
Fil Oct 31, 2023
a6ecc1c
enhanced group extent; findX sketch
mbostock Nov 1, 2023
136ffc2
Merge branch 'main' into mbostock/difference
mbostock Nov 3, 2023
9fa447a
shift transform
mbostock Nov 4, 2023
52cc5b1
shift domain hint
mbostock Nov 4, 2023
d10ce7e
promote stroke to z
mbostock Nov 4, 2023
94a48ee
simpler channel domain hint
mbostock Nov 4, 2023
d4b0685
more difference docs
mbostock Nov 5, 2023
67b3e3b
more difference docs
mbostock Nov 5, 2023
54bf3fe
more documentation
mbostock Nov 5, 2023
b7a95ad
Merge branch 'mbostock/difference' into fil/difference
Fil Nov 6, 2023
6452734
call next twice (once for the path, once for the clipPath)
Fil Nov 6, 2023
30db89c
support clip: frame
Fil Nov 6, 2023
60b48f9
document differenceY
Fil Nov 6, 2023
0c58cf0
test ordinal difference
Fil Nov 6, 2023
f1cee3d
adopt Plot.find
Fil Nov 6, 2023
36dd5ea
Merge branch 'main' into mbostock/difference
mbostock Nov 7, 2023
c7afcb8
Merge branch 'fil/difference' into mbostock/difference
mbostock Nov 7, 2023
bb30385
more docs
mbostock Nov 7, 2023
00ab6f0
fix z documentation
mbostock Nov 7, 2023
2426fb1
fix space
mbostock Nov 7, 2023
5b22d28
Merge branch 'main' into mbostock/difference
mbostock Nov 8, 2023
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
2 changes: 2 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export default defineConfig({
{text: "Contour", link: "/marks/contour"},
{text: "Delaunay", link: "/marks/delaunay"},
{text: "Density", link: "/marks/density"},
{text: "Difference", link: "/marks/difference"},
{text: "Dot", link: "/marks/dot"},
{text: "Frame", link: "/marks/frame"},
{text: "Geo", link: "/marks/geo"},
Expand Down Expand Up @@ -121,6 +122,7 @@ export default defineConfig({
{text: "Map", link: "/transforms/map"},
{text: "Normalize", link: "/transforms/normalize"},
{text: "Select", link: "/transforms/select"},
{text: "Shift", link: "/transforms/shift"},
{text: "Sort", link: "/transforms/sort"},
{text: "Stack", link: "/transforms/stack"},
{text: "Tree", link: "/transforms/tree"},
Expand Down
1 change: 1 addition & 0 deletions docs/data/tsa.csv
145 changes: 145 additions & 0 deletions docs/marks/difference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<script setup>

import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
import {computed, shallowRef, onMounted} from "vue";

const aapl = shallowRef([]);
const gistemp = shallowRef([]);
const tsa = shallowRef([{Date: new Date("2020-01-01")}]);
const temperature = shallowRef([{date: new Date("2020-01-01")}]);

onMounted(() => {
d3.csv("../data/aapl.csv", d3.autoType).then((data) => (aapl.value = data));
d3.csv("../data/gistemp.csv", d3.autoType).then((data) => (gistemp.value = data));
d3.csv("../data/tsa.csv",d3.autoType).then((data) => (tsa.value = data));
d3.csv("../data/sf-sj-temperatures.csv", d3.autoType).then((data) => (temperature.value = data.filter((d) => d.date.getUTCFullYear() === 2020)));
});

</script>

# Difference mark <VersionBadge pr="1896" />

The **difference mark** puts a metric in context by comparing it. Like the [area mark](./area.md), the region between two lines is filled; unlike the area mark, alternating color shows when the metric is above or below the comparison value.

In the simplest case, the difference mark compares a metric to a constant. For example, the plot below shows the [global surface temperature anomaly](https://data.giss.nasa.gov/gistemp/) from 1880–2016; 0° represents the 1951–1980 average; above-average temperatures are in <span style="border-bottom: solid var(--vp-c-red) 3px;">red</span>, while below-average temperatures are in <span style="border-bottom: solid var(--vp-c-blue) 3px;">blue</span>. (It’s getting hotter.)

:::plot
```js
Plot.differenceY(gistemp, {
x: "Date",
y: "Anomaly",
positiveFill: "red",
negativeFill: "blue",
tip: true
}).plot({y: {grid: true}})
```
:::

A 24-month [moving average](../transforms/window.md) improves readability by smoothing out the noise.

:::plot
```js
Plot.differenceY(
gistemp,
Plot.windowY(12 * 2, {
x: "Date",
y: "Anomaly",
positiveFill: "red",
negativeFill: "blue",
tip: true
})
).plot({y: {grid: true}})
```
:::

More powerfully, the difference mark compares two metrics. For example, the plot below shows the number of travelers per day through TSA checkpoints in 2020 compared to 2019. (This in effect compares a metric against itself, but as the data represents each year as a separate column, it is equivalent to two metrics.) In the first two months of 2020, there were on average <span style="border-bottom: solid #01ab63 3px;">more travelers</span> per day than 2019; yet when COVID-19 hit, there were many <span style="border-bottom: solid #4269d0 3px;">fewer travelers</span> per day, dropping almost to zero.

:::plot
```js
Plot.plot({
x: {tickFormat: "%b"},
y: {grid: true, label: "Travelers"},
marks: [
Plot.axisY({label: "Travelers per day (thousands, 2020 vs. 2019)", tickFormat: (d) => d / 1000}),
Plot.ruleY([0]),
Plot.differenceY(tsa, {x: "Date", y1: "2019", y2: "2020", tip: {format: {x: "%B %-d"}}})
]
})
```
:::

If the data is “tall” rather than “wide” — that is, if the two metrics we wish to compare are represented by separate *rows* rather than separate *columns* — we can use the [group transform](../transforms/group.md) with the [find reducer](../transforms/group.md#find): group the rows by **x** (date), then find the desired **y1** and **y2** for each group. The plot below shows daily minimum temperature for San Francisco compared to San Jose. Notice how the insulating fog keeps San Francisco <span style="border-bottom: solid #01ab63 3px;">warmer</span> in winter and <span style="border-bottom: solid #4269d0 3px;">cooler</span> in summer, reducing seasonal variation.

:::plot
```js
Plot.plot({
x: {tickFormat: "%b"},
y: {grid: true},
marks: [
Plot.ruleY([32]),
Plot.differenceY(
temperature,
Plot.windowY(
14,
Plot.groupX(
{
y1: Plot.find((d) => d.station === "SJ"),
y2: Plot.find((d) => d.station === "SF")
},
{
x: "date",
y: "tmin",
tip: true
}
)
)
)
]
})
```
:::

The difference mark can also be used to compare a metric to itself using the [shift transform](../transforms/shift.md). The chart below shows year-over-year growth in the price of Apple stock.

:::plot
```js
Plot.differenceY(aapl, Plot.shiftX("+1 year", {x: "Date", y: "Close"})).plot({y: {grid: true}})
```
:::

For most of the covered time period, you would have <span style="border-bottom: solid #01ab63 3px;">made a profit</span> by holding Apple stock for a year; however, if you bought in 2015 and sold in 2016, you would likely have <span style="border-bottom: solid #4269d0 3px;">lost money</span>.

## Difference options

The following channels are required:

* **x2** - the horizontal position of the metric; bound to the *x* scale
* **y2** - the vertical position of the metric; bound to the *y* scale

In addition to the [standard mark options](../features/marks.md#mark-options), the following optional channels are supported:

* **x1** - the horizontal position of the comparison; bound to the *x* scale
* **y1** - the vertical position of the comparison; bound to the *y* scale

If **x1** is not specified, it defaults to **x2**. If **y1** is not specified, it defaults to 0 if **x1** and **x2** are equal, and to **y2** otherwise. These defaults facilitate sharing *x* or *y* coordinates between the metric and its comparison.

The standard **fill** option is ignored; instead, there are separate channels based on the sign of the difference:

* **positiveFill** - the color for when the metric is greater, defaults to <span style="border-bottom:solid #01ab63 3px;">green</span>
* **negativeFill** - the color for when the comparison is greater, defaults to <span style="border-bottom:solid #4269d0 3px;">blue</span>
* **fillOpacity** - the areas’ opacity, defaults to 1
* **positiveFillOpacity** - the positive area’s opacity, defaults to *opacity*
* **negativeFillOpacity** - the negative area’s opacity, defaults to *opacity*
* **stroke** - the metric line’s stroke color, defaults to currentColor
* **strokeOpacity** - the metric line’s opacity, defaults to 1

These options are passed to the underlying area and line marks; in particular, when they are defined as a channel, the underlying marks are broken into contiguous overlapping segments when the values change. When any of these channels are used, setting an explicit **z** channel (possibly to null) is strongly recommended.

## differenceY(*data*, *options*) {#differenceY}

```js
Plot.differenceY(gistemp, {x: "Date", y: "Anomaly"})
```

Returns a new difference with the given *data* and *options*. The mark is a composite of a positive area, negative area, and line. The positive area extends from the bottom of the frame to the line, and is clipped by the area extending from the comparison to the top of the frame. The negative area conversely extends from the top of the frame to the line, and is clipped by the area extending from the comparison to the bottom of the frame.
1 change: 1 addition & 0 deletions docs/public/data/sf-sj-temperatures.csv
48 changes: 48 additions & 0 deletions docs/transforms/shift.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<script setup>

import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
import {ref, shallowRef, onMounted} from "vue";

const shift = ref(365);
const aapl = shallowRef([]);

onMounted(() => {
d3.csv("../data/aapl.csv", d3.autoType).then((data) => (aapl.value = data));
});

</script>

# Shift transform <VersionBadge pr="1896" />

The **shift transform** is a specialized [map transform](./map.md) that derives an output **x1** channel by shifting the **x** channel; it can be used with the [difference mark](../marks/difference.md) to show change over time. For example, the chart below shows the price of Apple stock. The <span style="border-bottom: solid #01ab63 3px;">green region</span> shows when the price went up over the given interval, while the <span style="border-bottom: solid #4269d0 3px;">blue region</span> shows when the price went down.

<p>
<label class="label-input" style="display: flex;">
<span style="display: inline-block; width: 7em;">Shift (days):</span>
<input type="range" v-model.number="shift" min="0" max="1000" step="1">
<span style="font-variant-numeric: tabular-nums;">{{shift}}</span>
</label>
</p>

:::plot hidden
```js
Plot.differenceY(aapl, Plot.shiftX(`${shift} days`, {x: "Date", y: "Close"})).plot({y: {grid: true}})
```
:::

```js-vue
Plot.differenceY(aapl, Plot.shiftX("{{shift}} days", {x: "Date", y: "Close"})).plot({y: {grid: true}})
```

When looking at year-over-year growth, the chart is mostly green, implying that you would make a profit by holding Apple stock for a year. However, if you bought in 2015 and sold in 2016, you would likely have lost money. Try adjusting the slider to a shorter or longer interval: how does that affect the typical return?

## shiftX(*interval*, *options*) {#shiftX}

```js
Plot.shiftX("7 days", {x: "Date", y: "Close"})
```

Derives an **x1** channel from the input **x** channel by shifting values by the given *interval*. The *interval* may be specified as: a name (*second*, *minute*, *hour*, *day*, *week*, *month*, *quarter*, *half*, *year*, *monday*, *tuesday*, *wednesday*, *thursday*, *friday*, *saturday*, *sunday*) with an optional number and sign (*e.g.*, *+3 days* or *-1 year*); or as a number; or as an implementation — such as d3.utcMonth — with *interval*.floor(*value*), *interval*.offset(*value*), and *interval*.range(*start*, *stop*) methods.

The shiftX also transform aliases the **x** channel to **x2** and applies a domain hint to the **x2** channel such that by default the plot shows only the intersection of **x1** and **x2**. For example, if the interval is *+1 year*, the first year of the data is not shown.
2 changes: 2 additions & 0 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export * from "./marks/contour.js";
export * from "./marks/crosshair.js";
export * from "./marks/delaunay.js";
export * from "./marks/density.js";
export * from "./marks/difference.js";
export * from "./marks/dot.js";
export * from "./marks/frame.js";
export * from "./marks/geo.js";
Expand Down Expand Up @@ -52,6 +53,7 @@ export * from "./transforms/hexbin.js";
export * from "./transforms/map.js";
export * from "./transforms/normalize.js";
export * from "./transforms/select.js";
export * from "./transforms/shift.js";
export * from "./transforms/stack.js";
export * from "./transforms/tree.js";
export * from "./transforms/window.js";
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {Contour, contour} from "./marks/contour.js";
export {crosshair, crosshairX, crosshairY} from "./marks/crosshair.js";
export {delaunayLink, delaunayMesh, hull, voronoi, voronoiMesh} from "./marks/delaunay.js";
export {Density, density} from "./marks/density.js";
export {differenceY} from "./marks/difference.js";
export {Dot, dot, dotX, dotY, circle, hexagon} from "./marks/dot.js";
export {Frame, frame} from "./marks/frame.js";
export {Geo, geo, sphere, graticule} from "./marks/geo.js";
Expand All @@ -38,6 +39,7 @@ export {find, group, groupX, groupY, groupZ} from "./transforms/group.js";
export {hexbin} from "./transforms/hexbin.js";
export {normalize, normalizeX, normalizeY} from "./transforms/normalize.js";
export {map, mapX, mapY} from "./transforms/map.js";
export {shiftX} from "./transforms/shift.js";
export {window, windowX, windowY} from "./transforms/window.js";
export {select, selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js";
export {stackX, stackX1, stackX2, stackY, stackY1, stackY2} from "./transforms/stack.js";
Expand Down
10 changes: 2 additions & 8 deletions src/marks/axis.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import {formatDefault} from "../format.js";
import {marks} from "../mark.js";
import {radians} from "../math.js";
import {arrayify, constant, identity, keyword, number, range, valueof} from "../options.js";
import {isIterable, isNoneish, isTemporal, isInterval, orderof} from "../options.js";
import {isIterable, isNoneish, isTemporal, isInterval} from "../options.js";
import {maybeColorChannel, maybeNumberChannel, maybeRangeInterval} from "../options.js";
import {inferScaleOrder} from "../scales.js";
import {offset} from "../style.js";
import {generalizeTimeInterval, inferTimeFormat, intervalDuration} from "../time.js";
import {initializer} from "../transforms/basic.js";
Expand Down Expand Up @@ -686,13 +687,6 @@ function inferFontVariant(scale) {
return scale.bandwidth && !scale.interval ? undefined : "tabular-nums";
}

// Determines whether the scale points in the “positive” (right or down) or
// “negative” (left or up) direction; if the scale order cannot be determined,
// returns NaN; used to assign an appropriate label arrow.
function inferScaleOrder(scale) {
return Math.sign(orderof(scale.domain())) * Math.sign(orderof(scale.range()));
}

// Takes the scale label, and if this is not an ordinal scale and the label was
// inferred from an associated channel, adds an orientation-appropriate arrow.
function formatAxisLabel(k, scale, {anchor, label = scale.label, labelAnchor, labelArrow} = {}) {
Expand Down
86 changes: 86 additions & 0 deletions src/marks/difference.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type {ChannelValue, ChannelValueSpec} from "../channel.js";
import type {CurveOptions} from "../curve.js";
import type {Data, MarkOptions, RenderableMark} from "../mark.js";

/** Options for the difference mark. */
export interface DifferenceOptions extends MarkOptions, CurveOptions {
/**
* The comparison horizontal position channel, typically bound to the *x*
* scale; if not specified, **x** is used.
*/
x1?: ChannelValueSpec;

/**
* The primary horizontal position channel, typically bound to the *x* scale;
* if not specified, **x1** is used.
*/
x2?: ChannelValueSpec;

/** The horizontal position channel, typically bound to the *x* scale. */
x?: ChannelValueSpec;

/**
* The comparison vertical position channel, typically bound to the *y* scale;
* if not specified, **y** is used. For differenceY, defaults to zero if only
* one *x* and *y* channel is specified.
*/
y1?: ChannelValueSpec;

/**
* The primary vertical position channel, typically bound to the *y* scale;
* if not specified, **y1** is used.
*/
y2?: ChannelValueSpec;

/** The vertical position channel, typically bound to the *y* scale. */
y?: ChannelValueSpec;

/**
* The fill color when the primary value is greater than the secondary value;
* defaults to green.
*/
positiveFill?: ChannelValueSpec;

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

/** The fill opacity; defaults to 1. */
fillOpacity?: number;

/**
* The fill opacity when the primary value is greater than the secondary
* value; defaults to **fillOpacity**.
*/
positiveFillOpacity?: number;

/**
* The fill opacity when the primary value is less than the secondary value;
* defaults to **fillOpacity**.
*/
negativeFillOpacity?: number;

/**
* An optional ordinal channel for grouping data into series to be drawn as
* separate areas; defaults to **stroke**, if a channel.
*/
z?: ChannelValue;
}

/**
* Returns a new vertical difference mark for the given the specified *data* and
* *options*, as in a time-series chart where time goes right→ (or ←left).
*
* The mark is a composite of a positive area, negative area, and line. The
* positive area extends from the bottom of the frame to the line, and is
* clipped by the area extending from the comparison to the top of the frame.
* The negative area conversely extends from the top of the frame to the line,
* and is clipped by the area extending from the comparison to the bottom of the
* frame.
*/
export function differenceY(data?: Data, options?: DifferenceOptions): Difference;

/** The difference mark. */
export class Difference extends RenderableMark {}
Loading