From 5645b5804a982a83dcb5d52ff316ff434abffa13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 12 Jan 2022 12:20:20 +0100 Subject: [PATCH 1/2] radius legends --- README.md | 14 +++++++- src/legends.js | 4 ++- src/legends/radius.js | 65 ++++++++++++++++++++++++++++++++++++ test/output/radiusLegend.svg | 29 ++++++++++++++++ test/plots/index.js | 1 + test/plots/legend-radius.js | 5 +++ 6 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 src/legends/radius.js create mode 100644 test/output/radiusLegend.svg create mode 100644 test/plots/legend-radius.js diff --git a/README.md b/README.md index 5bcaf9f771..bfae92752e 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ When the *include* or *exclude* facet mode is chosen, the mark data must be para ## Legends -Plot can generate legends for *color*, *opacity*, and *symbol* [scales](#scale-options). (An opacity scale is treated as a color scale with varying transparency.) For an inline legend, use the *scale*.**legend** option: +Plot can generate legends for *color*, *radius*, *opacity*, and *symbol* [scales](#scale-options). (An opacity scale is treated as a color scale with varying transparency.) For an inline legend, use the *scale*.**legend** option: * *scale*.**legend** - if truthy, generate a legend for the given scale @@ -551,6 +551,18 @@ Continuous color legends are rendered as a ramp, and can be configured with the * *options*.**marginBottom** - the legend’s bottom margin * *options*.**marginLeft** - the legend’s left margin +Radius legends are rendered as circles sharing a same base, and a line connecting each circle to the corresponding tick label. The ticks are computed in a way that guarantees that they are not occluded. Radius legens 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 function that formats the ticks +* *options*.**strokeWidth** - the stroke width for the connectors, defaults to 0.5 +* *options*.**strokeDasharray** - the stroke dash-array for the connectors, defaults to [5, 4] +* *options*.**lineHeight** - the minimum line height, to avoid label occlusion +* *options*.**gap** — the gap between the circles and the tick labels, defaults to 20 pixels +* *options*.**className** - a className for the legend +* *options*.**style** - styles (a string or an object) + ### Plot.legend(*options*) Returns a standalone legend for the given *scale* definition, passing the *options* described in the previous section. For example: diff --git a/src/legends.js b/src/legends.js index 90b054d65e..aa2c88dad6 100644 --- a/src/legends.js +++ b/src/legends.js @@ -2,12 +2,14 @@ import {rgb} from "d3"; import {isObject} from "./options.js"; import {normalizeScale} from "./scales.js"; import {legendRamp} from "./legends/ramp.js"; +import {legendRadius} from "./legends/radius.js"; import {legendSwatches, legendSymbols} from "./legends/swatches.js"; const legendRegistry = new Map([ ["color", legendColor], ["symbol", legendSymbols], - ["opacity", legendOpacity] + ["opacity", legendOpacity], + ["r", legendRadius] ]); export function legend(options = {}) { diff --git a/src/legends/radius.js b/src/legends/radius.js new file mode 100644 index 0000000000..a9859e45ca --- /dev/null +++ b/src/legends/radius.js @@ -0,0 +1,65 @@ +import {plot} from "../plot.js"; +import {link} from "../marks/link.js"; +import {text} from "../marks/text.js"; +import {dot} from "../marks/dot.js"; +import {maybeClassName} from "../style.js"; + +export function legendRadius(scale, { + label = scale.label, + ticks = 5, + tickFormat = d => d, + strokeWidth = 0.5, + strokeDasharray = [5, 4], + lineHeight = 8, + gap = 20, + style, + className +}) { + className = maybeClassName(className); + const s = scale.scale; + const r0 = scale.range[1]; + const shiftY = label ? 10 : 0; + + let h = Infinity; + const values = s.ticks(ticks).reverse() + .filter((t) => h - s(t) > lineHeight / 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, + className, + style + }); +} \ No newline at end of file diff --git a/test/output/radiusLegend.svg b/test/output/radiusLegend.svg new file mode 100644 index 0000000000..4e494ad12c --- /dev/null +++ b/test/output/radiusLegend.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + 4,0003,0002,0001,000 + radial + \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index d696cb620b..169efb1e3e 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -141,3 +141,4 @@ export {default as wordLengthMobyDick} from "./word-length-moby-dick.js"; export * from "./legend-color.js"; export * from "./legend-opacity.js"; +export * from "./legend-radius.js"; diff --git a/test/plots/legend-radius.js b/test/plots/legend-radius.js new file mode 100644 index 0000000000..2c0c4f4bf0 --- /dev/null +++ b/test/plots/legend-radius.js @@ -0,0 +1,5 @@ +import * as Plot from "@observablehq/plot"; + +export function radiusLegend() { + return Plot.plot({r: {label: "radial", domain: [0, 4500], range: [0, 100]}}).legend("r"); +} From a1bc4797b8c23bf4b92a3e195326293cc19bd320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 19 Jan 2022 12:27:36 +0100 Subject: [PATCH 2/2] fix crash; unfortunately this relies on a circular dependency --- src/legends/radius.js | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/legends/radius.js b/src/legends/radius.js index a9859e45ca..6a039dcc0d 100644 --- a/src/legends/radius.js +++ b/src/legends/radius.js @@ -1,7 +1,4 @@ -import {plot} from "../plot.js"; -import {link} from "../marks/link.js"; -import {text} from "../marks/text.js"; -import {dot} from "../marks/dot.js"; +import * as Plot from "../index.js"; import {maybeClassName} from "../style.js"; export function legendRadius(scale, { @@ -24,12 +21,12 @@ export function legendRadius(scale, { const values = s.ticks(ticks).reverse() .filter((t) => h - s(t) > lineHeight / 2 && (h = s(t))); - return plot({ + return Plot.plot({ x: { type: "identity", axis: null }, r: { type: "identity" }, y: { type: "identity", axis: null }, marks: [ - link(values, { + Plot.link(values, { x1: r0 + 2, y1: (d) => 8 + 2 * r0 - 2 * s(d) + shiftY, x2: 2 * r0 + 2 + gap, @@ -37,20 +34,20 @@ export function legendRadius(scale, { strokeWidth: strokeWidth / 2, strokeDasharray }), - dot(values, { + Plot.dot(values, { r: s, x: r0 + 2, y: (d) => 8 + 2 * r0 - s(d) + shiftY, strokeWidth }), - text(values, { + Plot.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] : [], { + Plot.text(label ? [label] : [], { x: 0, y: 6, textAnchor: "start",