Skip to content

color, opacity and radius legends #432

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
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,45 @@ Plot.scale(plot1.scales.color)

Returns a [D3 scale](https://github.com/d3/d3-scale) that matches the given Plot scale *options* object.

### Legends

Plot will add a color legend to the figure if the *color*.*legend* option is given.

* *color*.**legend** - a function that is passed the color options, and returns a DOM element to inserted at the top of the figure. If *color.legend* is true, defaults to Plot.legendColor.

#### Plot.legendColor(*scaleOptions*)

Generates a color legend, with swatches for categorical and ordinal scales, and a ramp for continuous scales.

The color swatches can be configured with the following options:
* *color*.**columns** - the number of swatches per row
* *color*.**format** - a format function for the labels
* *color*.**swatchSize** - the size of the swatch (if square)
* *color*.**swatchWidth** - the swatches’ width
* *color*.**swatchHeight** - the swatches’ height
* *color*.**marginLeft** - the legend’s left margin

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

#### Plot.legendOpacity(*scaleOptions*)

The default opacity legend—rendered as a grayscale color legend. Plot will add an opacity legend to the figure if the *opacity*.*legend* option is given. If *opacity*.*legend* is true, uses the default opacity legend; if *opacity*.*legend* is a function, it is called with the scale’s options and should return a DOM element.

#### Plot.legendRadius(*scaleOptions*)

The default radius legend—rendered as a circles on a common base. Plot will add a radius legend to the figure if the *r*.*legend* option is given. If *r*.*legend* is true, uses the default radius legend; if *r*.*legend* is a function, it is called with the scale’s options and should return a DOM element.

### Position options

The position scales (*x*, *y*, *fx*, and *fy*) support additional options:
Expand Down Expand Up @@ -261,7 +300,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** (for *x* and *y* only; see [facet.grid](#facet-options)), **inset**, **round**, **align**, and **padding**.
Top-level options are also supported as shorthand: **grid** (for *x* and *y* only; see [facet.grid](#facet-options)), **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 @@ -32,6 +32,7 @@
"devDependencies": {
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^11.2.1",
"canvas": "^2.8.0",
"clean-css": "^5.1.1",
"eslint": "^7.12.1",
"esm": "^3.2.25",
Expand Down
24 changes: 24 additions & 0 deletions src/figure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@

// Wrap the plot in a figure with a caption, if desired.
export function figureWrap(svg, {width}, caption, legends) {
if (caption == null && legends.length === 0) return svg;
const figure = document.createElement("figure");
figure.style = `max-width: ${width}px`;
if (legends.length > 0) {
const figlegends = document.createElement("div");
figlegends.className = "legends";
figure.appendChild(figlegends);
for (const l of legends) {
if (l instanceof Node) {
figlegends.appendChild(l);
}
}
}
figure.appendChild(svg);
if (caption != null) {
const figcaption = document.createElement("figcaption");
figcaption.appendChild(caption instanceof Node ? caption : document.createTextNode(caption));
figure.appendChild(figcaption);
}
return figure;
}
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ export {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 {legendColor} from "./legends/color.js";
export {legendOpacity} from "./legends/opacity.js";
18 changes: 18 additions & 0 deletions src/legends.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {legendColor} from "./legends/color.js";
import {legendOpacity} from "./legends/opacity.js";
import {legendRadius} from "./legends/radius.js";

export function createLegends(descriptors, dimensions) {
const legends = [];
for (let key in descriptors) {
let {legend, ...options} = descriptors[key];
if (key === "color" && legend === true) legend = legendColor;
if (key === "opacity" && legend === true) legend = legendOpacity;
if (key === "r" && legend === true) legend = legendRadius;
if (typeof legend === "function") {
const l = legend(options, dimensions);
if (l instanceof Node) legends.push(l);
}
}
return legends;
}
19 changes: 19 additions & 0 deletions src/legends/color.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {scale} from "../scales.js";
import {legendRamp} from "./ramp.js";
import {legendSwatches} from "./swatches.js";

export function legendColor(color, {width: maxWidth = 640} = {}) {
if (typeof color === "object" && "scales" in color) {
color = color.scales.color;
}
if (!color) return;
const {...options} = color;
switch (options.type) {
case "ordinal": case "categorical":
return legendSwatches(scale(options), options);
default:
options.key = "color"; // for diverging
if (options.width === undefined) options.width = Math.min(240, maxWidth);
return legendRamp(scale(options), options);
}
}
13 changes: 13 additions & 0 deletions src/legends/opacity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {legendColor} from "./color.js";

export function legendOpacity(plotOrScale, dimensions) {
if (!plotOrScale) return;
const opacity = "scales" in plotOrScale ? plotOrScale.scales.opacity : plotOrScale;
if (!opacity) return;
return legendColor({
...opacity,
range: undefined,
interpolate: undefined,
scheme: "greys"
}, dimensions);
}
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 {plot} from "../plot.js";
import {link} from "../marks/link.js";
import {text} from "../marks/text.js";
import {dot} from "../marks/dot.js";
import {scale} from "../scales.js";

export function legendRadius({
label,
ticks = 5,
tickFormat = (d) => d,
strokeWidth = 0.5,
strokeDasharray = [5, 4],
minStep = 8,
gap = 20,
...r
}) {
const s = scale(r);
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
});
}
153 changes: 153 additions & 0 deletions src/legends/ramp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import {create, scaleLinear, quantize, interpolate, interpolateRound, quantile, range, format, scaleBand, axisBottom} from "d3";

export function legendRamp(color, {
label,
tickSize = 6,
width = 240,
height = 44 + tickSize,
marginTop = 18,
marginRight = 0,
marginBottom = 16 + tickSize,
marginLeft = 0,
ticks = width / 64,
tickFormat,
tickValues
} = {}) {
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