Skip to content

legends - 2 #583

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

Merged
merged 74 commits into from
Nov 29, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
8601acc
legends
Fil Oct 27, 2021
9f3d9cd
Plot.legend takes a scale and options
Fil Oct 27, 2021
3398e54
Remove Plot.legend, use chart.legend instead.
Fil Oct 27, 2021
d1b9b81
reinstate Plot.legend
Fil Oct 28, 2021
dd32499
unused code
Fil Oct 28, 2021
dbd57f1
allow legend: "ramp" as an option
Fil Oct 29, 2021
0c7f150
snapshot test for a lot of color legends
Fil Oct 29, 2021
2096b24
no random in unit tests
Fil Oct 29, 2021
9f85747
remove redundant color tests
Fil Oct 29, 2021
b4d8a33
accept a label on swatches
Fil Oct 29, 2021
504fb45
className for legendSwatches
Fil Oct 29, 2021
5feae2e
remove radius and opacity legends for now
Fil Oct 29, 2021
e14cff8
more scope
Fil Oct 29, 2021
40da320
only color is available in this branch
Fil Oct 29, 2021
61257a7
do not expose any class
Fil Oct 29, 2021
8803da9
error on unknown legend type
mbostock Nov 8, 2021
3275764
categorical is normalized to ordinal
mbostock Nov 8, 2021
087d7ad
show unknown legend type in error
mbostock Nov 8, 2021
013a1ff
prEtTieR
mbostock Nov 8, 2021
729b4a2
prioritize type; avoid unnecessary default
mbostock Nov 8, 2021
1d700f6
non-nullish, not truthy
mbostock Nov 8, 2021
c226965
div.append(…nodes)
mbostock Nov 8, 2021
7bec561
legends
Fil Oct 27, 2021
20ae131
Plot.legend takes a scale and options
Fil Oct 27, 2021
47da1cb
Remove Plot.legend, use chart.legend instead.
Fil Oct 27, 2021
b6f3d92
reinstate Plot.legend
Fil Oct 28, 2021
53c8108
unused code
Fil Oct 28, 2021
e285768
allow legend: "ramp" as an option
Fil Oct 29, 2021
ff9669c
snapshot test for a lot of color legends
Fil Oct 29, 2021
f7d33b3
no random in unit tests
Fil Oct 29, 2021
7eb7d0a
remove redundant color tests
Fil Oct 29, 2021
0630304
accept a label on swatches
Fil Oct 29, 2021
c5be06d
className for legendSwatches
Fil Oct 29, 2021
e834bab
remove radius and opacity legends for now
Fil Oct 29, 2021
d744483
more scope
Fil Oct 29, 2021
a71a2c0
only color is available in this branch
Fil Oct 29, 2021
8cef472
do not expose any class
Fil Oct 29, 2021
12e0e47
error on unknown legend type
mbostock Nov 8, 2021
e7000c9
categorical is normalized to ordinal
mbostock Nov 8, 2021
7791d3e
show unknown legend type in error
mbostock Nov 8, 2021
a92d7da
prEtTieR
mbostock Nov 8, 2021
3ecdcb4
prioritize type; avoid unnecessary default
mbostock Nov 8, 2021
72e5278
non-nullish, not truthy
mbostock Nov 8, 2021
6bafb92
div.append(…nodes)
mbostock Nov 8, 2021
9630852
less duck typing
Fil Nov 18, 2021
e85d7e1
remove entity filtering
Fil Nov 18, 2021
9f01a29
document chart.legend and Plot.legend
Fil Nov 18, 2021
ad22830
reduce duck-typing
Fil Nov 18, 2021
dd53c0c
add a test for Plot.legend with options
Fil Nov 18, 2021
f9a3617
styles
Fil Nov 18, 2021
a09d7c0
clear up some confusion between scale options and legend options
Fil Nov 18, 2021
6ccf13f
className
Fil Nov 18, 2021
6fb58a9
apply() rather than color()
Fil Nov 22, 2021
5e6ef47
Update README
mbostock Nov 24, 2021
ff394be
revert figure changes
mbostock Nov 24, 2021
a3c14c6
avoid closure
mbostock Nov 24, 2021
453bfb4
applyInlineStyles
mbostock Nov 24, 2021
dcc2807
revert diverging scale changes
mbostock Nov 24, 2021
ee294c4
Merge branch 'main' into fil/legends-2
mbostock Nov 24, 2021
592ab4a
inline styles; fix diverging; separate tests
mbostock Nov 24, 2021
9f0fc76
stringify and lowercase legend option
mbostock Nov 25, 2021
6e1b5e8
normalizeScale
mbostock Nov 25, 2021
a2ca185
inherit scale options
mbostock Nov 25, 2021
8f02d27
fix ordinal tickFormat function
mbostock Nov 25, 2021
81583bb
explicit ordinal ticks
mbostock Nov 25, 2021
d0c213e
use pushState for tests
mbostock Nov 25, 2021
e458581
round option
mbostock Nov 25, 2021
740605f
fix for truncated schemes
mbostock Nov 25, 2021
6db63fc
opacity legend (#587)
Fil Nov 25, 2021
952fc08
legend: true
mbostock Nov 25, 2021
cc1d36e
fix test determinism
mbostock Nov 25, 2021
c748543
fix inline opacity legends
mbostock Nov 25, 2021
d3cd390
arrow key navigation
mbostock Nov 26, 2021
9fc55ab
ignore style if null
mbostock Nov 26, 2021
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
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,42 @@ For convenience, an apply method is exposed, which returns the scale’s output

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 scale definition, Plot can generate a legend.

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

Returns a suitable legend for the chart’s scale with the given *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*.**tickFormat** - 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*.**columns** - the number of swatches per row
* *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*.**ticks** - the desired number of ticks, or an array of tick values
* *options*.**tickFormat** - a format function for the legend’s ticks
* *options*.**tickSize** - the tick size
* *options*.**round** - if true (default), round tick positions to pixels
* *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

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

Returns a legend for the given *scale* definition, passing the options described in the previous section. Currently supports only *color* and *opacity* scales. An opacity scale is treated as a color scale with varying transparency.

### Position options

The position scales (*x*, *y*, *fx*, and *fy*) support additional options:
Expand Down Expand Up @@ -273,7 +309,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
19 changes: 12 additions & 7 deletions src/axis.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,14 +202,19 @@ function gridFacetY(index, fx, tx) {
.attr("d", (index ? take(domain, index) : domain).map(v => `M${fx(v) + tx},0h${dx}`).join(""));
}

function createAxis(axis, scale, {ticks, tickSize, tickPadding, tickFormat}) {
if (!scale.tickFormat && typeof tickFormat !== "function") {
// D3 doesn’t provide a tick format for ordinal scales; we want shorthand
// when an ordinal domain is numbers or dates, and we want null to mean the
// empty string, not the default identity format.
tickFormat = tickFormat === undefined ? (isTemporal(scale.domain()) ? formatIsoDate : string)
: (typeof tickFormat === "string" ? (isTemporal(scale.domain()) ? utcFormat : format)
// D3 doesn’t provide a tick format for ordinal scales; we want shorthand when
// an ordinal domain is numbers or dates, and we want null to mean the empty
// string, not the default identity format.
export function maybeTickFormat(tickFormat, domain) {
return tickFormat === undefined ? (isTemporal(domain) ? formatIsoDate : string)
: typeof tickFormat === "function" ? tickFormat
: (typeof tickFormat === "string" ? (isTemporal(domain) ? utcFormat : format)
: constant)(tickFormat);
}

function createAxis(axis, scale, {ticks, tickSize, tickPadding, tickFormat}) {
if (!scale.tickFormat) {
tickFormat = maybeTickFormat(tickFormat, scale.domain());
}
return axis(scale)
.ticks(Array.isArray(ticks) ? null : ticks, typeof tickFormat === "function" ? null : tickFormat)
Expand Down
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";
42 changes: 42 additions & 0 deletions src/legends.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {normalizeScale} from "./scales.js";
import {legendColor} from "./legends/color.js";
import {legendOpacity} from "./legends/opacity.js";
import {isObject} from "./mark.js";

const legendRegistry = new Map([
["color", legendColor],
["opacity", legendOpacity]
]);

export function legend(options = {}) {
for (const [key, value] of legendRegistry) {
const scale = options[key];
if (isObject(scale)) { // e.g., ignore {color: "red"}
return value(normalizeScale(key, scale), legendOptions(scale, options));
}
}
throw new Error("unknown legend type");
}

export function exposeLegends(scales, defaults = {}) {
return (key, options) => {
if (!legendRegistry.has(key)) throw new Error(`unknown legend type: ${key}`);
if (!(key in scales)) return;
return legendRegistry.get(key)(scales[key], legendOptions(defaults[key], options));
};
}

function legendOptions({label, ticks, tickFormat} = {}, options = {}) {
return {label, ticks, tickFormat, ...options};
}

export function Legends(scales, options) {
const legends = [];
for (const [key, value] of legendRegistry) {
const o = options[key];
if (o && o.legend) {
legends.push(value(scales[key], legendOptions(scales[key], o)));
}
}
return legends;
}
14 changes: 14 additions & 0 deletions src/legends/color.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {legendRamp} from "./ramp.js";
import {legendSwatches} from "./swatches.js";

export function legendColor(color, {
legend = true,
...options
}) {
if (legend === true) legend = color.type === "ordinal" ? "swatches" : "ramp";
switch (`${legend}`.toLowerCase()) {
case "swatches": return legendSwatches(color, options);
case "ramp": return legendRamp(color, options);
default: throw new Error(`unknown legend type: ${legend}`);
}
}
20 changes: 20 additions & 0 deletions src/legends/opacity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {rgb} from "d3";
import {legendColor} from "./color.js";

const black = rgb(0, 0, 0);

export function legendOpacity({type, interpolate, ...scale}, {
legend = true,
color = black,
...options
}) {
if (!interpolate) throw new Error(`${type} opacity scales are not supported`);
if (legend === true) legend = "ramp";
if (`${legend}`.toLowerCase() !== "ramp") throw new Error(`${legend} opacity legends are not supported`);
return legendColor({type, ...scale, interpolate: interpolateOpacity(color)}, {legend, ...options});
}

function interpolateOpacity(color) {
const {r, g, b} = rgb(color) || black; // treat invalid color as black
return t => `rgba(${r},${g},${b},${t})`;
}
166 changes: 166 additions & 0 deletions src/legends/ramp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import {create, quantize, interpolateNumber, piecewise, format, scaleBand, scaleLinear, axisBottom} from "d3";
import {interpolatePiecewise} from "../scales/quantitative.js";
import {applyInlineStyles, maybeClassName} from "../style.js";

export function legendRamp(color, {
label,
tickSize = 6,
width = 240,
height = 44 + tickSize,
marginTop = 18,
marginRight = 0,
marginBottom = 16 + tickSize,
marginLeft = 0,
style,
ticks = (width - marginLeft - marginRight) / 64,
tickFormat,
round = true,
className
}) {
className = maybeClassName(className);

const svg = create("svg")
.attr("class", className)
.attr("font-family", "system-ui, sans-serif")
.attr("font-size", 10)
.attr("font-variant", "tabular-nums")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`)
.call(svg => svg.append("style").text(`
.${className} {
display: block;
background: white;
height: auto;
height: intrinsic;
max-width: 100%;
overflow: visible;
}
.${className} text {
white-space: pre;
}
`))
.call(applyInlineStyles, style);

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

let x;

// Some D3 scales use scale.interpolate, some scale.interpolator, and some
// scale.round; this normalizes the API so it works with all scale types.
const applyRange = round
? (x, range) => x.rangeRound(range)
: (x, range) => x.range(range);

const {type, domain, range, interpolate, scale, pivot} = color;

// Continuous
if (interpolate) {

// Often interpolate is a “fixed” interpolator on the [0, 1] interval, as
// with a built-in color scheme, but sometimes it is a function that takes
// two arguments and is used in conjunction with the range.
const interpolator = range === undefined ? interpolate
: piecewise(interpolate.length === 1 ? interpolatePiecewise(interpolate)
: interpolate, range);

// Construct a D3 scale of the same type, but with a range that evenly
// divides the horizontal extent of the legend. (In the common case, the
// domain.length is two, and so the range is simply the extent.) For a
// diverging scale, we need an extra point in the range for the pivot such
// that the pivot is always drawn in the middle.
x = applyRange(
scale.copy(),
quantize(
interpolateNumber(marginLeft, width - marginRight),
Math.min(
domain.length + (pivot !== undefined),
range === undefined ? Infinity : range.length
)
)
);

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(interpolator).toDataURL());
}

// Threshold
else if (type === "threshold") {
const thresholds = domain;

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

// Construct a linear scale with evenly-spaced ticks for each of the
// thresholds; the domain extends one beyond the threshold extent.
x = applyRange(scaleLinear().domain([-1, range.length - 1]), [marginLeft, width - marginRight]);

svg.append("g")
.selectAll("rect")
.data(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);

ticks = Array.from(thresholds, (_, i) => i);
tickFormat = i => thresholdFormat(thresholds[i], i);
}

// Ordinal (hopefully!)
else {
x = applyRange(scaleBand().domain(domain), [marginLeft, width - marginRight]);

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

tickAdjust = () => {};
}

svg.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(axisBottom(x)
.ticks(Array.isArray(ticks) ? null : ticks, typeof tickFormat === "string" ? tickFormat : undefined)
.tickFormat(typeof tickFormat === "function" ? tickFormat : undefined)
.tickSize(tickSize)
.tickValues(Array.isArray(ticks) ? ticks : null))
.attr("font-size", null)
.attr("font-family", null)
.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") // TODO move to stylesheet?
.attr("text-anchor", "start")
.attr("font-weight", "bold")
.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