diff --git a/src/facet.js b/src/facet.js index 051aee2468..936794f121 100644 --- a/src/facet.js +++ b/src/facet.js @@ -1,6 +1,8 @@ import {cross, difference, groups, InternMap} from "d3"; import {create} from "d3"; -import {Mark, values, first, second} from "./mark.js"; +import {Mark, first, second} from "./mark.js"; +import {applyScales} from "./scales.js"; +import {filterStyles} from "./style.js"; export function facets(data, {x, y, ...options}, marks) { return x === undefined && y === undefined @@ -76,7 +78,7 @@ class Facet extends Mark { const fyMargins = fy && {marginTop: 0, marginBottom: 0, height: fy.bandwidth()}; const fxMargins = fx && {marginRight: 0, marginLeft: 0, width: fx.bandwidth()}; const subdimensions = {...dimensions, ...fxMargins, ...fyMargins}; - const marksValues = marksChannels.map(channels => values(channels, scales)); + const marksValues = marksChannels.map(channels => applyScales(channels, scales)); return create("svg:g") .call(g => { if (fy && axes.y) { @@ -110,10 +112,12 @@ class Facet extends Mark { .each(function(key) { const marksFacetIndex = marksIndexByFacet.get(key) || marksIndex; for (let i = 0; i < marks.length; ++i) { + const values = marksValues[i]; + const index = filterStyles(marksFacetIndex[i], values); const node = marks[i].render( - marksFacetIndex[i], + index, scales, - marksValues[i], + values, subdimensions ); if (node != null) this.appendChild(node); diff --git a/src/mark.js b/src/mark.js index 7af27702a5..662749574a 100644 --- a/src/mark.js +++ b/src/mark.js @@ -1,18 +1,21 @@ import {color} from "d3"; import {ascendingDefined, nonempty} from "./defined.js"; import {plot} from "./plot.js"; +import {styles} from "./style.js"; // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray const TypedArray = Object.getPrototypeOf(Uint8Array); const objectToString = Object.prototype.toString; export class Mark { - constructor(data, channels = [], {facet = "auto", ...options} = {}) { + constructor(data, channels = [], options = {}, defaults) { + const {facet = "auto"} = options; const names = new Set(); this.data = data; this.facet = facet ? keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]) : null; const {transform} = maybeTransform(options); this.transform = transform; + if (defaults !== undefined) channels = styles(this, options, channels, defaults); this.channels = channels.filter(channel => { const {name, value, optional} = channel; if (value == null) { @@ -309,23 +312,6 @@ export function numberChannel(source) { }; } -// TODO use Float64Array.from for position and radius scales? -export function values(channels = [], scales) { - const values = Object.create(null); - for (let [name, {value, scale}] of channels) { - if (name !== undefined) { - if (scale !== undefined) { - scale = scales[scale]; - if (scale !== undefined) { - value = Array.from(value, scale); - } - } - values[name] = value; - } - } - return values; -} - export function isOrdinal(values) { for (const value of values) { if (value == null) continue; diff --git a/src/marks/area.js b/src/marks/area.js index bd274c41a8..15b85f18e0 100644 --- a/src/marks/area.js +++ b/src/marks/area.js @@ -1,37 +1,18 @@ -import {group} from "d3"; -import {create} from "d3"; -import {area as shapeArea} from "d3"; +import {area as shapeArea, create, group} from "d3"; import {Curve} from "../curve.js"; import {defined} from "../defined.js"; -import {Mark, indexOf, maybeColor, titleGroup, maybeNumber} from "../mark.js"; -import {Style, applyDirectStyles, applyIndirectStyles, applyTransform, applyAttr} from "../style.js"; +import {Mark, indexOf, maybeZ} from "../mark.js"; +import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles} from "../style.js"; import {maybeStackX, maybeStackY} from "../transforms/stack.js"; +const defaults = { + strokeWidth: 1, + strokeMiterlimit: 1 +}; + export class Area extends Mark { - constructor( - data, - { - x1, - y1, - x2, - y2, - z, // optional grouping for multiple series - title, - fill, - fillOpacity, - stroke, - strokeOpacity, - curve, - tension, - ...options - } = {} - ) { - const [vstroke, cstroke] = maybeColor(stroke, "none"); - const [vstrokeOpacity, cstrokeOpacity] = maybeNumber(strokeOpacity); - const [vfill, cfill] = maybeColor(fill, cstroke === "none" ? "currentColor" : "none"); - const [vfillOpacity, cfillOpacity] = maybeNumber(fillOpacity); - if (z === undefined && vfill != null) z = vfill; - if (z === undefined && vstroke != null) z = vstroke; + constructor(data, options = {}) { + const {x1, y1, x2, y2, curve, tension} = options; super( data, [ @@ -39,26 +20,15 @@ export class Area extends Mark { {name: "y1", value: y1, scale: "y"}, {name: "x2", value: x2, scale: "x", optional: true}, {name: "y2", value: y2, scale: "y", optional: true}, - {name: "z", value: z, optional: true}, - {name: "title", value: title, optional: true}, - {name: "fill", value: vfill, scale: "color", optional: true}, - {name: "fillOpacity", value: vfillOpacity, scale: "opacity", optional: true}, - {name: "stroke", value: vstroke, scale: "color", optional: true}, - {name: "strokeOpacity", value: vstrokeOpacity, scale: "opacity", optional: true} + {name: "z", value: maybeZ(options), optional: true} ], - options + options, + defaults ); this.curve = Curve(curve, tension); - Style(this, { - fill: cfill, - fillOpacity: cfillOpacity, - stroke: cstroke, - strokeMiterlimit: cstroke === "none" ? undefined : 1, - strokeOpacity: cstrokeOpacity, - ...options - }); } - render(I, {x, y}, {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1, z: Z, title: L, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO}) { + render(I, {x, y}, channels) { + const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1, z: Z} = channels; return create("svg:g") .call(applyIndirectStyles, this) .call(applyTransform, x, y) @@ -66,18 +36,14 @@ export class Area extends Mark { .data(Z ? group(I, i => Z[i]).values() : [I]) .join("path") .call(applyDirectStyles, this) - .call(applyAttr, "fill", F && (([i]) => F[i])) - .call(applyAttr, "fill-opacity", FO && (([i]) => FO[i])) - .call(applyAttr, "stroke", S && (([i]) => S[i])) - .call(applyAttr, "stroke-opacity", SO && (([i]) => SO[i])) + .call(applyGroupedChannelStyles, channels) .attr("d", shapeArea() .curve(this.curve) .defined(i => defined(X1[i]) && defined(Y1[i]) && defined(X2[i]) && defined(Y2[i])) .x0(i => X1[i]) .y0(i => Y1[i]) .x1(i => X2[i]) - .y1(i => Y2[i])) - .call(titleGroup(L))) + .y1(i => Y2[i]))) .node(); } } diff --git a/src/marks/bar.js b/src/marks/bar.js index 971c6412a3..7395929c9f 100644 --- a/src/marks/bar.js +++ b/src/marks/bar.js @@ -1,52 +1,15 @@ import {create} from "d3"; import {filter} from "../defined.js"; -import {Mark, number, maybeColor, title, maybeNumber} from "../mark.js"; -import {Style, applyDirectStyles, applyIndirectStyles, applyTransform, impliedString, applyAttr} from "../style.js"; +import {Mark, number} from "../mark.js"; +import {applyDirectStyles, applyIndirectStyles, applyTransform, impliedString, applyAttr, applyChannelStyles} from "../style.js"; import {maybeStackX, maybeStackY} from "../transforms/stack.js"; +const defaults = {}; + export class AbstractBar extends Mark { - constructor( - data, - channels, - { - title, - fill, - fillOpacity, - stroke, - strokeOpacity, - inset = 0, - insetTop = inset, - insetRight = inset, - insetBottom = inset, - insetLeft = inset, - rx, - ry, - ...options - } = {} - ) { - const [vstroke, cstroke] = maybeColor(stroke, "none"); - const [vstrokeOpacity, cstrokeOpacity] = maybeNumber(strokeOpacity); - const [vfill, cfill] = maybeColor(fill, cstroke === "none" ? "currentColor" : "none"); - const [vfillOpacity, cfillOpacity] = maybeNumber(fillOpacity); - super( - data, - [ - ...channels, - {name: "title", value: title, optional: true}, - {name: "fill", value: vfill, scale: "color", optional: true}, - {name: "fillOpacity", value: vfillOpacity, scale: "opacity", optional: true}, - {name: "stroke", value: vstroke, scale: "color", optional: true}, - {name: "strokeOpacity", value: vstrokeOpacity, scale: "opacity", optional: true} - ], - options - ); - Style(this, { - fill: cfill, - fillOpacity: cfillOpacity, - stroke: cstroke, - strokeOpacity: cstrokeOpacity, - ...options - }); + constructor(data, channels, options = {}) { + super(data, channels, options, defaults); + const {inset = 0, insetTop = inset, insetRight = inset, insetBottom = inset, insetLeft = inset, rx, ry} = options; this.insetTop = number(insetTop); this.insetRight = number(insetRight); this.insetBottom = number(insetBottom); @@ -56,8 +19,7 @@ export class AbstractBar extends Mark { } render(I, scales, channels, dimensions) { const {rx, ry} = this; - const {title: L, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO} = channels; - const index = filter(I, ...this._positions(channels), F, FO, S, SO); + const index = filter(I, ...this._positions(channels)); return create("svg:g") .call(applyIndirectStyles, this) .call(this._transform, scales) @@ -69,13 +31,9 @@ export class AbstractBar extends Mark { .attr("width", this._width(scales, channels, dimensions)) .attr("y", this._y(scales, channels, dimensions)) .attr("height", this._height(scales, channels, dimensions)) - .call(applyAttr, "fill", F && (i => F[i])) - .call(applyAttr, "fill-opacity", FO && (i => FO[i])) - .call(applyAttr, "stroke", S && (i => S[i])) - .call(applyAttr, "stroke-opacity", SO && (i => SO[i])) .call(applyAttr, "rx", rx) .call(applyAttr, "ry", ry) - .call(title(L))) + .call(applyChannelStyles, channels)) .node(); } _x(scales, {x: X}, {marginLeft}) { @@ -99,7 +57,8 @@ export class AbstractBar extends Mark { } export class BarX extends AbstractBar { - constructor(data, {x1, x2, y, ...options} = {}) { + constructor(data, options = {}) { + const {x1, x2, y} = options; super( data, [ @@ -127,7 +86,8 @@ export class BarX extends AbstractBar { } export class BarY extends AbstractBar { - constructor(data, {x, y1, y2, ...options} = {}) { + constructor(data, options = {}) { + const {x, y1, y2} = options; super( data, [ diff --git a/src/marks/dot.js b/src/marks/dot.js index b1bfa83553..33158bfc10 100644 --- a/src/marks/dot.js +++ b/src/marks/dot.js @@ -1,59 +1,38 @@ import {create} from "d3"; import {filter, positive} from "../defined.js"; -import {Mark, identity, maybeColor, maybeNumber, maybeTuple, title} from "../mark.js"; -import {Style, applyDirectStyles, applyIndirectStyles, applyTransform, applyAttr} from "../style.js"; +import {Mark, identity, maybeNumber, maybeTuple} from "../mark.js"; +import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; + +const defaults = { + fill: "none", + stroke: "currentColor", + strokeWidth: 1.5 +}; export class Dot extends Mark { - constructor( - data, - { - x, - y, - r, - title, - fill, - fillOpacity, - stroke, - strokeOpacity, - ...options - } = {} - ) { + constructor(data, options = {}) { + const {x, y, r} = options; const [vr, cr] = maybeNumber(r, 3); - const [vfill, cfill] = maybeColor(fill, "none"); - const [vfillOpacity, cfillOpacity] = maybeNumber(fillOpacity); - const [vstroke, cstroke] = maybeColor(stroke, cfill === "none" ? "currentColor" : "none"); - const [vstrokeOpacity, cstrokeOpacity] = maybeNumber(strokeOpacity); super( data, [ {name: "x", value: x, scale: "x", optional: true}, {name: "y", value: y, scale: "y", optional: true}, - {name: "r", value: vr, scale: "r", optional: true}, - {name: "title", value: title, optional: true}, - {name: "fill", value: vfill, scale: "color", optional: true}, - {name: "fillOpacity", value: vfillOpacity, scale: "opacity", optional: true}, - {name: "stroke", value: vstroke, scale: "color", optional: true}, - {name: "strokeOpacity", value: vstrokeOpacity, scale: "opacity", optional: true} + {name: "r", value: vr, scale: "r", optional: true} ], - options + options, + defaults ); this.r = cr; - Style(this, { - fill: cfill, - fillOpacity: cfillOpacity, - stroke: cstroke, - strokeOpacity: cstrokeOpacity, - strokeWidth: cstroke === "none" ? undefined : 1.5, - ...options - }); } render( I, {x, y}, - {x: X, y: Y, r: R, title: L, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO}, + channels, {width, height, marginTop, marginRight, marginBottom, marginLeft} ) { - let index = filter(I, X, Y, F, FO, S, SO); + const {x: X, y: Y, r: R} = channels; + let index = filter(I, X, Y); if (R) index = index.filter(i => positive(R[i])); return create("svg:g") .call(applyIndirectStyles, this) @@ -65,11 +44,7 @@ export class Dot extends Mark { .attr("cx", X ? i => X[i] : (marginLeft + width - marginRight) / 2) .attr("cy", Y ? i => Y[i] : (marginTop + height - marginBottom) / 2) .attr("r", R ? i => R[i] : this.r) - .call(applyAttr, "fill", F && (i => F[i])) - .call(applyAttr, "fill-opacity", FO && (i => FO[i])) - .call(applyAttr, "stroke", S && (i => S[i])) - .call(applyAttr, "stroke-opacity", SO && (i => SO[i])) - .call(title(L))) + .call(applyChannelStyles, channels)) .node(); } } diff --git a/src/marks/frame.js b/src/marks/frame.js index b714525a29..fec4b32769 100644 --- a/src/marks/frame.js +++ b/src/marks/frame.js @@ -1,20 +1,22 @@ import {create} from "d3"; import {Mark, number} from "../mark.js"; -import {Style, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; +import {applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; + +const defaults = { + fill: "none", + stroke: "currentColor" +}; export class Frame extends Mark { - constructor({ - fill = "none", - stroke = fill === null || fill === "none" ? "currentColor" : "none", - inset = 0, - insetTop = inset, - insetRight = inset, - insetBottom = inset, - insetLeft = inset, - ...style - } = {}) { - super(); - Style(this, {fill, stroke, ...style}); + constructor(options = {}) { + const { + inset = 0, + insetTop = inset, + insetRight = inset, + insetBottom = inset, + insetLeft = inset + } = options; + super(undefined, undefined, options, defaults); this.insetTop = number(insetTop); this.insetRight = number(insetRight); this.insetBottom = number(insetBottom); diff --git a/src/marks/line.js b/src/marks/line.js index 62094ffe4a..20e93ac7f4 100644 --- a/src/marks/line.js +++ b/src/marks/line.js @@ -1,60 +1,33 @@ -import {group} from "d3"; -import {create} from "d3"; -import {line as shapeLine} from "d3"; +import {create, group, line as shapeLine} from "d3"; import {Curve} from "../curve.js"; import {defined} from "../defined.js"; -import {Mark, indexOf, identity, maybeColor, maybeTuple, titleGroup, maybeNumber} from "../mark.js"; -import {Style, applyDirectStyles, applyIndirectStyles, applyTransform, applyAttr} from "../style.js"; +import {Mark, indexOf, identity, maybeTuple, maybeZ} from "../mark.js"; +import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles} from "../style.js"; + +const defaults = { + fill: "none", + stroke: "currentColor", + strokeWidth: 1.5, + strokeMiterlimit: 1 +}; export class Line extends Mark { - constructor( - data, - { - x, - y, - z, // optional grouping for multiple series - title, - fill, - fillOpacity, - stroke, - strokeOpacity, - curve, - tension, - ...options - } = {} - ) { - const [vfill, cfill] = maybeColor(fill, "none"); - const [vfillOpacity, cfillOpacity] = maybeNumber(fillOpacity); - const [vstroke, cstroke] = maybeColor(stroke, "currentColor"); - const [vstrokeOpacity, cstrokeOpacity] = maybeNumber(strokeOpacity); - if (z === undefined && vstroke != null) z = vstroke; - if (z === undefined && vfill != null) z = vfill; + constructor(data, options = {}) { + const {x, y, curve, tension} = options; super( data, [ {name: "x", value: x, scale: "x"}, {name: "y", value: y, scale: "y"}, - {name: "z", value: z, optional: true}, - {name: "title", value: title, optional: true}, - {name: "fill", value: vfill, scale: "color", optional: true}, - {name: "fillOpacity", value: vfillOpacity, scale: "opacity", optional: true}, - {name: "stroke", value: vstroke, scale: "color", optional: true}, - {name: "strokeOpacity", value: vstrokeOpacity, scale: "opacity", optional: true} + {name: "z", value: maybeZ(options), optional: true} ], - options + options, + defaults ); this.curve = Curve(curve, tension); - Style(this, { - fill: cfill, - fillOpacity: cfillOpacity, - stroke: cstroke, - strokeMiterlimit: cstroke === "none" ? undefined : 1, - strokeOpacity: cstrokeOpacity, - strokeWidth: cstroke === "none" ? undefined : 1.5, - ...options - }); } - render(I, {x, y}, {x: X, y: Y, z: Z, title: L, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO}) { + render(I, {x, y}, channels) { + const {x: X, y: Y, z: Z} = channels; return create("svg:g") .call(applyIndirectStyles, this) .call(applyTransform, x, y, 0.5, 0.5) @@ -62,16 +35,12 @@ export class Line extends Mark { .data(Z ? group(I, i => Z[i]).values() : [I]) .join("path") .call(applyDirectStyles, this) - .call(applyAttr, "fill", F && (([i]) => F[i])) - .call(applyAttr, "fill-opacity", FO && (([i]) => FO[i])) - .call(applyAttr, "stroke", S && (([i]) => S[i])) - .call(applyAttr, "stroke-opacity", SO && (([i]) => SO[i])) + .call(applyGroupedChannelStyles, channels) .attr("d", shapeLine() .curve(this.curve) .defined(i => defined(X[i]) && defined(Y[i])) .x(i => X[i]) - .y(i => Y[i])) - .call(titleGroup(L))) + .y(i => Y[i]))) .node(); } } diff --git a/src/marks/link.js b/src/marks/link.js index 955bd104e5..96997adb45 100644 --- a/src/marks/link.js +++ b/src/marks/link.js @@ -1,61 +1,34 @@ import {create, path} from "d3"; import {filter} from "../defined.js"; -import {Mark, maybeColor, maybeNumber, title} from "../mark.js"; +import {Mark} from "../mark.js"; import {Curve} from "../curve.js"; -import {Style, applyDirectStyles, applyIndirectStyles, applyTransform, applyAttr} from "../style.js"; +import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; + +const defaults = { + fill: "none", + stroke: "currentColor", + strokeMiterlimit: 1 +}; export class Link extends Mark { - constructor( - data, - { - x1, - y1, - x2, - y2, - title, - fill, - fillOpacity, - stroke, - strokeOpacity, - curve, - ...options - } = {} - ) { - const [vfill, cfill] = maybeColor(fill, "none"); - const [vfillOpacity, cfillOpacity] = maybeNumber(fillOpacity); - const [vstroke, cstroke] = maybeColor(stroke, "currentColor"); - const [vstrokeOpacity, cstrokeOpacity] = maybeNumber(strokeOpacity); + constructor(data, options = {}) { + const {x1, y1, x2, y2, curve} = options; super( data, [ {name: "x1", value: x1, scale: "x"}, {name: "y1", value: y1, scale: "y"}, {name: "x2", value: x2, scale: "x", optional: true}, - {name: "y2", value: y2, scale: "y", optional: true}, - {name: "title", value: title, optional: true}, - {name: "fill", value: vfill, scale: "color", optional: true}, - {name: "fillOpacity", value: vfillOpacity, scale: "opacity", optional: true}, - {name: "stroke", value: vstroke, scale: "color", optional: true}, - {name: "strokeOpacity", value: vstrokeOpacity, scale: "opacity", optional: true} + {name: "y2", value: y2, scale: "y", optional: true} ], - options + options, + defaults ); this.curve = Curve(curve); - Style(this, { - fill: cfill, - fillOpacity: cfillOpacity, - stroke: cstroke, - strokeMiterlimit: cstroke === "none" ? undefined : 1, - strokeOpacity: cstrokeOpacity, - ...options - }); } - render( - I, - {x, y}, - {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1, title: L, stroke: S, strokeOpacity: SO} - ) { - const index = filter(I, X1, Y1, X2, Y2, S, SO); + render(I, {x, y}, channels) { + const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels; + const index = filter(I, X1, Y1, X2, Y2); return create("svg:g") .call(applyIndirectStyles, this) .call(applyTransform, x, y, 0.5, 0.5) @@ -72,9 +45,7 @@ export class Link extends Mark { c.lineEnd(); return p + ""; }) - .call(applyAttr, "stroke", S && (i => S[i])) - .call(applyAttr, "stroke-opacity", SO && (i => SO[i])) - .call(title(L))) + .call(applyChannelStyles, channels)) .node(); } } diff --git a/src/marks/rect.js b/src/marks/rect.js index cb8d55aaf4..9a3a4ebd75 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -1,58 +1,37 @@ import {create} from "d3"; import {filter} from "../defined.js"; -import {Mark, number, maybeColor, title, maybeNumber} from "../mark.js"; -import {Style, applyDirectStyles, applyIndirectStyles, applyTransform, impliedString, applyAttr} from "../style.js"; +import {Mark, number} from "../mark.js"; +import {applyDirectStyles, applyIndirectStyles, applyTransform, impliedString, applyAttr, applyChannelStyles} from "../style.js"; import {maybeStackX, maybeStackY} from "../transforms/stack.js"; +const defaults = {}; + export class Rect extends Mark { - constructor( - data, - { + constructor(data, options = {}) { + const { x1, y1, x2, y2, - title, - fill, - fillOpacity, - stroke, - strokeOpacity, inset = 0, insetTop = inset, insetRight = inset, insetBottom = inset, insetLeft = inset, rx, - ry, - ...options - } = {} - ) { - const [vstroke, cstroke] = maybeColor(stroke, "none"); - const [vstrokeOpacity, cstrokeOpacity] = maybeNumber(strokeOpacity); - const [vfill, cfill] = maybeColor(fill, cstroke === "none" ? "currentColor" : "none"); - const [vfillOpacity, cfillOpacity] = maybeNumber(fillOpacity); + ry + } = options; super( data, [ {name: "x1", value: x1, scale: "x"}, {name: "y1", value: y1, scale: "y"}, {name: "x2", value: x2, scale: "x"}, - {name: "y2", value: y2, scale: "y"}, - {name: "title", value: title, optional: true}, - {name: "fill", value: vfill, scale: "color", optional: true}, - {name: "fillOpacity", value: vfillOpacity, scale: "opacity", optional: true}, - {name: "stroke", value: vstroke, scale: "color", optional: true}, - {name: "strokeOpacity", value: vstrokeOpacity, scale: "opacity", optional: true} + {name: "y2", value: y2, scale: "y"} ], - options + options, + defaults ); - Style(this, { - fill: cfill, - fillOpacity: cfillOpacity, - stroke: cstroke, - strokeOpacity: cstrokeOpacity, - ...options - }); this.insetTop = number(insetTop); this.insetRight = number(insetRight); this.insetBottom = number(insetBottom); @@ -60,13 +39,10 @@ export class Rect extends Mark { this.rx = impliedString(rx, "auto"); // number or percentage this.ry = impliedString(ry, "auto"); } - render( - I, - {x, y}, - {x1: X1, y1: Y1, x2: X2, y2: Y2, title: L, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO} - ) { + render(I, {x, y}, channels) { + const {x1: X1, y1: Y1, x2: X2, y2: Y2} = channels; const {rx, ry} = this; - const index = filter(I, X1, Y2, X2, Y2, F, FO, S, SO); + const index = filter(I, X1, Y2, X2, Y2); return create("svg:g") .call(applyIndirectStyles, this) .call(applyTransform, x, y) @@ -78,13 +54,9 @@ export class Rect extends Mark { .attr("y", i => Math.min(Y1[i], Y2[i]) + this.insetTop) .attr("width", i => Math.max(0, Math.abs(X2[i] - X1[i]) - this.insetLeft - this.insetRight)) .attr("height", i => Math.max(0, Math.abs(Y1[i] - Y2[i]) - this.insetTop - this.insetBottom)) - .call(applyAttr, "fill", F && (i => F[i])) - .call(applyAttr, "fill-opacity", FO && (i => FO[i])) - .call(applyAttr, "stroke", S && (i => S[i])) - .call(applyAttr, "stroke-opacity", SO && (i => SO[i])) .call(applyAttr, "rx", rx) .call(applyAttr, "ry", ry) - .call(title(L))) + .call(applyChannelStyles, channels)) .node(); } } diff --git a/src/marks/rule.js b/src/marks/rule.js index cf732e3519..a989f620c8 100644 --- a/src/marks/rule.js +++ b/src/marks/rule.js @@ -1,49 +1,44 @@ import {create} from "d3"; import {filter} from "../defined.js"; -import {Mark, identity, maybeColor, title, number, maybeNumber} from "../mark.js"; -import {Style, applyDirectStyles, applyIndirectStyles, applyTransform, applyAttr} from "../style.js"; +import {Mark, identity, number} from "../mark.js"; +import {applyDirectStyles, applyIndirectStyles, applyTransform, applyChannelStyles} from "../style.js"; + +const defaults = { + fill: null, + stroke: "currentColor" +}; export class RuleX extends Mark { - constructor( - data, - { + constructor(data, options = {}) { + const { x, y1, y2, - title, - stroke, - strokeOpacity, inset = 0, insetTop = inset, - insetBottom = inset, - ...options - } = {} - ) { - const [vstroke, cstroke] = maybeColor(stroke, "currentColor"); - const [vstrokeOpacity, cstrokeOpacity] = maybeNumber(strokeOpacity); + insetBottom = inset + } = options; super( data, [ {name: "x", value: x, scale: "x", optional: true}, {name: "y1", value: y1, scale: "y", optional: true}, - {name: "y2", value: y2, scale: "y", optional: true}, - {name: "title", value: title, optional: true}, - {name: "stroke", value: vstroke, scale: "color", optional: true}, - {name: "strokeOpacity", value: vstrokeOpacity, scale: "opacity", optional: true} + {name: "y2", value: y2, scale: "y", optional: true} ], - options + options, + defaults ); - Style(this, {stroke: cstroke, strokeOpacity: cstrokeOpacity, ...options}); this.insetTop = number(insetTop); this.insetBottom = number(insetBottom); } render( I, {x, y}, - {x: X, y1: Y1, y2: Y2, title: L, stroke: S, strokeOpacity: SO}, + channels, {width, height, marginTop, marginRight, marginLeft, marginBottom} ) { - const index = filter(I, X, Y1, Y2, S); + const {x: X, y1: Y1, y2: Y2} = channels; + const index = filter(I, X, Y1, Y2); return create("svg:g") .call(applyIndirectStyles, this) .call(applyTransform, X && x, null, 0.5, 0) @@ -55,53 +50,41 @@ export class RuleX extends Mark { .attr("x2", X ? i => X[i] : (marginLeft + width - marginRight) / 2) .attr("y1", Y1 ? i => Y1[i] + this.insetTop : marginTop + this.insetTop) .attr("y2", Y2 ? (y.bandwidth ? i => Y2[i] + y.bandwidth() - this.insetBottom : i => Y2[i] - this.insetBottom) : height - marginBottom - this.insetBottom) - .call(applyAttr, "stroke", S && (i => S[i])) - .call(applyAttr, "stroke-opacity", SO && (i => SO[i])) - .call(title(L))) + .call(applyChannelStyles, channels)) .node(); } } export class RuleY extends Mark { - constructor( - data, - { + constructor(data, options = {}) { + const { x1, x2, y, - title, - stroke, - strokeOpacity, inset = 0, insetRight = inset, - insetLeft = inset, - ...options - } = {} - ) { - const [vstroke, cstroke] = maybeColor(stroke, "currentColor"); - const [vstrokeOpacity, cstrokeOpacity] = maybeNumber(strokeOpacity); + insetLeft = inset + } = options; super( data, [ {name: "y", value: y, scale: "y", optional: true}, {name: "x1", value: x1, scale: "x", optional: true}, - {name: "x2", value: x2, scale: "x", optional: true}, - {name: "title", value: title, optional: true}, - {name: "stroke", value: vstroke, scale: "color", optional: true}, - {name: "strokeOpacity", value: vstrokeOpacity, scale: "opacity", optional: true} + {name: "x2", value: x2, scale: "x", optional: true} ], - options + options, + defaults ); - Style(this, {stroke: cstroke, strokeOpacity: cstrokeOpacity, ...options}); this.insetRight = number(insetRight); this.insetLeft = number(insetLeft); } render( I, {x, y}, - {y: Y, x1: X1, x2: X2, title: L, stroke: S, strokeOpacity: SO}, + channels, {width, height, marginTop, marginRight, marginLeft, marginBottom} ) { + const {y: Y, x1: X1, x2: X2} = channels; const index = filter(I, Y, X1, X2); return create("svg:g") .call(applyIndirectStyles, this) @@ -114,9 +97,7 @@ export class RuleY extends Mark { .attr("x2", X2 ? (x.bandwidth ? i => X2[i] + x.bandwidth() - this.insetRight : i => X2[i] - this.insetRight) : width - marginRight - this.insetRight) .attr("y1", Y ? i => Y[i] : (marginTop + height - marginBottom) / 2) .attr("y2", Y ? i => Y[i] : (marginTop + height - marginBottom) / 2) - .call(applyAttr, "stroke", S && (i => S[i])) - .call(applyAttr, "stroke-opacity", SO && (i => SO[i])) - .call(title(L))) + .call(applyChannelStyles, channels)) .node(); } } diff --git a/src/marks/text.js b/src/marks/text.js index 17649bef30..9daf2afab9 100644 --- a/src/marks/text.js +++ b/src/marks/text.js @@ -1,20 +1,16 @@ import {create} from "d3"; import {filter, nonempty} from "../defined.js"; -import {Mark, indexOf, identity, string, title, maybeColor, maybeNumber, maybeTuple, numberChannel} from "../mark.js"; -import {Style, applyDirectStyles, applyIndirectStyles, applyAttr, applyTransform} from "../style.js"; +import {Mark, indexOf, identity, string, maybeNumber, maybeTuple, numberChannel} from "../mark.js"; +import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyAttr, applyTransform} from "../style.js"; + +const defaults = {}; export class Text extends Mark { - constructor( - data, - { + constructor(data, options = {}) { + const { x, y, text = indexOf, - title, - fill, - fillOpacity, - stroke, - strokeOpacity, textAnchor, fontFamily, fontSize, @@ -23,14 +19,8 @@ export class Text extends Mark { fontWeight, dx, dy = "0.32em", - rotate, - ...options - } = {} - ) { - const [vstroke, cstroke] = maybeColor(stroke, "none"); - const [vstrokeOpacity, cstrokeOpacity] = maybeNumber(strokeOpacity); - const [vfill, cfill] = maybeColor(fill, "currentColor"); - const [vfillOpacity, cfillOpacity] = maybeNumber(fillOpacity); + rotate + } = options; const [vrotate, crotate] = maybeNumber(rotate, 0); const [vfontSize, cfontSize] = maybeNumber(fontSize); super( @@ -40,22 +30,11 @@ export class Text extends Mark { {name: "y", value: y, scale: "y", optional: true}, {name: "fontSize", value: numberChannel(vfontSize), optional: true}, {name: "rotate", value: numberChannel(vrotate), optional: true}, - {name: "text", value: text}, - {name: "title", value: title, optional: true}, - {name: "fill", value: vfill, scale: "color", optional: true}, - {name: "fillOpacity", value: vfillOpacity, scale: "opacity", optional: true}, - {name: "stroke", value: vstroke, scale: "color", optional: true}, - {name: "strokeOpacity", value: vstrokeOpacity, scale: "opacity", optional: true} + {name: "text", value: text} ], - options + options, + defaults ); - Style(this, { - fill: cfill, - fillOpacity: cfillOpacity, - stroke: cstroke, - strokeOpacity: cstrokeOpacity, - ...options - }); this.rotate = crotate; this.textAnchor = string(textAnchor); this.fontFamily = string(fontFamily); @@ -69,11 +48,12 @@ export class Text extends Mark { render( I, {x, y}, - {x: X, y: Y, rotate: R, text: T, title: L, fill: F, fillOpacity: FO, fontSize: FS, stroke: S, strokeOpacity: SO}, + channels, {width, height, marginTop, marginRight, marginBottom, marginLeft} ) { + const {x: X, y: Y, rotate: R, text: T, fontSize: FS} = channels; const {rotate} = this; - const index = filter(I, X, Y, F, FO, R).filter(i => nonempty(T[i])); + const index = filter(I, X, Y, R).filter(i => nonempty(T[i])); const cx = (marginLeft + width - marginRight) / 2; const cy = (marginTop + height - marginBottom) / 2; return create("svg:g") @@ -92,13 +72,9 @@ export class Text extends Mark { : Y ? i => `translate(${cx},${Y[i]}) rotate(${rotate})` : `translate(${cx},${cy}) rotate(${rotate})`) : text => text.attr("x", X ? i => X[i] : cx).attr("y", Y ? i => Y[i] : cy)) - .call(applyAttr, "fill", F && (i => F[i])) - .call(applyAttr, "fill-opacity", FO && (i => FO[i])) .call(applyAttr, "font-size", FS && (i => FS[i])) - .call(applyAttr, "stroke", S && (i => S[i])) - .call(applyAttr, "stroke-opacity", SO && (i => SO[i])) .text(i => T[i]) - .call(title(L))) + .call(applyChannelStyles, channels)) .node(); } } diff --git a/src/marks/tick.js b/src/marks/tick.js index 841493833f..9d83fcaea0 100644 --- a/src/marks/tick.js +++ b/src/marks/tick.js @@ -1,36 +1,20 @@ import {create} from "d3"; import {filter} from "../defined.js"; -import {Mark, identity, maybeColor, title, maybeNumber, number} from "../mark.js"; -import {Style, applyDirectStyles, applyIndirectStyles, applyTransform, applyAttr} from "../style.js"; +import {Mark, identity, number} from "../mark.js"; +import {applyDirectStyles, applyIndirectStyles, applyTransform, applyChannelStyles} from "../style.js"; + +const defaults = { + fill: null, + stroke: "currentColor" +}; class AbstractTick extends Mark { - constructor( - data, - channels, - { - title, - stroke, - strokeOpacity, - ...options - } = {} - ) { - const [vstroke, cstroke] = maybeColor(stroke, "currentColor"); - const [vstrokeOpacity, cstrokeOpacity] = maybeNumber(strokeOpacity); - super( - data, - [ - ...channels, - {name: "title", value: title, optional: true}, - {name: "stroke", value: vstroke, scale: "color", optional: true}, - {name: "strokeOpacity", value: vstrokeOpacity, scale: "opacity", optional: true} - ], - options - ); - Style(this, {stroke: cstroke, strokeOpacity: cstrokeOpacity, ...options}); + constructor(data, channels, options) { + super(data, channels, options, defaults); } render(I, scales, channels, dimensions) { - const {x: X, y: Y, title: L, stroke: S, strokeOpacity: SO} = channels; - const index = filter(I, X, Y, S, SO); + const {x: X, y: Y} = channels; + const index = filter(I, X, Y); return create("svg:g") .call(applyIndirectStyles, this) .call(this._transform, scales) @@ -42,25 +26,20 @@ class AbstractTick extends Mark { .attr("x2", this._x2(scales, channels, dimensions)) .attr("y1", this._y1(scales, channels, dimensions)) .attr("y2", this._y2(scales, channels, dimensions)) - .call(applyAttr, "stroke", S && (i => S[i])) - .call(applyAttr, "stroke-opacity", SO && (i => SO[i])) - .call(title(L))) + .call(applyChannelStyles, channels)) .node(); } } export class TickX extends AbstractTick { - constructor( - data, - { + constructor(data, options = {}) { + const { x, y, inset = 0, insetTop = inset, - insetBottom = inset, - ...options - } = {} - ) { + insetBottom = inset + } = options; super( data, [ @@ -90,17 +69,14 @@ export class TickX extends AbstractTick { } export class TickY extends AbstractTick { - constructor( - data, - { + constructor(data, options = {}) { + const { x, y, inset = 0, insetRight = inset, - insetLeft = inset, - ...options - } = {} - ) { + insetLeft = inset + } = options; super( data, [ diff --git a/src/plot.js b/src/plot.js index 06bda82abf..777cd76a81 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,9 +1,8 @@ import {create} from "d3"; import {Axes, autoAxisTicks, autoAxisLabels} from "./axes.js"; import {facets} from "./facet.js"; -import {values} from "./mark.js"; -import {Scales, autoScaleRange} from "./scales.js"; -import {offset} from "./style.js"; +import {Scales, autoScaleRange, applyScales} from "./scales.js"; +import {filterStyles, offset} from "./style.js"; export function plot(options = {}) { const {facet, style, caption} = options; @@ -83,8 +82,9 @@ export function plot(options = {}) { for (const mark of marks) { const channels = markChannels.get(mark); - const index = markIndex.get(mark); - const node = mark.render(index, scales, values(channels, scales), dimensions, axes); + const values = applyScales(channels, scales); + const index = filterStyles(markIndex.get(mark), values); + const node = mark.render(index, scales, values, dimensions, axes); if (node != null) svg.appendChild(node); } diff --git a/src/scales.js b/src/scales.js index 6a2b328260..810cd0649a 100644 --- a/src/scales.js +++ b/src/scales.js @@ -112,3 +112,20 @@ function inferScaleType(key, channels, {type, domain, range}) { function asOrdinalType(key) { return registry.get(key) === position ? "point" : "ordinal"; } + +// TODO use Float64Array.from for position and radius scales? +export function applyScales(channels = [], scales) { + const values = Object.create(null); + for (let [name, {value, scale}] of channels) { + if (name !== undefined) { + if (scale !== undefined) { + scale = scales[scale]; + if (scale !== undefined) { + value = Array.from(value, scale); + } + } + values[name] = value; + } + } + return values; +} diff --git a/src/style.js b/src/style.js index 7979bc7f53..e71aba7f0b 100644 --- a/src/style.js +++ b/src/style.js @@ -1,31 +1,103 @@ -import {string, number} from "./mark.js"; +import {string, number, maybeColor, maybeNumber, title, titleGroup} from "./mark.js"; +import {filter} from "./defined.js"; export const offset = typeof window !== "undefined" && window.devicePixelRatio > 1 ? 0 : 0.5; -export function Style(mark, { - fill, - fillOpacity, - stroke, - strokeWidth, - strokeOpacity, - strokeLinejoin, - strokeLinecap, - strokeMiterlimit, - strokeDasharray, - mixBlendMode, - shapeRendering -} = {}) { - mark.fill = impliedString(fill, "currentColor"); - mark.fillOpacity = impliedNumber(fillOpacity, 1); - mark.stroke = impliedString(stroke, "none"); +export function styles( + mark, + { + title, + fill, + fillOpacity, + stroke, + strokeWidth, + strokeOpacity, + strokeLinejoin, + strokeLinecap, + strokeMiterlimit, + strokeDasharray, + mixBlendMode, + shapeRendering + }, + channels, + { + fill: defaultFill = "currentColor", + stroke: defaultStroke = "none", + strokeWidth: defaultStrokeWidth, + strokeMiterlimit: defaultStrokeMiterlimit + } +) { + + // Some marks don’t support fill (e.g., tick and rule). + if (defaultFill === null) { + fill = null; + fillOpacity = null; + } + + // Some marks default to fill with no stroke, while others default to stroke + // with no fill. For example, bar and area default to fill, while dot and line + // default to stroke. For marks that fill by default, the default fill only + // applies if the stroke is (constant) none; if you set a stroke, then the + // default fill becomes none. Similarly for marks that stroke by stroke, the + // default stroke only applies if the fill is (constant) none. + if (none(defaultFill)) { + if (!none(defaultStroke) && !none(fill)) defaultStroke = "none"; + } else { + if (none(defaultStroke) && !none(stroke)) defaultFill = "none"; + } + + const [vfill, cfill] = maybeColor(fill, defaultFill); + const [vfillOpacity, cfillOpacity] = maybeNumber(fillOpacity); + const [vstroke, cstroke] = maybeColor(stroke, defaultStroke); + const [vstrokeOpacity, cstrokeOpacity] = maybeNumber(strokeOpacity); + + // For styles that have no effect if there is no stroke, only apply the + // defaults if the stroke is not (constant) none. + if (cstroke !== "none") { + if (strokeWidth === undefined) strokeWidth = defaultStrokeWidth; + if (strokeMiterlimit === undefined) strokeMiterlimit = defaultStrokeMiterlimit; + } + + // Some marks don’t support fill (e.g., tick and rule). + if (defaultFill !== null) { + mark.fill = impliedString(cfill, "currentColor"); + mark.fillOpacity = impliedNumber(cfillOpacity, 1); + } + + mark.stroke = impliedString(cstroke, "none"); mark.strokeWidth = impliedNumber(strokeWidth, 1); - mark.strokeOpacity = impliedNumber(strokeOpacity, 1); + mark.strokeOpacity = impliedNumber(cstrokeOpacity, 1); mark.strokeLinejoin = impliedString(strokeLinejoin, "miter"); mark.strokeLinecap = impliedString(strokeLinecap, "butt"); mark.strokeMiterlimit = impliedNumber(strokeMiterlimit, 4); mark.strokeDasharray = string(strokeDasharray); mark.mixBlendMode = impliedString(mixBlendMode, "normal"); mark.shapeRendering = impliedString(shapeRendering, "auto"); + + return [ + ...channels, + {name: "title", value: title, optional: true}, + {name: "fill", value: vfill, scale: "color", optional: true}, + {name: "fillOpacity", value: vfillOpacity, scale: "opacity", optional: true}, + {name: "stroke", value: vstroke, scale: "color", optional: true}, + {name: "strokeOpacity", value: vstrokeOpacity, scale: "opacity", optional: true} + ]; +} + +export function applyChannelStyles(selection, {title: L, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO}) { + applyAttr(selection, "fill", F && (i => F[i])); + applyAttr(selection, "fill-opacity", FO && (i => FO[i])); + applyAttr(selection, "stroke", S && (i => S[i])); + applyAttr(selection, "stroke-opacity", SO && (i => SO[i])); + title(L)(selection); +} + +export function applyGroupedChannelStyles(selection, {title: L, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO}) { + applyAttr(selection, "fill", F && (([i]) => F[i])); + applyAttr(selection, "fill-opacity", FO && (([i]) => FO[i])); + applyAttr(selection, "stroke", S && (([i]) => S[i])); + applyAttr(selection, "stroke-opacity", SO && (([i]) => SO[i])); + titleGroup(L)(selection); } export function applyIndirectStyles(selection, mark) { @@ -68,3 +140,11 @@ export function impliedString(value, impliedValue) { export function impliedNumber(value, impliedValue) { if ((value = number(value)) !== impliedValue) return value; } + +export function filterStyles(index, {fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO}) { + return filter(index, F, FO, S, SO); +} + +function none(color) { + return color == null || color === "none"; +} diff --git a/test/marks/frame-test.js b/test/marks/frame-test.js new file mode 100644 index 0000000000..7fbac5bca2 --- /dev/null +++ b/test/marks/frame-test.js @@ -0,0 +1,36 @@ +import * as Plot from "@observablehq/plot"; +import assert from "assert"; + +it("frame(options) has the expected defaults", () => { + const frame = Plot.frame(); + assert.strictEqual(frame.data, undefined); + assert.strictEqual(frame.transform, undefined); + assert.deepStrictEqual(frame.channels, []); + assert.strictEqual(frame.fill, "none"); + assert.strictEqual(frame.fillOpacity, undefined); + assert.strictEqual(frame.stroke, "currentColor"); + assert.strictEqual(frame.strokeWidth, undefined); + assert.strictEqual(frame.strokeOpacity, undefined); + assert.strictEqual(frame.strokeLinejoin, undefined); + assert.strictEqual(frame.strokeLinecap, undefined); + assert.strictEqual(frame.strokeMiterlimit, undefined); + assert.strictEqual(frame.strokeDasharray, undefined); + assert.strictEqual(frame.mixBlendMode, undefined); + assert.strictEqual(frame.shapeRendering, undefined); + assert.strictEqual(frame.insetTop, 0); + assert.strictEqual(frame.insetRight, 0); + assert.strictEqual(frame.insetBottom, 0); + assert.strictEqual(frame.insetLeft, 0); +}); + +it("frame({fill}) allows fill to be a constant color", () => { + const frame = Plot.frame({fill: "red"}); + assert.strictEqual(frame.fill, "red"); + assert.strictEqual(frame.stroke, undefined); +}); + +it("frame({stroke}) allows stroke to be a constant color", () => { + const frame = Plot.frame({stroke: "red"}); + assert.strictEqual(frame.stroke, "red"); + assert.strictEqual(frame.fill, "none"); +});