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 @@
+
\ 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 @@
+
\ 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
+ })
+ ]
+ });
+}