Skip to content

hexbin #740

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 8 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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1543,6 +1543,42 @@ Plot.groupZ({x: "proportion"}, {fill: "species"})

Groups on the first channel of *z*, *fill*, or *stroke*, if any. If none of *z*, *fill*, or *stroke* are channels, then all data (within each facet) is placed into a single group.

### Hexbin

[Source](./src/transforms/hexbin.js) · [Examples](https://observablehq.com/@observablehq/plot-hexbin) · Groups the scaled values into hexagonal bins, and returns the ⟨x,y⟩ positions of these hexagons together with their aggregated value. Binning happens in pixel space, ensuring symmetrical hexagons.

This transform is available in several flavors:

#### Plot.hexbin(data, *options*)

This shortcut extends the [dot](#dot) mark by applying a hexbinFill transform and requesting the hexagon symbol.

#### Plot.hexbinFill(*hexbinOptions*, **options*)

This transforms derives new output channels x, y, and fill, where x and y are the coordinates of the hexagons’ centers and fill depends on the number of data points they contain.

#### Plot.hexbinOpacity(*hexbinOptions*, **options*)

This transforms derives new output channels x, y, and fill, where x and y are the coordinates of the hexagons’ centers and opacity is proportional to the number of data points they contain, scaled so that the largest bin as an opacity of 1.

#### Plot.hexbinR(*hexbinOptions*, **options*)

This transforms derives new output channels x, y, and fill, where x and y are the coordinates of the hexagons’ centers and r specifies a symbol with a size proportional to the bin’s value, scaled so that the largest bin has the full radius.

#### Plot.hexbinText(*hexbinOptions*, **options*)

This transforms derives new output channels x, y, and fill, where x and y are the coordinates of the hexagons’ centers and text depends on the number of data points they contain.

The following *hexbinOptions* are supported:
* *radius* - the radius of the hexagons tesselation, in pixels, which defaults to 10
* *value* - the value of a bin: a function that receives as input the binned data, and default to the bin’s length
* *r* - the radius of a bin, as a function of the bin’s value (uses the scale r which defaults to a type sqrt)
* *opacity* - the opacity of a bin, as a function of the bin’s value (on the opacity scale)
* *fill* - the fill color of a bin, as a function of the bin’s value (on the color scale)
* *text* - a text that represents the contents of the bin, to use with Plot.text; defaults to its value
* *title* - a text that represents the contents of the bin, to use as a title; defaults to its value

The other *options* are passed to the mark.
### Map

[<img src="./img/window.png" width="320" height="198" alt="moving averages of daily highs and lows">](https://observablehq.com/@observablehq/plot-map)
Expand Down
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export {Arrow, arrow} from "./marks/arrow.js";
export {BarX, BarY, barX, barY} from "./marks/bar.js";
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
export {Dot, dot, dotX, dotY} from "./marks/dot.js";
export {Hexgrid, hexgrid} from "./marks/hexgrid.js";
export {Frame, frame} from "./marks/frame.js";
export {Image, image} from "./marks/image.js";
export {Line, line, lineX, lineY} from "./marks/line.js";
Expand All @@ -15,6 +16,7 @@ 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 {hexbin} from "./layouts/hexbin.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
10 changes: 6 additions & 4 deletions src/layouts/dodge.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,14 @@ 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, (I, scales, channels, dimensions) => {
let {[x]: X, [y]: Y, r: R} = channels;
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);
if (!R) R = channels.r = new Float64Array(X.length).fill(r);
if (!Y) Y = channels.y = new Float64Array(X.length).fill(0);
const tree = IntervalTree();
for (const i of I) {
const intervals = [];
Expand Down Expand Up @@ -75,7 +76,8 @@ function dodge(y, x, anchor, padding, options) {
// Insert the placed circle into the interval tree.
tree.insert([l, r, i]);
}
return {[y]: Y.map(y => y * ky + ty)};
for (const i of I) Y[i] = Y[i] * ky + ty;
return {[y]: Y};
});
}

Expand Down
92 changes: 92 additions & 0 deletions src/layouts/hexbin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {groups} from "d3";
import {layout} from "./index.js";
import {basic} from "../transforms/basic.js";
import {maybeOutputs, hasOutput} from "../transforms/group.js";
import {valueof} from "../options.js";

const defaults = {
ariaLabel: "hex",
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) {
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"}
};
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 bins = hbin(index, X, Y, radius);
for (const o of outputs) {
o.initialize(this.data);
o.scope("facet", index);
}
for (const bin of bins) {
for (const o of outputs) o.reduce(bin);
}
return {
reindex: true, // we're sending transformed data!
x: valueof(bins, "x"),
y: valueof(bins, "y"),
...!hasOutput(outputs, "r") && {r: new Float64Array(bins.length).fill(radius)}, // TODO: constant??
...Object.fromEntries(outputs.map(({name, output}) => [name, name in rescales ? {values: output.transform(), ...rescales[name]} : output.transform()]))
};
});
}

export function hexbin(outputs, options) {
([outputs, options] = mergeOptions(outputs, options));
const {radius, ...inputs} = options;
return basic(hexbinLayout(radius, outputs, inputs), (data, facets) => ({data, facets}));
}
52 changes: 52 additions & 0 deletions src/marks/hexgrid.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {create} from "d3";
import {Mark} from "../plot.js";
import {number} from "../options.js";
import {applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js";

// width factor (allows the hexbin transform to work with circular dots!)
const w0 = Math.sin(Math.PI / 3);

const defaultsMesh = {
ariaLabel: "hexagonal mesh",
fill: "none",
stroke: "currentColor",
strokeWidth: 0.25
};

export function hexgrid(options) {
return new Hexgrid(options);
}

export class Hexgrid extends Mark {
constructor({radius = 10, clip = true, ...options} = {}) {
super(undefined, undefined, {clip, ...options}, defaultsMesh);
this.radius = number(radius);
}
render(I, scales, channels, dimensions) {
const {dx, dy, radius} = this;
const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions;
return create("svg:g")
.call(applyIndirectStyles, this, dimensions)
.call(g => g.append("path")
.call(applyDirectStyles, this)
.call(applyTransform, null, null, offset + dx, offset + dy)
.attr("d", mesh(radius, marginLeft, width - marginRight, marginTop, height - marginBottom)))
.node();
}
}

function mesh(r, x0, x1, y0, y1) {
const dx = r * 2 * w0;
const dy = r * 1.5;
x1 += dx / 2;
y1 += r;
const fragment = Array.from({length: 4}, (_, i) => [r * Math.sin((i + 1) * Math.PI / 3), r * Math.cos((i + 1) * Math.PI / 3)]).join("l");
const m = [];
let j = Math.round(y0 / dy);
for (let y = dy * j; y < y1; y += dy, ++j) {
for (let x = (Math.round(x0 / dx) + (j & 1) / 2) * dx; x < x1; x += dx) {
m.push(`M${x - dx / 2},${y + dy / 3}m${fragment}`);
}
}
return m.join("");
}
10 changes: 10 additions & 0 deletions src/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,12 +250,22 @@ export function isColor(value) {
|| color(value) !== null;
}

const hex = Array.from({length: 6}, (_, i) => [Math.sin(i * Math.PI / 3), Math.cos(i * Math.PI / 3)]);
const symbolHexagon = {
draw(context, size) {
const s = Math.sqrt(size / Math.PI);
for (let i = 0; i < 6; i++) context[i ? "lineTo" : "moveTo"](hex[i][0] * s, hex[i][1] * s);
context.closePath();
}
};

const symbols = new Map([
["asterisk", symbolAsterisk],
["circle", symbolCircle],
["cross", symbolCross],
["diamond", symbolDiamond],
["diamond2", symbolDiamond2],
["hexagon", symbolHexagon],
["plus", symbolPlus],
["square", symbolSquare],
["square2", symbolSquare2],
Expand Down
Loading