Skip to content

Commit 17e6126

Browse files
committed
first pass for legends
- implements color legend (#23)
1 parent a362a0d commit 17e6126

File tree

2 files changed

+211
-1
lines changed

2 files changed

+211
-1
lines changed

src/legend.js

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import {axisBottom, create, format, interpolate, interpolateRound, range, scaleBand, scaleLinear, quantile, quantize} from "d3";
2+
3+
export function Legend(
4+
{color: colorScale},
5+
{color} = {}
6+
) {
7+
return {
8+
...(color && color.legend && {color: new ColorLegend(colorScale, color)})
9+
};
10+
}
11+
12+
export class ColorLegend {
13+
constructor({name}, color) {
14+
this.name = name || "color";
15+
this.color = color;
16+
}
17+
render(
18+
index,
19+
{[this.name]: color}
20+
,
21+
channels,
22+
{
23+
width: canvasWidth,
24+
height: canvasHeight
25+
}
26+
) {
27+
let { color: {
28+
legend: {
29+
title,
30+
tickSize = 6,
31+
width = 320,
32+
height = 44 + tickSize,
33+
top = -20,
34+
right = 0,
35+
bottom,
36+
left,
37+
ticks = width / 64,
38+
tickFormat,
39+
tickValues
40+
} = {}
41+
} = {} } = this;
42+
console.warn({
43+
title,
44+
tickSize,
45+
width,
46+
height,
47+
top,
48+
bottom,
49+
left,
50+
right
51+
});
52+
const tx = left !== undefined ? left : canvasWidth - width + right;
53+
const ty = bottom !== undefined ? canvasHeight - bottom - height : top;
54+
return create("svg:g")
55+
.attr("transform", `translate(${tx},${ty})`)
56+
.call(g => legend(g, {color, title, tickSize, width, height, ticks, tickFormat, tickValues}))
57+
.node();
58+
}
59+
}
60+
61+
function legend(svg, {
62+
color,
63+
title,
64+
tickSize,
65+
width,
66+
height,
67+
marginTop = 18,
68+
marginRight = 0,
69+
marginBottom = 16 + tickSize,
70+
marginLeft = 0,
71+
ticks,
72+
tickFormat,
73+
tickValues
74+
} = {}) {
75+
76+
let tickAdjust = g => g.selectAll(".tick line").attr("y1", marginTop + marginBottom - height);
77+
let x;
78+
79+
// Continuous
80+
if (color.interpolate) {
81+
const n = Math.min(color.domain().length, color.range().length);
82+
83+
x = color.copy().rangeRound(quantize(interpolate(marginLeft, width - marginRight), n));
84+
85+
svg.append("image")
86+
.attr("x", marginLeft)
87+
.attr("y", marginTop)
88+
.attr("width", width - marginLeft - marginRight)
89+
.attr("height", height - marginTop - marginBottom)
90+
.attr("preserveAspectRatio", "none")
91+
.attr("xlink:href", ramp(color.copy().domain(quantize(interpolate(0, 1), n))).toDataURL());
92+
}
93+
94+
// Sequential
95+
else if (color.interpolator) {
96+
x = Object.assign(color.copy()
97+
.interpolator(interpolateRound(marginLeft, width - marginRight)),
98+
{range() { return [marginLeft, width - marginRight]; }});
99+
100+
svg.append("image")
101+
.attr("x", marginLeft)
102+
.attr("y", marginTop)
103+
.attr("width", width - marginLeft - marginRight)
104+
.attr("height", height - marginTop - marginBottom)
105+
.attr("preserveAspectRatio", "none")
106+
.attr("xlink:href", ramp(color.interpolator()).toDataURL());
107+
108+
// scaleSequentialQuantile doesn’t implement ticks or tickFormat.
109+
if (!x.ticks) {
110+
if (tickValues === undefined) {
111+
const n = Math.round(ticks + 1);
112+
tickValues = range(n).map(i => quantile(color.domain(), i / (n - 1)));
113+
}
114+
if (typeof tickFormat !== "function") {
115+
tickFormat = format(tickFormat === undefined ? ",f" : tickFormat);
116+
}
117+
}
118+
}
119+
120+
// Threshold
121+
else if (color.invertExtent) {
122+
const thresholds
123+
= color.thresholds ? color.thresholds() // scaleQuantize
124+
: color.quantiles ? color.quantiles() // scaleQuantile
125+
: color.domain(); // scaleThreshold
126+
127+
const thresholdFormat
128+
= tickFormat === undefined ? d => d
129+
: typeof tickFormat === "string" ? format(tickFormat)
130+
: tickFormat;
131+
132+
x = scaleLinear()
133+
.domain([-1, color.range().length - 1])
134+
.rangeRound([marginLeft, width - marginRight]);
135+
136+
svg.append("g")
137+
.selectAll("rect")
138+
.data(color.range())
139+
.join("rect")
140+
.attr("x", (d, i) => x(i - 1))
141+
.attr("y", marginTop)
142+
.attr("width", (d, i) => x(i) - x(i - 1))
143+
.attr("height", height - marginTop - marginBottom)
144+
.attr("fill", d => d);
145+
146+
tickValues = range(thresholds.length);
147+
tickFormat = i => thresholdFormat(thresholds[i], i);
148+
}
149+
150+
// Ordinal
151+
else {
152+
x = scaleBand()
153+
.domain(color.domain())
154+
.rangeRound([marginLeft, width - marginRight]);
155+
156+
svg.append("g")
157+
.selectAll("rect")
158+
.data(color.domain())
159+
.join("rect")
160+
.attr("x", x)
161+
.attr("y", marginTop)
162+
.attr("width", Math.max(0, x.bandwidth() - 1))
163+
.attr("height", height - marginTop - marginBottom)
164+
.attr("fill", color);
165+
166+
tickAdjust = () => {};
167+
}
168+
169+
svg.append("g")
170+
.attr("transform", `translate(0,${height - marginBottom})`)
171+
.call(axisBottom(x)
172+
.ticks(ticks, typeof tickFormat === "string" ? tickFormat : undefined)
173+
.tickFormat(typeof tickFormat === "function" ? tickFormat : undefined)
174+
.tickSize(tickSize)
175+
.tickValues(tickValues))
176+
.call(tickAdjust)
177+
.call(g => g.select(".domain").remove())
178+
.call(g => g.append("text")
179+
.attr("x", marginLeft)
180+
.attr("y", marginTop + marginBottom - height - 6)
181+
.attr("fill", "currentColor")
182+
.attr("text-anchor", "start")
183+
.attr("font-weight", "bold")
184+
.attr("class", "title")
185+
.text(title));
186+
187+
return svg.node();
188+
}
189+
190+
191+
function ramp(color, n = 256) {
192+
const canvas = create("canvas")
193+
.attr("width", n)
194+
.attr("height", 1)
195+
.node();
196+
const context = canvas.getContext("2d");
197+
for (let i = 0; i < n; ++i) {
198+
context.fillStyle = color(i / (n - 1));
199+
context.fillRect(i, 0, 1, 1);
200+
}
201+
return canvas;
202+
}

src/plot.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {create} from "d3-selection";
22
import {Axes, autoAxisTicks, autoAxisLabels} from "./axes.js";
3+
import {Legend} from "./legend.js";
34
import {facets} from "./facet.js";
45
import {Scales, autoScaleRange} from "./scales.js";
56

@@ -51,7 +52,8 @@ export function plot(options = {}) {
5152
const scaleDescriptors = Scales(scaleChannels, options);
5253
const scales = ScaleFunctions(scaleDescriptors);
5354
const axes = Axes(scaleDescriptors, options);
54-
const dimensions = Dimensions(scaleDescriptors, axes, options);
55+
const legend = Legend(scaleDescriptors, options);
56+
const dimensions = Dimensions(scaleDescriptors, axes, legend, options);
5557

5658
autoScaleRange(scaleDescriptors, dimensions);
5759
autoAxisTicks(scaleDescriptors, axes);
@@ -69,6 +71,8 @@ export function plot(options = {}) {
6971
const y = facet !== undefined && scales.fy ? "fy" : "y";
7072
if (axes[x]) marks.unshift(axes[x]);
7173
if (axes[y]) marks.unshift(axes[y]);
74+
75+
if (legend.color) marks.push(legend.color);
7276

7377
const {width, height} = dimensions;
7478

@@ -102,6 +106,9 @@ function Dimensions(
102106
fx: {axis: fxAxis} = {},
103107
fy: {axis: fyAxis} = {}
104108
},
109+
{
110+
color
111+
},
105112
{
106113
width = 640,
107114
height = y || fy ? 396 : 60,
@@ -117,6 +124,7 @@ function Dimensions(
117124
marginLeft = Math.max((yAxis === "left" ? 40 : 0) + facetMarginLeft, xAxis || fxAxis ? 20 : 0)
118125
} = {}
119126
) {
127+
color; // TDB: reserve space for the color legend?
120128
return {
121129
width,
122130
height,

0 commit comments

Comments
 (0)