Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

group aesthetics #761

Merged
merged 6 commits into from
Feb 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/legends/swatches.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {create, path} from "d3";
import {inferFontVariant} from "../axes.js";
import {maybeTickFormat} from "../axis.js";
import {maybeColorChannel, maybeNumberChannel} from "../options.js";
import {applyInlineStyles, impliedString, maybeClassName, none} from "../style.js";
import {isNoneish, maybeColorChannel, maybeNumberChannel} from "../options.js";
import {applyInlineStyles, impliedString, maybeClassName} from "../style.js";

function maybeScale(scale, key) {
if (key == null) return key;
Expand All @@ -29,7 +29,7 @@ export function legendSwatches(color, options) {
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",
stroke = symbol.hint?.stroke !== undefined ? symbol.hint.stroke : isNoneish(fill) ? "currentColor" : "none",
strokeOpacity = 1,
strokeWidth = 1.5,
r = 4.5,
Expand Down
25 changes: 14 additions & 11 deletions src/marks/area.js
Original file line number Diff line number Diff line change
@@ -1,49 +1,52 @@
import {area as shapeArea, create, group} from "d3";
import {area as shapeArea, create} from "d3";
import {Curve} from "../curve.js";
import {defined} from "../defined.js";
import {Mark} from "../plot.js";
import {indexOf, maybeZ} from "../options.js";
import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles} from "../style.js";
import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles, groupIndex} from "../style.js";
import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js";
import {maybeStackX, maybeStackY} from "../transforms/stack.js";

const defaults = {
filter: null,
ariaLabel: "area",
strokeWidth: 1,
strokeLinecap: "round",
strokeLinejoin: "round",
strokeMiterlimit: 1
};

export class Area extends Mark {
constructor(data, options = {}) {
const {x1, y1, x2, y2, curve, tension} = options;
const {x1, y1, x2, y2, z, curve, tension} = options;
super(
data,
[
{name: "x1", value: x1, filter: null, scale: "x"},
{name: "y1", value: y1, filter: null, scale: "y"},
{name: "x2", value: x2, filter: null, scale: "x", optional: true},
{name: "y2", value: y2, filter: null, scale: "y", optional: true},
{name: "x1", value: x1, scale: "x"},
{name: "y1", value: y1, scale: "y"},
{name: "x2", value: x2, scale: "x", optional: true},
{name: "y2", value: y2, scale: "y", optional: true},
{name: "z", value: maybeZ(options), optional: true}
],
options,
defaults
);
this.z = z;
this.curve = Curve(curve, tension);
}
render(I, {x, y}, channels, dimensions) {
const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1, z: Z} = channels;
const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels;
const {dx, dy} = this;
return create("svg:g")
.call(applyIndirectStyles, this, dimensions)
.call(applyTransform, x, y, dx, dy)
.call(g => g.selectAll()
.data(Z ? group(I, i => Z[i]).values() : [I])
.data(groupIndex(I, [X1, Y1, X2, Y2], this, channels))
.join("path")
.call(applyDirectStyles, this)
.call(applyGroupedChannelStyles, this, channels)
.attr("d", shapeArea()
.curve(this.curve)
.defined(i => defined(X1[i]) && defined(Y1[i]) && defined(X2[i]) && defined(Y2[i]))
.defined(i => i >= 0)
.x0(i => X1[i])
.y0(i => Y1[i])
.x1(i => X2[i])
Expand Down
21 changes: 12 additions & 9 deletions src/marks/line.js
Original file line number Diff line number Diff line change
@@ -1,50 +1,53 @@
import {create, group, line as shapeLine} from "d3";
import {create, line as shapeLine} from "d3";
import {Curve} from "../curve.js";
import {defined} from "../defined.js";
import {Mark} from "../plot.js";
import {indexOf, identity, maybeTuple, maybeZ} from "../options.js";
import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles, offset} from "../style.js";
import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles, offset, groupIndex} from "../style.js";
import {applyGroupedMarkers, markers} from "./marker.js";

const defaults = {
filter: null,
ariaLabel: "line",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round",
strokeLinejoin: "round",
strokeMiterlimit: 1
};

export class Line extends Mark {
constructor(data, options = {}) {
const {x, y, curve, tension} = options;
const {x, y, z, curve, tension} = options;
super(
data,
[
{name: "x", value: x, filter: null, scale: "x"},
{name: "y", value: y, filter: null, scale: "y"},
{name: "x", value: x, scale: "x"},
{name: "y", value: y, scale: "y"},
{name: "z", value: maybeZ(options), optional: true}
],
options,
defaults
);
this.z = z;
this.curve = Curve(curve, tension);
markers(this, options);
}
render(I, {x, y}, channels, dimensions) {
const {x: X, y: Y, z: Z} = channels;
const {x: X, y: Y} = channels;
const {dx, dy} = this;
return create("svg:g")
.call(applyIndirectStyles, this, dimensions)
.call(applyTransform, x, y, offset + dx, offset + dy)
.call(g => g.selectAll()
.data(Z ? group(I, i => Z[i]).values() : [I])
.data(groupIndex(I, [X, Y], this, channels))
.join("path")
.call(applyDirectStyles, this)
.call(applyGroupedChannelStyles, this, channels)
.call(applyGroupedMarkers, this, channels)
.attr("d", shapeLine()
.curve(this.curve)
.defined(i => defined(X[i]) && defined(Y[i]))
.defined(i => i >= 0)
.x(i => X[i])
.y(i => Y[i])))
.node();
Expand Down
17 changes: 17 additions & 0 deletions src/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ export function take(values, index) {
return Array.from(index, i => values[i]);
}

// Based on InternMap (d3.group).
export function keyof(value) {
return value !== null && typeof value === "object" ? value.valueOf() : value;
}

export function maybeInput(key, options) {
if (options[key] !== undefined) return options[key];
switch (key) {
Expand Down Expand Up @@ -271,6 +276,18 @@ export function isColor(value) {
|| color(value) !== null;
}

export function isNoneish(value) {
return value == null || isNone(value);
}

export function isNone(value) {
return /^\s*none\s*$/i.test(value);
}

export function isRound(value) {
return /^\s*round\s*$/i.test(value);
}

const symbols = new Map([
["asterisk", symbolAsterisk],
["circle", symbolCircle],
Expand Down
12 changes: 8 additions & 4 deletions src/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ export function plot(options = {}) {
for (const mark of marks) {
const channels = markChannels.get(mark) ?? [];
const values = applyScales(channels, scales);
const index = filter(markIndex.get(mark), channels, values);
let index = markIndex.get(mark);
if (mark.filter != null) index = mark.filter(index, channels, values);
const node = mark.render(index, scales, values, dimensions, axes);
if (node != null) svg.appendChild(node);
}
Expand Down Expand Up @@ -136,7 +137,7 @@ export function plot(options = {}) {
return figure;
}

function filter(index, channels, values) {
function defaultFilter(index, channels, values) {
for (const [name, {filter = defined}] of channels) {
if (name !== undefined && filter !== null) {
const value = values[name];
Expand All @@ -154,6 +155,7 @@ export class Mark {
this.sort = isOptions(sort) ? sort : null;
this.facet = facet == null || facet === false ? null : keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]);
const {transform} = basic(options);
this.filter = defaults?.filter === undefined ? defaultFilter : defaults.filter;
this.transform = transform;
if (defaults !== undefined) channels = styles(this, options, channels, defaults);
this.channels = channels.filter(channel => {
Expand Down Expand Up @@ -328,9 +330,11 @@ class Facet extends Mark {
.each(function(key) {
const marksFacetIndex = marksIndexByFacet.get(key);
for (let i = 0; i < marks.length; ++i) {
const mark = marks[i];
const values = marksValues[i];
const index = filter(marksFacetIndex[i], marksChannels[i], values);
const node = marks[i].render(index, scales, values, subdimensions);
let index = marksFacetIndex[i];
if (mark.filter != null) index = mark.filter(index, marksChannels[i], values);
const node = mark.render(index, scales, values, subdimensions);
if (node != null) this.appendChild(node);
}
}))
Expand Down
5 changes: 2 additions & 3 deletions src/scales/ordinal.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
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 {maybeSymbol, isNoneish} from "../options.js";
import {registry, color, symbol} from "./index.js";
import {maybeBooleanRange, ordinalScheme, quantitativeScheme} from "./schemes.js";

Expand Down Expand Up @@ -127,5 +126,5 @@ function inferSymbolHint(channels) {
}

function inferSymbolRange(hint) {
return none(hint.fill) ? symbolsStroke : symbolsFill;
return isNoneish(hint.fill) ? symbolsStroke : symbolsFill;
}
86 changes: 72 additions & 14 deletions src/style.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {isoFormat, namespaces} from "d3";
import {nonempty} from "./defined.js";
import {group, isoFormat, namespaces} from "d3";
import {defined, nonempty} from "./defined.js";
import {formatNumber} from "./format.js";
import {string, number, maybeColorChannel, maybeNumberChannel, isTemporal, isNumeric} from "./options.js";
import {string, number, maybeColorChannel, maybeNumberChannel, isTemporal, isNumeric, isNoneish, isNone, isRound, keyof} from "./options.js";
import {warn} from "./warnings.js";

export const offset = typeof window !== "undefined" && window.devicePixelRatio > 1 ? 0 : 0.5;

Expand Down Expand Up @@ -64,10 +65,10 @@ export function styles(
// applies if the stroke is (constant) none; if you set a stroke, then the
// default fill becomes none. Similarly for marks that stroke by stroke, the
// default stroke only applies if the fill is (constant) none.
if (none(defaultFill)) {
if (!none(defaultStroke) && !none(fill)) defaultStroke = "none";
if (isNoneish(defaultFill)) {
if (!isNoneish(defaultStroke) && !isNoneish(fill)) defaultStroke = "none";
} else {
if (none(defaultStroke) && !none(stroke)) defaultFill = "none";
if (isNoneish(defaultStroke) && !isNoneish(stroke)) defaultFill = "none";
}

const [vfill, cfill] = maybeColorChannel(fill, defaultFill);
Expand All @@ -79,16 +80,19 @@ export function styles(
// For styles that have no effect if there is no stroke, only apply the
// defaults if the stroke is not the constant none. (If stroke is a channel,
// then cstroke will be undefined, but there’s still a stroke; hence we don’t
// use the none helper here.)
if (cstroke !== "none") {
// use isNoneish here.)
if (!isNone(cstroke)) {
if (strokeWidth === undefined) strokeWidth = defaultStrokeWidth;
if (strokeLinecap === undefined) strokeLinecap = defaultStrokeLinecap;
if (strokeLinejoin === undefined) strokeLinejoin = defaultStrokeLinejoin;
if (strokeMiterlimit === undefined) strokeMiterlimit = defaultStrokeMiterlimit;

// The default stroke miterlimit need not be applied if the current stroke
// is the constant round; this only has effect on miter joins.
if (strokeMiterlimit === undefined && !isRound(strokeLinejoin)) strokeMiterlimit = defaultStrokeMiterlimit;

// The paint order only takes effect if there is both a fill and a stroke
// (at least if we ignore markers, which no built-in marks currently use).
if (cfill !== "none" && paintOrder === undefined) paintOrder = defaultPaintOrder;
if (!isNone(cfill) && paintOrder === undefined) paintOrder = defaultPaintOrder;
}

const [vstrokeWidth, cstrokeWidth] = maybeNumberChannel(strokeWidth);
Expand Down Expand Up @@ -176,6 +180,64 @@ export function applyGroupedChannelStyles(selection, {target}, {ariaLabel: AL, t
applyTitleGroup(selection, T);
}

function groupAesthetics({ariaLabel: AL, title: T, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW, opacity: O, href: H}) {
return [AL, T, F, FO, S, SO, SW, O, H].filter(c => c !== undefined);
}

function groupZ(I, Z, z) {
const G = group(I, i => Z[i]);
if (z === undefined && G.size > I.length >> 2) {
warn(`Warning: the implicit z channel has high cardinality. This may occur when the fill or stroke channel is associated with quantitative data rather than ordinal or categorical data. You can suppress this warning by setting the z option explicitly; if this data represents a single series, set z to null.`);
}
return G.values();
}

export function* groupIndex(I, position, {z}, channels) {
const {z: Z} = channels; // group channel
const A = groupAesthetics(channels); // aesthetic channels
const C = [...position, ...A]; // all channels

// Group the current index by Z (if any).
for (const G of Z ? groupZ(I, Z, z) : [I]) {
let Ag; // the A-values (aesthetics) of the current group, if any
let Gg; // the current group index (a subset of G, and I), if any
out: for (const i of G) {

// If any channel has an undefined value for this index, skip it.
for (const c of C) {
if (!defined(c[i])) {
if (Gg) Gg.push(-1);
continue out;
}
}

// Otherwise, if this is a new group, record the aesthetics for this
// group. Yield the current group and start a new one.
if (Ag === undefined) {
if (Gg) yield Gg;
Ag = A.map(c => keyof(c[i])), Gg = [i];
continue;
}

// Otherwise, add the current index to the current group. Then, if any of
// the aesthetics don’t match the current group, yield the current group
// and start a new group of the current index.
Gg.push(i);
for (let j = 0; j < A.length; ++j) {
const k = keyof(A[j][i]);
if (k !== Ag[j]) {
yield Gg;
Ag = A.map(c => keyof(c[i])), Gg = [i];
continue out;
}
}
}

// Yield the current group, if any.
if (Gg) yield Gg;
}
}

// clip: true clips to the frame
// TODO: accept other types of clips (paths, urls, x, y, other marks?…)
// https://github.com/observablehq/plot/issues/181
Expand Down Expand Up @@ -254,10 +316,6 @@ export function impliedNumber(value, impliedValue) {
if ((value = number(value)) !== impliedValue) return value;
}

export function none(color) {
return color == null || /^\s*none\s*$/i.test(color);
}

const validClassName = /^-?([_a-z]|[\240-\377]|\\[0-9a-f]{1,6}(\r\n|[ \t\r\n\f])?|\\[^\r\n\f0-9a-f])([_a-z0-9-]|[\240-\377]|\\[0-9a-f]{1,6}(\r\n|[ \t\r\n\f])?|\\[^\r\n\f0-9a-f])*$/;

export function maybeClassName(name) {
Expand Down
6 changes: 3 additions & 3 deletions test/marks/line-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ it("line() has the expected defaults", () => {
assert.strictEqual(line.stroke, "currentColor");
assert.strictEqual(line.strokeWidth, 1.5);
assert.strictEqual(line.strokeOpacity, undefined);
assert.strictEqual(line.strokeLinejoin, undefined);
assert.strictEqual(line.strokeLinecap, undefined);
assert.strictEqual(line.strokeMiterlimit, 1);
assert.strictEqual(line.strokeLinejoin, "round");
assert.strictEqual(line.strokeLinecap, "round");
assert.strictEqual(line.strokeMiterlimit, undefined);
assert.strictEqual(line.strokeDasharray, undefined);
assert.strictEqual(line.strokeDashoffset, undefined);
assert.strictEqual(line.mixBlendMode, undefined);
Expand Down
4 changes: 2 additions & 2 deletions test/output/aaplBollinger.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion test/output/aaplClose.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion test/output/aaplCloseUntyped.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion test/output/availability.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion test/output/carsMpg.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion test/output/carsParcoords.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading