Skip to content

Commit

Permalink
symbol legend tests; fix symbol scale hint; symbol identity scale (#680)
Browse files Browse the repository at this point in the history
* symbol legend fixes

* fix crash with fill: true

* symbol hints

* rename x to times; document

* symbol identity scale
  • Loading branch information
mbostock authored Jan 19, 2022
1 parent 1482d8c commit 60a54ca
Show file tree
Hide file tree
Showing 19 changed files with 3,298 additions and 64 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,8 @@ The **r** option can be specified as either a channel or constant. When the radi

The **stroke** defaults to none. The **fill** defaults to currentColor if the stroke is none, and to none otherwise. The **strokeWidth** defaults to 1.5. The **rotate** and **symbol** options can be specified as either channels or constants. When rotate is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. When symbol is a valid symbol name or symbol object (implementing the draw method), it is interpreted as a constant; otherwise it is interpreted as a channel.

The built-in **symbol** types are: *asterisk*, *circle*, *cross*, *diamond*, *diamond2*, *plus*, *square*, *square2*, *star*, *times*, *triangle*, *triangle2*, and *wye*. You can also specify a D3 or custom symbol type as an object that implements the [*symbol*.draw(*context*, *size*)](https://github.com/d3/d3-shape/blob/main/README.md#custom-symbol-types) method.

Dots are drawn in input order, with the last data drawn on top. If sorting is needed, say to mitigate overplotting by drawing the smallest dots on top, consider a [sort and reverse transform](#transforms).

#### Plot.dot(*data*, *options*)
Expand Down
14 changes: 12 additions & 2 deletions src/legends.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,26 @@ import {legendRamp} from "./legends/ramp.js";
import {legendSwatches, legendSymbols} from "./legends/swatches.js";

const legendRegistry = new Map([
["color", legendColor],
["symbol", legendSymbols],
["color", legendColor],
["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), key => isObject(options[key]) ? normalizeScale(key, options[key]) : null);
let hint;
// For symbol legends, pass a hint to the symbol scale.
if (key === "symbol") {
const {fill, stroke = fill === undefined && isObject(options.color) ? "color" : undefined} = options;
hint = {fill, stroke};
}
return value(
normalizeScale(key, scale, hint),
legendOptions(scale, options),
key => isObject(options[key]) ? normalizeScale(key, options[key]) : null
);
}
}
throw new Error("unknown legend type");
Expand Down
7 changes: 3 additions & 4 deletions src/marks/dot.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import {create, path, symbolCircle} from "d3";
import {positive} from "../defined.js";
import {identity, maybeNumberChannel, maybeTuple} from "../options.js";
import {identity, maybeNumberChannel, maybeSymbolChannel, maybeTuple} from "../options.js";
import {Mark} from "../plot.js";
import {maybeSymbolChannel} from "../scales/symbol.js";
import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js";

const defaults = {
Expand Down Expand Up @@ -42,8 +41,8 @@ export class Dot extends Mark {
const fillChannel = channels.find(({name}) => name === "fill");
const strokeChannel = channels.find(({name}) => name === "stroke");
symbolChannel.hint = {
fill: fillChannel?.value === symbolChannel.value ? "color" : this.fill,
stroke: strokeChannel?.value === symbolChannel.value ? "color" : this.stroke
fill: fillChannel ? (fillChannel.value === symbolChannel.value ? "color" : "currentColor") : this.fill,
stroke: strokeChannel ? (strokeChannel.value === symbolChannel.value ? "color" : "currentColor") : this.stroke
};
}
}
Expand Down
58 changes: 51 additions & 7 deletions src/options.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {color, descending} from "d3";
import {symbolAsterisk, symbolDiamond2, symbolPlus, symbolSquare2, symbolTriangle2, symbolX as symbolTimes} from "d3";
import {symbolCircle, symbolCross, symbolDiamond, symbolSquare, symbolStar, symbolTriangle, symbolWye} from "d3";

// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray
const TypedArray = Object.getPrototypeOf(Uint8Array);
Expand Down Expand Up @@ -207,21 +209,21 @@ export function isNumeric(values) {
}
}

export function isColors(values) {
export function isFirst(values, is) {
for (const value of values) {
if (value == null) continue;
return isColor(value);
return is(value);
}
}

// Whereas isColors only tests the first defined value and returns undefined for
// Whereas isFirst only tests the first defined value and returns undefined for
// an empty array, this tests all defined values and only returns true if all of
// them are valid colors. It also returns true for an empty array, and thus
// should generally be used in conjunction with isColors.
export function isAllColors(values) {
// should generally be used in conjunction with isFirst.
export function isEvery(values, is) {
for (const value of values) {
if (value == null) continue;
if (!isColor(value)) return false;
if (!is(value)) return false;
}
return true;
}
Expand All @@ -231,11 +233,53 @@ export function isAllColors(values) {
// coercion here, though note that d3-color instances would need to support
// valueOf to work correctly with InternMap.
export function isColor(value) {
if (!(typeof value === "string")) return false;
if (typeof value !== "string") return false;
value = value.toLowerCase();
return value === "currentcolor" || value === "none" || color(value) !== null;
}

const symbols = new Map([
["asterisk", symbolAsterisk],
["circle", symbolCircle],
["cross", symbolCross],
["diamond", symbolDiamond],
["diamond2", symbolDiamond2],
["plus", symbolPlus],
["square", symbolSquare],
["square2", symbolSquare2],
["star", symbolStar],
["times", symbolTimes],
["triangle", symbolTriangle],
["triangle2", symbolTriangle2],
["wye", symbolWye]
]);

function isSymbolObject(value) {
return value && typeof value.draw === "function";
}

export function isSymbol(value) {
if (isSymbolObject(value)) return true;
if (typeof value !== "string") return false;
return symbols.has(value.toLowerCase());
}

export function maybeSymbol(symbol) {
if (symbol == null || isSymbolObject(symbol)) return symbol;
const value = symbols.get(`${symbol}`.toLowerCase());
if (value) return value;
throw new Error(`invalid symbol: ${symbol}`);
}

export function maybeSymbolChannel(symbol) {
if (symbol == null || isSymbolObject(symbol)) return [undefined, symbol];
if (typeof symbol === "string") {
const value = symbols.get(`${symbol}`.toLowerCase());
if (value) return [undefined, value];
}
return [symbol, undefined];
}

// Like a sort comparator, returns a positive value if the given array of values
// is in ascending order, a negative value if the values are in descending
// order. Assumes monotonicity; only tests the first and last values.
Expand Down
37 changes: 25 additions & 12 deletions src/scales.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {parse as isoParse} from "isoformat";
import {isAllColors, isColors, isOrdinal, isTemporal, order} from "./options.js";
import {isColor, isEvery, isOrdinal, isFirst, isSymbol, isTemporal, maybeSymbol, order} from "./options.js";
import {registry, color, position, radius, opacity, symbol, length} from "./scales/index.js";
import {ScaleLinear, ScaleSqrt, ScalePow, ScaleLog, ScaleSymlog, ScaleQuantile, ScaleThreshold, ScaleIdentity} from "./scales/quantitative.js";
import {ScaleDiverging, ScaleDivergingSqrt, ScaleDivergingPow, ScaleDivergingLog, ScaleDivergingSymlog} from "./scales/diverging.js";
Expand Down Expand Up @@ -127,8 +127,8 @@ function piecewiseRange(scale) {
return Array.from({length}, (_, i) => start + i / (length - 1) * (end - start));
}

export function normalizeScale(key, scale) {
return Scale(key, undefined, {...scale});
export function normalizeScale(key, scale, hint) {
return Scale(key, hint === undefined ? undefined : [{hint}], {...scale});
}

function Scale(key, channels = [], options = {}) {
Expand All @@ -155,7 +155,10 @@ function Scale(key, channels = [], options = {}) {
options = coerceType(channels, options, coerceNumber, Float64Array);
break;
case "identity":
if (registry.get(key) === position) options = coerceType(channels, options, coerceNumber, Float64Array);
switch (registry.get(key)) {
case position: options = coerceType(channels, options, coerceNumber, Float64Array); break;
case symbol: options = coerceType(channels, options, maybeSymbol); break;
}
break;
case "utc":
case "time":
Expand Down Expand Up @@ -203,11 +206,7 @@ function inferScaleType(key, channels, {type, domain, range, scheme}) {
// If the scale, a channel, or user specified a (consistent) type, return it.
if (type !== undefined) return type;

// Some scales have default types.
const kind = registry.get(key);
if (kind === radius) return "sqrt";
if (kind === opacity || kind === length) return "linear";
if (kind === symbol) return "ordinal";

// For color scales, if no range or scheme is specified and all associated
// defined values (from the domain if present, and otherwise from channels)
Expand All @@ -217,10 +216,17 @@ function inferScaleType(key, channels, {type, domain, range, scheme}) {
if (kind === color
&& range === undefined
&& scheme === undefined
&& (domain !== undefined
? isColors(domain) && isAllColors(domain)
: channels.some(({value}) => value !== undefined && isColors(value))
&& channels.every(({value}) => value === undefined || isAllColors(value)))) return "identity";
&& isAll(domain, channels, isColor)) return "identity";

// Similarly for symbols…
if (kind === symbol
&& range === undefined
&& isAll(domain, channels, isSymbol)) return "identity";

// Some scales have default types.
if (kind === radius) return "sqrt";
if (kind === opacity || kind === length) return "linear";
if (kind === symbol) return "ordinal";

// If the domain or range has more than two values, assume it’s ordinal. You
// can still use a “piecewise” (or “polylinear”) scale, but you must set the
Expand Down Expand Up @@ -254,6 +260,13 @@ function asOrdinalType(kind) {
}
}

function isAll(domain, channels, is) {
return domain !== undefined
? isFirst(domain, is) && isEvery(domain, is)
: channels.some(({value}) => value !== undefined && isFirst(value, is))
&& channels.every(({value}) => value === undefined || isEvery(value, is));
}

export function isTemporalScale({type}) {
return type === "time" || type === "utc";
}
Expand Down
2 changes: 1 addition & 1 deletion src/scales/ordinal.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {InternSet, quantize, reverse as reverseof, sort, symbolsFill, symbolsStroke} from "d3";
import {scaleBand, scaleOrdinal, scalePoint, scaleImplicit} from "d3";
import {ascendingDefined} from "../defined.js";
import {maybeSymbol} from "../options.js";
import {none} from "../style.js";
import {registry, color, symbol} from "./index.js";
import {ordinalScheme, quantitativeScheme} from "./schemes.js";
import {maybeSymbol} from "./symbol.js";

export function ScaleO(scale, channels, {
type,
Expand Down
38 changes: 0 additions & 38 deletions src/scales/symbol.js

This file was deleted.

50 changes: 50 additions & 0 deletions test/output/symbolLegendBasic.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<div class="plot" style="
--swatchWidth: 15px;
--swatchHeight: 15px;
">
<style>
.plot {
font-family: system-ui, sans-serif;
font-size: 10px;
margin-bottom: 0.5em;
margin-left: 0px;
}

.plot-swatch>svg {
width: var(--swatchWidth);
height: var(--swatchHeight);
margin-right: 0.5em;
overflow: visible;
fill: none;
fill-opacity: 1;
stroke: currentColor;
stroke-width: 1.5px;
stroke-opacity: 1;
}

.plot {
display: flex;
align-items: center;
min-height: 33px;
flex-wrap: wrap;
}

.plot-swatch {
display: inline-flex;
align-items: center;
margin-right: 1em;
}
</style><span class="plot-swatch"><svg viewBox="-8 -8 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M4.5,0A4.5,4.5,0,1,1,-4.5,0A4.5,4.5,0,1,1,4.5,0"></path>
</svg>A</span><span class="plot-swatch"><svg viewBox="-8 -8 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M-6.873088769818877,0L6.873088769818877,0M0,6.873088769818877L0,-6.873088769818877"></path>
</svg>B</span><span class="plot-swatch"><svg viewBox="-8 -8 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M-4.869970345289333,-4.869970345289333L4.869970345289333,4.869970345289333M-4.869970345289333,4.869970345289333L4.869970345289333,-4.869970345289333"></path>
</svg>C</span><span class="plot-swatch"><svg viewBox="-8 -8 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M0,-5.442851285360659L4.713647482143115,2.7214256426803294L-4.713647482143115,2.7214256426803294Z"></path>
</svg>D</span><span class="plot-swatch"><svg viewBox="-8 -8 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M0,4.768502950069832L0,-4.768502950069832M-4.129644692781513,-2.384251475034916L4.129644692781513,2.384251475034916M-4.129644692781513,2.384251475034916L4.129644692781513,-2.384251475034916"></path>
</svg>E</span><span class="plot-swatch"><svg viewBox="-8 -8 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M3.5341843560130535,3.5341843560130535L3.5341843560130535,-3.5341843560130535L-3.5341843560130535,-3.5341843560130535L-3.5341843560130535,3.5341843560130535Z"></path>
</svg>F</span>
</div>
50 changes: 50 additions & 0 deletions test/output/symbolLegendColorFill.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<div class="plot" style="
--swatchWidth: 15px;
--swatchHeight: 15px;
">
<style>
.plot {
font-family: system-ui, sans-serif;
font-size: 10px;
margin-bottom: 0.5em;
margin-left: 0px;
}

.plot-swatch>svg {
width: var(--swatchWidth);
height: var(--swatchHeight);
margin-right: 0.5em;
overflow: visible;
fill: undefined;
fill-opacity: 1;
stroke: none;
stroke-width: 1.5px;
stroke-opacity: 1;
}

.plot {
display: flex;
align-items: center;
min-height: 33px;
flex-wrap: wrap;
}

.plot-swatch {
display: inline-flex;
align-items: center;
margin-right: 1em;
}
</style><span class="plot-swatch"><svg viewBox="-8 -8 16 16" fill="#4e79a7" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M4.5,0A4.5,4.5,0,1,1,-4.5,0A4.5,4.5,0,1,1,4.5,0"></path>
</svg>A</span><span class="plot-swatch"><svg viewBox="-8 -8 16 16" fill="#f28e2c" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M-5.350491851768115,-1.7834972839227048L-1.7834972839227048,-1.7834972839227048L-1.7834972839227048,-5.350491851768115L1.7834972839227048,-5.350491851768115L1.7834972839227048,-1.7834972839227048L5.350491851768115,-1.7834972839227048L5.350491851768115,1.7834972839227048L1.7834972839227048,1.7834972839227048L1.7834972839227048,5.350491851768115L-1.7834972839227048,5.350491851768115L-1.7834972839227048,1.7834972839227048L-5.350491851768115,1.7834972839227048Z"></path>
</svg>B</span><span class="plot-swatch"><svg viewBox="-8 -8 16 16" fill="#e15759" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M0,-7.422543747841057L4.285407630887808,0L0,7.422543747841057L-4.285407630887808,0Z"></path>
</svg>C</span><span class="plot-swatch"><svg viewBox="-8 -8 16 16" fill="#76b7b2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M-3.988021164537411,-3.988021164537411h7.976042329074822v7.976042329074822h-7.976042329074822Z"></path>
</svg>D</span><span class="plot-swatch"><svg viewBox="-8 -8 16 16" fill="#59a14f" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M0,-7.528019676343693L1.6901457214599565,-2.326286013979192L7.159572167984802,-2.3262860139791925L2.7347132232624225,0.8885621897865381L4.42485894472238,6.090295852151038L6.661338147750939e-16,2.8754476483853075L-4.424858944722378,6.090295852151039L-2.734713223262422,0.8885621897865386L-7.159572167984803,-2.3262860139791908L-1.690145721459957,-2.3262860139791917Z"></path>
</svg>E</span><span class="plot-swatch"><svg viewBox="-8 -8 16 16" fill="#edc949" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M0,-6.998041357002963L6.060481591898691,3.4990206785014815L-6.060481591898691,3.4990206785014815Z"></path>
</svg>F</span>
</div>
Loading

0 comments on commit 60a54ca

Please sign in to comment.