Skip to content

Facet expand #1069

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

Closed
wants to merge 18 commits into from
Closed
32 changes: 16 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1038,7 +1038,7 @@ used when the baseline and topline share *y* values, as in a time-series area
chart where time goes up↑. If neither the **x1** nor **x2** option is
specified, the **x** option may be specified as shorthand to apply an
implicit [stackX
transform](#plotstackxstack-options);
transform](#plotstackxstackoptions-options);
this is the typical configuration for an area chart with a baseline at *x* =
0. If the **x** option is not specified, it defaults to the identity
function. The **y** option specifies the **y1** channel; and the **y1** and
Expand Down Expand Up @@ -1072,7 +1072,7 @@ used when the baseline and topline share *x* values, as in a time-series area
chart where time goes right→. If neither the **y1** nor **y2** option is
specified, the **y** option may be specified as shorthand to apply an
implicit [stackY
transform](#plotstackystack-options);
transform](#plotstackystackoptions-options);
this is the typical configuration for an area chart with a baseline at *y* =
0. If the **y** option is not specified, it defaults to the identity
function. The **x** option specifies the **x1** channel; and the **x1** and
Expand Down Expand Up @@ -1157,7 +1157,7 @@ following channels are required:

If neither the **x1** nor **x2** option is specified, the **x** option may be
specified as shorthand to apply an implicit [stackX
transform](#plotstackxstack-options);
transform](#plotstackxstackoptions-options);
this is the typical configuration for a horizontal bar chart with bars
aligned at *x* = 0. If the **x** option is not specified, it defaults to the
identity function. If *options* is undefined, then it defaults to **x2** as
Expand Down Expand Up @@ -1195,7 +1195,7 @@ following channels are required:

If neither the **y1** nor **y2** option is specified, the **y** option may be
specified as shorthand to apply an implicit [stackY
transform](#plotstackystack-options);
transform](#plotstackystackoptions-options);
this is the typical configuration for a vertical bar chart with bars aligned
at *y* = 0. If the **y** option is not specified, it defaults to the identity
function. If *options* is undefined, then it defaults to **y2** as the
Expand Down Expand Up @@ -1797,7 +1797,7 @@ Equivalent to
[Plot.rect](#plotrectdata-options),
except that if neither the **x1** nor **x2** option is specified, the **x**
option may be specified as shorthand to apply an implicit [stackX
transform](#plotstackxstack-options);
transform](#plotstackxstackoptions-options);
this is the typical configuration for a histogram with rects aligned at *x* =
0. If the **x** option is not specified, it defaults to the identity
function.
Expand All @@ -1814,7 +1814,7 @@ Equivalent to
[Plot.rect](#plotrectdata-options),
except that if neither the **y1** nor **y2** option is specified, the **y**
option may be specified as shorthand to apply an implicit [stackY
transform](#plotstackystack-options);
transform](#plotstackystackoptions-options);
this is the typical configuration for a histogram with rects aligned at *y* =
0. If the **y** option is not specified, it defaults to the identity
function.
Expand Down Expand Up @@ -2789,7 +2789,7 @@ If two arguments are passed to the stack transform functions below, the stack-sp

<!-- jsdoc stackY -->

#### Plot.stackY(*stack*, *options*)
#### Plot.stackY(*stackOptions*, *options*)

```js
Plot.stackY({x: "year", y: "revenue", z: "format", fill: "group"})
Expand All @@ -2806,35 +2806,35 @@ the only argument, or as a separate *stack* options argument.

<!-- jsdoc stackY1 -->

#### Plot.stackY1(*stack*, *options*)
#### Plot.stackY1(*stackOptions*, *options*)

```js
Plot.stackY1({x: "year", y: "revenue", z: "format", fill: "group"})
```

Equivalent to
[Plot.stackY](#plotstackystack-options),
[Plot.stackY](#plotstackystackoptions-options),
except that the **y1** channel is returned as the **y** channel. This can be
used, for example, to draw a line at the bottom of each stacked area.


<!-- jsdoc stackY2 ->

#### Plot.stackY2(*stack*, *options*)
#### Plot.stackY2(*stackOptions*, *options*)

```js
Plot.stackY2({x: "year", y: "revenue", z: "format", fill: "group"})
```

Equivalent to
[Plot.stackY](#plotstackystack-options),
[Plot.stackY](#plotstackystackoptions-options),
except that the **y2** channel is returned as the **y** channel. This can be
used, for example, to draw a line at the top of each stacked area.


<!-- jsdoc stackX -->

#### Plot.stackX(*stack*, *options*)
#### Plot.stackX(*stackOptions*, *options*)

```js
Plot.stackX({y: "year", x: "revenue", z: "format", fill: "group"})
Expand All @@ -2846,28 +2846,28 @@ index, *x1*, *x2* and *x* as the output channels.

<!-- jsdoc stackX1 -->

#### Plot.stackX1(*stack*, *options*)
#### Plot.stackX1(*stackOptions*, *options*)

```js
Plot.stackX1({y: "year", x: "revenue", z: "format", fill: "group"})
```

Equivalent to
[Plot.stackX](#plotstackxstack-options),
[Plot.stackX](#plotstackxstackoptions-options),
except that the **x1** channel is returned as the **x** channel. This can be
used, for example, to draw a line at the left edge of each stacked area.


<!-- jsdoc stackX2 -->

#### Plot.stackX2(*stack*, *options*)
#### Plot.stackX2(*stackOptions*, *options*)

```js
Plot.stackX2({y: "year", x: "revenue", z: "format", fill: "group"})
```

Equivalent to
[Plot.stackX](#plotstackxstack-options),
[Plot.stackX](#plotstackxstackoptions-options),
except that the **x2** channel is returned as the **x** channel. This can be
used, for example, to draw a line at the right edge of each stacked area.

Expand Down
4 changes: 2 additions & 2 deletions src/axes.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {extent} from "d3";
import {AxisX, AxisY} from "./axis.js";
import {formatDefault} from "./format.js";
import {isOrdinalScale, isTemporalScale, scaleOrder} from "./scales.js";
import {position, registry} from "./scales/index.js";
import {position, scaleRegistry} from "./scales/index.js";

export function Axes(
{x: xScale, y: yScale, fx: fxScale, fy: fyScale},
Expand Down Expand Up @@ -114,7 +114,7 @@ export function autoScaleLabels(channels, scales, {x, y, fx, fy}, dimensions, op
y.labelOffset = y.axis === "left" ? marginLeft - facetMarginLeft : marginRight - facetMarginRight;
}
}
for (const [key, type] of registry) {
for (const [key, type] of scaleRegistry) {
if (type !== position && scales[key]) {
// not already handled above
autoScaleLabel(key, scales[key], channels.get(key), options[key]);
Expand Down
107 changes: 105 additions & 2 deletions src/channel.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,54 @@
import {ascending, descending, rollup, sort} from "d3";
import {first, isIterable, labelof, map, maybeValue, range, valueof} from "./options.js";
import {registry} from "./scales/index.js";
import {scaleRegistry} from "./scales/index.js";
import {maybeReduce} from "./transforms/group.js";
import {maybeColorChannel, maybeNumberChannel} from "./options.js";
import {maybeSymbolChannel} from "./symbols.js";
import {warn} from "./warnings.js";

// An array of known channels, with an associated scale name, and a definition
// that returns [variable, undefined] if variable, or [undefined, constant] if
// constant (such as "#eee" for the color channel)
export const channelRegistry = new Map([
["x", {scale: "x"}],
["x1", {scale: "x"}],
["x2", {scale: "x"}],
["y", {scale: "y"}],
["y1", {scale: "y"}],
["y2", {scale: "y"}],
["z", {}],
["ariaLabel", {}],
["href", {}],
["title", {}],
["fill", {scale: "color", definition: maybeColorChannel}],
["stroke", {scale: "color", definition: maybeColorChannel}],
["fillOpacity", {scale: "opacity", definition: maybeNumberChannel}],
["strokeOpacity", {scale: "opacity", definition: maybeNumberChannel}],
["opacity", {scale: "opacity", definition: maybeNumberChannel}],
["strokeWidth", {definition: maybeNumberChannel}],
["symbol", {scale: "symbol", definition: maybeSymbolChannel}], // dot
["r", {scale: "r", definition: maybeNumberChannel}], // dot
["rotate", {definition: maybeNumberChannel}], // dot, text
["fontSize", {definition: maybeFontSizeChannel}], // text
["text", {}], // text
["length", {scale: "length", definition: maybeNumberChannel}], // vector
["width", {definition: maybeNumberChannel}], // image
["height", {definition: maybeNumberChannel}], // image
["src", {definition: maybePathChannel}], // image
["weight", {definition: maybeNumberChannel}] // density
]);

export function maybeChannel(name, value, defaultValue) {
if (!channelRegistry.has(name)) {
warn(`The ${name} channel is not registered and might be incompatible with some transforms.`);
}
const {definition} = channelRegistry.get(name) ?? {};
return definition !== undefined
? definition(value, defaultValue)
: value === undefined
? [undefined, defaultValue]
: [value];
}

// TODO Type coercion?
export function Channel(data, {scale, type, value, filter, hint}) {
Expand Down Expand Up @@ -39,7 +86,7 @@ export function valueObject(channels, scales) {
export function channelDomain(channels, facetChannels, data, options) {
const {reverse: defaultReverse, reduce: defaultReduce = true, limit: defaultLimit} = options;
for (const x in options) {
if (!registry.has(x)) continue; // ignore unknown scale keys (including generic options)
if (!scaleRegistry.has(x)) continue; // ignore unknown scale keys (including generic options)
let {value: y, reverse = defaultReverse, reduce = defaultReduce, limit = defaultLimit} = maybeValue(options[x]);
if (reverse === undefined) reverse = y === "width" || y === "height"; // default to descending for lengths
if (reduce == null || reduce === false) continue; // disabled reducer
Expand Down Expand Up @@ -105,3 +152,59 @@ function ascendingGroup([ak, av], [bk, bv]) {
function descendingGroup([ak, av], [bk, bv]) {
return descending(av, bv) || ascending(ak, bk);
}

// https://developer.mozilla.org/en-US/docs/Web/CSS/font-size
const fontSizes = new Set([
// global keywords
"inherit",
"initial",
"revert",
"unset",
// absolute keywords
"xx-small",
"x-small",
"small",
"medium",
"large",
"x-large",
"xx-large",
"xxx-large",
// relative keywords
"larger",
"smaller"
]);

// The font size may be expressed as a constant in the following forms:
// - number in pixels
// - string keyword: see above
// - string <length>: e.g., "12px"
// - string <percentage>: e.g., "80%"
// Anything else is assumed to be a channel definition.
export function maybeFontSizeChannel(fontSize) {
if (fontSize == null || typeof fontSize === "number") return [undefined, fontSize];
if (typeof fontSize !== "string") return [fontSize, undefined];
fontSize = fontSize.trim().toLowerCase();
return fontSizes.has(fontSize) || /^[+-]?\d*\.?\d+(e[+-]?\d+)?(\w*|%)$/.test(fontSize)
? [undefined, fontSize]
: [fontSize, undefined];
}

// Tests if the given string is a path: does it start with a dot-slash
// (./foo.png), dot-dot-slash (../foo.png), or slash (/foo.png)?
function isPath(string) {
return /^\.*\//.test(string);
}

// Tests if the given string is a URL (e.g., https://placekitten.com/200/300).
// The allowed protocols is overly restrictive, but we don’t want to allow any
// scheme here because it would increase the likelihood of a false positive with
// a field name that happens to contain a colon.
function isUrl(string) {
return /^(blob|data|file|http|https):/i.test(string);
}

// Disambiguates a constant src definition from a channel. A path or URL string
// is assumed to be a constant; any other string is assumed to be a field name.
export function maybePathChannel(value) {
return typeof value === "string" && (isPath(value) || isUrl(value)) ? [undefined, value] : [value, undefined];
}
80 changes: 80 additions & 0 deletions src/facet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {labelof, slice, valueof} from "./options.js";
import {channelRegistry} from "./channel.js";

function facetReindex(facets, n) {
if (facets.length === 1) return {facets};
const overlap = new Uint8Array(n);
let count = 0;
let plan;

// Count the number of overlapping indexes across facets.
for (const facet of facets) {
for (const i of facet) {
if (i >= n) return {facets}; // already dedup'ed!
if (overlap[i]) ++count;
overlap[i] = 1;
}
}

// For each overlapping index (duplicate number), assign a new unique index at
// the end of the existing array. For example, [[0, 1, 2], [2, 1, 3]] would
// become [[0, 1, 2], [4, 5, 3]]. Attach a plan to the facets array, to be
// able to read the values associated with the old index in unaffected
// channels.
if (count > 0) {
facets = facets.map((facet) => slice(facet, Uint32Array));
plan = new Uint32Array(n + count);
let j = 0;
for (; j < n; ++j) plan[j] = j;
overlap.fill(0);
for (const facet of facets) {
for (let k = 0; k < facet.length; ++k) {
const i = facet[k];
if (overlap[i]) {
plan[j] = i;
facet[k] = j;
j++;
}
overlap[i] = 1;
}
}
}

return {facets, plan};
}

export function maybeExpand(X, plan) {
if (!X || !plan) return X;
const V = new X.constructor(plan.length);
for (let i = 0; i < plan.length; ++i) V[i] = X[plan[i]];
return V;
}

// Iterate over the options and pull out any that represent columns of values.
function maybeExpandChannels(options) {
const channels = {};
let data, plan;
for (const [name, {definition = (value) => [value]}] of channelRegistry) {
const value = definition(options[name])[0];
if (value != null) {
channels[name] = {
transform: () => maybeExpand(valueof(data, value), plan),
label: labelof(value)
};
}
}
return [channels, (v) => ({data, plan} = v)];
}

export function exclusiveFacets(options) {
const [other, setPlan] = maybeExpandChannels(options);
return [
other,
(facets, data) => {
let plan;
({facets, plan} = facetReindex(facets, data.length));
setPlan({data, plan});
return {facets, plan, n: plan ? plan.length : data.length};
}
];
}
13 changes: 7 additions & 6 deletions src/legends/swatches.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import {path} from "d3";
import {inferFontVariant} from "../axes.js";
import {maybeAutoTickFormat} from "../axis.js";
import {Context, create} from "../context.js";
import {isNoneish, maybeColorChannel, maybeNumberChannel} from "../options.js";
import {isNoneish} from "../options.js";
import {maybeChannel} from "../channel.js";
import {isOrdinalScale, isThresholdScale} from "../scales.js";
import {applyInlineStyles, impliedString, maybeClassName} from "../style.js";

Expand Down Expand Up @@ -42,14 +43,14 @@ export function legendSymbols(
} = {},
scale
) {
const [vf, cf] = maybeColorChannel(fill);
const [vs, cs] = maybeColorChannel(stroke);
const [vf, cf] = maybeChannel("fill", fill);
const [vs, cs] = maybeChannel("stroke", 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];
fillOpacity = maybeChannel("fillOpacity", fillOpacity)[1];
strokeOpacity = maybeChannel("strokeOpacity", strokeOpacity)[1];
strokeWidth = maybeChannel("strokeWidth", strokeWidth)[1];
return legendItems(
symbol,
options,
Expand Down
Loading