Skip to content

radius legend #586

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 16 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
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,51 @@ const plot2 = Plot.plot({…, color: plot1.scale("color")});

The returned scale object represents the actual (or “materialized”) values encountered in the plot, including the domain, range, interpolate function, *etc.* The scale’s label, if any, is also returned; however, note that other axis properties are not currently exposed. The scale object is undefined if the associated plot has no scale with the given *name*, and throws an error if the *name* is invalid (*i.e.*, not one of the known scale names: *x*, *y*, *fx*, *fy*, *r*, *color*, or *opacity*).

### Legends

Given a chart’s *color* or *r* (radius) scale, Plot can generate a legend:

#### chart.legend(*name*[, *options*])

A suitable legend is returned for the chart’s scale name; for now only *color* legends are supported.

Categorical and ordinal color legends are rendered as swatches, unless *options*.**legend** is set to "ramp".

The swatches can be configured with the following options:
* *options*.**columns** - the number of swatches per row
* *options*.**format** - a format function for the labels
* *options*.**swatchSize** - the size of the swatch (if square)
* *options*.**swatchWidth** - the swatches’ width
* *options*.**swatchHeight** - the swatches’ height
* *options*.**marginLeft** - the legend’s left margin
* *options*.**className** - a class name, that defaults to a randomly generated string scoping the styles

Continuous color legends are rendered as a ramp, and can be configured with the following options:
* *options*.**label** - the scale’s label
* *options*.**tickSize** - the tick size
* *options*.**width** - the legend’s width
* *options*.**height** - the legend’s height
* *options*.**marginTop** - the legend’s top margin
* *options*.**marginRight** - the legend’s right margin
* *options*.**marginBottom** - the legend’s bottom margin
* *options*.**marginLeft** - the legend’s left margin
* *options*.**ticks** - number of ticks
* *options*.**tickFormat** - a format function for the legend’s ticks
* *options*.**tickValues** - the legend’s tick values

The r (radius) legend is rendered as circles on a common base. It can be configured with the following options:
* *options*.**label** - the scale’s label
* *options*.**ticks** - the number of ticks (circles)
* *options*.**tickFormat** - a format function for the ticks (TODO: format??)
* *options*.**strokeWidth** - the circles’ stroke width, in pixels; default to 0.5
* *options*.**strokeDasharray** - the connector’s stroke dash-array, defaults to [5, 4]
* *options*.**minStep** - the minimal step between subsequent circles (in pixels), defauts to 8
* *options*.**gap** - the horizontal gap between the circles and the labels; defauts to 20 pixels.

#### Plot.legend({*name*: *scale*, ...*options*})

Builds a legend from a scale description object, passing the options described in the previous section. The only supported name for now is *color*.

### Position options

The position scales (*x*, *y*, *fx*, and *fy*) support additional options:
Expand Down Expand Up @@ -269,7 +314,7 @@ Plot automatically generates axes for position scales. You can configure these a
* *scale*.**labelAnchor** - the label anchor: *top*, *right*, *bottom*, *left*, or *center*
* *scale*.**labelOffset** - the label position offset (in pixels; default 0, typically for facet axes)

Plot does not currently generate a legend for the *color*, *radius*, or *opacity* scales, but when it does, we expect that some of the above options will also be used to configure legends. Top-level options are also supported as shorthand: **grid** and **line** (for *x* and *y* only; see also [facet.grid](#facet-options)), **label**, **axis**, **inset**, **round**, **align**, and **padding**.
Top-level options are also supported as shorthand: **grid** (for *x* and *y* only; see [facet.grid](#facet-options)), **label**, **axis**, **inset**, **round**, **align**, and **padding**.

### Color options

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"devDependencies": {
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.0.4",
"canvas": "^2.8.0",
"eslint": "^7.12.1",
"htl": "^0.3.0",
"js-beautify": "^1.13.0",
Expand Down
12 changes: 12 additions & 0 deletions src/figure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@

// Wrap the plot in a figure with a caption, if desired.
export function figureWrap(svg, {width}, caption) {
if (caption == null) return svg;
const figure = document.createElement("figure");
figure.style = `max-width: ${width}px`;
figure.appendChild(svg);
const figcaption = document.createElement("figcaption");
figcaption.appendChild(caption instanceof Node ? caption : document.createTextNode(caption));
figure.appendChild(figcaption);
return figure;
}
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ export {window, windowX, windowY} from "./transforms/window.js";
export {selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js";
export {stackX, stackX1, stackX2, stackY, stackY1, stackY2} from "./transforms/stack.js";
export {formatIsoDate, formatWeekday, formatMonth} from "./format.js";
export {legend} from "./legends.js";
7 changes: 7 additions & 0 deletions src/legends.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {legendColor} from "./legends/color.js";
import {legendRadius} from "./legends/radius.js";

export function legend({color, r, ...options}) {
if (color) return legendColor({...color, ...options});
if (r) return legendRadius({...r, ...options});
}
16 changes: 16 additions & 0 deletions src/legends/color.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {Scale} from "../scales.js";
import {legendRamp} from "./ramp.js";
import {legendSwatches} from "./swatches.js";

export function legendColor({legend, ...options}) {
const scale = Scale("color", undefined, options);
if (legend === undefined) legend = scale.type === "ordinal" || scale.type === "categorical" ? "swatches" : "ramp";
switch (legend) {
case "swatches":
return legendSwatches({...scale, ...options});
case "ramp":
return legendRamp({...scale, ...options});
default:
throw new Error(`unknown legend type ${legend}`);
}
}
64 changes: 64 additions & 0 deletions src/legends/radius.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {Scale} from "../scales.js";
import {plot} from "../plot.js";
import {link} from "../marks/link.js";
import {text} from "../marks/text.js";
import {dot} from "../marks/dot.js";

export function legendRadius({
label,
ticks = 5,
tickFormat = (d) => d,
strokeWidth = 0.5,
strokeDasharray = [5, 4],
minStep = 8,
gap = 20,
...options
}) {
const s = Scale("r", undefined, options).scale;
const r0 = s.range()[1];

const shiftY = label ? 10 : 0;

let h = Infinity;
const values = s
.ticks(ticks)
.reverse()
.filter((t) => h - s(t) > minStep / 2 && (h = s(t)));

return plot({
x: { type: "identity", axis: null },
r: { type: "identity" },
y: { type: "identity", axis: null },
marks: [
link(values, {
x1: r0 + 2,
y1: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY,
x2: 2 * r0 + 2 + gap,
y2: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY,
strokeWidth: strokeWidth / 2,
strokeDasharray
}),
dot(values, {
r: s,
x: r0 + 2,
y: (d) => 8 + 2 * r0 - s(d) + shiftY,
strokeWidth
}),
text(values, {
x: 2 * r0 + 2 + gap,
y: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY,
textAnchor: "start",
dx: 4,
text: tickFormat
}),
text(label ? [label] : [], {
x: 0,
y: 6,
textAnchor: "start",
fontWeight: "bold",
text: tickFormat
})
],
height: 2 * r0 + 10 + shiftY
});
}
154 changes: 154 additions & 0 deletions src/legends/ramp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import {create, scaleLinear, quantize, interpolate, interpolateRound, quantile, range, format, scaleBand, axisBottom} from "d3";

export function legendRamp({
label,
tickSize = 6,
width = 240,
height = 44 + tickSize,
marginTop = 18,
marginRight = 0,
marginBottom = 16 + tickSize,
marginLeft = 0,
ticks = width / 64,
tickFormat,
tickValues,
scale: color
} = {}) {
const svg = create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.style("overflow", "visible")
.style("display", "block");

let tickAdjust = g => g.selectAll(".tick line").attr("y1", marginTop + marginBottom - height);
let x;

// Continuous
if (color.interpolate) {
const n = Math.min(color.domain().length, color.range().length);
x = color.copy().rangeRound(quantize(interpolate(marginLeft, width - marginRight), n));
let color2 = color.copy().domain(quantize(interpolate(0, 1), n));
// special case for log scales
if (color.base) {
const p = scaleLinear(
quantize(interpolate(0, 1), color.domain().length),
color.domain().map(d => Math.log(d))
);
color2 = t => color(Math.exp(p(t)));
}
svg.append("image")
.attr("x", marginLeft)
.attr("y", marginTop)
.attr("width", width - marginLeft - marginRight)
.attr("height", height - marginTop - marginBottom)
.attr("preserveAspectRatio", "none")
.attr("xlink:href", ramp(color2).toDataURL());
}

// Sequential
else if (color.interpolator) {
x = Object.assign(color.copy()
.interpolator(interpolateRound(marginLeft, width - marginRight)),
{range() { return [marginLeft, width - marginRight]; }});

svg.append("image")
.attr("x", marginLeft)
.attr("y", marginTop)
.attr("width", width - marginLeft - marginRight)
.attr("height", height - marginTop - marginBottom)
.attr("preserveAspectRatio", "none")
.attr("xlink:href", ramp(color.interpolator()).toDataURL());

// scaleSequentialQuantile doesn’t implement ticks or tickFormat.
if (!x.ticks) {
if (tickValues === undefined) {
const n = Math.round(ticks + 1);
tickValues = range(n).map(i => quantile(color.domain(), i / (n - 1)));
}
if (typeof tickFormat !== "function") {
tickFormat = format(tickFormat === undefined ? ",f" : tickFormat);
}
}
}

// Threshold
else if (color.invertExtent) {
const thresholds
= color.thresholds ? color.thresholds() // scaleQuantize
: color.quantiles ? color.quantiles() // scaleQuantile
: color.domain(); // scaleThreshold

const thresholdFormat
= tickFormat === undefined ? d => d
: typeof tickFormat === "string" ? format(tickFormat)
: tickFormat;

x = scaleLinear()
.domain([-1, color.range().length - 1])
.rangeRound([marginLeft, width - marginRight]);

svg.append("g")
.selectAll("rect")
.data(color.range())
.join("rect")
.attr("x", (d, i) => x(i - 1))
.attr("y", marginTop)
.attr("width", (d, i) => x(i) - x(i - 1))
.attr("height", height - marginTop - marginBottom)
.attr("fill", d => d);

tickValues = range(thresholds.length);
tickFormat = i => thresholdFormat(thresholds[i], i);
}

// Ordinal
else {
x = scaleBand()
.domain(color.domain())
.rangeRound([marginLeft, width - marginRight]);

svg.append("g")
.selectAll("rect")
.data(color.domain())
.join("rect")
.attr("x", x)
.attr("y", marginTop)
.attr("width", Math.max(0, x.bandwidth() - 1))
.attr("height", height - marginTop - marginBottom)
.attr("fill", color);

tickAdjust = () => {};
}

svg.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(axisBottom(x)
.ticks(ticks, typeof tickFormat === "string" ? tickFormat : undefined)
.tickFormat(typeof tickFormat === "function" ? tickFormat : undefined)
.tickSize(tickSize)
.tickValues(tickValues))
.call(tickAdjust)
.call(g => g.select(".domain").remove())
.call(label === undefined ? () => {}
: g => g.append("text")
.attr("x", marginLeft)
.attr("y", marginTop + marginBottom - height - 6)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.attr("font-weight", "bold")
.attr("class", "label")
.text(label));

return svg.node();
}

function ramp(color, n = 256) {
const canvas = create("canvas").attr("width", n).attr("height", 1).node();
const context = canvas.getContext("2d");
for (let i = 0; i < n; ++i) {
context.fillStyle = color(i / (n - 1));
context.fillRect(i, 0, 1, 1);
}
return canvas;
}
Loading