diff --git a/docs/marks/area.md b/docs/marks/area.md index 1079114ef9..aaec65e6d8 100644 --- a/docs/marks/area.md +++ b/docs/marks/area.md @@ -265,6 +265,10 @@ Plot.plot((() => { ``` ::: +The **line** option draws a line connecting the points with coordinates **x2** and **y2** (the “top of the area”). + +Example TK. + See also the [ridgeline chart](https://observablehq.com/@observablehq/plot-ridgeline) example. Interpolation is controlled by the [**curve** option](../features/curves.md). The default curve is *linear*, which draws straight line segments between pairs of adjacent points. A *step* curve is nice for emphasizing when the value changes, while *basis* and *catmull–rom* are nice for smoothing. @@ -292,6 +296,8 @@ Points along the baseline and topline are connected in input order. Likewise, if The area mark supports [curve options](../features/curves.md) to control interpolation between points. If any of the **x1**, **y1**, **x2**, or **y2** values are invalid (undefined, null, or NaN), the baseline and topline will be interrupted, resulting in a break that divides the area shape into multiple segments. (See [d3-shape’s *area*.defined](https://d3js.org/d3-shape/area#area_defined) for more.) If an area segment consists of only a single point, it may appear invisible unless rendered with rounded or square line caps. In addition, some curves such as *cardinal-open* only render a visible segment if it contains multiple points. +The **line** option (boolean, defaults to false) indicates whether the mark should draw a line connecting the points with coordinates **x2** and **y2** (the “top of the area”). In that case, the **stroke** attribute defaults to *currentColor* and is applied to the line only, as well as the stroke opacity. The line uses the same **curve** as the area. + ## areaY(*data*, *options*) {#areaY} ```js diff --git a/src/marks/area.d.ts b/src/marks/area.d.ts index 49aa3011be..34d71c63e9 100644 --- a/src/marks/area.d.ts +++ b/src/marks/area.d.ts @@ -42,6 +42,12 @@ export interface AreaOptions extends MarkOptions, StackOptions, CurveOptions { * **stroke** if a channel. */ z?: ChannelValue; + + /** + * Whether a line should be drawn connecting the points with coordinates *x2*, + * *y2*; the **stroke** then applies to that line and defaults to *currentColor*. + */ + line?: boolean; } /** Options for the areaX mark. */ diff --git a/src/marks/area.js b/src/marks/area.js index bd8393926c..07682f943c 100644 --- a/src/marks/area.js +++ b/src/marks/area.js @@ -1,4 +1,4 @@ -import {area as shapeArea} from "d3"; +import {area as shapeArea, line as shapeLine} from "d3"; import {create} from "../context.js"; import {maybeCurve} from "../curve.js"; import {Mark} from "../mark.js"; @@ -24,7 +24,7 @@ const defaults = { export class Area extends Mark { constructor(data, options = {}) { - const {x1, y1, x2, y2, z, curve, tension} = options; + const {x1, y1, x2, y2, z, curve, tension, line} = options; super( data, { @@ -35,10 +35,11 @@ export class Area extends Mark { z: {value: maybeZ(options), optional: true} }, options, - defaults + line ? {...defaults, stroke: "currentColor"} : defaults ); this.z = z; this.curve = maybeCurve(curve, tension); + this.line = !!line; } filter(index) { return index; @@ -48,11 +49,14 @@ export class Area extends Mark { return create("svg:g", context) .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, scales, 0, 0) - .call((g) => - g + .call((g) => { + g = g .selectAll() .data(groupIndex(index, [X1, Y1, X2, Y2], this, channels)) - .enter() + .enter(); + + if (this.line) g = g.append("g"); + const area = g .append("path") .call(applyDirectStyles, this) .call(applyGroupedChannelStyles, this, channels) @@ -65,8 +69,23 @@ export class Area extends Mark { .y0((i) => Y1[i]) .x1((i) => X2[i]) .y1((i) => Y2[i]) - ) - ) + ); + if (this.line) { + area.attr("stroke", "none"); + g.append("path") + .call(applyDirectStyles, this) + .call(applyGroupedChannelStyles, this, channels) + .attr( + "d", + shapeLine() + .curve(this.curve) + .defined((i) => i >= 0) + .x((i) => X2[i]) + .y((i) => Y2[i]) + ) + .attr("fill", "none"); + } + }) .node(); } } diff --git a/test/output/aaplCloseLine.svg b/test/output/aaplCloseLine.svg new file mode 100644 index 0000000000..d64353cf8a --- /dev/null +++ b/test/output/aaplCloseLine.svg @@ -0,0 +1,80 @@ + + + + + + 0 + 20 + 40 + 60 + 80 + 100 + 120 + 140 + 160 + 180 + + + ↑ Close + + + + Dec2017 + Jan2018 + Feb + Mar + Apr + May + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/ridgeline.svg b/test/output/ridgeline.svg new file mode 100644 index 0000000000..442fb7d674 --- /dev/null +++ b/test/output/ridgeline.svg @@ -0,0 +1,394 @@ + + + + + Von der Heydt + + + Kirschheck + + + Saarbrücken-Neuhaus + + + Riegelsberg + + + Holz + + + Göttelborn + + + Illingen + + + AS Eppelborn + + + Hasborn + + + Kastel + + + Otzenhausen + + + Bierfeld + + + Nonnweiler + + + Hetzerath + + + Laufeld + + + Nettersheim + + + Euskirchen/Bliesheim + + + Hürth + + + Köln-Nord + + + Schloss Burg + + + Hagen-Vorhalle + + + Hengsen + + + Unna + + + Ascheberg + + + Ladbergen + + + Lotte + + + HB-Silbersee + + + HB-Weserbrücke + + + HB-Mahndorfer See + + + Groß Ippener + + + Uphusen + + + Bockel + + + Dibbersen + + + Glüsingen + + + Barsbüttel + + + Bad Schwartau + + + Oldenburg (Holstein) + + + Neustadt i. H.-Süd + + + + + + Jan 412 AM + 12 PM + Jan 512 AM + 12 PM + Jan 612 AM + 12 PM + Jan 712 AM + 12 PM + Jan 812 AM + 12 PM + Jan 912 AM + 12 PM + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/aapl-close.ts b/test/plots/aapl-close.ts index 21595c72a0..dfcd64dce8 100644 --- a/test/plots/aapl-close.ts +++ b/test/plots/aapl-close.ts @@ -13,6 +13,14 @@ export async function aaplClose() { }); } +export async function aaplCloseLine() { + const aapl = (await d3.csv("data/aapl.csv", d3.autoType)).slice(-120); + return Plot.plot({ + y: {grid: true}, + marks: [Plot.areaY(aapl, {x: "Date", y: "Close", fillOpacity: 0.1, line: true, stroke: "red"}), Plot.ruleY([0])] + }); +} + export async function aaplCloseClip() { const aapl = await d3.csv("data/aapl.csv", d3.autoType); return Plot.plot({ diff --git a/test/plots/index.ts b/test/plots/index.ts index b13d1ec45b..8a7e556e3a 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -258,6 +258,7 @@ export * from "./raster-vapor.js"; export * from "./raster-walmart.js"; export * from "./rect-band.js"; export * from "./reducer-scale-override.js"; +export * from "./ridgeline.js"; export * from "./rounded-rect.js"; export * from "./seattle-precipitation-density.js"; export * from "./seattle-precipitation-rule.js"; diff --git a/test/plots/ridgeline.ts b/test/plots/ridgeline.ts new file mode 100644 index 0000000000..1eed4dd701 --- /dev/null +++ b/test/plots/ridgeline.ts @@ -0,0 +1,27 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export async function ridgeline() { + const traffic = d3.sort(await d3.csv("data/traffic.csv", d3.autoType), (d) => d.date); + const overlap = 4.5; + return Plot.plot({ + height: 40 + new Set(traffic.map((d) => d.location)).size * 17, + width: 928, + marginBottom: 1, + marginLeft: 120, + x: {axis: "top"}, + y: {axis: null, range: [2.5 * 17 - 2, (2.5 - overlap) * 17 - 2]}, + fy: {label: null, domain: traffic.map((d) => d.location)}, // preserve input order + marks: [ + Plot.areaY(traffic, { + x: "date", + y: "vehicles", + fy: "location", + curve: "basis", + sort: "date", + fill: "color-mix(in oklab, var(--plot-background), currentColor 20%)", + line: true + }) + ] + }); +}