Skip to content

Commit

Permalink
mark initializers
Browse files Browse the repository at this point in the history
  • Loading branch information
mbostock committed Mar 11, 2022
1 parent 66142e2 commit 87c8e42
Show file tree
Hide file tree
Showing 13 changed files with 423 additions and 120 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@rollup/plugin-json": "4",
"@rollup/plugin-node-resolve": "13",
"canvas": "2",
"d3-hexbin": "^0.2.2",
"eslint": "8",
"htl": "0.3",
"js-beautify": "1",
Expand Down
43 changes: 36 additions & 7 deletions src/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@ import {first, labelof, maybeValue, range, valueof} from "./options.js";
import {registry} from "./scales/index.js";
import {maybeReduce} from "./transforms/group.js";

export function channelObject(channelDescriptors, data) {
const channels = {};
for (const channel of channelDescriptors) {
channels[channel.name] = Channel(data, channel);
}
return channels;
}

// TODO use Float64Array.from for position and radius scales?
export function valueObject(channels, scales) {
const values = {};
for (const channelName in channels) {
const {scale: scaleName, value} = channels[channelName];
const scale = scales[scaleName];
values[channelName] = scale === undefined ? value : Array.from(value, scale);
}
return values;
}

// TODO Type coercion?
export function Channel(data, {scale, type, value, filter, hint}) {
return {
Expand All @@ -15,19 +34,22 @@ export function Channel(data, {scale, type, value, filter, hint}) {
};
}

// Note: mutates channel.domain! This is set to a function so that it is lazily
// computed; i.e., if the scale’s domain is set explicitly, that takes priority
// over the sort option, and we don’t need to do additional work.
export function channelSort(channels, facetChannels, data, options) {
const {reverse: defaultReverse, reduce: defaultReduce = true, limit: defaultLimit} = options;
for (const x in options) {
if (!registry.has(x)) continue; // ignore unknown scale keys
let {value: y, reverse = defaultReverse, reduce = defaultReduce, limit = defaultLimit} = maybeValue(options[x]);
if (reverse === undefined) reverse = y === "width" || y === "height"; // default to descending for lengths
if (reduce == null || reduce === false) continue; // disabled reducer
const X = channels.find(([, {scale}]) => scale === x) || facetChannels && facetChannels.find(([, {scale}]) => scale === x);
const X = findScaleChannel(channels, x) || facetChannels && findScaleChannel(facetChannels, x);
if (!X) throw new Error(`missing channel for scale: ${x}`);
const XV = X[1].value;
const XV = X.value;
const [lo = 0, hi = Infinity] = limit && typeof limit[Symbol.iterator] === "function" ? limit : limit < 0 ? [limit] : [0, limit];
if (y == null) {
X[1].domain = () => {
X.domain = () => {
let domain = XV;
if (reverse) domain = domain.slice().reverse();
if (lo !== 0 || hi !== Infinity) domain = domain.slice(lo, hi);
Expand All @@ -39,7 +61,7 @@ export function channelSort(channels, facetChannels, data, options) {
: y === "width" ? difference(channels, "x1", "x2")
: values(channels, y, y === "y" ? "y2" : y === "x" ? "x2" : undefined);
const reducer = maybeReduce(reduce === true ? "max" : reduce, YV);
X[1].domain = () => {
X.domain = () => {
let domain = rollup(range(XV), I => reducer.reduce(I, YV), i => XV[i]);
domain = sort(domain, reverse ? descendingGroup : ascendingGroup);
if (lo !== 0 || hi !== Infinity) domain = domain.slice(lo, hi);
Expand All @@ -49,16 +71,23 @@ export function channelSort(channels, facetChannels, data, options) {
}
}

function findScaleChannel(channels, scale) {
for (const name in channels) {
const channel = channels[name];
if (channel.scale === scale) return channel;
}
}

function difference(channels, k1, k2) {
const X1 = values(channels, k1);
const X2 = values(channels, k2);
return Float64Array.from(X2, (x2, i) => Math.abs(x2 - X1[i]));
}

function values(channels, name, alias) {
let channel = channels.find(([n]) => n === name);
if (!channel && alias !== undefined) channel = channels.find(([n]) => n === alias);
if (channel) return channel[1].value;
let channel = channels[name];
if (!channel && alias !== undefined) channel = channels[alias];
if (channel) return channel.value;
throw new Error(`missing channel: ${name}`);
}

Expand Down
1 change: 1 addition & 0 deletions src/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const field = name => d => d[name];
export const indexOf = (d, i) => i;
export const identity = {transform: d => d};
export const zero = () => 0;
export const yes = () => true;
export const string = x => x == null ? x : `${x}`;
export const number = x => x == null ? x : +x;
export const boolean = x => x == null ? x : !!x;
Expand Down
151 changes: 97 additions & 54 deletions src/plot.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {create, cross, difference, groups, InternMap, select} from "d3";
import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js";
import {Channel, channelSort} from "./channel.js";
import {Channel, channelObject, channelSort, valueObject} from "./channel.js";
import {defined} from "./defined.js";
import {Dimensions} from "./dimensions.js";
import {Legends, exposeLegends} from "./legends.js";
import {arrayify, isOptions, keyword, range, second, where} from "./options.js";
import {Scales, ScaleFunctions, autoScaleRange, applyScales, exposeScales} from "./scales.js";
import {arrayify, isOptions, isScaleOptions, keyword, range, second, where, yes} from "./options.js";
import {Scales, ScaleFunctions, autoScaleRange, exposeScales} from "./scales.js";
import {registry as scaleRegistry} from "./scales/index.js";
import {applyInlineStyles, maybeClassName, maybeClip, styles} from "./style.js";
import {basic} from "./transforms/basic.js";
import {consumeWarnings} from "./warnings.js";
Expand All @@ -29,25 +30,35 @@ export function plot(options = {}) {
// A Map from scale name to an array of associated channels.
const channelsByScale = new Map();

// If a scale is explicitly declared in options, initialize its associated
// channels to the empty array; this will guarantee that a corresponding scale
// will be created later (even if there are no other channels). But ignore
// facet scale declarations if faceting is not enabled.
for (const key of scaleRegistry.keys()) {
if (isScaleOptions(options[key]) && key !== "fx" && key !== "fy") {
channelsByScale.set(key, []);
}
}

// Faceting!
let facets; // array of facet definitions (e.g. [["foo", [0, 1, 3, …]], …])
let facetIndex; // index over the facet data, e.g. [0, 1, 2, 3, …]
let facetChannels; // e.g. [["fx", {value}], ["fy", {value}]]
let facetChannels; // e.g. {fx: {value}, fy: {value}}
let facetsIndex; // nested array of facet indexes [[0, 1, 3, …], [2, 5, …], …]
let facetsExclude; // lazily-constructed opposite of facetsIndex
if (facet !== undefined) {
const {x, y} = facet;
if (x != null || y != null) {
const facetData = arrayify(facet.data);
facetChannels = [];
facetChannels = {};
if (x != null) {
const fx = Channel(facetData, {value: x, scale: "fx"});
facetChannels.push(["fx", fx]);
facetChannels.fx = fx;
channelsByScale.set("fx", [fx]);
}
if (y != null) {
const fy = Channel(facetData, {value: y, scale: "fy"});
facetChannels.push(["fy", fy]);
facetChannels.fy = fy;
channelsByScale.set("fy", [fy]);
}
facetIndex = range(facetData);
Expand All @@ -56,33 +67,20 @@ export function plot(options = {}) {
}
}

// Initialize the marks’ channels, indexing them by mark and scale as needed.
// Initialize the marks’ state.
for (const mark of marks) {
if (stateByMark.has(mark)) throw new Error("duplicate mark");
const markFacets = facets === undefined ? undefined
const markFacets = facetsIndex === undefined ? undefined
: mark.facet === "auto" ? mark.data === facet.data ? facetsIndex : undefined
: mark.facet === "include" ? facetsIndex
: mark.facet === "exclude" ? facetsExclude || (facetsExclude = facetsIndex.map(f => Uint32Array.from(difference(facetIndex, f))))
: undefined;
const {index, channels} = mark.initialize(markFacets, facetChannels);
for (const [, channel] of channels) {
const {scale} = channel;
if (scale !== undefined) {
const channels = channelsByScale.get(scale);
if (channels !== undefined) channels.push(channel);
else channelsByScale.set(scale, [channel]);
}
}
stateByMark.set(mark, {index, channels, faceted: markFacets !== undefined});
}

// Apply scale transforms, mutating channel.value.
for (const [scale, channels] of channelsByScale) {
const {percent, transform = percent ? x => x * 100 : undefined} = options[scale] || {};
if (transform != null) for (const c of channels) c.value = Array.from(c.value, transform);
const {facets, channels} = mark.initialize(markFacets, facetChannels);
stateByMark.set(mark, {facets, channels: applyScaleTransforms(channels, options)});
}

const scaleDescriptors = Scales(channelsByScale, options);
// Initalize the scales and axes.
const scaleDescriptors = Scales(addScaleChannels(channelsByScale, stateByMark), options);
const scales = ScaleFunctions(scaleDescriptors);
const axes = Axes(scaleDescriptors, options);
const dimensions = Dimensions(scaleDescriptors, axes, options);
Expand All @@ -91,9 +89,30 @@ export function plot(options = {}) {
autoScaleLabels(channelsByScale, scaleDescriptors, axes, dimensions, options);
autoAxisTicks(scaleDescriptors, axes);

// Reinitialize; for deriving channels dependent on other channels.
const newByScale = new Set();
for (const [mark, state] of stateByMark) {
if (mark.reinitialize != null) {
const {facets, channels} = mark.reinitialize(state.facets, state.channels, scales);
if (facets !== undefined) state.facets = facets;
if (channels !== undefined) {
Object.assign(state.channels, applyScaleTransforms(channels, options));
for (const name in channels) newByScale.add(channels[name].scale);
}
}
}

// Reconstruct scales if new scaled channels were created during reinitialization.
if (newByScale.size) {
const newScaleDescriptors = Scales(addScaleChannels(new Map(), stateByMark, key => newByScale.has(key)), options);
const newScales = ScaleFunctions(newScaleDescriptors);
Object.assign(scaleDescriptors, newScaleDescriptors);
Object.assign(scales, newScales);
}

// Compute value objects, applying scales as needed.
for (const state of stateByMark.values()) {
state.values = applyScales(state.channels, scales);
state.values = valueObject(state.channels, scales);
}

const {width, height} = dimensions;
Expand Down Expand Up @@ -175,16 +194,16 @@ export function plot(options = {}) {
.attr("transform", facetTranslate(fx, fy))
.each(function(key) {
const j = indexByFacet.get(key);
for (const [mark, {channels, values, index, faceted}] of stateByMark) {
const renderIndex = mark.filter(faceted ? index[j] : index, channels, values);
const node = mark.render(renderIndex, scales, values, subdimensions);
for (const [mark, {channels, values, facets}] of stateByMark) {
const facet = facets ? mark.filter(facets[j] ?? facets[0], channels, values) : null;
const node = mark.render(facet, scales, values, subdimensions);
if (node != null) this.appendChild(node);
}
});
} else {
for (const [mark, {channels, values, index}] of stateByMark) {
const renderIndex = mark.filter(index, channels, values);
const node = mark.render(renderIndex, scales, values, dimensions);
for (const [mark, {channels, values, facets}] of stateByMark) {
const facet = facets ? mark.filter(facets[0], channels, values) : null;
const node = mark.render(facet, scales, values, dimensions);
if (node != null) svg.appendChild(node);
}
}
Expand Down Expand Up @@ -227,6 +246,7 @@ export class Mark {
const {facet = "auto", sort, dx, dy, clip} = options;
const names = new Set();
this.data = data;
this.reinitialize = options.initialize;
this.sort = isOptions(sort) ? sort : null;
this.facet = facet == null || facet === false ? null : keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]);
const {transform} = basic(options);
Expand All @@ -249,25 +269,18 @@ export class Mark {
this.dy = +dy || 0;
this.clip = maybeClip(clip);
}
initialize(facetIndex, facetChannels) {
initialize(facets, facetChannels) {
let data = arrayify(this.data);
let index = facetIndex === undefined && data != null ? range(data) : facetIndex;
if (data !== undefined && this.transform !== undefined) {
if (facetIndex === undefined) index = index.length ? [index] : [];
({facets: index, data} = this.transform(data, index));
data = arrayify(data);
if (facetIndex === undefined && index.length) ([index] = index);
}
const channels = this.channels.map(channel => {
const {name} = channel;
return [name == null ? undefined : `${name}`, Channel(data, channel)];
});
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);
if (this.sort != null) channelSort(channels, facetChannels, data, this.sort);
return {index, channels};
return {facets, channels};
}
filter(index, channels, values) {
for (const [name, {filter = defined}] of channels) {
if (name !== undefined && filter !== null) {
for (const name in channels) {
const {filter = defined} = channels[name];
if (filter !== null) {
const value = values[name];
index = index.filter(i => filter(value[i]));
}
Expand Down Expand Up @@ -298,6 +311,34 @@ class Render extends Mark {
render() {}
}

// Note: mutates channel.value to apply the scale transform, if any.
function applyScaleTransforms(channels, options) {
for (const name in channels) {
const channel = channels[name];
const {scale} = channel;
if (scale != null) {
const {percent, transform = percent ? x => x * 100 : undefined} = options[scale] || {};
if (transform != null) channel.value = Array.from(channel.value, transform);
}
}
return channels;
}

function addScaleChannels(channelsByScale, stateByMark, filter = yes) {
for (const {channels} of stateByMark.values()) {
for (const name in channels) {
const channel = channels[name];
const {scale} = channel;
if (scale != null && filter(scale)) {
const channels = channelsByScale.get(scale);
if (channels !== undefined) channels.push(channel);
else channelsByScale.set(scale, [channel]);
}
}
}
return channelsByScale;
}

// Derives a copy of the specified axis with the label disabled.
function nolabel(axis) {
return axis === undefined || axis.label === undefined
Expand All @@ -316,15 +357,17 @@ function facetKeys({fx, fy}) {
// Returns an array of [[key1, index1], [key2, index2], …] representing the data
// indexes associated with each facet. For two-dimensional faceting, each key
// is a two-element array; see also facetMap.
function facetGroups(index, channels) {
return (channels.length > 1 ? facetGroup2 : facetGroup1)(index, ...channels);
function facetGroups(index, {fx, fy}) {
return fx && fy ? facetGroup2(index, fx, fy)
: fx ? facetGroup1(index, fx)
: facetGroup1(index, fy);
}

function facetGroup1(index, [, {value: F}]) {
function facetGroup1(index, {value: F}) {
return groups(index, i => F[i]);
}

function facetGroup2(index, [, {value: FX}], [, {value: FY}]) {
function facetGroup2(index, {value: FX}, {value: FY}) {
return groups(index, i => FX[i], i => FY[i])
.flatMap(([x, xgroup]) => xgroup
.map(([y, ygroup]) => [[x, y], ygroup]));
Expand All @@ -337,8 +380,8 @@ function facetTranslate(fx, fy) {
: ky => `translate(0,${fy(ky)})`;
}

function facetMap(channels) {
return new (channels.length > 1 ? FacetMap2 : FacetMap);
function facetMap({fx, fy}) {
return new (fx && fy ? FacetMap2 : FacetMap);
}

class FacetMap {
Expand Down
Loading

0 comments on commit 87c8e42

Please sign in to comment.