From 17e61267f3fe47d0ea6ee8144725f4bc0c7f455a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 11 Jan 2021 17:58:44 +0100 Subject: [PATCH] first pass for legends - implements color legend (#23) --- src/legend.js | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/plot.js | 10 ++- 2 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 src/legend.js diff --git a/src/legend.js b/src/legend.js new file mode 100644 index 00000000000..d037c55c332 --- /dev/null +++ b/src/legend.js @@ -0,0 +1,202 @@ +import {axisBottom, create, format, interpolate, interpolateRound, range, scaleBand, scaleLinear, quantile, quantize} from "d3"; + +export function Legend( + {color: colorScale}, + {color} = {} +) { + return { + ...(color && color.legend && {color: new ColorLegend(colorScale, color)}) + }; +} + +export class ColorLegend { + constructor({name}, color) { + this.name = name || "color"; + this.color = color; + } + render( + index, + {[this.name]: color} + , + channels, + { + width: canvasWidth, + height: canvasHeight + } + ) { + let { color: { + legend: { + title, + tickSize = 6, + width = 320, + height = 44 + tickSize, + top = -20, + right = 0, + bottom, + left, + ticks = width / 64, + tickFormat, + tickValues + } = {} + } = {} } = this; + console.warn({ + title, + tickSize, + width, + height, + top, + bottom, + left, + right + }); + const tx = left !== undefined ? left : canvasWidth - width + right; + const ty = bottom !== undefined ? canvasHeight - bottom - height : top; + return create("svg:g") + .attr("transform", `translate(${tx},${ty})`) + .call(g => legend(g, {color, title, tickSize, width, height, ticks, tickFormat, tickValues})) + .node(); + } +} + +function legend(svg, { + color, + title, + tickSize, + width, + height, + marginTop = 18, + marginRight = 0, + marginBottom = 16 + tickSize, + marginLeft = 0, + ticks, + tickFormat, + tickValues +} = {}) { + + 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)); + + 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.copy().domain(quantize(interpolate(0, 1), n))).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(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", "title") + .text(title)); + + 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; +} diff --git a/src/plot.js b/src/plot.js index 276807d095a..054d22390a2 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,5 +1,6 @@ import {create} from "d3-selection"; import {Axes, autoAxisTicks, autoAxisLabels} from "./axes.js"; +import {Legend} from "./legend.js"; import {facets} from "./facet.js"; import {Scales, autoScaleRange} from "./scales.js"; @@ -51,7 +52,8 @@ export function plot(options = {}) { const scaleDescriptors = Scales(scaleChannels, options); const scales = ScaleFunctions(scaleDescriptors); const axes = Axes(scaleDescriptors, options); - const dimensions = Dimensions(scaleDescriptors, axes, options); + const legend = Legend(scaleDescriptors, options); + const dimensions = Dimensions(scaleDescriptors, axes, legend, options); autoScaleRange(scaleDescriptors, dimensions); autoAxisTicks(scaleDescriptors, axes); @@ -69,6 +71,8 @@ export function plot(options = {}) { const y = facet !== undefined && scales.fy ? "fy" : "y"; if (axes[x]) marks.unshift(axes[x]); if (axes[y]) marks.unshift(axes[y]); + + if (legend.color) marks.push(legend.color); const {width, height} = dimensions; @@ -102,6 +106,9 @@ function Dimensions( fx: {axis: fxAxis} = {}, fy: {axis: fyAxis} = {} }, + { + color + }, { width = 640, height = y || fy ? 396 : 60, @@ -117,6 +124,7 @@ function Dimensions( marginLeft = Math.max((yAxis === "left" ? 40 : 0) + facetMarginLeft, xAxis || fxAxis ? 20 : 0) } = {} ) { + color; // TDB: reserve space for the color legend? return { width, height,