Skip to content

dodge (beeswarms) #648

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 6 commits into from
Closed
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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
},
"sideEffects": false,
"devDependencies": {
"@rollup/plugin-commonjs": "^21.0.1",
"@rollup/plugin-json": "4",
"@rollup/plugin-node-resolve": "13",
"canvas": "2",
Expand All @@ -50,6 +51,7 @@
},
"dependencies": {
"d3": "^7.3.0",
"interval-tree-1d": "1",
"isoformat": "0.2"
},
"engines": {
Expand Down
2 changes: 2 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from "fs";
import {terser} from "rollup-plugin-terser";
import commonjs from "@rollup/plugin-commonjs";
import json from "@rollup/plugin-json";
import node from "@rollup/plugin-node-resolve";
import * as meta from "./package.json";
Expand All @@ -25,6 +26,7 @@ const config = {
banner: `// ${meta.name} v${meta.version} Copyright ${copyrights.join(", ")}`
},
plugins: [
commonjs(),
json(),
node()
]
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ 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 {valueof} from "./options.js";
export {dodgeX, dodgeY} from "./layouts/dodge.js";
export {filter, reverse, sort, shuffle} from "./transforms/basic.js";
export {bin, binX, binY} from "./transforms/bin.js";
export {group, groupX, groupY, groupZ} from "./transforms/group.js";
Expand Down
88 changes: 88 additions & 0 deletions src/layouts/dodge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {max} from "d3";
import IntervalTree from "interval-tree-1d";
import {maybeNumberChannel} from "../options.js";
import {layout} from "./index.js";

const anchorXLeft = ({marginLeft}) => [1, marginLeft];
const anchorXRight = ({width, marginRight}) => [-1, width - marginRight];
const anchorXMiddle = ({width, marginLeft, marginRight}) => [0, (marginLeft + width - marginRight) / 2];
const anchorYTop = ({marginTop}) => [1, marginTop];
const anchorYBottom = ({height, marginBottom}) => [-1, height - marginBottom];
const anchorYMiddle = ({height, marginTop, marginBottom}) => [0, (marginTop + height - marginBottom) / 2];

function maybeAnchor(anchor) {
return typeof anchor === "string" ? {anchor} : anchor;
}

export function dodgeX(dodgeOptions = {}, options = {}) {
if (arguments.length === 1) [options, dodgeOptions] = [dodgeOptions, options];
let {anchor = "left", padding = 1} = maybeAnchor(dodgeOptions);
switch (`${anchor}`.toLowerCase()) {
case "left": anchor = anchorXLeft; break;
case "right": anchor = anchorXRight; break;
case "middle": anchor = anchorXMiddle; break;
default: throw new Error(`unknown dodge anchor: ${anchor}`);
}
return dodge("x", "y", anchor, +padding, options);
}

export function dodgeY(dodgeOptions = {}, options = {}) {
if (arguments.length === 1) [options, dodgeOptions] = [dodgeOptions, options];
let {anchor = "bottom", padding = 1} = maybeAnchor(dodgeOptions);
switch (`${anchor}`.toLowerCase()) {
case "top": anchor = anchorYTop; break;
case "bottom": anchor = anchorYBottom; break;
case "middle": anchor = anchorYMiddle; break;
default: throw new Error(`unknown dodge anchor: ${anchor}`);
}
return dodge("y", "x", anchor, +padding, options);
}

function dodge(y, x, anchor, padding, options) {
const [, r] = maybeNumberChannel(options.r, 3);
return layout(options, (I, scales, {[x]: X, r: R}, dimensions) => {
if (X == null) throw new Error(`missing channel: ${x}`);
let [ky, ty] = anchor(dimensions);
const compare = ky ? compareAscending : compareSymmetric;
if (ky) ty += ky * ((R ? max(I, i => R[i]) : r) + padding); else ky = 1;
if (!R) R = new Float64Array(X.length).fill(r);
const Y = new Float64Array(X.length);
const tree = IntervalTree();
for (const i of I) {
const intervals = [];
const l = X[i] - R[i];
const r = X[i] + R[i];

// For any previously placed circles that may overlap this circle, compute
// the y-positions that place this circle tangent to these other circles.
// https://observablehq.com/@mbostock/circle-offset-along-line
tree.queryInterval(l - padding, r + padding, ([,, j]) => {
const yj = Y[j];
const dx = X[i] - X[j];
const dr = R[i] + padding + R[j];
const dy = Math.sqrt(dr * dr - dx * dx);
intervals.push([yj - dy, yj + dy]);
});

// Find the best y-value where this circle can fit.
for (let y of intervals.flat().sort(compare)) {
if (intervals.every(([lo, hi]) => y <= lo || y >= hi)) {
Y[i] = y;
break;
}
}

// Insert the placed circle into the interval tree.
tree.insert([l, r, i]);
}
return {[y]: Y.map(y => y * ky + ty)};
});
}

function compareSymmetric(a, b) {
return Math.abs(a) - Math.abs(b);
}

function compareAscending(a, b) {
return (a < 0) - (b < 0) || (a - b);
}
19 changes: 19 additions & 0 deletions src/layouts/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export function layout({layout: layout1, ...options}, layout2) {
if (layout2 == null) throw new Error("invalid layout");
layout2 = partialLayout(layout2);
if (layout1 != null) layout2 = composeLayout(layout1, layout2);
return {...options, layout: layout2};
}

function composeLayout(l1, l2) {
return function(index, scales, values, dimensions) {
values = l1.call(this, index, scales, values, dimensions);
return l2.call(this, index, scales, values, dimensions);
};
}

function partialLayout(l) {
return function(index, scales, values, dimensions) {
return {...values, ...l.call(this, index, scales, values, dimensions)};
};
}
12 changes: 8 additions & 4 deletions src/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,9 @@ export function plot(options = {}) {

for (const mark of marks) {
const channels = markChannels.get(mark) ?? [];
const values = applyScales(channels, scales);
let values = applyScales(channels, scales);
const index = filter(markIndex.get(mark), channels, values);
if (mark.layout != null) values = mark.layout(index, scales, values, dimensions);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (mark.layout != null) values = mark.layout(index, scales, values, dimensions);
if (mark.layout != null) values = mark.layout(index.slice(), scales, values, dimensions);

should we make a defensive copy?

Alternatively, if we assume that it is legal for the layout to modify the index, write it as:

Suggested change
if (mark.layout != null) values = mark.layout(index, scales, values, dimensions);
if (mark.layout != null) ({index, values} = mark.layout(index, scales, values, dimensions));

(and same in the facet call).

I think my preference would go to explicitly allowing a layout to change the index, for example a hexbin layout could group points into hexagons, and a unit chart/isotype layout could create several symbols out of (say) a bar.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we make a defensive copy?

I feel like we’ve discussed this a bunch, so I want to reiterate what our conventions are.

  1. We favor immutability. We typically use “copy on write” (e.g., returning a new object or array) rather than mutating the value in-place. This is especially true regarding arguments to a function, where the caller typically would not expect the passed values to be mutated as a side effect. In cases where we violate this principle, we should call it out (e.g., axes.js).

  2. We aren’t defensive. If we invoke a user function, e.g. a custom reducer, we do not create a defensive copy to protect against mutation. Instead we assume that the user function will not modify the input. If user code violates this assumption, the behavior is undefined. “All bets are off.” We do this because creating a defensive copy adds significant overhead and would effectively penalize “well-behaved” users because of hypothetical bad behavior.

  3. We create copies when transferring ownership. (This could be considered an exception or nuance to the previous rule.) For functions that return values, as opposed to function arguments, we consider the returned values to be owned by the user. The user should be allowed to mutate the values if they desire. This means we need to return a copy. For example this is used in plot.scale when we return a domain.

Mutation is also acceptable if it’s not “visible externally” as a performance optimization, though this should still be used with caution.

const node = mark.render(index, scales, values, dimensions, axes);
if (node != null) svg.appendChild(node);
}
Expand Down Expand Up @@ -132,9 +133,10 @@ function filter(index, channels, values) {

export class Mark {
constructor(data, channels = [], options = {}, defaults) {
const {facet = "auto", sort, dx, dy} = options;
const {layout, facet = "auto", sort, dx, dy} = options;
const names = new Set();
this.data = data;
this.layout = layout;
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 Down Expand Up @@ -311,9 +313,11 @@ class Facet extends Mark {
.each(function(key) {
const marksFacetIndex = marksIndexByFacet.get(key);
for (let i = 0; i < marks.length; ++i) {
const values = marksValues[i];
const mark = marks[i];
let values = marksValues[i];
const index = filter(marksFacetIndex[i], marksChannels[i], values);
const node = marks[i].render(index, scales, values, subdimensions);
if (mark.layout != null) values = mark.layout(index, scales, values, subdimensions);
const node = mark.render(index, scales, values, subdimensions);
if (node != null) this.appendChild(node);
}
}))
Expand Down
Loading