Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ export function Channel(data, {scale, type, value, filter, hint}) {
};
}

export function channelObject(channelDescriptors, data) {
export function Channels(channelDescriptors, data) {
const channels = {};
for (const channel of channelDescriptors) {
channels[channel.name] = Channel(data, channel);
for (const name in channelDescriptors) {
channels[name] = Channel(data, channelDescriptors[name]);
}
return channels;
}
Expand Down
47 changes: 42 additions & 5 deletions src/marks/dot.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {path, symbolCircle} from "d3";
import {path, select, symbolCircle} from "d3";
import {create} from "../context.js";
import {positive} from "../defined.js";
import {identity, maybeFrameAnchor, maybeNumberChannel, maybeTuple} from "../options.js";
import {Mark} from "../plot.js";
import {applyChannelStyles, applyDirectStyles, applyFrameAnchor, applyIndirectStyles, applyTransform} from "../style.js";
import {applyAttr, applyChannelStyles, applyDirectStyles, applyFrameAnchor, applyIndirectStyles, applyTransform} from "../style.js";
import {maybeSymbolChannel} from "../symbols.js";
import {sort} from "../transforms/basic.js";
import {maybeIntervalMidX, maybeIntervalMidY} from "../transforms/interval.js";
Expand Down Expand Up @@ -42,10 +42,9 @@ export class Dot extends Mark {
// appropriate default symbols based on whether the dots are filled or
// stroked, and for the symbol legend to match the appearance of the dots.
const {channels} = this;
const symbolChannel = channels.find(({scale}) => scale === "symbol");
const {symbol: symbolChannel} = channels;
if (symbolChannel) {
const fillChannel = channels.find(({name}) => name === "fill");
const strokeChannel = channels.find(({name}) => name === "stroke");
const {fill: fillChannel, stroke: strokeChannel} = channels;
symbolChannel.hint = {
fill: fillChannel ? (fillChannel.value === symbolChannel.value ? "color" : "currentColor") : this.fill,
stroke: strokeChannel ? (strokeChannel.value === symbolChannel.value ? "color" : "currentColor") : this.stroke
Expand Down Expand Up @@ -89,6 +88,44 @@ export class Dot extends Mark {
.call(applyChannelStyles, this, channels))
.node();
}
// TODO Support symbols.
// TODO Support other things being changed besides x and y channels.
// TODO Memoize the selection for faster updates?
// TODO Access to old channels as well as new channels.
renderUpdate(g, index, scales, channels) {
const {x: X, y: Y} = channels;
select(g).selectChildren()
.call(applyAttr, "cx", X && (i => X[i]))
.call(applyAttr, "cy", Y && (i => Y[i]));
}
renderAnimation(g, index, scales, channels, timing) {
const {x: X, y: Y} = channels;
const finishes = [];
const mark = this;
select(g).selectChildren().each(function(i) {
const animation = this.animate(
[{cx: X ? X[i] : undefined, cy: Y ? Y[i] : undefined}],
// TODO Should this be mark.data here (which is not arrayify’d), or
// should it be the transformed data that is in stateByMark, which would
// need to be passed-in to this function, perhaps as a “data” channel?
typeof timing === "function" ? timing(mark.data[i], i) : timing
);
// Per the spec: “Authors are discouraged from using fill modes to produce
// animations whose effect is applied indefinitely… [Fill modes] produce
// situations where animation state would be accumulated indefinitely
// necessitating the automatic removal of animations defined in §5.5
// Replacing animations. Furthermore, indefinitely filling animations can
// cause changes to specified style to be ineffective long after all
// animations have completed since the animation style takes precedence in
// the CSS cascade [css-cascade-3].”
// https://drafts.csswg.org/web-animations-1/#example-515a2006
finishes.push(animation.finished.then(() => {
if (X) this.setAttribute("cx", X[i]);
if (Y) this.setAttribute("cy", Y[i]);
}));
});
return Promise.all(finishes);
}
}

export function dot(data, {x, y, ...options} = {}) {
Expand Down
46 changes: 40 additions & 6 deletions src/plot.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {cross, difference, groups, InternMap, select} from "d3";
import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js";
import {Channel, channelObject, channelDomain, valueObject} from "./channel.js";
import {Channel, Channels, channelDomain, valueObject} from "./channel.js";
import {Context, create} from "./context.js";
import {defined} from "./defined.js";
import {Dimensions} from "./dimensions.js";
Expand Down Expand Up @@ -198,6 +198,9 @@ export function plot(options = {}) {
context
));
}
for (const state of stateByMark.values()) {
state.nodes = new Array(facets.length);
}
selection.selectAll()
.data(facetKeys(scales).filter(indexByFacet.has, indexByFacet))
.enter()
Expand All @@ -206,16 +209,20 @@ export function plot(options = {}) {
.attr("transform", facetTranslate(fx, fy))
.each(function(key) {
const j = indexByFacet.get(key);
for (const [mark, {channels, values, facets}] of stateByMark) {
for (const [mark, state] of stateByMark) {
const {channels, values, facets} = state;
const facet = facets ? mark.filter(facets[j] ?? facets[0], channels, values) : null;
const node = mark.render(facet, scales, values, subdimensions, context);
state.nodes[j] = node;
if (node != null) this.appendChild(node);
}
});
} else {
for (const [mark, {channels, values, facets}] of stateByMark) {
for (const [mark, state] of stateByMark) {
const {channels, values, facets} = state;
const facet = facets ? mark.filter(facets[0], channels, values) : null;
const node = mark.render(facet, scales, values, dimensions, context);
state.nodes = [node];
if (node != null) svg.appendChild(node);
}
}
Expand All @@ -239,6 +246,33 @@ export function plot(options = {}) {
figure.scale = exposeScales(scaleDescriptors);
figure.legend = exposeLegends(scaleDescriptors, context, options);

// TODO Combine multiple updates.
// TODO Update scale domains and axes.
// TODO Apply valueof for channel values expressed as accessors.
// TODO Reuse an existing array when instantiating channel values.
// TODO Re-apply transforms and initializers?
// TODO Update mark state.
// TODO If mark.update returns a node, replace the old one?
figure.replot = ({mark, data, animation, ...options}) => {
const {facets, nodes} = stateByMark.get(mark);
const channels = {};
for (const name in options) {
const channel = mark.channels[name];
if (!channel) throw new Error(`missing channel: ${name}`);
channels[name] = {value: options[name], scale: channel.scale};
}
const values = valueObject(channels, scales);
const promises = [];
for (let i = 0, n = facets.length; i < n; ++i) {
if (animation === undefined) {
mark.renderUpdate(nodes[i], facets[i], scales, values);
} else {
promises.push(mark.renderAnimation(nodes[i], facets[i], scales, values, animation));
}
}
return Promise.all(promises);
};

const w = consumeWarnings();
if (w > 0) {
select(svg).append("text")
Expand Down Expand Up @@ -266,7 +300,7 @@ export class Mark {
this.facet = facet == null || facet === false ? null : keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]);
if (extraChannels !== undefined) channels = [...channels, ...extraChannels.filter(e => !channels.some(c => c.name === e.name))];
if (defaults !== undefined) channels = [...channels, ...styles(this, options, defaults)];
this.channels = channels.filter(channel => {
this.channels = Object.fromEntries(channels.filter(channel => {
const {name, value, optional} = channel;
if (value == null) {
if (optional) return false;
Expand All @@ -278,7 +312,7 @@ export class Mark {
if (names.has(key)) throw new Error(`duplicate channel: ${key}`);
names.add(key);
return true;
});
}).map(channel => [channel.name, channel]));
this.dx = +dx || 0;
this.dy = +dy || 0;
this.clip = maybeClip(clip);
Expand All @@ -287,7 +321,7 @@ export class Mark {
let data = arrayify(this.data);
if (facets === undefined && data != null) facets = [range(data)];
if (this.transform != null) ({facets, data} = this.transform(data, facets)), data = arrayify(data);
const channels = channelObject(this.channels, data);
const channels = Channels(this.channels, data);
if (this.sort != null) channelDomain(channels, facetChannels, data, this.sort);
return {data, facets, channels};
}
Expand Down
4 changes: 4 additions & 0 deletions test/jsdom.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ function withJsdom(run) {
global.Node = jsdom.window.Node;
global.NodeList = jsdom.window.NodeList;
global.HTMLCollection = jsdom.window.HTMLCollection;
global.requestAnimationFrame = setImmediate;
global.cancelAnimationFrame = clearImmediate;
global.fetch = async (href) => new Response(path.resolve("./test", href));
try {
return await run();
Expand All @@ -35,6 +37,8 @@ function withJsdom(run) {
delete global.Node;
delete global.NodeList;
delete global.HTMLCollection;
delete global.requestAnimationFrame;
delete global.cancelAnimationFrame;
delete global.fetch;
}
};
Expand Down
34 changes: 17 additions & 17 deletions test/marks/area-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ it("area(data, options) has the expected defaults", () => {
const area = Plot.area(undefined, {x1: "0", y1: "1"});
assert.strictEqual(area.data, undefined);
// assert.strictEqual(area.transform, undefined);
assert.deepStrictEqual(area.channels.map(c => c.name), ["x1", "y1"]);
assert.deepStrictEqual(area.channels.map(c => c.value), ["0", "1"]);
assert.deepStrictEqual(area.channels.map(c => c.scale), ["x", "y"]);
assert.deepStrictEqual(Object.keys(area.channels), ["x1", "y1"]);
assert.deepStrictEqual(Object.values(area.channels).map(c => c.value), ["0", "1"]);
assert.deepStrictEqual(Object.values(area.channels).map(c => c.scale), ["x", "y"]);
assert.strictEqual(area.curve, curveLinear);
assert.strictEqual(area.fill, undefined);
assert.strictEqual(area.fillOpacity, undefined);
Expand All @@ -26,28 +26,28 @@ it("area(data, options) has the expected defaults", () => {

it("area(data, {x1, y1, y2}) specifies an optional y2 channel", () => {
const area = Plot.area(undefined, {x1: "0", y1: "1", y2: "2"});
const y2 = area.channels.find(c => c.name === "y2");
const {y2} = area.channels;
assert.strictEqual(y2.value, "2");
assert.strictEqual(y2.scale, "y");
});

it("area(data, {x1, x2, y1}) specifies an optional x2 channel", () => {
const area = Plot.area(undefined, {x1: "0", x2: "1", y1: "2"});
const x2 = area.channels.find(c => c.name === "x2");
const {x2} = area.channels;
assert.strictEqual(x2.value, "1");
assert.strictEqual(x2.scale, "x");
});

it("area(data, {z}) specifies an optional z channel", () => {
const area = Plot.area(undefined, {x1: "0", y1: "1", z: "2"});
const z = area.channels.find(c => c.name === "z");
const {z} = area.channels;
assert.strictEqual(z.value, "2");
assert.strictEqual(z.scale, undefined);
});

it("area(data, {title}) specifies an optional title channel", () => {
const area = Plot.area(undefined, {x1: "0", y1: "1", title: "2"});
const title = area.channels.find(c => c.name === "title");
const {title} = area.channels;
assert.strictEqual(title.value, "2");
assert.strictEqual(title.scale, undefined);
});
Expand All @@ -65,14 +65,14 @@ it("area(data, {fill}) allows fill to be null", () => {
it("area(data, {fill}) allows fill to be a variable color", () => {
const area = Plot.area(undefined, {x1: "0", y1: "1", fill: "x"});
assert.strictEqual(area.fill, undefined);
const fill = area.channels.find(c => c.name === "fill");
const {fill} = area.channels;
assert.strictEqual(fill.value, "x");
assert.strictEqual(fill.scale, "color");
});

it("area(data, {fill}) implies a default z channel if fill is variable", () => {
const area = Plot.area(undefined, {x1: "0", y1: "1", fill: "2", stroke: "3"}); // fill takes priority
const z = area.channels.find(c => c.name === "z");
const {z} = area.channels;
assert.strictEqual(z.value, "2");
assert.strictEqual(z.scale, undefined);
});
Expand All @@ -90,14 +90,14 @@ it("area(data, {stroke}) allows stroke to be null", () => {
it("area(data, {stroke}) allows stroke to be a variable color", () => {
const area = Plot.area(undefined, {x1: "0", y1: "1", stroke: "x"});
assert.strictEqual(area.stroke, undefined);
const stroke = area.channels.find(c => c.name === "stroke");
const {stroke} = area.channels;
assert.strictEqual(stroke.value, "x");
assert.strictEqual(stroke.scale, "color");
});

it("area(data, {stroke}) implies a default z channel if stroke is variable", () => {
const area = Plot.area(undefined, {x1: "0", y1: "1", stroke: "2"});
const z = area.channels.find(c => c.name === "z");
const {z} = area.channels;
assert.strictEqual(z.value, "2");
assert.strictEqual(z.scale, undefined);
});
Expand All @@ -109,26 +109,26 @@ it("area(data, {curve}) specifies a named curve or function", () => {

it("areaX(data, {x, y}) defaults x1 to zero, x2 to x, and y1 to y", () => {
const area = Plot.areaX(undefined, {x: "0", y: "1"});
const x1 = area.channels.find(c => c.name === "x1");
const {x1} = area.channels;
// assert.strictEqual(x1.value, 0);
assert.strictEqual(x1.scale, "x");
const x2 = area.channels.find(c => c.name === "x2");
const {x2} = area.channels;
assert.strictEqual(x2.value.label, "0");
assert.strictEqual(x2.scale, "x");
const y1 = area.channels.find(c => c.name === "y1");
const {y1} = area.channels;
assert.strictEqual(y1.value, "1");
assert.strictEqual(y1.scale, "y");
});

it("areaY(data, {x, y}) defaults x1 to x, y1 to zero, and y2 to y", () => {
const area = Plot.areaY(undefined, {x: "0", y: "1"});
const x1 = area.channels.find(c => c.name === "x1");
const {x1} = area.channels;
assert.strictEqual(x1.value, "0");
assert.strictEqual(x1.scale, "x");
const y1 = area.channels.find(c => c.name === "y1");
const {y1} = area.channels;
// assert.strictEqual(y1.value, 0);
assert.strictEqual(y1.scale, "y");
const y2 = area.channels.find(c => c.name === "y2");
const {y2} = area.channels;
assert.strictEqual(y2.value.label, "1");
assert.strictEqual(y2.scale, "y");
});
Loading