-
Notifications
You must be signed in to change notification settings - Fork 185
layouts: dodge, hexbin #775
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
Changes from all commits
be34f4a
0100971
d6f750f
28eab01
1e5b767
38e39c9
352d086
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
import {max} from "d3"; | ||
import IntervalTree from "interval-tree-1d"; | ||
import {layout} from "./index.js"; | ||
import {finite, positive} from "../defined.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) { | ||
return layout(options, function(index, scales, values, dimensions) { | ||
let {[x]: X, [y]: Y, r: R} = values; | ||
const r = R ? undefined : this.r !== undefined ? this.r : options.r !== undefined ? +options.r : 3; | ||
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(index.flat(), i => R[i]) : r) + padding); else ky = 1; | ||
if (!R) R = values.r = new Float64Array(X.length).fill(r); | ||
if (!Y) Y = values[y] = new Float64Array(X.length); | ||
for (let I of index) { | ||
const tree = IntervalTree(); | ||
I = I.filter(i => finite(X[i]) && positive(R[i])); | ||
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]); | ||
} | ||
for (const i of I) Y[i] = Y[i] * ky + ty; | ||
} | ||
return {index, values}; | ||
}); | ||
} | ||
|
||
function compareSymmetric(a, b) { | ||
return Math.abs(a) - Math.abs(b); | ||
} | ||
|
||
function compareAscending(a, b) { | ||
return (a < 0) - (b < 0) || (a - b); | ||
} |
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,112 @@ | ||||||||
import {groups} from "d3"; | ||||||||
import {layout} from "./index.js"; | ||||||||
import {basic} from "../transforms/basic.js"; | ||||||||
import {maybeOutputs} from "../transforms/group.js"; | ||||||||
|
||||||||
const defaults = { | ||||||||
ariaLabel: "hex", | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The defaults here mean something different than they do for marks, where they are passed as a separate argument to the Mark constructor: these defaults mean default options. If you specify {ariaLabel: "hex"}, it means that you want an ariaLabel channel derived from the “hex” field ( |
||||||||
symbol: "hexagon" | ||||||||
}; | ||||||||
|
||||||||
// width factor (allows the hexbin transform to work with circular dots!) | ||||||||
const w0 = Math.sin(Math.PI / 3); | ||||||||
|
||||||||
function hbin(I, X, Y, r) { | ||||||||
const dx = r * 2 * w0; | ||||||||
const dy = r * 1.5; | ||||||||
const keys = new Map(); | ||||||||
return groups(I, i => { | ||||||||
let px = X[i] / dx; | ||||||||
let py = Y[i] / dy; | ||||||||
if (isNaN(px) || isNaN(py)) return; | ||||||||
let pj = Math.round(py), | ||||||||
pi = Math.round(px = px - (pj & 1) / 2), | ||||||||
py1 = py - pj; | ||||||||
if (Math.abs(py1) * 3 > 1) { | ||||||||
let px1 = px - pi, | ||||||||
pi2 = pi + (px < pi ? -1 : 1) / 2, | ||||||||
pj2 = pj + (py < pj ? -1 : 1), | ||||||||
px2 = px - pi2, | ||||||||
py2 = py - pj2; | ||||||||
if (px1 * px1 + py1 * py1 > px2 * px2 + py2 * py2) pi = pi2 + (pj & 1 ? 1 : -1) / 2, pj = pj2; | ||||||||
} | ||||||||
const key = `${pi}|${pj}`; | ||||||||
keys.set(key, [pi, pj]); | ||||||||
return key; | ||||||||
}) | ||||||||
.filter(([p]) => p) | ||||||||
.map(([p, bin]) => { | ||||||||
const [pi, pj] = keys.get(p); | ||||||||
bin.x = (pi + (pj & 1) / 2) * dx; | ||||||||
bin.y = pj * dy; | ||||||||
return bin; | ||||||||
}); | ||||||||
} | ||||||||
|
||||||||
// Allow hexbin options to be specified as part of outputs; merge them into options. | ||||||||
function mergeOptions({radius = 10, ...outputs}, options) { | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the default radius of 10 is defined this way, then no default will be provided if options says something like {radius: undefined}. |
||||||||
return [outputs, {radius, ...options}]; | ||||||||
} | ||||||||
|
||||||||
function hexbinLayout(radius, outputs, options) { | ||||||||
// we defer to Plot.bin’s reducers, but some of them are not supported | ||||||||
for (const reduce of Object.values(outputs)) { | ||||||||
if (typeof reduce === "string" | ||||||||
&& !reduce.match(/^(first|last|count|distinct|sum|deviation|min|min-index|max|max-index|mean|median|variance|mode|proportion|proportion-facet)$/i)) | ||||||||
throw new Error(`invalid reduce ${reduce}`); | ||||||||
} | ||||||||
outputs = maybeOutputs(outputs, options); | ||||||||
const rescales = { | ||||||||
r: {scale: "r", options: {range: [0, radius * w0]}}, | ||||||||
fill: {scale: "color"}, | ||||||||
stroke: {scale: "color"}, | ||||||||
fillOpacity: {scale: "opacity"}, | ||||||||
strokeOpacity: {scale: "opacity"}, | ||||||||
symbol: {scale: "symbol"} | ||||||||
}; | ||||||||
const {x, y} = options; | ||||||||
if (x == null) throw new Error("missing channel: x"); | ||||||||
if (y == null) throw new Error("missing channel: y"); | ||||||||
return layout({...defaults, ...options}, function(index, scales, {x: X, y: Y}) { | ||||||||
const values = {x: [], y: [], r: []}; | ||||||||
const channels = []; | ||||||||
const newIndex = []; | ||||||||
for (const o of outputs) { | ||||||||
o.initialize(this.data); | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is referencing mark.data, rather than whatever the possible prior mark.transform returned as data during mark.initialize. Probably this means we’ll need to pass the (possibly transformed) data to the layout rather than expecting layouts to reference this.data. |
||||||||
o.scope("data", index); | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This call shouldn’t be necessary—calling output.initialize will invoke the reducer: Lines 177 to 179 in 2b59461
Also here index is a nested array (like facets). |
||||||||
} | ||||||||
let n = 0; | ||||||||
for (const I of index) { | ||||||||
const facetIndex = []; | ||||||||
newIndex.push(facetIndex); | ||||||||
const bins = hbin(I, X, Y, radius); | ||||||||
for (const o of outputs) { | ||||||||
o.scope("facet", I); | ||||||||
for (const bin of bins) o.reduce(bin); | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are typically many more bins than outputs, so I suspect it’s more efficient to do another |
||||||||
} | ||||||||
for (const bin of bins) { | ||||||||
values.x[n] = bin.x; | ||||||||
values.y[n] = bin.y; | ||||||||
facetIndex.push(n++); | ||||||||
} | ||||||||
} | ||||||||
for (const o of outputs) { | ||||||||
if (o.name in rescales) { | ||||||||
const {scale, options} = rescales[o.name]; | ||||||||
const value = o.output.transform(); | ||||||||
channels.push([o.name, {scale, value, options}]); | ||||||||
} else { | ||||||||
values[o.name] = o.output.transform(); | ||||||||
} | ||||||||
} | ||||||||
Comment on lines
+93
to
+101
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is using the “materialized” representation of a channel, similar to that returned by the Channel constructor, rather than the channel “descriptors” that are used e.g. by the Mark constructor and mark.channels. Channel descriptors are materialized inside of the mark.initialize, given data. I wonder if it would be more appropriate to use the “channel descriptor” representation here, rather than having layouts return materialized channels, so as to avoid introducing a new public signature for channel objects. This is the sort of thing that makes me wish we used TypeScript, since we’d be forced to be explicit about the shapes of these objects. We’ll get there eventually I guess. 😄 |
||||||||
if (!channels.find(([key]) => key === "r")) values.r = Array.from(values.x).fill(radius); | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather than constructing an r channel when the radius is constant, we can instead populate the r option as a constant up above. |
||||||||
|
||||||||
return {index: newIndex, values, channels}; | ||||||||
}); | ||||||||
} | ||||||||
|
||||||||
export function hexbin(outputs, options) { | ||||||||
([outputs, options] = mergeOptions(outputs, options)); | ||||||||
const {radius, ...inputs} = options; | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For both hexbin and hexgrid, I think we’ll want to use r instead of radius for consistency with dot. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually, it’s complicated. We can’t just use r because you might want to use the r channel, e.g., Plot.hexbin({r: "sum"}, {x: "culmen_depth_mm", y: "culmen_length_mm", r: "body_mass_g"}) so you need a separate option to specify the size of the hexagonal grid. At the same time, though, it would really be nice to say: Plot.hexbin({fill: "count"}, {x: "culmen_depth_mm", y: "culmen_length_mm", r: 20}) Perhaps we can find a way to make both work. |
||||||||
return basic(hexbinLayout(radius, outputs, inputs)); | ||||||||
} |
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Under the redesigned signature, mark.layout returns {index, values, channels}, so we don’t want to assign to values here. Similarly the implementation of partialLayout will need to change. |
||
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)}; | ||
}; | ||
} |
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.
This is mutating an input argument (values). Watch out for this! (I’ll fix.)