-
Notifications
You must be signed in to change notification settings - Fork 185
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
Closed
dodge (beeswarms) #648
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)}; | ||
}; | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
Alternatively, if we assume that it is legal for the layout to modify the index, write it as:
(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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel like we’ve discussed this a bunch, so I want to reiterate what our conventions are.
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).
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.
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.