diff --git a/docs/marks/area.md b/docs/marks/area.md index e967127b5d..767d6f9bd6 100644 --- a/docs/marks/area.md +++ b/docs/marks/area.md @@ -20,7 +20,7 @@ Plot.areaY(aapl, {x: "Date", y: "Close"}).plot() The area mark has three constructors: [areaY](#areaY) for when the baseline and topline share *x* values, as in a time-series area chart where time goes right→ (or ←left); [areaX](#areaX) for when the baseline and topline share *y* values, as in a time-series area chart where time goes up↑ (or down↓); and lastly the rarely-used [area](#area) where the baseline and topline share neither *x* nor *y* values. -The area mark is often paired with a [line](./line.md) and [rule](./rule.md) mark to accentuate the topline and baseline. +The **line** option strokes the topline. It is often paired with a [rule](./rule.md) mark to denote the baseline. :::plot https://observablehq.com/@observablehq/plot-area-and-line ```js @@ -29,8 +29,7 @@ Plot.plot({ grid: true }, marks: [ - Plot.areaY(aapl, {x: "Date", y: "Close", fillOpacity: 0.3}), - Plot.lineY(aapl, {x: "Date", y: "Close"}), + Plot.areaY(aapl, {x: "Date", y: "Close", line: true}), Plot.ruleY([0]) ] }) @@ -94,8 +93,7 @@ Plot.plot({ reverse: true }, marks: [ - Plot.areaY(aapl, {x: "Date", y: "Close", fillOpacity: 0.3}), - Plot.lineY(aapl, {x: "Date", y: "Close"}), + Plot.areaY(aapl, {x: "Date", y: "Close", line: true}), Plot.ruleY([0]) ] }) @@ -111,8 +109,7 @@ Plot.plot({ grid: true }, marks: [ - Plot.areaX(aapl, {y: "Date", x: "Close", fillOpacity: 0.3}), - Plot.lineX(aapl, {y: "Date", x: "Close"}), + Plot.areaX(aapl, {y: "Date", x: "Close", line: true}), Plot.ruleX([0]) ] }) @@ -128,8 +125,7 @@ Plot.plot({ grid: true }, marks: [ - Plot.areaY(aapl, {x: "Date", y: (d) => d.Date.getUTCMonth() < 3 ? NaN : d.Close, fillOpacity: 0.3}), - Plot.lineY(aapl, {x: "Date", y: (d) => d.Date.getUTCMonth() < 3 ? NaN : d.Close}), + Plot.areaY(aapl, {x: "Date", y: (d) => d.Date.getUTCMonth() < 3 ? NaN : d.Close, line: true}), Plot.ruleY([0]) ] }) @@ -308,7 +304,7 @@ Plot.areaY(observations, {x: "date", y: "temperature", interval: "day"}) The **interval** option is recommended to “regularize” sampled data; for example, if your data represents timestamped temperature measurements and you expect one sample per day, use "day" as the interval. -The **areaY** mark draws the region between a baseline (*y1*) and a topline (*y2*) as in an area chart. When the baseline is *y* = 0, the *y* channel can be specified instead of *y1* and *y2*. +The **areaY** mark draws the region between a vertically-separated baseline (*y1*) and topline (*y2*) as in an area chart. When the baseline is *y* = 0, the *y* channel can be specified instead of *y1* and *y2*. If the **line** option is true, the **stroke** applies exclusively to the topline, and the **fillOpacity** defaults to 0.3. ## areaX(*data*, *options*) {#areaX} @@ -326,6 +322,8 @@ Plot.areaX(observations, {y: "date", x: "temperature", interval: "day"}) The **interval** option is recommended to “regularize” sampled data; for example, if your data represents timestamped temperature measurements and you expect one sample per day, use "day" as the interval. +The **areaX** mark draws the region between a horizontally-separated baseline (*x1*) and topline (*x2*) as in a vertical area chart. When the baseline is *x* = 0, the *x* channel can be specified instead of *x1* and *x2*. If the **line** option is true, the **stroke** applies exclusively to the topline, and the **fillOpacity** defaults to 0.3. + ## area(*data*, *options*) {#area} ```js diff --git a/src/marks/area.d.ts b/src/marks/area.d.ts index 49aa3011be..8eed9953e1 100644 --- a/src/marks/area.d.ts +++ b/src/marks/area.d.ts @@ -1,6 +1,7 @@ import type {ChannelValue, ChannelValueDenseBinSpec, ChannelValueSpec} from "../channel.js"; import type {CurveOptions} from "../curve.js"; import type {Data, MarkOptions, RenderableMark} from "../mark.js"; +import {MarkerOptions} from "../marker.js"; import type {BinOptions, BinReducer} from "../transforms/bin.js"; import type {StackOptions} from "../transforms/stack.js"; @@ -42,6 +43,9 @@ export interface AreaOptions extends MarkOptions, StackOptions, CurveOptions { * **stroke** if a channel. */ z?: ChannelValue; + + /** Shorthand for setting both the fill and the stroke. */ + color?: ChannelValueSpec; } /** Options for the areaX mark. */ @@ -124,6 +128,9 @@ export interface AreaYOptions extends Omit, BinOptions reduce?: BinReducer; } +/** The area mark’s line option. */ +export type AreaLineOptions = {line?: false} | ({line: true} & MarkerOptions); + /** * Returns a new area mark with the given *data* and *options*. The area mark is * rarely used directly; it is only needed when the baseline and topline have @@ -163,7 +170,7 @@ export function area(data?: Data, options?: AreaOptions): Area; * channels. When any of these channels are used, setting an explicit **z** * channel (possibly to null) is strongly recommended. */ -export function areaX(data?: Data, options?: AreaXOptions): Area; +export function areaX(data?: Data, options?: AreaXOptions & AreaLineOptions): Area; /** * Returns a new horizontally-oriented area mark for the given *data* and @@ -195,7 +202,7 @@ export function areaX(data?: Data, options?: AreaXOptions): Area; * channels. When any of these channels are used, setting an explicit **z** * channel (possibly to null) is strongly recommended. */ -export function areaY(data?: Data, options?: AreaYOptions): Area; +export function areaY(data?: Data, options?: AreaYOptions & AreaLineOptions): Area; /** The area mark. */ export class Area extends RenderableMark {} diff --git a/src/marks/area.js b/src/marks/area.js index f820302a27..902edfcb83 100644 --- a/src/marks/area.js +++ b/src/marks/area.js @@ -1,14 +1,15 @@ -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"; +import {applyGroupedMarkers, markers} from "../marker.js"; import {first, maybeZ, second} from "../options.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles} from "../style.js"; -import {groupIndex} from "../style.js"; +import {groupIndex, offset} from "../style.js"; import {maybeDenseIntervalX, maybeDenseIntervalY} from "../transforms/bin.js"; import {maybeStackX, maybeStackY} from "../transforms/stack.js"; -const defaults = { +const areaDefaults = { ariaLabel: "area", strokeWidth: 1, strokeLinecap: "round", @@ -16,8 +17,18 @@ const defaults = { strokeMiterlimit: 1 }; +const areaLineDefaults = { + ariaLabel: "area-line", + fillOpacity: 0.3, + stroke: "currentColor", + strokeWidth: 1.5, + strokeLinecap: "round", + strokeLinejoin: "round", + strokeMiterlimit: 1 +}; + export class Area extends Mark { - constructor(data, options = {}) { + constructor(data, options = {}, defaults = areaDefaults) { const {x1, y1, x2, y2, z, curve, tension} = options; super( data, @@ -65,17 +76,70 @@ export class Area extends Mark { } } +class AreaLine extends Area { + constructor(data, options = {}) { + super(data, options, areaLineDefaults); + markers(this, options); + } + render(index, scales, channels, dimensions, context) { + const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels; + return create("svg:g", context) + .call(applyIndirectStyles, this, dimensions, context) + .call(applyTransform, this, scales, 0, 0) + .call((g) => + g + .selectAll() + .data(groupIndex(index, [X1, Y1, X2, Y2], this, channels)) + .enter() + .append("g") + .call(applyDirectStyles, this) + .call(applyGroupedChannelStyles, this, channels) + .call((e) => + e + .append("path") + .attr("stroke", "none") + .attr( + "d", + shapeArea() + .curve(this.curve) + .defined((i) => i >= 0) + .x0((i) => X1[i]) + .y0((i) => Y1[i]) + .x1((i) => X2[i]) + .y1((i) => Y2[i]) + ) + ) + .call((e) => + e + .append("path") + .call(applyGroupedMarkers, this, channels, context) + .attr("fill", "none") + .attr("transform", offset ? `translate(${offset},${offset})` : null) + .attr( + "d", + shapeLine() + .curve(this.curve) + .defined((i) => i >= 0) + .x((i) => X2[i]) + .y((i) => Y2[i]) + ) + ) + ) + .node(); + } +} + export function area(data, options) { if (options === undefined) return areaY(data, {x: first, y: second}); return new Area(data, options); } export function areaX(data, options) { - const {x, y, fill, z = x === fill ? null : undefined, ...rest} = maybeDenseIntervalY(options); - return new Area(data, maybeStackX({...rest, x, y1: y, y2: undefined, z, fill})); + const {x, y, line, color, stroke = color, fill = color, z = x === fill || x === stroke ? null : undefined, ...rest} = maybeDenseIntervalY(options); // prettier-ignore + return new (line ? AreaLine : Area)(data, maybeStackX({...rest, x, y1: y, y2: undefined, z, stroke, fill})); } export function areaY(data, options) { - const {x, y, fill, z = y === fill ? null : undefined, ...rest} = maybeDenseIntervalX(options); - return new Area(data, maybeStackY({...rest, x1: x, x2: undefined, y, z, fill})); + const {x, y, line, color, stroke = color, fill = color, z = y === fill || y === stroke ? null : undefined, ...rest} = maybeDenseIntervalX(options); // prettier-ignore + return new (line ? AreaLine : Area)(data, maybeStackY({...rest, x1: x, x2: undefined, y, z, stroke, fill})); } diff --git a/test/output/aaplClose.svg b/test/output/aaplClose.svg index 5fb51a1e86..ab85b87a1f 100644 --- a/test/output/aaplClose.svg +++ b/test/output/aaplClose.svg @@ -66,11 +66,11 @@ 2017 2018 - - - - - + + + + + diff --git a/test/plots/aapl-close.ts b/test/plots/aapl-close.ts index a764857dac..6c94f732a4 100644 --- a/test/plots/aapl-close.ts +++ b/test/plots/aapl-close.ts @@ -6,11 +6,7 @@ test(async function aaplClose() { const aapl = await d3.csv("data/aapl.csv", d3.autoType); return Plot.plot({ y: {grid: true}, - marks: [ - Plot.areaY(aapl, {x: "Date", y: "Close", fillOpacity: 0.1}), - Plot.lineY(aapl, {x: "Date", y: "Close"}), - Plot.ruleY([0]) - ] + marks: [Plot.areaY(aapl, {x: "Date", y: "Close", line: true}), Plot.ruleY([0])] }); });