diff --git a/README.md b/README.md index d5a58e8f35..f26e5fa1ed 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ Continuous color legends are rendered as a ramp, and can be configured with the #### Plot.legend({[*name*]: *scale*, ...*options*}) -Returns a legend for the given *scale* definition, passing the options described in the previous section. The only supported name for now is *color*. +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 diff --git a/src/legends.js b/src/legends.js index d492ee5d58..5370475978 100644 --- a/src/legends.js +++ b/src/legends.js @@ -1,14 +1,17 @@ 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] + ["color", legendColor], + ["opacity", legendOpacity] ]); export function legend(options = {}) { for (const [key, value] of legendRegistry) { const scale = options[key]; - if (scale != null) { + if (isObject(scale)) { // e.g., ignore {color: "red"} return value(normalizeScale(key, scale), legendOptions(scale, options)); } } diff --git a/src/legends/opacity.js b/src/legends/opacity.js new file mode 100644 index 0000000000..dca39a523d --- /dev/null +++ b/src/legends/opacity.js @@ -0,0 +1,19 @@ +import {rgb} from "d3"; +import {legendColor} from "./color.js"; + +const black = rgb(0, 0, 0); + +export function legendOpacity({type, interpolate, ...scale}, { + legend = "ramp", + color = black, + ...options +}) { + if (!interpolate) throw new Error(`${type} opacity scales are not supported`); + 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})`; +} diff --git a/src/mark.js b/src/mark.js index 4336013c5a..86039ad5f6 100644 --- a/src/mark.js +++ b/src/mark.js @@ -173,12 +173,15 @@ export function arrayify(data, type) { : (data instanceof type ? data : type.from(data))); } +// Disambiguates an options object (e.g., {y: "x2"}) from a primitive value. +export function isObject(option) { + return option && option.toString === objectToString; +} + // Disambiguates an options object (e.g., {y: "x2"}) from a channel value // definition expressed as a channel transform (e.g., {transform: …}). export function isOptions(option) { - return option - && option.toString === objectToString - && typeof option.transform !== "function"; + return isObject(option) && typeof option.transform !== "function"; } // For marks specified either as [0, x] or [x1, x2], such as areas and bars. diff --git a/test/output/opacityLegend.svg b/test/output/opacityLegend.svg new file mode 100644 index 0000000000..3b26c8aae6 --- /dev/null +++ b/test/output/opacityLegend.svg @@ -0,0 +1,37 @@ + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + Quantitative + + \ No newline at end of file diff --git a/test/output/opacityLegendColor.svg b/test/output/opacityLegendColor.svg new file mode 100644 index 0000000000..e14b6d2c0a --- /dev/null +++ b/test/output/opacityLegendColor.svg @@ -0,0 +1,37 @@ + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + Linear + + \ No newline at end of file diff --git a/test/output/opacityLegendLinear.svg b/test/output/opacityLegendLinear.svg new file mode 100644 index 0000000000..48096cc4a2 --- /dev/null +++ b/test/output/opacityLegendLinear.svg @@ -0,0 +1,37 @@ + + + + + + 0 + + + 2 + + + 4 + + + 6 + + + 8 + + + 10 + Linear + + \ No newline at end of file diff --git a/test/output/opacityLegendLog.svg b/test/output/opacityLegendLog.svg new file mode 100644 index 0000000000..268e96fe41 --- /dev/null +++ b/test/output/opacityLegendLog.svg @@ -0,0 +1,49 @@ + + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + + + + + + + + + + + + 10 + Log + + \ No newline at end of file diff --git a/test/output/opacityLegendRange.svg b/test/output/opacityLegendRange.svg new file mode 100644 index 0000000000..8be2684365 --- /dev/null +++ b/test/output/opacityLegendRange.svg @@ -0,0 +1,37 @@ + + + + + + 0.0 + + + 0.2 + + + 0.4 + + + 0.6 + + + 0.8 + + + 1.0 + Range + + \ No newline at end of file diff --git a/test/output/opacityLegendSqrt.svg b/test/output/opacityLegendSqrt.svg new file mode 100644 index 0000000000..0517968c76 --- /dev/null +++ b/test/output/opacityLegendSqrt.svg @@ -0,0 +1,37 @@ + + + + + + 0.0 + + + 0.2 + + + 0.4 + + + 0.6 + + + 0.8 + + + 1.0 + Sqrt + + \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index 510e9b7957..ca859a8424 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -52,7 +52,6 @@ export {default as industryUnemployment} from "./industry-unemployment.js"; export {default as industryUnemploymentShare} from "./industry-unemployment-share.js"; export {default as industryUnemploymentStream} from "./industry-unemployment-stream.js"; export {default as learningPoverty} from "./learning-poverty.js"; -export * from "./legend-color.js"; export {default as letterFrequencyBar} from "./letter-frequency-bar.js"; export {default as letterFrequencyCloud} from "./letter-frequency-cloud.js"; export {default as letterFrequencyColumn} from "./letter-frequency-column.js"; @@ -129,3 +128,6 @@ export {default as usRetailSales} from "./us-retail-sales.js"; export {default as usStatePopulationChange} from "./us-state-population-change.js"; export {default as wordCloud} from "./word-cloud.js"; export {default as wordLengthMobyDick} from "./word-length-moby-dick.js"; + +export * from "./legend-color.js"; +export * from "./legend-opacity.js"; diff --git a/test/plots/legend-opacity.js b/test/plots/legend-opacity.js new file mode 100644 index 0000000000..0bcc76a208 --- /dev/null +++ b/test/plots/legend-opacity.js @@ -0,0 +1,25 @@ +import * as Plot from "@observablehq/plot"; + +export function opacityLegend() { + return Plot.legend({opacity: {domain: [0, 10], label: "Quantitative"}}); +} + +export function opacityLegendRange() { + return Plot.legend({opacity: {domain: [0, 1], range: [0.5, 1], label: "Range"}}); +} + +export function opacityLegendLinear() { + return Plot.legend({opacity: {type: "linear", domain: [0, 10], label: "Linear"}}); +} + +export function opacityLegendColor() { + return Plot.legend({opacity: {type: "linear", domain: [0, 10], label: "Linear"}, color: "steelblue"}); +} + +export function opacityLegendLog() { + return Plot.legend({opacity: {type: "log", domain: [1, 10], label: "Log"}}); +} + +export function opacityLegendSqrt() { + return Plot.legend({opacity: {type: "sqrt", domain: [0, 1], label: "Sqrt"}}); +}