diff --git a/docs/features/legends.md b/docs/features/legends.md index dd0cfb2762..534334d6fc 100644 --- a/docs/features/legends.md +++ b/docs/features/legends.md @@ -76,7 +76,7 @@ Plot does not yet generate legends for the *r* (radius) scale or the *length* sc ## Legend options -If the **legend** [scale option](./scales.md#scale-options) is true, the default legend will be produced for the scale; otherwise, the meaning of the **legend** option depends on the scale: for quantitative color scales, it defaults to *ramp* but may be set to *swatches* for a discrete scale (most commonly for *threshold* color scales); for *ordinal* *color* scales and *symbol* scales, only the *swatches* value is supported. +If the **legend** [scale option](./scales.md#scale-options) is true, the default legend will be produced for the scale; otherwise, the meaning of the **legend** option depends on the scale: for quantitative color scales, it defaults to *ramp* but may be set to *swatches* for a discrete scale (most commonly for *threshold* color scales); for *ordinal* *color* scales and *symbol* scales, only the *swatches* value is supported. If the **legend* scale option is undefined, it will be inherited from the top-level **legend** plot option. diff --git a/src/legends.d.ts b/src/legends.d.ts index 38dcaf159d..c07d6f3761 100644 --- a/src/legends.d.ts +++ b/src/legends.d.ts @@ -1,7 +1,89 @@ import type {ScaleName, ScaleOptions} from "./scales.js"; +export interface SwatchesLegendOptions { + /** + * The width of the legend in pixels. Defaults to undefined, allowing swatches + * to wrap based on content flow. + */ + width?: number; + + /** + * The [CSS columns property][1], for a multi-column layout. + * + * [1]: https://developer.mozilla.org/en-US/docs/Web/CSS/columns + */ + columns?: string; + + /** The swatch width and height in pixels; defaults to 15. */ + swatchSize?: number; + + /** The swatch width in pixels; defaults to **swatchSize**. */ + swatchWidth?: number; + + /** The swatch height in pixels; defaults to **swatchSize**. */ + swatchHeight?: number; +} + +export interface RampLegendOptions { + /** The width of the legend in pixels; defaults to 240. */ + width?: number; + /** The height of the legend in pixels; defaults to 44 plus **tickSize**. */ + height?: number; + /** The top margin in pixels; defaults to 18. */ + marginTop?: number; + /** The right margin in pixels; defaults to 0. */ + marginRight?: number; + /** The bottom margin in pixels; defaults to 16 plus **tickSize**. */ + marginBottom?: number; + /** The left margin in pixels; defaults to 0. */ + marginLeft?: number; + + /** + * The desired approximate number of axis ticks, or an explicit array of tick + * values, or an interval such as *day* or *month*. + */ + ticks?: ScaleOptions["ticks"]; + + /** + * The length of axis tick marks in pixels; negative values extend in the + * opposite direction. + */ + tickSize?: ScaleOptions["tickSize"]; + + /** + * If true, round the output value to the nearest integer (pixel); useful for + * crisp edges when rendering. + */ + round?: ScaleOptions["round"]; +} + +export interface OpacityLegendOptions extends RampLegendOptions { + /** The constant color the ramp; defaults to black. */ + color?: string; +} + +export interface ColorLegendOptions extends SwatchesLegendOptions, RampLegendOptions { + /** The desired opacity of the color swatches or ramp; defaults to 1. */ + opacity?: number; +} + +export interface SymbolLegendOptions extends SwatchesLegendOptions { + /** The desired fill color of symbols; use *color* for a redundant encoding. */ + fill?: string; + /** The desired fill opacity of symbols; defaults to 1. */ + fillOpacity?: number; + /** The desired stroke color of symbols; use *color* for a redundant encoding. */ + stroke?: string; + /** The desired stroke opacity of symbols; defaults to 1. */ + strokeOpacity?: number; + /** The desired stroke width of symbols; defaults to 1.5. */ + strokeWidth?: number; + /** The desired radius of symbols in pixels; defaults to 4.5. */ + r?: number; +} + /** Options for generating a scale legend. */ -export interface LegendOptions { +export interface LegendOptions extends ColorLegendOptions, SymbolLegendOptions, OpacityLegendOptions { /** * The desired legend type; one of: * @@ -15,6 +97,9 @@ export interface LegendOptions { */ legend?: "ramp" | "swatches"; + /** A textual label to place above the legend. */ + label?: string | null; + /** * How to format tick values sampled from the scale’s domain. This may be a * function, which will be passed the tick value *t* and zero-based index *i* @@ -44,81 +129,6 @@ export interface LegendOptions { * default, a random string prefixed with “plot-”. */ className?: string | null; - - /** The constant color the ramp; defaults to black. For *ramp* *opacity* legends only. */ - color?: string; - /** The desired fill color of symbols; use *color* for a redundant encoding. For *symbol* legends only. */ - fill?: string; - /** The desired fill opacity of symbols. For *symbol* legends only. */ - fillOpacity?: number; - /** The desired opacity of the color swatches or ramp. For *color* legends only. */ - opacity?: number; - /** The desired stroke color of symbols; use *color* for a redundant encoding. For *symbol* legends only. */ - stroke?: string; - /** The desired stroke opacity of symbols. For *symbol* legends only. */ - strokeOpacity?: number; - /** The desired stroke width of symbols. For *symbol* legends only. */ - strokeWidth?: number; - /** The desired radius of symbols in pixels. For *symbol* legends only. */ - r?: number; - - /** - * The width of the legend in pixels. For *ramp* legends, defaults to 240; for - * *swatch* legends, defaults to undefined, allowing the swatches to wrap - * based on content flow. - */ - width?: number; - - /** - * The height of the legend in pixels; defaults to 44 plus **tickSize**. For - * *ramp* legends only. - */ - height?: number; - - /** The top margin in pixels; defaults to 18. For *ramp* legends only. */ - marginTop?: number; - /** The right margin in pixels; defaults to 0. For *ramp* legends only. */ - marginRight?: number; - /** The bottom margin in pixels; defaults to 16 plus **tickSize**. For *ramp* legends only. */ - marginBottom?: number; - /** The left margin in pixels; defaults to 0. For *ramp* legends only. */ - marginLeft?: number; - - /** A textual label to place above the legend. For *ramp* legends only. */ - label?: string | null; - - /** - * The desired approximate number of axis ticks, or an explicit array of tick - * values, or an interval such as *day* or *month*. For *ramp* legends only. - */ - ticks?: ScaleOptions["ticks"]; - - /** - * The length of axis tick marks in pixels; negative values extend in the - * opposite direction. For *ramp* legends only. - */ - tickSize?: ScaleOptions["tickSize"]; - - /** - * If true, round the output value to the nearest integer (pixel); useful for - * crisp edges when rendering. For *ramp* legends only. - */ - round?: ScaleOptions["round"]; - - /** - * The [CSS columns property][1], for a multi-column layout. For *swatches* - * legends only. - * - * [1]: https://developer.mozilla.org/en-US/docs/Web/CSS/columns - */ - columns?: string; - - /** The swatch width and height in pixels; defaults to 15; For *swatches* legends only. */ - swatchSize?: number; - /** The swatch width in pixels; defaults to **swatchSize**; For *swatches* legends only. */ - swatchWidth?: number; - /** The swatch height in pixels; defaults to **swatchSize**; For *swatches* legends only. */ - swatchHeight?: number; } /** Scale definitions and options for a standalone legend. */ diff --git a/src/legends.js b/src/legends.js index 64215ebaf3..77e1fb3f6e 100644 --- a/src/legends.js +++ b/src/legends.js @@ -1,7 +1,7 @@ import {rgb} from "d3"; import {createContext} from "./context.js"; import {legendRamp} from "./legends/ramp.js"; -import {legendSwatches, legendSymbols} from "./legends/swatches.js"; +import {isSymbolColorLegend, legendSwatches, legendSymbols} from "./legends/swatches.js"; import {inherit, isScaleOptions} from "./options.js"; import {normalizeScale} from "./scales.js"; @@ -70,12 +70,16 @@ function interpolateOpacity(color) { export function createLegends(scales, context, options) { const legends = []; + let hasColor = false; for (const [key, value] of legendRegistry) { - const o = options[key]; - if (o?.legend && key in scales) { - const legend = value(scales[key], legendOptions(context, scales[key], o), (key) => scales[key]); - if (legend != null) legends.push(legend); - } + if (!(key in scales)) continue; + if (key === "color" && hasColor) continue; + const o = inherit(options[key], {legend: options.legend}); + if (!o.legend) continue; + const legend = value(scales[key], legendOptions(context, scales[key], o), (key) => scales[key]); + if (legend == null) continue; + if (key === "symbol" && isSymbolColorLegend(legend)) hasColor = true; + legends.push(legend); } return legends; } diff --git a/src/legends/swatches.js b/src/legends/swatches.js index b628a0a5af..9c05717b43 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -29,6 +29,8 @@ export function legendSwatches(color, {opacity, ...options} = {}) { ); } +const legendSymbolColor = new WeakSet(); + export function legendSymbols( symbol, { @@ -50,7 +52,7 @@ export function legendSymbols( fillOpacity = maybeNumberChannel(fillOpacity)[1]; strokeOpacity = maybeNumberChannel(strokeOpacity)[1]; strokeWidth = maybeNumberChannel(strokeWidth)[1]; - return legendItems(symbol, options, (selection, scale, width, height) => + const legend = legendItems(symbol, options, (selection, scale, width, height) => selection .append("svg") .attr("viewBox", "-8 -8 16 16") @@ -68,6 +70,17 @@ export function legendSymbols( return p; }) ); + if (vf === "color" || vs === "color") legendSymbolColor.add(legend); + return legend; +} + +/** + * Symbol legends can serve as color legends when the associated symbol channel + * is also bound to the color scale; this test allows Plot to avoid displaying a + * redundant color legend when a satisfying symbol legend is present. + */ +export function isSymbolColorLegend(legend) { + return legendSymbolColor.has(legend); } function legendItems(scale, options = {}, swatch) { diff --git a/src/plot.d.ts b/src/plot.d.ts index 05fc238dc5..2dee66cae4 100644 --- a/src/plot.d.ts +++ b/src/plot.d.ts @@ -1,5 +1,5 @@ import type {ChannelValue} from "./channel.js"; -import type {LegendOptions} from "./legends.js"; +import type {ColorLegendOptions, LegendOptions, OpacityLegendOptions, SymbolLegendOptions} from "./legends.js"; import type {Data, MarkOptions, Markish} from "./mark.js"; import type {ProjectionFactory, ProjectionImplementation, ProjectionName, ProjectionOptions} from "./projection.js"; import type {Scale, ScaleDefaults, ScaleName, ScaleOptions} from "./scales.js"; @@ -244,7 +244,7 @@ export interface PlotOptions extends ScaleDefaults { * scale associated with a channel by specifying the value as a {value, scale} * object. */ - color?: ScaleOptions; + color?: ScaleOptions & ColorLegendOptions; /** * Options for the *opacity* scale for fill or stroke opacity. The *opacity* @@ -257,7 +257,7 @@ export interface PlotOptions extends ScaleDefaults { * override the scale associated with a channel by specifying the value as a * {value, scale} object. */ - opacity?: ScaleOptions; + opacity?: ScaleOptions & OpacityLegendOptions; /** * Options for the categorical *symbol* scale for dots. The *symbol* scale @@ -270,7 +270,7 @@ export interface PlotOptions extends ScaleDefaults { * override the scale associated with a channel by specifying the value as a * {value, scale} object. */ - symbol?: ScaleOptions; + symbol?: ScaleOptions & SymbolLegendOptions; /** * Options for the *length* scale for vectors. The *length* scale defaults to diff --git a/src/scales.d.ts b/src/scales.d.ts index 94112d6180..1817b67fd8 100644 --- a/src/scales.d.ts +++ b/src/scales.d.ts @@ -323,6 +323,17 @@ export interface ScaleDefaults extends InsetOptions { */ grid?: boolean | string | RangeInterval | Iterable; + /** + * If true, produces a legend for the scale. For quantitative color scales, + * the legend defaults to *ramp* but may be set to *swatches* for discrete + * scale types such as *threshold*. An opacity scale is treated as a color + * scale with varying transparency. The symbol legend is combined with color + * if they encode the same channels. + * + * For *color*, *opacity*, and *symbol* scales only. See also *plot*.legend. + */ + legend?: LegendOptions["legend"] | boolean | null; + /** * A textual label to show on the axis or legend; if null, show no label. By * default the scale label is inferred from channel definitions, possibly with @@ -534,17 +545,6 @@ export interface ScaleOptions extends ScaleDefaults { */ paddingOuter?: number; - /** - * If true, produces a legend for the scale. For quantitative color scales, - * the legend defaults to *ramp* but may be set to *swatches* for discrete - * scale types such as *threshold*. An opacity scale is treated as a color - * scale with varying transparency. The symbol legend is combined with color - * if they encode the same channels. - * - * For *color*, *opacity*, and *symbol* scales only. See also *plot*.legend. - */ - legend?: LegendOptions["legend"] | boolean | null; - /** * The desired approximate number of axis ticks, or an explicit array of tick * values, or an interval such as *day* or *month*. diff --git a/test/output/colorLegendOrdinalRampInline.html b/test/output/colorLegendOrdinalRampInline.html new file mode 100644 index 0000000000..dfa992ff58 --- /dev/null +++ b/test/output/colorLegendOrdinalRampInline.html @@ -0,0 +1,120 @@ +
+ + + + + + + + + + + + + + + + + A + + + + B + + + + C + + + + D + + + + E + + + + F + + + + G + + + + H + + + + I + + + + J + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/plots/athletes-sort.ts b/test/plots/athletes-sort.ts index 1b8b20ad0e..ab35b6f8fa 100644 --- a/test/plots/athletes-sort.ts +++ b/test/plots/athletes-sort.ts @@ -16,7 +16,7 @@ export async function athletesSortFacet() { export async function athletesSortNationality() { const athletes = await d3.csv("data/athletes.csv", d3.autoType); return Plot.plot({ - color: {legend: true}, + legend: true, marks: [ Plot.dot( athletes, @@ -34,7 +34,7 @@ export async function athletesSortNationality() { export async function athletesSortNullLimit() { const athletes = await d3.csv("data/athletes.csv", d3.autoType); return Plot.plot({ - color: {legend: true}, + legend: true, marks: [Plot.dot(athletes, {x: "height", y: "weight", stroke: "nationality", sort: {color: null, limit: 10}})] }); } @@ -42,7 +42,6 @@ export async function athletesSortNullLimit() { export async function athletesSortWeightLimit() { const athletes = await d3.csv("data/athletes.csv", d3.autoType); return Plot.plot({ - color: {legend: true}, marks: [ Plot.dot(athletes, {x: "weight", y: "nationality", sort: {y: "x", reduce: "median", limit: 10}}), Plot.tickX(athletes, Plot.groupY({x: "median"}, {x: "weight", y: "nationality", stroke: "red", strokeWidth: 2})) diff --git a/test/plots/decathlon.ts b/test/plots/decathlon.ts index 45fc92c1a4..45ba385e40 100644 --- a/test/plots/decathlon.ts +++ b/test/plots/decathlon.ts @@ -6,9 +6,7 @@ export async function decathlon() { return Plot.plot({ grid: true, inset: 12, - symbol: { - legend: true - }, + symbol: {legend: true}, marks: [Plot.dot(decathlon, {x: "Long Jump", y: "100 Meters", symbol: "Country", stroke: "Country"})] }); } diff --git a/test/plots/legend-color.ts b/test/plots/legend-color.ts index 031dfdbc78..3609823998 100644 --- a/test/plots/legend-color.ts +++ b/test/plots/legend-color.ts @@ -58,6 +58,14 @@ export function colorLegendOrdinalRamp() { return Plot.legend({color: {type: "ordinal", domain: "ABCDEFGHIJ"}, legend: "ramp"}); } +export function colorLegendOrdinalRampInline() { + return Plot.plot({ + legend: "ramp", + color: {type: "ordinal", domain: "ABCDEFGHIJ"}, + marks: [Plot.cellX("ABCDEFGHIJ")] + }); +} + export function colorLegendOrdinalRampTickSize() { return Plot.legend({ color: {