diff --git a/src/layouts/dodge.js b/src/layouts/dodge.js index 5e7371519b..57776ab6cf 100644 --- a/src/layouts/dodge.js +++ b/src/layouts/dodge.js @@ -40,40 +40,43 @@ export function dodgeY(dodgeOptions = {}, options = {}) { function dodge(y, x, anchor, padding, options) { const [, r] = maybeNumberChannel(options.r, 3); - return layout(options, (I, scales, {[x]: X, r: R}, dimensions) => { + return layout(options, (index, 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 (ky) ty += ky * ((R ? max(index, I => 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; + for (const I of index) { + 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]); } - - // Insert the placed circle into the interval tree. - tree.insert([l, r, i]); } return {[y]: Y.map(y => y * ky + ty)}; }); diff --git a/src/plot.js b/src/plot.js index 857d6fff41..cd19db8d61 100644 --- a/src/plot.js +++ b/src/plot.js @@ -97,7 +97,7 @@ export function plot(options = {}) { const channels = markChannels.get(mark) ?? []; let values = applyScales(channels, scales); const index = filter(markIndex.get(mark), channels, values); - if (mark.layout != null) values = mark.layout(index, scales, values, dimensions); + if (mark.layout != null) values = mark.layout([index], scales, values, dimensions); const node = mark.render(index, scales, values, dimensions, axes); if (node != null) svg.appendChild(node); } @@ -272,6 +272,16 @@ class Facet extends Mark { const fxMargins = fx && {marginRight: 0, marginLeft: 0, width: fx.bandwidth()}; const subdimensions = {...dimensions, ...fxMargins, ...fyMargins}; const marksValues = marksChannels.map(channels => applyScales(channels, scales)); + const keys = facetKeys(scales).filter(marksIndexByFacet.has, marksIndexByFacet); + const nodes = []; + for (let i = 0; i < marks.length; ++i) { + const mark = marks[i]; + let values = marksValues[i]; + const index = keys.map(key => filter(marksIndexByFacet.get(key)[i], marksChannels[i], values)); + if (mark.layout != null) values = mark.layout(index, scales, values, subdimensions); + nodes[i] = keys.map((key, j) => mark.render(index[j], scales, values, subdimensions)); + } + return create("svg:g") .call(g => { if (fy && axes.y) { @@ -307,17 +317,12 @@ class Facet extends Mark { } }) .call(g => g.selectAll() - .data(facetKeys(scales).filter(marksIndexByFacet.has, marksIndexByFacet)) + .data(keys) .join("g") .attr("transform", facetTranslate(fx, fy)) - .each(function(key) { - const marksFacetIndex = marksIndexByFacet.get(key); - for (let i = 0; i < marks.length; ++i) { - const mark = marks[i]; - let values = marksValues[i]; - const index = filter(marksFacetIndex[i], marksChannels[i], values); - if (mark.layout != null) values = mark.layout(index, scales, values, subdimensions); - const node = mark.render(index, scales, values, subdimensions); + .each(function(key, j) { + for (const n of nodes) { + const node = n[j]; if (node != null) this.appendChild(node); } })) diff --git a/test/plots/index.js b/test/plots/index.js index df7cc1044e..f2a401f685 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -88,6 +88,7 @@ export {default as ordinalBar} from "./ordinal-bar.js"; export {default as penguinCulmen} from "./penguin-culmen.js"; export {default as penguinCulmenArray} from "./penguin-culmen-array.js"; export {default as penguinDodge} from "./penguin-dodge.js"; +export {default as penguinDodgeChannel} from "./penguin-dodge-channel.js"; export {default as penguinFacetDodge} from "./penguin-facet-dodge.js"; export {default as penguinIslandUnknown} from "./penguin-island-unknown.js"; export {default as penguinMass} from "./penguin-mass.js"; diff --git a/test/plots/penguin-dodge-channel.js b/test/plots/penguin-dodge-channel.js new file mode 100644 index 0000000000..e64e6bc6e8 --- /dev/null +++ b/test/plots/penguin-dodge-channel.js @@ -0,0 +1,12 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + height: 200, + marks: [ + Plot.dot(penguins, Plot.dodgeY({x: "body_mass_g", r: d3.randomLcg(42)})) + ] + }); +}