diff --git a/src/channel.js b/src/channel.js index 3e6c4793ba..21baaf1d46 100644 --- a/src/channel.js +++ b/src/channel.js @@ -4,12 +4,13 @@ import {registry} from "./scales/index.js"; import {maybeReduce} from "./transforms/group.js"; // TODO Type coercion? -export function Channel(data, {scale, type, value, hint}) { +export function Channel(data, {scale, type, value, filter, hint}) { return { scale, type, value: valueof(data, value), label: labelof(value), + filter, hint }; } diff --git a/src/defined.js b/src/defined.js index 00adbad436..7536baa21d 100644 --- a/src/defined.js +++ b/src/defined.js @@ -16,13 +16,6 @@ export function nonempty(x) { return x != null && `${x}` !== ""; } -export function filter(index, ...channels) { - for (const c of channels) { - if (c) index = index.filter(i => defined(c[i])); - } - return index; -} - export function finite(x) { return isFinite(x) ? x : NaN; } diff --git a/src/marks/area.js b/src/marks/area.js index 0d5620637e..5e9a35a4c8 100644 --- a/src/marks/area.js +++ b/src/marks/area.js @@ -18,10 +18,10 @@ export class Area extends Mark { 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: "x1", value: x1, filter: null, scale: "x"}, + {name: "y1", value: y1, filter: null, scale: "y"}, + {name: "x2", value: x2, filter: null, scale: "x", optional: true}, + {name: "y2", value: y2, filter: null, scale: "y", optional: true}, {name: "z", value: maybeZ(options), optional: true} ], options, diff --git a/src/marks/bar.js b/src/marks/bar.js index 0f0d44030c..d70f2aee29 100644 --- a/src/marks/bar.js +++ b/src/marks/bar.js @@ -1,5 +1,4 @@ import {create} from "d3"; -import {filter} from "../defined.js"; import {Mark} from "../plot.js"; import {number} from "../options.js"; import {isCollapsed} from "../scales.js"; @@ -21,9 +20,8 @@ export class AbstractBar extends Mark { this.rx = impliedString(rx, "auto"); // number or percentage this.ry = impliedString(ry, "auto"); } - render(I, scales, channels, dimensions) { + render(index, scales, channels, dimensions) { const {dx, dy, rx, ry} = this; - const index = filter(I, ...this._positions(channels)); return create("svg:g") .call(applyIndirectStyles, this) .call(this._transform, scales, dx, dy) @@ -76,9 +74,6 @@ export class BarX extends AbstractBar { _transform(selection, {x}, dx, dy) { selection.call(applyTransform, x, null, dx, dy); } - _positions({x1: X1, x2: X2, y: Y}) { - return [X1, X2, Y]; - } _x({x}, {x1: X1, x2: X2}, {marginLeft}) { const {insetLeft} = this; return isCollapsed(x) ? marginLeft + insetLeft : i => Math.min(X1[i], X2[i]) + insetLeft; @@ -105,9 +100,6 @@ export class BarY extends AbstractBar { _transform(selection, {y}, dx, dy) { selection.call(applyTransform, null, y, dx, dy); } - _positions({y1: Y1, y2: Y2, x: X}) { - return [Y1, Y2, X]; - } _y({y}, {y1: Y1, y2: Y2}, {marginTop}) { const {insetTop} = this; return isCollapsed(y) ? marginTop + insetTop : i => Math.min(Y1[i], Y2[i]) + insetTop; diff --git a/src/marks/cell.js b/src/marks/cell.js index 2f097a992f..905b376124 100644 --- a/src/marks/cell.js +++ b/src/marks/cell.js @@ -15,9 +15,6 @@ export class Cell extends AbstractBar { _transform() { // noop } - _positions({x: X, y: Y}) { - return [X, Y]; - } } export function cell(data, {x, y, ...options} = {}) { diff --git a/src/marks/dot.js b/src/marks/dot.js index d5ca83171a..05e1ecbb1b 100644 --- a/src/marks/dot.js +++ b/src/marks/dot.js @@ -1,7 +1,7 @@ import {create, path, symbolCircle} from "d3"; -import {filter, positive} from "../defined.js"; -import {Mark} from "../plot.js"; +import {positive} from "../defined.js"; import {identity, maybeNumberChannel, maybeTuple} from "../options.js"; +import {Mark} from "../plot.js"; import {maybeSymbolChannel} from "../scales/symbol.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; @@ -22,7 +22,7 @@ export class Dot extends Mark { [ {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: "r", value: vr, scale: "r", filter: positive, optional: true}, {name: "rotate", value: vrotate, optional: true}, {name: "symbol", value: vsymbol, scale: "symbol", optional: true} ], @@ -48,7 +48,7 @@ export class Dot extends Mark { } } render( - I, + index, {x, y}, channels, {width, height, marginTop, marginRight, marginBottom, marginLeft} @@ -57,8 +57,6 @@ export class Dot extends Mark { const {dx, dy} = this; const cx = (marginLeft + width - marginRight) / 2; const cy = (marginTop + height - marginBottom) / 2; - let index = filter(I, X, Y, A, S); - if (R) index = index.filter(i => positive(R[i])); const circle = this.symbol === symbolCircle; return create("svg:g") .call(applyIndirectStyles, this) diff --git a/src/marks/image.js b/src/marks/image.js index b20ad9d707..384c2fefeb 100644 --- a/src/marks/image.js +++ b/src/marks/image.js @@ -1,7 +1,7 @@ import {create} from "d3"; -import {filter, positive} from "../defined.js"; -import {Mark} from "../plot.js"; +import {positive} from "../defined.js"; import {maybeNumberChannel, maybeTuple, string} from "../options.js"; +import {Mark} from "../plot.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, applyAttr, offset, impliedString} from "../style.js"; const defaults = { @@ -44,8 +44,8 @@ export class Image extends Mark { [ {name: "x", value: x, scale: "x", optional: true}, {name: "y", value: y, scale: "y", optional: true}, - {name: "width", value: vw, optional: true}, - {name: "height", value: vh, optional: true}, + {name: "width", value: vw, filter: positive, optional: true}, + {name: "height", value: vh, filter: positive, optional: true}, {name: "src", value: vs, optional: true} ], options, @@ -58,15 +58,12 @@ export class Image extends Mark { this.crossOrigin = string(crossOrigin); } render( - I, + index, {x, y}, channels, {width, height, marginTop, marginRight, marginBottom, marginLeft} ) { const {x: X, y: Y, width: W, height: H, src: S} = channels; - let index = filter(I, X, Y, S); - if (W) index = index.filter(i => positive(W[i])); - if (H) index = index.filter(i => positive(H[i])); const cx = (marginLeft + width - marginRight) / 2; const cy = (marginTop + height - marginBottom) / 2; const {dx, dy} = this; diff --git a/src/marks/line.js b/src/marks/line.js index 6fce9f39c0..32fefee8d1 100644 --- a/src/marks/line.js +++ b/src/marks/line.js @@ -18,8 +18,8 @@ export class Line extends Mark { super( data, [ - {name: "x", value: x, scale: "x"}, - {name: "y", value: y, scale: "y"}, + {name: "x", value: x, filter: null, scale: "x"}, + {name: "y", value: y, filter: null, scale: "y"}, {name: "z", value: maybeZ(options), optional: true} ], options, diff --git a/src/marks/link.js b/src/marks/link.js index 8eef19690c..1da8500008 100644 --- a/src/marks/link.js +++ b/src/marks/link.js @@ -1,7 +1,6 @@ import {create, path} from "d3"; -import {filter} from "../defined.js"; -import {Mark} from "../plot.js"; import {Curve} from "../curve.js"; +import {Mark} from "../plot.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; const defaults = { @@ -26,10 +25,9 @@ export class Link extends Mark { ); this.curve = Curve(curve); } - render(I, {x, y}, channels) { + render(index, {x, y}, channels) { const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels; const {dx, dy} = this; - const index = filter(I, X1, Y1, X2, Y2); return create("svg:g") .call(applyIndirectStyles, this) .call(applyTransform, x, y, offset + dx, offset + dy) diff --git a/src/marks/rect.js b/src/marks/rect.js index d69a4dfdcf..4e805b9c25 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -1,7 +1,6 @@ import {create} from "d3"; -import {filter} from "../defined.js"; -import {Mark} from "../plot.js"; import {number} from "../options.js"; +import {Mark} from "../plot.js"; import {isCollapsed} from "../scales.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, impliedString, applyAttr, applyChannelStyles} from "../style.js"; import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js"; @@ -43,11 +42,10 @@ export class Rect extends Mark { this.rx = impliedString(rx, "auto"); // number or percentage this.ry = impliedString(ry, "auto"); } - render(I, {x, y}, channels, dimensions) { + render(index, {x, y}, channels, dimensions) { const {x1: X1, y1: Y1, x2: X2, y2: Y2} = channels; const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions; const {insetTop, insetRight, insetBottom, insetLeft, dx, dy, rx, ry} = this; - const index = filter(I, X1, Y2, X2, Y2); return create("svg:g") .call(applyIndirectStyles, this) .call(applyTransform, x, y, dx, dy) diff --git a/src/marks/rule.js b/src/marks/rule.js index 0ed542bcb1..cade4c564f 100644 --- a/src/marks/rule.js +++ b/src/marks/rule.js @@ -1,7 +1,6 @@ import {create} from "d3"; -import {filter} from "../defined.js"; -import {Mark} from "../plot.js"; import {identity, number} from "../options.js"; +import {Mark} from "../plot.js"; import {isCollapsed} from "../scales.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, applyChannelStyles, offset} from "../style.js"; import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js"; @@ -34,11 +33,10 @@ export class RuleX extends Mark { this.insetTop = number(insetTop); this.insetBottom = number(insetBottom); } - render(I, {x, y}, channels, dimensions) { + render(index, {x, y}, channels, dimensions) { const {x: X, y1: Y1, y2: Y2} = channels; const {width, height, marginTop, marginRight, marginLeft, marginBottom} = dimensions; const {insetTop, insetBottom} = this; - const index = filter(I, X, Y1, Y2); return create("svg:g") .call(applyIndirectStyles, this) .call(applyTransform, X && x, null, offset, 0) @@ -78,11 +76,10 @@ export class RuleY extends Mark { this.insetRight = number(insetRight); this.insetLeft = number(insetLeft); } - render(I, {x, y}, channels, dimensions) { + render(index, {x, y}, channels, dimensions) { const {y: Y, x1: X1, x2: X2} = channels; const {width, height, marginTop, marginRight, marginLeft, marginBottom} = dimensions; const {insetLeft, insetRight, dx, dy} = this; - const index = filter(I, Y, X1, X2); return create("svg:g") .call(applyIndirectStyles, this) .call(applyTransform, null, Y && y, dx, offset + dy) diff --git a/src/marks/text.js b/src/marks/text.js index 3007e50ec8..5b1fc41703 100644 --- a/src/marks/text.js +++ b/src/marks/text.js @@ -1,7 +1,7 @@ import {create} from "d3"; -import {filter, nonempty} from "../defined.js"; -import {Mark} from "../plot.js"; +import {nonempty} from "../defined.js"; import {indexOf, identity, string, maybeNumberChannel, maybeTuple, numberChannel, isNumeric, isTemporal} from "../options.js"; +import {Mark} from "../plot.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyAttr, applyText, applyTransform, offset} from "../style.js"; const defaults = { @@ -33,7 +33,7 @@ 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: "text", value: text, filter: nonempty} ], options, defaults @@ -48,11 +48,10 @@ export class Text extends Mark { this.dx = string(dx); this.dy = string(dy); } - render(I, {x, y}, channels, dimensions) { + render(index, {x, y}, channels, dimensions) { const {x: X, y: Y, rotate: R, text: T, fontSize: FS} = channels; const {width, height, marginTop, marginRight, marginBottom, marginLeft} = dimensions; const {rotate} = this; - 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") diff --git a/src/marks/tick.js b/src/marks/tick.js index fbd433ce65..f70de8ba50 100644 --- a/src/marks/tick.js +++ b/src/marks/tick.js @@ -1,5 +1,4 @@ import {create} from "d3"; -import {filter} from "../defined.js"; import {Mark} from "../plot.js"; import {identity, number} from "../options.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, applyChannelStyles, offset} from "../style.js"; @@ -13,10 +12,8 @@ class AbstractTick extends Mark { constructor(data, channels, options) { super(data, channels, options, defaults); } - render(I, scales, channels, dimensions) { - const {x: X, y: Y} = channels; + render(index, scales, channels, dimensions) { const {dx, dy} = this; - const index = filter(I, X, Y); return create("svg:g") .call(applyIndirectStyles, this) .call(this._transform, scales, dx, dy) diff --git a/src/marks/vector.js b/src/marks/vector.js index 51902bff44..de898f71d5 100644 --- a/src/marks/vector.js +++ b/src/marks/vector.js @@ -1,8 +1,7 @@ import {create} from "d3"; -import {filter} from "../defined.js"; -import {Mark} from "../plot.js"; -import {maybeNumberChannel, maybeTuple, keyword} from "../options.js"; import {radians} from "../math.js"; +import {maybeNumberChannel, maybeTuple, keyword} from "../options.js"; +import {Mark} from "../plot.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; const defaults = { @@ -33,14 +32,13 @@ export class Vector extends Mark { this.anchor = keyword(anchor, "anchor", ["start", "middle", "end"]); } render( - I, + index, {x, y}, channels, {width, height, marginTop, marginRight, marginBottom, marginLeft} ) { const {x: X, y: Y, length: L, rotate: R} = channels; const {dx, dy, length, rotate, anchor} = this; - const index = filter(I, X, Y, L, R); const fl = L ? i => L[i] : () => length; const fr = R ? i => R[i] : () => rotate; const fx = X ? i => X[i] : () => (marginLeft + width - marginRight) / 2; diff --git a/src/options.js b/src/options.js index 4095f06969..ee7dfd428f 100644 --- a/src/options.js +++ b/src/options.js @@ -178,8 +178,11 @@ export function maybeValue(value) { return value === undefined || isOptions(value) ? value : {value}; } +// Coerces the given channel values (if any) to numbers. This is useful when +// values will be interpolated into other code, such as an SVG transform, and +// where we don’t wish to allow unexpected behavior for weird input. export function numberChannel(source) { - return { + return source == null ? null : { transform: data => valueof(data, source, Float64Array), label: labelof(source) }; diff --git a/src/plot.js b/src/plot.js index 8648b3d918..82f683d603 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,11 +1,12 @@ import {create, cross, difference, groups, InternMap} from "d3"; import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js"; import {Channel, channelSort} from "./channel.js"; +import {defined} from "./defined.js"; import {Dimensions} from "./dimensions.js"; import {Legends, exposeLegends} from "./legends.js"; import {arrayify, isOptions, keyword, range, first, second, where} from "./options.js"; import {Scales, ScaleFunctions, autoScaleRange, applyScales, exposeScales} from "./scales.js"; -import {applyInlineStyles, filterStyles, maybeClassName, styles} from "./style.js"; +import {applyInlineStyles, maybeClassName, styles} from "./style.js"; import {basic} from "./transforms/basic.js"; export function plot(options = {}) { @@ -92,9 +93,9 @@ export function plot(options = {}) { .node(); for (const mark of marks) { - const channels = markChannels.get(mark); + const channels = markChannels.get(mark) ?? []; const values = applyScales(channels, scales); - const index = filterStyles(markIndex.get(mark), values); + const index = filter(markIndex.get(mark), channels, values); const node = mark.render(index, scales, values, dimensions, axes); if (node != null) svg.appendChild(node); } @@ -118,6 +119,16 @@ export function plot(options = {}) { return figure; } +function filter(index, channels, values) { + for (const [name, {filter = defined}] of channels) { + if (name !== undefined && filter !== null) { + const value = values[name]; + index = index.filter(i => filter(value[i])); + } + } + return index; +} + export class Mark { constructor(data, channels = [], options = {}, defaults) { const {facet = "auto", sort, dx, dy} = options; @@ -300,13 +311,8 @@ class Facet extends Mark { const marksFacetIndex = marksIndexByFacet.get(key); for (let i = 0; i < marks.length; ++i) { const values = marksValues[i]; - const index = filterStyles(marksFacetIndex[i], values); - const node = marks[i].render( - index, - scales, - values, - subdimensions - ); + const index = filter(marksFacetIndex[i], marksChannels[i], values); + const node = marks[i].render(index, scales, values, subdimensions); if (node != null) this.appendChild(node); } })) diff --git a/src/scales.js b/src/scales.js index 84592ba274..68fc8314fd 100644 --- a/src/scales.js +++ b/src/scales.js @@ -249,7 +249,7 @@ export function scaleOrder({range, domain = range}) { } // TODO use Float64Array.from for position and radius scales? -export function applyScales(channels = [], scales) { +export function applyScales(channels, scales) { const values = Object.create(null); for (let [name, {value, scale}] of channels) { if (name !== undefined) { diff --git a/src/style.js b/src/style.js index 1ef197d99d..5c9d860bae 100644 --- a/src/style.js +++ b/src/style.js @@ -1,7 +1,7 @@ import {isoFormat, namespaces} from "d3"; -import {string, number, maybeColorChannel, maybeNumberChannel, isTemporal, isNumeric} from "./options.js"; -import {filter, nonempty} from "./defined.js"; +import {nonempty} from "./defined.js"; import {formatNumber} from "./format.js"; +import {string, number, maybeColorChannel, maybeNumberChannel, isTemporal, isNumeric} from "./options.js"; export const offset = typeof window !== "undefined" && window.devicePixelRatio > 1 ? 0 : 0.5; @@ -203,10 +203,6 @@ export function impliedNumber(value, impliedValue) { if ((value = number(value)) !== impliedValue) return value; } -export function filterStyles(index, {fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW}) { - return filter(index, F, FO, S, SO, SW); -} - export function none(color) { return color == null || color === "none"; } diff --git a/test/marks/text-test.js b/test/marks/text-test.js index 4d47b6d90c..0635ff7050 100644 --- a/test/marks/text-test.js +++ b/test/marks/text-test.js @@ -5,9 +5,9 @@ it("text() has the expected defaults", () => { const text = Plot.text(); assert.strictEqual(text.data, undefined); assert.strictEqual(text.transform, undefined); - assert.deepStrictEqual(text.channels.map(c => c.name), ["x", "y", "fontSize", "rotate", "text"]); - assert.deepStrictEqual(text.channels.map(c => Plot.valueof([[1, 2], [3, 4]], c.value)), [[1, 3], [2, 4], undefined, undefined, [0, 1]]); - assert.deepStrictEqual(text.channels.map(c => c.scale), ["x", "y", undefined, undefined, undefined]); + assert.deepStrictEqual(text.channels.map(c => c.name), ["x", "y", "text"]); + assert.deepStrictEqual(text.channels.map(c => Plot.valueof([[1, 2], [3, 4]], c.value)), [[1, 3], [2, 4], [0, 1]]); + assert.deepStrictEqual(text.channels.map(c => c.scale), ["x", "y", undefined]); assert.strictEqual(text.fill, undefined); assert.strictEqual(text.fillOpacity, undefined); assert.strictEqual(text.stroke, undefined);