diff --git a/src/facet.js b/src/facet.js index 62b198f2b4..625ee01086 100644 --- a/src/facet.js +++ b/src/facet.js @@ -4,14 +4,14 @@ import {Mark, first, second, markify, where} from "./mark.js"; import {applyScales} from "./scales.js"; import {filterStyles} from "./style.js"; -export function facets(data, {x, y, ...options}, marks) { +export function facets(data, {x, y, ...options}, marks, selectionData) { return x === undefined && y === undefined ? marks // if no facets are specified, ignore! - : [new Facet(data, {x, y, ...options}, marks)]; + : [new Facet(data, {x, y, ...options}, marks, selectionData)]; } class Facet extends Mark { - constructor(data, {x, y, ...options} = {}, marks = []) { + constructor(data, {x, y, ...options} = {}, marks = [], selectionData) { if (data == null) throw new Error("missing facet data"); super( data, @@ -19,12 +19,13 @@ class Facet extends Mark { {name: "fx", value: x, scale: "fx", optional: true}, {name: "fy", value: y, scale: "fy", optional: true} ], - options + {selection: !!selectionData, ...options} ); this.marks = marks.flat(Infinity).map(markify); // The following fields are set by initialize: this.marksChannels = undefined; // array of mark channels this.marksIndexByFacet = undefined; // map from facet key to array of mark indexes + this.selectionData = selectionData; } initialize() { const {index, channels} = super.initialize(); @@ -34,10 +35,12 @@ class Facet extends Mark { const subchannels = []; const marksChannels = this.marksChannels = []; const marksIndexByFacet = this.marksIndexByFacet = facetMap(channels); + const {selectionData} = this; for (const facetKey of facetsKeys) { marksIndexByFacet.set(facetKey, new Array(this.marks.length)); } let facetsExclude; + let selectable; for (let i = 0; i < this.marks.length; ++i) { const mark = this.marks[i]; const {facet} = mark; @@ -45,7 +48,7 @@ class Facet extends Mark { : facet === "include" ? facetsIndex : facet === "exclude" ? facetsExclude || (facetsExclude = facetsIndex.map(f => Uint32Array.from(difference(index, f)))) : undefined; - const {index: I, channels: markChannels} = mark.initialize(markFacets, channels); + const {index: I, channels: markChannels} = mark.initialize(markFacets, channels, selectionData); // If an index is returned by mark.initialize, its structure depends on // whether or not faceting has been applied: it is a flat index ([0, 1, 2, // …]) when not faceted, and a nested index ([[0, 1, …], [2, 3, …], …]) @@ -64,9 +67,11 @@ class Facet extends Mark { for (const [, channel] of markChannels) { subchannels.push([, channel]); } + if (mark.selectable) selectable = true; + mark.onchange = (event) => this.onchange(event, I); marksChannels.push(markChannels); } - return {index, channels: [...channels, ...subchannels]}; + return {index, channels: [...channels, ...subchannels], selectable}; } render(I, scales, channels, dimensions, axes) { const {marks, marksChannels, marksIndexByFacet} = this; @@ -126,11 +131,19 @@ class Facet extends Mark { values, subdimensions ); - if (node != null) this.appendChild(node); + if (node != null) { + marks[i].nodes.push(node); + this.appendChild(node); + } } })) .node(); } + select(S, options) { + for (const mark of this.marks) { + if (mark.selectable) mark.select(S, options); + } + } } // Derives a copy of the specified axis with the label disabled. diff --git a/src/index.js b/src/index.js index 4ee74bbb00..60685db793 100644 --- a/src/index.js +++ b/src/index.js @@ -13,6 +13,7 @@ export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js"; export {Text, text, textX, textY} from "./marks/text.js"; export {TickX, TickY, tickX, tickY} from "./marks/tick.js"; export {Vector, vector} from "./marks/vector.js"; +export {Brush, brush, brushX, brushY} from "./marks/brush.js"; export {filter} from "./transforms/filter.js"; export {reverse} from "./transforms/reverse.js"; export {sort, shuffle} from "./transforms/sort.js"; diff --git a/src/mark.js b/src/mark.js index 7996b1a420..3009f78a60 100644 --- a/src/mark.js +++ b/src/mark.js @@ -1,4 +1,4 @@ -import {ascending, color, descending, rollup, sort} from "d3"; +import {ascending, color, descending, rollup, selectAll, sort} from "d3"; import {plot} from "./plot.js"; import {registry} from "./scales/index.js"; import {styles} from "./style.js"; @@ -11,11 +11,12 @@ const objectToString = Object.prototype.toString; export class Mark { constructor(data, channels = [], options = {}, defaults) { - const {facet = "auto", sort, dx, dy} = options; + const {facet = "auto", selection, sort, dx, dy} = options; const names = new Set(); this.data = data; this.sort = isOptions(sort) ? sort : null; this.facet = facet == null || facet === false ? null : keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]); + this.selection = selection == null || selection === false ? null : keyword(selection === true ? "include" : selection, "selection", ["auto", "include"]); const {transform} = basic(options); this.transform = transform; if (defaults !== undefined) channels = styles(this, options, channels, defaults); @@ -35,8 +36,9 @@ export class Mark { }); this.dx = +dx || 0; this.dy = +dy || 0; + this.nodes = []; } - initialize(facets, facetChannels) { + initialize(facets, facetChannels, selection) { let data = arrayify(this.data); let index = facets === undefined && data != null ? range(data) : facets; if (data !== undefined && this.transform !== undefined) { @@ -50,8 +52,22 @@ export class Mark { return [name == null ? undefined : `${name}`, Channel(data, channel)]; }); if (this.sort != null) channelSort(channels, facetChannels, data, this.sort); + this.selectable = this.selection === "include" || (this.selection === "auto" && this.data === selection); return {index, channels}; } + select(S, {transition}) { + let sel = selectAll(this.nodes).selectChildren(); + if (transition) { + const {delay, duration} = typeof transition === "object" ? transition : {}; + sel = sel.transition(); + if (delay !== undefined) sel.delay(delay); + if (duration !== undefined) sel.duration(duration); + } + return sel + .style("opacity", 1e-6) + .filter(i => S[i]) + .style("opacity", 1); + } plot({marks = [], ...options} = {}) { return plot({...options, marks: [...marks, this]}); } diff --git a/src/marks/area.js b/src/marks/area.js index dca3302aff..8c277cdbbb 100644 --- a/src/marks/area.js +++ b/src/marks/area.js @@ -1,4 +1,4 @@ -import {area as shapeArea, create, group} from "d3"; +import {area as shapeArea, create, group, selectAll} from "d3"; import {Curve} from "../curve.js"; import {defined} from "../defined.js"; import {Mark, indexOf, maybeZ} from "../mark.js"; @@ -48,6 +48,19 @@ export class Area extends Mark { .y1(i => Y2[i]))) .node(); } + select(S, {transition}) { + let sel = selectAll(this.nodes).selectChildren(); + if (transition) { + const {delay, duration} = typeof transition === "object" ? transition : {}; + sel = sel.transition(); + if (delay !== undefined) sel.delay(delay); + if (duration !== undefined) sel.duration(duration); + } + return sel + .style("opacity", 1e-6) + .filter(([i]) => S[i]) + .style("opacity", 1); + } } export function area(data, options) { diff --git a/src/marks/bar.js b/src/marks/bar.js index 7ac336f5f1..7d57f5b84d 100644 --- a/src/marks/bar.js +++ b/src/marks/bar.js @@ -21,8 +21,9 @@ export class AbstractBar extends Mark { this.ry = impliedString(ry, "auto"); } render(I, scales, channels, dimensions) { - const {dx, dy, rx, ry} = this; + const {dx, dy, rx, ry, onchange} = this; const index = filter(I, ...this._positions(channels)); + let selected; return create("svg:g") .call(applyIndirectStyles, this) .call(this._transform, scales, dx, dy) @@ -36,7 +37,14 @@ export class AbstractBar extends Mark { .attr("height", this._height(scales, channels, dimensions)) .call(applyAttr, "rx", rx) .call(applyAttr, "ry", ry) - .call(applyChannelStyles, this, channels)) + .call(applyChannelStyles, this, channels) + .call(!(onchange && this.clickable) + ? () => {} + : e => e.on("click", function(event, i) { + selected = selected === this || event.shiftKey ? undefined : this; + onchange({ detail: { filter: selected ? ((d, j) => i === j) : true }}); + }) + )) .node(); } _x(scales, {x: X}, {marginLeft}) { diff --git a/src/marks/brush.js b/src/marks/brush.js new file mode 100644 index 0000000000..202d251bb0 --- /dev/null +++ b/src/marks/brush.js @@ -0,0 +1,73 @@ +import {brush as brusher, brushX as brusherX, brushY as brusherY, create} from "d3"; +import {Mark, identity, first, second} from "../mark.js"; + +const defaults = {}; +export class Brush extends Mark { + constructor(data, {x = first, y = second, quiet = false, selection, ...options} = {}) { + super( + data, + [ + {name: "x", value: x, scale: "x", optional: true}, + {name: "y", value: y, scale: "y", optional: true} + ], + {...options, selection: true}, + defaults + ); + this.initialSelection = selection; + this.quiet = !!quiet; + this.brushes = []; + } + render( + I, + scales, + {x: X, y: Y}, + {marginLeft, width, marginRight, marginTop, height, marginBottom} + ) { + const bounds = [ + [Math.floor(marginLeft), Math.floor(marginTop)], + [Math.ceil(width - marginRight), Math.ceil(height - marginBottom)] + ]; + const F = new Uint8Array(X ? X.length : Y.length); + const {brushes, quiet} = this; + const origin = brushes.length; + for (const i of I) F[i] = 1; + const {onchange} = this; + const brush = (X && Y ? brusher : X ? brusherX : brusherY)() + .extent(bounds) + .on("start brush end", (event) => { + const {selection, sourceEvent} = event; + if (sourceEvent === undefined) return; // a programmatic selection clears all the brushes + if (!selection) { + onchange({detail: {filter: quiet, origin}}); + } else { + let x0, x1, y0, y1; + if (X) ([x0, x1] = Y ? [selection[0][0], selection[1][0]] : selection); + if (Y) ([y0, y1] = X ? [selection[0][1], selection[1][1]] : selection); + onchange({detail: { + filter: X && Y ? (d, i) => F[i] && X[i] >= x0 && X[i] <= x1 && Y[i] >= y0 && Y[i] <= y1 + : X ? (d, i) => F[i] && X[i] >= x0 && X[i] <= x1 + : (d, i) => F[i] && Y[i] >= y0 && Y[i] <= y1, + origin + }}); + } + }); + const g = create("svg:g").call(brush); + brushes.push(() => g.call(brush.clear)); + return g.node(); + } + select(event, {origin}) { + this.brushes.forEach((clear, i) => i !== origin && clear()); + } +} + +export function brush(data, options) { + return new Brush(data, options); +} + +export function brushX(data, {x = identity, ...options} = {}) { + return new Brush(data, {...options, x, y: null}); +} + +export function brushY(data, {y = identity, ...options} = {}) { + return new Brush(data, {...options, x: null, y}); +} diff --git a/src/marks/dot.js b/src/marks/dot.js index 3953406401..13d4b22dc0 100644 --- a/src/marks/dot.js +++ b/src/marks/dot.js @@ -12,7 +12,7 @@ const defaults = { export class Dot extends Mark { constructor(data, options = {}) { - const {x, y, r, rotate, symbol = symbolCircle} = options; + const {x, y, r, rotate, symbol = symbolCircle, clickable} = options; const [vrotate, crotate] = maybeNumberChannel(rotate, 0); const [vsymbol, csymbol] = maybeSymbolChannel(symbol); const [vr, cr] = maybeNumberChannel(r, vsymbol == null ? 3 : 4.5); @@ -31,6 +31,7 @@ export class Dot extends Mark { this.r = cr; this.rotate = crotate; this.symbol = csymbol; + this.clickable = !!clickable; // Give a hint to the symbol scale; this allows the symbol scale to chose // appropriate default symbols based on whether the dots are filled or @@ -53,12 +54,13 @@ export class Dot extends Mark { {width, height, marginTop, marginRight, marginBottom, marginLeft} ) { const {x: X, y: Y, r: R, rotate: A, symbol: S} = channels; - const {dx, dy} = this; + const {dx, dy, onchange} = 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; + let selected; return create("svg:g") .call(applyIndirectStyles, this) .call(applyTransform, x, y, offset + dx, offset + dy) @@ -88,7 +90,14 @@ export class Dot extends Mark { return p; }); }) - .call(applyChannelStyles, this, channels)) + .call(applyChannelStyles, this, channels) + .call(!(onchange && this.clickable) + ? () => {} + : e => e.on("click", function(event, i) { + selected = selected === this || event.shiftKey ? undefined : this; + onchange({ detail: { filter: selected ? ((d, j) => i === j) : true }}); + }) + )) .node(); } } diff --git a/src/marks/image.js b/src/marks/image.js index dd037de450..34283226dd 100644 --- a/src/marks/image.js +++ b/src/marks/image.js @@ -68,7 +68,8 @@ export class Image extends Mark { 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; + const {dx, dy, onchange} = this; + let selected; return create("svg:g") .call(applyIndirectStyles, this) .call(applyTransform, x, y, offset + dx, offset + dy) @@ -83,7 +84,14 @@ export class Image extends Mark { .call(applyAttr, "href", S ? i => S[i] : this.src) .call(applyAttr, "preserveAspectRatio", this.preserveAspectRatio) .call(applyAttr, "crossorigin", this.crossOrigin) - .call(applyChannelStyles, this, channels)) + .call(applyChannelStyles, this, channels) + .call(!(onchange && this.clickable) + ? () => {} + : e => e.on("click", function(event, i) { + selected = selected === this || event.shiftKey ? undefined : this; + onchange({ detail: { filter: selected ? ((d, j) => i === j) : true }}); + }) + )) .node(); } } diff --git a/src/marks/line.js b/src/marks/line.js index f8be691af3..ffcba1e842 100644 --- a/src/marks/line.js +++ b/src/marks/line.js @@ -1,4 +1,4 @@ -import {create, group, line as shapeLine} from "d3"; +import {create, group, line as shapeLine, selectAll} from "d3"; import {Curve} from "../curve.js"; import {defined} from "../defined.js"; import {Mark, indexOf, identity, maybeTuple, maybeZ} from "../mark.js"; @@ -44,6 +44,19 @@ export class Line extends Mark { .y(i => Y[i]))) .node(); } + select(S, {transition}) { + let sel = selectAll(this.nodes).selectChildren(); + if (transition) { + const {delay, duration} = typeof transition === "object" ? transition : {}; + sel = sel.transition(); + if (delay !== undefined) sel.delay(delay); + if (duration !== undefined) sel.duration(duration); + } + return sel + .style("opacity", 1e-6) + .filter(([i]) => S[i]) + .style("opacity", 1); + } } export function line(data, {x, y, ...options} = {}) { diff --git a/src/marks/link.js b/src/marks/link.js index 86acb22dc0..c93dfa9d0d 100644 --- a/src/marks/link.js +++ b/src/marks/link.js @@ -28,8 +28,9 @@ export class Link extends Mark { } render(I, {x, y}, channels) { const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels; - const {dx, dy} = this; + const {dx, dy, onchange} = this; const index = filter(I, X1, Y1, X2, Y2); + let selected; return create("svg:g") .call(applyIndirectStyles, this) .call(applyTransform, x, y, offset + dx, offset + dy) @@ -46,7 +47,14 @@ export class Link extends Mark { c.lineEnd(); return `${p}`; }) - .call(applyChannelStyles, this, channels)) + .call(applyChannelStyles, this, channels) + .call(!(onchange && this.clickable) + ? () => {} + : e => e.on("click", function(event, i) { + selected = selected === this || event.shiftKey ? undefined : this; + onchange({ detail: { filter: selected ? ((d, j) => i === j) : true }}); + }) + )) .node(); } } diff --git a/src/marks/rect.js b/src/marks/rect.js index 849b6c1dcd..55338c4506 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -45,8 +45,9 @@ export class Rect extends Mark { render(I, {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 {insetTop, insetRight, insetBottom, insetLeft, dx, dy, rx, ry, onchange} = this; const index = filter(I, X1, Y2, X2, Y2); + let selected; return create("svg:g") .call(applyIndirectStyles, this) .call(applyTransform, x, y, dx, dy) @@ -60,7 +61,14 @@ export class Rect extends Mark { .attr("height", Y1 && Y2 && !isCollapsed(y) ? i => Math.max(0, Math.abs(Y1[i] - Y2[i]) - insetTop - insetBottom) : height - marginTop - marginBottom - insetTop - insetBottom) .call(applyAttr, "rx", rx) .call(applyAttr, "ry", ry) - .call(applyChannelStyles, this, channels)) + .call(applyChannelStyles, this, channels) + .call(!(onchange && this.clickable) + ? () => {} + : e => e.on("click", function(event, i) { + selected = selected === this || event.shiftKey ? undefined : this; + onchange({ detail: { filter: selected ? ((d, j) => i === j) : true }}); + }) + )) .node(); } } diff --git a/src/marks/rule.js b/src/marks/rule.js index 2ca13bbcff..aeceabd3ef 100644 --- a/src/marks/rule.js +++ b/src/marks/rule.js @@ -18,7 +18,8 @@ export class RuleX extends Mark { y2, inset = 0, insetTop = inset, - insetBottom = inset + insetBottom = inset, + clickable } = options; super( data, @@ -32,12 +33,14 @@ export class RuleX extends Mark { ); this.insetTop = number(insetTop); this.insetBottom = number(insetBottom); + this.clickable = !!clickable; } render(I, {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 {insetTop, insetBottom, onchange} = this; const index = filter(I, X, Y1, Y2); + let selected; return create("svg:g") .call(applyIndirectStyles, this) .call(applyTransform, X && x, null, offset, 0) @@ -49,8 +52,15 @@ export class RuleX extends Mark { .attr("x2", X ? i => X[i] : (marginLeft + width - marginRight) / 2) .attr("y1", Y1 && !isCollapsed(y) ? i => Y1[i] + insetTop : marginTop + insetTop) .attr("y2", Y2 && !isCollapsed(y) ? (y.bandwidth ? i => Y2[i] + y.bandwidth() - insetBottom : i => Y2[i] - insetBottom) : height - marginBottom - insetBottom) - .call(applyChannelStyles, this, channels)) - .node(); + .call(applyChannelStyles, this, channels) + .call(!(onchange && this.clickable) + ? () => {} + : e => e.on("click", function(event, i) { + selected = selected === this || event.shiftKey ? undefined : this; + onchange({ detail: { filter: selected ? ((d, j) => i === j) : true }}); + }) + )) + .node(); } } @@ -80,8 +90,9 @@ export class RuleY extends Mark { render(I, {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 {insetLeft, insetRight, dx, dy, onchange} = this; const index = filter(I, Y, X1, X2); + let selected; return create("svg:g") .call(applyIndirectStyles, this) .call(applyTransform, null, Y && y, dx, offset + dy) @@ -93,7 +104,14 @@ export class RuleY extends Mark { .attr("x2", X2 && !isCollapsed(x) ? (x.bandwidth ? i => X2[i] + x.bandwidth() - insetRight : i => X2[i] - insetRight) : width - marginRight - insetRight) .attr("y1", Y ? i => Y[i] : (marginTop + height - marginBottom) / 2) .attr("y2", Y ? i => Y[i] : (marginTop + height - marginBottom) / 2) - .call(applyChannelStyles, this, channels)) + .call(applyChannelStyles, this, channels) + .call(!(onchange && this.clickable) + ? () => {} + : e => e.on("click", function(event, i) { + selected = selected === this || event.shiftKey ? undefined : this; + onchange({ detail: { filter: selected ? ((d, j) => i === j) : true }}); + }) + )) .node(); } } diff --git a/src/marks/text.js b/src/marks/text.js index d344329e25..e1087992a0 100644 --- a/src/marks/text.js +++ b/src/marks/text.js @@ -50,10 +50,11 @@ export class Text extends Mark { render(I, {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 {rotate, onchange} = 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; + let selected; return create("svg:g") .call(applyIndirectTextStyles, this, T) .call(applyTransform, x, y, offset, offset) @@ -72,7 +73,14 @@ export class Text extends Mark { : text => text.attr("x", X ? i => X[i] : cx).attr("y", Y ? i => Y[i] : cy)) .call(applyAttr, "font-size", FS && (i => FS[i])) .call(applyText, T) - .call(applyChannelStyles, this, channels)) + .call(applyChannelStyles, this, channels) + .call(!(onchange && this.clickable) + ? () => {} + : e => e.on("click", function(event, i) { + selected = selected === this || event.shiftKey ? undefined : this; + onchange({ detail: { filter: selected ? ((d, j) => i === j) : true }}); + }) + )) .node(); } } diff --git a/src/marks/tick.js b/src/marks/tick.js index 09740e7282..389c8189e7 100644 --- a/src/marks/tick.js +++ b/src/marks/tick.js @@ -14,8 +14,9 @@ class AbstractTick extends Mark { } render(I, scales, channels, dimensions) { const {x: X, y: Y} = channels; - const {dx, dy} = this; + const {dx, dy, onchange} = this; const index = filter(I, X, Y); + let selected; return create("svg:g") .call(applyIndirectStyles, this) .call(this._transform, scales, dx, dy) @@ -27,7 +28,14 @@ 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(applyChannelStyles, this, channels)) + .call(applyChannelStyles, this, channels) + .call(!(onchange && this.clickable) + ? () => {} + : e => e.on("click", function(event, i) { + selected = selected === this || event.shiftKey ? undefined : this; + onchange({ detail: { filter: selected ? ((d, j) => i === j) : true }}); + }) + )) .node(); } } diff --git a/src/marks/vector.js b/src/marks/vector.js index 03e39c86e2..33f3030df2 100644 --- a/src/marks/vector.js +++ b/src/marks/vector.js @@ -38,13 +38,14 @@ export class Vector extends Mark { {width, height, marginTop, marginRight, marginBottom, marginLeft} ) { const {x: X, y: Y, length: L, rotate: R} = channels; - const {dx, dy, length, rotate, anchor} = this; + const {dx, dy, length, rotate, anchor, onchange} = 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; const fy = Y ? i => Y[i] : () => (marginTop + height - marginBottom) / 2; const k = anchor === "start" ? 0 : anchor === "end" ? 1 : 0.5; + let selected; return create("svg:g") .attr("fill", "none") .call(applyIndirectStyles, this) @@ -59,7 +60,14 @@ export class Vector extends Mark { const d = (x + y) / 5, e = (x - y) / 5; return `M${fx(i) - x * k},${fy(i) - y * k}l${x},${y}m${-e},${-d}l${e},${d}l${-d},${e}`; }) - .call(applyChannelStyles, this, channels)) + .call(applyChannelStyles, this, channels) + .call(!(onchange && this.clickable) + ? () => {} + : e => e.on("click", function(event, i) { + selected = selected === this || event.shiftKey ? undefined : this; + onchange({ detail: { filter: selected ? ((d, j) => i === j) : true }}); + }) + )) .node(); } } diff --git a/src/plot.js b/src/plot.js index d83e5715e7..ad2df9f9ed 100644 --- a/src/plot.js +++ b/src/plot.js @@ -2,21 +2,25 @@ import {create} from "d3"; import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js"; import {facets} from "./facet.js"; import {Legends, exposeLegends} from "./legends.js"; -import {markify} from "./mark.js"; +import {arrayify, markify, valueof} from "./mark.js"; import {Scales, autoScaleRange, applyScales, exposeScales, isOrdinalScale} from "./scales.js"; import {applyInlineStyles, filterStyles, maybeClassName, offset} from "./style.js"; export function plot(options = {}) { - const {facet, style, caption} = options; + const {facet, selection = {}, style, caption} = options; // className for inline styles const className = maybeClassName(options.className); + // An array of selection listeners + const {data: selectionData, filter: selectionFilter = true} = selection || {}; // TODO: error if selection is given without data + const selectionListeners = new Set(); + // When faceting, wrap all marks in a faceting mark. if (facet !== undefined) { const {marks} = options; const {data} = facet; - options = {...options, marks: facets(data, facet, marks)}; + options = {...options, marks: facets(data, facet, marks, selectionData)}; } // Flatten any nested marks. @@ -33,7 +37,7 @@ export function plot(options = {}) { // Also apply any scale transforms. for (const mark of marks) { if (markChannels.has(mark)) throw new Error("duplicate mark"); - const {index, channels} = mark.initialize(); + const {index, channels} = mark.initialize(undefined, undefined, selectionData); for (const [, channel] of channels) { const {scale} = channel; if (scale !== undefined) { @@ -46,8 +50,12 @@ export function plot(options = {}) { } markChannels.set(mark, channels); markIndex.set(mark, index); + if (mark.selectable) { + mark.onchange = change; + if (mark.select) selectionListeners.add(mark); + } } - + const scaleDescriptors = Scales(scaleChannels, options); const scales = ScaleFunctions(scaleDescriptors); const axes = Axes(scaleDescriptors, options); @@ -94,7 +102,10 @@ export function plot(options = {}) { 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); + if (node != null) { + if (mark.nodes) mark.nodes.push(node); + svg.appendChild(node); + } } // Wrap the plot in a figure with a caption, if desired. @@ -113,7 +124,29 @@ export function plot(options = {}) { figure.scale = exposeScales(scaleDescriptors); figure.legend = exposeLegends(scaleDescriptors, options); + + const selectionValue = selectionData && new Uint8Array(arrayify(selectionData).length); + if (selectionData) { + figure.addEventListener("change", change); + figure.dispatchEvent(new CustomEvent("change", {detail: { + filter: selectionFilter + }})); + } + return figure; + + function change({detail: {filter, origin, transition} = {}} = {}) { + if (filter !== undefined) { + const values = valueof(selectionData, ["boolean", "number"].includes(typeof filter) ? (() => +filter) : filter); + if (values.length !== selectionValue.length) throw new Error("selection length mismatch"); + for (let i = 0; i < values.length; i++) selectionValue[i] = values[i]; + } + for (const mark of selectionListeners) { + mark.select(selectionValue, {origin, transition}); + } + figure.value = selectionData.filter((d, i) => selectionValue[i]); + figure.dispatchEvent(new CustomEvent("input", {bubbles: true})); + } } function Dimensions(