Skip to content

Commit

Permalink
symbol (#652)
Browse files Browse the repository at this point in the history
* symbol

* optimize rendering

* maybeSymbolChannel

* move default symbol to Plot.Dot

* symbol legend

* symbol legend paint options

* color+symbol legend

* automatic symbol hints

* fix for orthogonal color and symbol

* preattentive symbols
  • Loading branch information
mbostock authored Jan 7, 2022
1 parent f2eda5b commit 6ff1538
Show file tree
Hide file tree
Showing 24 changed files with 667 additions and 265 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"snowpack": "3"
},
"dependencies": {
"d3": "7",
"d3": "^7.3.0",
"isoformat": "0.2"
},
"engines": {
Expand Down
40 changes: 35 additions & 5 deletions src/legends.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import {rgb} from "d3";
import {normalizeScale} from "./scales.js";
import {legendColor} from "./legends/color.js";
import {legendOpacity} from "./legends/opacity.js";
import {legendRamp} from "./legends/ramp.js";
import {legendSwatches, legendSymbols} from "./legends/swatches.js";
import {isObject} from "./mark.js";

const legendRegistry = new Map([
["color", legendColor],
["symbol", legendSymbols],
["opacity", legendOpacity]
]);

export function legend(options = {}) {
for (const [key, value] of legendRegistry) {
const scale = options[key];
if (isObject(scale)) { // e.g., ignore {color: "red"}
return value(normalizeScale(key, scale), legendOptions(scale, options));
return value(normalizeScale(key, scale), legendOptions(scale, options), key => isObject(options[key]) ? normalizeScale(key, options[key]) : null);
}
}
throw new Error("unknown legend type");
Expand All @@ -22,20 +24,48 @@ export function exposeLegends(scales, defaults = {}) {
return (key, options) => {
if (!legendRegistry.has(key)) throw new Error(`unknown legend type: ${key}`);
if (!(key in scales)) return;
return legendRegistry.get(key)(scales[key], legendOptions(defaults[key], options));
return legendRegistry.get(key)(scales[key], legendOptions(defaults[key], options), key => scales[key]);
};
}

function legendOptions({label, ticks, tickFormat} = {}, options = {}) {
return {label, ticks, tickFormat, ...options};
}

function legendColor(color, {
legend = true,
...options
}) {
if (legend === true) legend = color.type === "ordinal" ? "swatches" : "ramp";
switch (`${legend}`.toLowerCase()) {
case "swatches": return legendSwatches(color, options);
case "ramp": return legendRamp(color, options);
default: throw new Error(`unknown legend type: ${legend}`);
}
}

function legendOpacity({type, interpolate, ...scale}, {
legend = true,
color = rgb(0, 0, 0),
...options
}) {
if (!interpolate) throw new Error(`${type} opacity scales are not supported`);
if (legend === true) legend = "ramp";
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) || rgb(0, 0, 0); // treat invalid color as black
return t => `rgba(${r},${g},${b},${t})`;
}

export function Legends(scales, options) {
const legends = [];
for (const [key, value] of legendRegistry) {
const o = options[key];
if (o && o.legend) {
legends.push(value(scales[key], legendOptions(scales[key], o)));
legends.push(value(scales[key], legendOptions(scales[key], o), key => scales[key]));
}
}
return legends;
Expand Down
14 changes: 0 additions & 14 deletions src/legends/color.js

This file was deleted.

20 changes: 0 additions & 20 deletions src/legends/opacity.js

This file was deleted.

99 changes: 81 additions & 18 deletions src/legends/swatches.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,79 @@
import {create} from "d3";
import {create, path} from "d3";
import {inferFontVariant} from "../axes.js";
import {maybeTickFormat} from "../axis.js";
import {applyInlineStyles, impliedString, maybeClassName} from "../style.js";
import {maybeColorChannel, maybeNumberChannel} from "../mark.js";
import {applyInlineStyles, impliedString, maybeClassName, none} from "../style.js";

export function legendSwatches(color, {
function maybeScale(scale, key) {
if (key == null) return key;
const s = scale(key);
if (!s) throw new Error(`scale not found: ${key}`);
return s;
}

export function legendSwatches(color, options) {
return legendItems(
color,
options,
selection => selection.style("--color", color.scale),
className => `.${className}-swatch::before {
content: "";
width: var(--swatchWidth);
height: var(--swatchHeight);
margin-right: 0.5em;
background: var(--color);
}`
);
}

export function legendSymbols(symbol, {
fill = symbol.hint?.fill !== undefined ? symbol.hint.fill : "none",
fillOpacity = 1,
stroke = symbol.hint?.stroke !== undefined ? symbol.hint.stroke : none(fill) ? "currentColor" : "none",
strokeOpacity = 1,
strokeWidth = 1.5,
r = 4.5,
...options
} = {}, scale) {
const [vf, cf] = maybeColorChannel(fill);
const [vs, cs] = maybeColorChannel(stroke);
const sf = maybeScale(scale, vf);
const ss = maybeScale(scale, vs);
const size = r * r * Math.PI;
fillOpacity = maybeNumberChannel(fillOpacity)[1];
strokeOpacity = maybeNumberChannel(strokeOpacity)[1];
strokeWidth = maybeNumberChannel(strokeWidth)[1];
return legendItems(
symbol,
options,
selection => selection.append("svg")
.attr("viewBox", "-8 -8 16 16")
.attr("fill", vf === "color" ? d => sf.scale(d) : null)
.attr("stroke", vs === "color" ? d => ss.scale(d) : null)
.append("path")
.attr("d", d => {
const p = path();
symbol.scale(d).draw(p, size);
return p;
}),
className => `.${className}-swatch > svg {
width: var(--swatchWidth);
height: var(--swatchHeight);
margin-right: 0.5em;
overflow: visible;
fill: ${cf};
fill-opacity: ${fillOpacity};
stroke: ${cs};
stroke-width: ${strokeWidth}px;
stroke-opacity: ${strokeOpacity};
}`
);
}

function legendItems(scale, {
columns,
tickFormat,
fontVariant = inferFontVariant(color),
fontVariant = inferFontVariant(scale),
// TODO label,
swatchSize = 15,
swatchWidth = swatchSize,
Expand All @@ -15,9 +82,9 @@ export function legendSwatches(color, {
className,
style,
width
} = {}) {
} = {}, swatch, swatchStyle) {
className = maybeClassName(className);
tickFormat = maybeTickFormat(tickFormat, color.domain);
tickFormat = maybeTickFormat(tickFormat, scale.domain);

const swatches = create("div")
.attr("class", className)
Expand Down Expand Up @@ -49,10 +116,10 @@ export function legendSwatches(color, {
swatches
.style("columns", columns)
.selectAll()
.data(color.domain)
.data(scale.domain)
.join("div")
.attr("class", `${className}-swatch`)
.style("--color", color.scale)
.call(swatch, scale)
.call(item => item.append("div")
.attr("class", `${className}-label`)
.attr("title", tickFormat)
Expand All @@ -74,11 +141,13 @@ export function legendSwatches(color, {

swatches
.selectAll()
.data(color.domain)
.data(scale.domain)
.join("span")
.attr("class", `${className}-swatch`)
.style("--color", color.scale)
.text(tickFormat);
.call(swatch, scale)
.append(function() {
return document.createTextNode(tickFormat.apply(this, arguments));
});
}

return swatches
Expand All @@ -90,13 +159,7 @@ export function legendSwatches(color, {
margin-left: ${+marginLeft}px;`}${width === undefined ? "" : `
width: ${width}px;`}
}
.${className}-swatch::before {
content: "";
width: var(--swatchWidth);
height: var(--swatchHeight);
margin-right: 0.5em;
background: var(--color);
}
${swatchStyle(className)}
${extraStyle}
`))
.style("font-variant", impliedString(fontVariant, "normal"))
Expand Down
15 changes: 8 additions & 7 deletions src/mark.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,13 @@ export class Mark {
}

// TODO Type coercion?
function Channel(data, {scale, type, value}) {
function Channel(data, {scale, type, value, hint}) {
return {
scale,
type,
value: valueof(data, value),
label: labelof(value)
label: labelof(value),
hint
};
}

Expand Down Expand Up @@ -134,16 +135,16 @@ const colors = new Set(["currentColor", "none"]);
// tuple [channel, constant] where one of the two is undefined, and the other is
// the given value. If you wish to reference a named field that is also a valid
// CSS color, use an accessor (d => d.red) instead.
export function maybeColor(value, defaultValue) {
export function maybeColorChannel(value, defaultValue) {
if (value === undefined) value = defaultValue;
return value === null ? [undefined, "none"]
: typeof value === "string" && (colors.has(value) || color(value)) ? [undefined, value]
: [value, undefined];
}

// Similar to maybeColor, this tests whether the given value is a number
// Similar to maybeColorChannel, this tests whether the given value is a number
// indicating a constant, and otherwise assumes that it’s a channel value.
export function maybeNumber(value, defaultValue) {
export function maybeNumberChannel(value, defaultValue) {
if (value === undefined) value = defaultValue;
return value === null || typeof value === "number" ? [undefined, value]
: [value, undefined];
Expand Down Expand Up @@ -202,8 +203,8 @@ export function maybeTuple(x, y) {
// A helper for extracting the z channel, if it is variable. Used by transforms
// that require series, such as moving average and normalize.
export function maybeZ({z, fill, stroke} = {}) {
if (z === undefined) ([z] = maybeColor(fill));
if (z === undefined) ([z] = maybeColor(stroke));
if (z === undefined) ([z] = maybeColorChannel(fill));
if (z === undefined) ([z] = maybeColorChannel(stroke));
return z;
}

Expand Down
6 changes: 3 additions & 3 deletions src/marks/cell.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {identity, indexOf, maybeColor, maybeTuple} from "../mark.js";
import {identity, indexOf, maybeColorChannel, maybeTuple} from "../mark.js";
import {AbstractBar} from "./bar.js";

export class Cell extends AbstractBar {
Expand Down Expand Up @@ -26,11 +26,11 @@ export function cell(data, {x, y, ...options} = {}) {
}

export function cellX(data, {x = indexOf, fill, stroke, ...options} = {}) {
if (fill === undefined && maybeColor(stroke)[0] === undefined) fill = identity;
if (fill === undefined && maybeColorChannel(stroke)[0] === undefined) fill = identity;
return new Cell(data, {...options, x, fill, stroke});
}

export function cellY(data, {y = indexOf, fill, stroke, ...options} = {}) {
if (fill === undefined && maybeColor(stroke)[0] === undefined) fill = identity;
if (fill === undefined && maybeColorChannel(stroke)[0] === undefined) fill = identity;
return new Cell(data, {...options, y, fill, stroke});
}
Loading

0 comments on commit 6ff1538

Please sign in to comment.