diff --git a/README.md b/README.md
index a0acc8de91..85491cf2b6 100644
--- a/README.md
+++ b/README.md
@@ -569,6 +569,21 @@ Plot.plot({
When the *include* or *exclude* facet mode is chosen, the mark data must be parallel to the facet data: the mark data must have the same length and order as the facet data. If the data are not parallel, then the wrong data may be shown in each facet. The default *auto* therefore requires strict equality (`===`) for safety, and using the facet data as mark data is recommended when using the *exclude* facet mode. (To construct parallel data safely, consider using [*array*.map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) on the facet data.)
+Alternatively, facets can be defined for each individual mark by specifying the channel options **fx** or **fy**. In that case, the **facet** option only considers the mark data, and the default *auto* setting is equivalent to *include*. Other values of the *facet* option are unchanged: null or false disable faceting, and *exclude* draws the subset of the mark’s data *not* in the current facet.
+
+```js
+Plot.plot({
+ marks: [
+ Plot.dot(penguins, {
+ x: "culmen_length_mm",
+ y: "culmen_depth_mm",
+ fx: "sex",
+ fy: "island"
+ })
+ ]
+})
+```
+
## Legends
Plot can generate legends for *color*, *opacity*, and *symbol* [scales](#scale-options). (An opacity scale is treated as a color scale with varying transparency.) For an inline legend, use the *scale*.**legend** option:
diff --git a/src/channel.js b/src/channel.js
index 2a5bdc37e6..bce56f0ca5 100644
--- a/src/channel.js
+++ b/src/channel.js
@@ -16,11 +16,7 @@ export function Channel(data, {scale, type, value, filter, hint}) {
}
export function Channels(descriptors, data) {
- return Object.fromEntries(
- Object.entries(descriptors).map(([name, channel]) => {
- return [name, Channel(data, channel)];
- })
- );
+ return Object.fromEntries(Object.entries(descriptors).map(([name, channel]) => [name, Channel(data, channel)]));
}
// TODO Use Float64Array for scales with numeric ranges, e.g. position?
diff --git a/src/plot.js b/src/plot.js
index 8962e72a7f..ecab470bc3 100644
--- a/src/plot.js
+++ b/src/plot.js
@@ -1,22 +1,11 @@
-import {cross, difference, groups, InternMap, select} from "d3";
+import {cross, group, sum, select, sort, InternMap} from "d3";
import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js";
import {Channel, Channels, channelDomain, valueObject} from "./channel.js";
import {Context, create} from "./context.js";
import {defined} from "./defined.js";
import {Dimensions} from "./dimensions.js";
import {Legends, exposeLegends} from "./legends.js";
-import {
- arrayify,
- isDomainSort,
- isScaleOptions,
- keyword,
- map,
- maybeNamed,
- range,
- second,
- where,
- yes
-} from "./options.js";
+import {arrayify, isDomainSort, isScaleOptions, keyword, map, maybeNamed, range, where, yes} from "./options.js";
import {maybeProject} from "./projection.js";
import {Scales, ScaleFunctions, autoScaleRange, exposeScales} from "./scales.js";
import {position, registry as scaleRegistry} from "./scales/index.js";
@@ -35,87 +24,102 @@ export function plot(options = {}) {
// Flatten any nested marks.
const marks = options.marks === undefined ? [] : options.marks.flat(Infinity).map(markify);
- // A Map from Mark instance to its render state, including:
- // index - the data index e.g. [0, 1, 2, 3, …]
- // channels - an array of materialized channels e.g. [["x", {value}], …]
- // faceted - a boolean indicating whether this mark is faceted
- // values - an object of scaled values e.g. {x: [40, 32, …], …}
- const stateByMark = new Map();
+ // Compute the top-level facet state. This has roughly the same structure as
+ // mark-specific facet state, except there isn’t a facetsIndex, and there’s a
+ // data and dataLength so we can warn the user if a different data of the same
+ // length is used in a mark.
+ const topFacetState = maybeTopFacet(facet, options);
+
+ // Construct a map from (faceted) Mark instance to facet state, including:
+ // channels - an {fx?, fy?} object to add to the fx and fy scale
+ // groups - a possibly-nested map from facet values to indexes in the data array
+ // facetsIndex - a sparse nested array of indices corresponding to the valid facets
+ const facetStateByMark = new Map();
+ for (const mark of marks) {
+ const facetState = maybeMarkFacet(mark, topFacetState, options);
+ if (facetState) facetStateByMark.set(mark, facetState);
+ }
- // A Map from scale name to an array of associated channels.
+ // Compute a Map from scale name to an array of associated channels.
const channelsByScale = new Map();
+ if (topFacetState) addScaleChannels(channelsByScale, [topFacetState]);
+ addScaleChannels(channelsByScale, facetStateByMark);
+
+ // All the possible facets are given by the domains of the fx or fy scales, or
+ // the cross-product of these domains if we facet by both x and y. We sort
+ // them in order to apply the facet filters afterwards.
+ let facets = Facets(channelsByScale, options);
+
+ if (facets !== undefined) {
+ const topFacetsIndex = topFacetState ? filterFacets(facets, topFacetState) : undefined;
+
+ // Compute a facet index for each mark, parallel to the facets array. For
+ // mark-level facets, compute an index for that mark’s data and options.
+ // Otherwise, use the top-level facet index.
+ for (const mark of marks) {
+ if (mark.facet === null) continue;
+ const facetState = facetStateByMark.get(mark);
+ if (facetState === undefined) continue;
+ facetState.facetsIndex = mark.fx != null || mark.fy != null ? filterFacets(facets, facetState) : topFacetsIndex;
+ }
+
+ // The cross product of the domains of fx and fy can include fx-fy
+ // combinations for which no mark has an instance associated with that
+ // combination, and therefore we don’t want to render this facet (not even
+ // the frame). The same can occur if you specify the domain of fx and fy
+ // explicitly, but there is no mark instance associated with some values in
+ // the domain. Expunge empty facets, and clear the corresponding elements
+ // from the nested index in each mark.
+ const nonEmpty = new Set();
+ for (const {facetsIndex} of facetStateByMark.values()) {
+ facetsIndex?.forEach((index, i) => {
+ if (index?.length > 0) {
+ nonEmpty.add(i);
+ }
+ });
+ }
+ if (0 < nonEmpty.size && nonEmpty.size < facets.length) {
+ facets = facets.filter((_, i) => nonEmpty.has(i));
+ for (const state of facetStateByMark.values()) {
+ const {facetsIndex} = state;
+ if (!facetsIndex) continue;
+ state.facetsIndex = facetsIndex.filter((_, i) => nonEmpty.has(i));
+ }
+ }
+
+ // For any mark using the “exclude” facet mode, invert the index.
+ for (const mark of marks) {
+ if (mark.facet === "exclude") {
+ const facetState = facetStateByMark.get(mark);
+ facetState.facetsIndex = excludeIndex(facetState.facetsIndex);
+ }
+ }
+ }
// If a scale is explicitly declared in options, initialize its associated
// channels to the empty array; this will guarantee that a corresponding scale
- // will be created later (even if there are no other channels). But ignore
- // facet scale declarations if faceting is not enabled.
+ // will be created later (even if there are no other channels). Ignore facet
+ // scale declarations, which are handled above.
for (const key of scaleRegistry.keys()) {
if (isScaleOptions(options[key]) && key !== "fx" && key !== "fy") {
channelsByScale.set(key, []);
}
}
- // Faceting!
- let facets; // array of facet definitions (e.g. [["foo", [0, 1, 3, …]], …])
- let facetIndex; // index over the facet data, e.g. [0, 1, 2, 3, …]
- let facetChannels; // e.g. {fx: {value}, fy: {value}}
- let facetsIndex; // nested array of facet indexes [[0, 1, 3, …], [2, 5, …], …]
- let facetsExclude; // lazily-constructed opposite of facetsIndex
- let facetData;
- if (facet !== undefined) {
- const {x, y} = facet;
- if (x != null || y != null) {
- facetData = arrayify(facet.data);
- if (facetData == null) throw new Error("missing facet data");
- facetChannels = {};
- if (x != null) {
- const fx = Channel(facetData, {value: x, scale: "fx"});
- applyScaleTransforms({fx}, options);
- facetChannels.fx = fx;
- channelsByScale.set("fx", [fx]);
- }
- if (y != null) {
- const fy = Channel(facetData, {value: y, scale: "fy"});
- applyScaleTransforms({fy}, options);
- facetChannels.fy = fy;
- channelsByScale.set("fy", [fy]);
- }
- facetIndex = range(facetData);
- facets = facetGroups(facetIndex, facetChannels);
- facetsIndex = facets.map(second);
- }
- }
+ // A Map from Mark instance to its render state, including:
+ // index - the data index e.g. [0, 1, 2, 3, …]
+ // channels - an array of materialized channels e.g. [["x", {value}], …]
+ // faceted - a boolean indicating whether this mark is faceted
+ // values - an object of scaled values e.g. {x: [40, 32, …], …}
+ const stateByMark = new Map();
// Initialize the marks’ state.
for (const mark of marks) {
if (stateByMark.has(mark)) throw new Error("duplicate mark; each mark must be unique");
- const markFacets =
- facetsIndex === undefined
- ? undefined
- : mark.facet === "auto"
- ? mark.data === facet.data
- ? facetsIndex
- : undefined
- : mark.facet === "include"
- ? facetsIndex
- : mark.facet === "exclude"
- ? facetsExclude || (facetsExclude = facetsIndex.map((f) => Uint32Array.from(difference(facetIndex, f))))
- : undefined;
- const {data, facets, channels} = mark.initialize(markFacets, facetChannels);
+ const {facetsIndex, channels: facetChannels} = facetStateByMark.get(mark) || {};
+ const {data, facets, channels} = mark.initialize(facetsIndex, facetChannels);
applyScaleTransforms(channels, options);
stateByMark.set(mark, {data, facets, channels});
-
- // Warn for the common pitfall of wanting to facet mapped data.
- if (
- facetIndex?.length > 1 && // non-trivial faceting
- mark.facet === "auto" && // no explicit mark facet option
- mark.data !== facet.data && // mark not implicitly faceted (different data)
- arrayify(mark.data)?.length === facetData.length // mark data seems parallel to facet data
- ) {
- warn(
- `Warning: the ${mark.ariaLabel} mark appears to use faceted data, but isn’t faceted. The mark data has the same length as the facet data and the mark facet option is "auto", but the mark data and facet data are distinct. If this mark should be faceted, set the mark facet option to true; otherwise, suppress this warning by setting the mark facet option to false.`
- );
- }
}
// Initalize the scales and axes.
@@ -159,8 +163,11 @@ export function plot(options = {}) {
// Reconstruct scales if new scaled channels were created during reinitialization.
if (newByScale.size) {
- for (const key of newByScale)
- if (scaleRegistry.get(key) === position) throw new Error(`initializers cannot declare position scales: ${key}`);
+ for (const key of newByScale) {
+ if (scaleRegistry.get(key) === position) {
+ throw new Error(`initializers cannot declare position scales: ${key}`);
+ }
+ }
const newScaleDescriptors = Scales(
addScaleChannels(new Map(), stateByMark, (key) => newByScale.has(key)),
options
@@ -223,11 +230,19 @@ export function plot(options = {}) {
// Render (possibly faceted) marks.
if (facets !== undefined) {
- const fyDomain = fy && fy.domain();
- const fxDomain = fx && fx.domain();
- const indexByFacet = facetMap(facetChannels);
- facets.forEach(([key], i) => indexByFacet.set(key, i));
+ const fxDomain = fx?.domain();
+ const fyDomain = fy?.domain();
const selection = select(svg);
+ // When faceting by both fx and fy, this nested Map allows to look up the
+ // non-empty facets and draw the grid lines properly.
+ const fxy =
+ fx && fy && (axes.x || axes.y)
+ ? group(
+ facets,
+ ({x}) => x,
+ ({y}) => y
+ )
+ : undefined;
if (fy && axes.y) {
const axis1 = axes.y,
axis2 = nolabel(axis1);
@@ -243,7 +258,7 @@ export function plot(options = {}) {
.enter()
.append((ky, i) =>
(i === j ? axis1 : axis2).render(
- fx && where(fxDomain, (kx) => indexByFacet.has([kx, ky])),
+ fx && where(fxDomain, (kx) => fxy.get(kx).has(ky)),
scales,
{...dimensions, ...fyMargins, offsetTop: fy(ky)},
context
@@ -262,7 +277,7 @@ export function plot(options = {}) {
.enter()
.append((kx, i) =>
(i === j ? axis1 : axis2).render(
- fy && where(fyDomain, (ky) => indexByFacet.has([kx, ky])),
+ fy && where(fyDomain, (ky) => fxy.get(kx).has(ky)),
scales,
{
...dimensions,
@@ -275,24 +290,37 @@ export function plot(options = {}) {
)
);
}
+
+ // Render facets in the order of the fx-fy domain, which might not be the
+ // ordering used to build the nested index initially; see domainChannel.
+ const facetPosition = new Map(facets.map((f, j) => [f, j]));
selection
.selectAll()
- .data(facetKeys(scales).filter(indexByFacet.has, indexByFacet))
+ .data(facetKeys(facets, fx, fy))
.enter()
.append("g")
.attr("aria-label", "facet")
.attr("transform", facetTranslate(fx, fy))
.each(function (key) {
- const j = indexByFacet.get(key);
for (const [mark, {channels, values, facets}] of stateByMark) {
- const facet = facets ? mark.filter(facets[j] ?? facets[0], channels, values) : null;
+ let facet = null;
+ if (facets) {
+ facet = facets[facetPosition.get(key)] ?? facets[0];
+ if (!facet) continue;
+ facet = mark.filter(facet, channels, values);
+ }
const node = mark.render(facet, scales, values, subdimensions, context);
if (node != null) this.appendChild(node);
}
});
} else {
for (const [mark, {channels, values, facets}] of stateByMark) {
- const facet = facets ? mark.filter(facets[0], channels, values) : null;
+ let facet = null;
+ if (facets) {
+ facet = facets[0];
+ if (!facet) continue;
+ facet = mark.filter(facet, channels, values);
+ }
const node = mark.render(facet, scales, values, dimensions, context);
if (node != null) svg.appendChild(node);
}
@@ -336,15 +364,18 @@ export function plot(options = {}) {
export class Mark {
constructor(data, channels = {}, options = {}, defaults) {
- const {facet = "auto", sort, dx, dy, clip, channels: extraChannels} = options;
+ const {facet = "auto", fx, fy, sort, dx, dy, clip, channels: extraChannels} = options;
this.data = data;
this.sort = isDomainSort(sort) ? sort : null;
this.initializer = initializer(options).initializer;
this.transform = this.initializer ? options.transform : basic(options).transform;
- this.facet =
- facet == null || facet === false
- ? null
- : keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]);
+ if (facet === null || facet === false) {
+ this.facet = null;
+ } else {
+ this.facet = keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]);
+ this.fx = fx;
+ this.fy = fy;
+ }
channels = maybeNamed(channels);
if (extraChannels !== undefined) channels = {...maybeNamed(extraChannels), ...channels};
if (defaults !== undefined) channels = {...styles(this, options, defaults), ...channels};
@@ -364,7 +395,7 @@ export class Mark {
if (facets === undefined && data != null) facets = [range(data)];
if (this.transform != null) ({facets, data} = this.transform(data, facets)), (data = arrayify(data));
const channels = Channels(this.channels, data);
- if (this.sort != null) channelDomain(channels, facetChannels, data, this.sort);
+ if (this.sort != null) channelDomain(channels, facetChannels, data, this.sort); // mutates facetChannels!
return {data, facets, channels};
}
filter(index, channels, values) {
@@ -465,8 +496,8 @@ function addScaleChannels(channelsByScale, stateByMark, filter = yes) {
const channel = channels[name];
const {scale} = channel;
if (scale != null && filter(scale)) {
- const channels = channelsByScale.get(scale);
- if (channels !== undefined) channels.push(channel);
+ const scaleChannels = channelsByScale.get(scale);
+ if (scaleChannels !== undefined) scaleChannels.push(channel);
else channelsByScale.set(scale, [channel]);
}
}
@@ -488,73 +519,134 @@ function nolabel(axis) {
: Object.assign(Object.create(axis), {label: undefined});
}
-// Unlike facetGroups, which returns groups in order of input data, this returns
-// keys in order of the associated scale’s domains.
-function facetKeys({fx, fy}) {
- return fx && fy ? cross(fx.domain(), fy.domain()) : fx ? fx.domain() : fy.domain();
+// Returns an array of {x?, y?} objects representing the facet domain.
+function Facets(channelsByScale, options) {
+ const {fx, fy} = Scales(channelsByScale, options);
+ const fxDomain = fx?.scale.domain();
+ const fyDomain = fy?.scale.domain();
+ return fxDomain && fyDomain
+ ? cross(fxDomain, fyDomain).map(([x, y]) => ({x, y}))
+ : fxDomain
+ ? fxDomain.map((x) => ({x}))
+ : fyDomain
+ ? fyDomain.map((y) => ({y}))
+ : undefined;
}
-// Returns an array of [[key1, index1], [key2, index2], …] representing the data
-// indexes associated with each facet. For two-dimensional faceting, each key
-// is a two-element array; see also facetMap.
-function facetGroups(index, {fx, fy}) {
+// Returns keys in order of the associated scale’s domains. (We don’t want to
+// recompute the keys here because facets may already be filtered, and facets
+// isn’t sorted because it’s constructed prior to the other mark channels.)
+function facetKeys(facets, fx, fy) {
+ const fxI = fx && new InternMap(fx.domain().map((x, i) => [x, i]));
+ const fyI = fy && new InternMap(fy.domain().map((y, i) => [y, i]));
+ return sort(facets, (a, b) => (fxI && fxI.get(a.x) - fxI.get(b.x)) || (fyI && fyI.get(a.y) - fyI.get(b.y)));
+}
+
+// Returns a (possibly nested) Map of [[key1, index1], [key2, index2], …]
+// representing the data indexes associated with each facet.
+function facetGroups(data, {fx, fy}) {
+ const index = range(data);
return fx && fy ? facetGroup2(index, fx, fy) : fx ? facetGroup1(index, fx) : facetGroup1(index, fy);
}
function facetGroup1(index, {value: F}) {
- return groups(index, (i) => F[i]);
+ return group(index, (i) => F[i]);
}
function facetGroup2(index, {value: FX}, {value: FY}) {
- return groups(
+ return group(
index,
(i) => FX[i],
(i) => FY[i]
- ).flatMap(([x, xgroup]) => xgroup.map(([y, ygroup]) => [[x, y], ygroup]));
+ );
}
-// This must match the key structure returned by facetGroups.
function facetTranslate(fx, fy) {
return fx && fy
- ? ([kx, ky]) => `translate(${fx(kx)},${fy(ky)})`
+ ? ({x, y}) => `translate(${fx(x)},${fy(y)})`
: fx
- ? (kx) => `translate(${fx(kx)},0)`
- : (ky) => `translate(0,${fy(ky)})`;
+ ? ({x}) => `translate(${fx(x)},0)`
+ : ({y}) => `translate(0,${fy(y)})`;
+}
+
+// Returns an index that for each facet lists all the elements present in other
+// facets in the original index. TODO Memoize to avoid repeated work?
+function excludeIndex(index) {
+ const ex = [];
+ const e = new Uint32Array(sum(index, (d) => d.length));
+ for (const i of index) {
+ let n = 0;
+ for (const j of index) {
+ if (i === j) continue;
+ e.set(j, n);
+ n += j.length;
+ }
+ ex.push(e.slice(0, n));
+ }
+ return ex;
}
-function facetMap({fx, fy}) {
- return new (fx && fy ? FacetMap2 : FacetMap)();
+// Returns the facet groups, and possibly fx and fy channels, associated with
+// the top-level facet option {data, x, y}.
+function maybeTopFacet(facet, options) {
+ if (facet == null) return;
+ const {x, y} = facet;
+ if (x == null && y == null) return;
+ const data = arrayify(facet.data);
+ if (data == null) throw new Error(`missing facet data`);
+ const channels = {};
+ if (x != null) channels.fx = Channel(data, {value: x, scale: "fx"});
+ if (y != null) channels.fy = Channel(data, {value: y, scale: "fy"});
+ applyScaleTransforms(channels, options);
+ const groups = facetGroups(data, channels);
+ // When the top-level facet option generated several frames, track the
+ // corresponding data length in order to compare it for the warning above.
+ const dataLength =
+ groups.size > 1 || (channels.fx && channels.fy && groups.size === 1 && [...groups][0][1].size > 1)
+ ? data.length
+ : undefined;
+ return {channels, groups, data: facet.data, dataLength};
}
-class FacetMap {
- constructor() {
- this._ = new InternMap();
- }
- has(key) {
- return this._.has(key);
- }
- get(key) {
- return this._.get(key);
+// Returns the facet groups, and possibly fx and fy channels, associated with a
+// mark, either through top-level faceting or mark-level facet options {fx, fy}.
+function maybeMarkFacet(mark, topFacetState, options) {
+ if (mark.facet === null) return;
+
+ // This mark defines a mark-level facet. TODO There’s some code duplication
+ // here with maybeTopFacet that we could reduce.
+ const {fx: x, fy: y} = mark;
+ if (x != null || y != null) {
+ const data = arrayify(mark.data);
+ if (data == null) throw new Error(`missing facet data in ${mark.ariaLabel}`);
+ const channels = {};
+ if (x != null) channels.fx = Channel(data, {value: x, scale: "fx"});
+ if (y != null) channels.fy = Channel(data, {value: y, scale: "fy"});
+ applyScaleTransforms(channels, options);
+ return {channels, groups: facetGroups(data, channels)};
}
- set(key, value) {
- return this._.set(key, value), this;
+
+ // This mark links to a top-level facet, if present.
+ if (topFacetState === undefined) return;
+
+ // TODO Can we link the top-level facet channels here?
+ const {channels, groups, data, dataLength} = topFacetState;
+ if (mark.facet !== "auto" || mark.data === data) return {channels, groups};
+
+ // Warn for the common pitfall of wanting to facet mapped data. See above for
+ // the initialization of dataLength.
+ if (dataLength !== undefined && arrayify(mark.data)?.length === dataLength) {
+ warn(
+ `Warning: the ${mark.ariaLabel} mark appears to use faceted data, but isn’t faceted. The mark data has the same length as the facet data and the mark facet option is "auto", but the mark data and facet data are distinct. If this mark should be faceted, set the mark facet option to true; otherwise, suppress this warning by setting the mark facet option to false.`
+ );
}
}
-// A Map-like interface that supports paired keys.
-class FacetMap2 extends FacetMap {
- has([key1, key2]) {
- const map = super.get(key1);
- return map ? map.has(key2) : false;
- }
- get([key1, key2]) {
- const map = super.get(key1);
- return map && map.get(key2);
- }
- set([key1, key2], value) {
- const map = super.get(key1);
- if (map) map.set(key2, value);
- else super.set(key1, new InternMap([[key2, value]]));
- return this;
- }
+// Facet filter, by mark; for now only the "eq" filter is provided.
+function filterFacets(facets, {channels: {fx, fy}, groups}) {
+ return fx && fy
+ ? facets.map(({x, y}) => groups.get(x)?.get(y))
+ : fx
+ ? facets.map(({x}) => groups.get(x))
+ : facets.map(({y}) => groups.get(y));
}
diff --git a/test/output/multiplicationTable.svg b/test/output/multiplicationTable.svg
new file mode 100644
index 0000000000..62c9632384
--- /dev/null
+++ b/test/output/multiplicationTable.svg
@@ -0,0 +1,644 @@
+
\ No newline at end of file
diff --git a/test/output/penguinCulmen.svg b/test/output/penguinCulmen.svg
index ea4102a7dc..02ae4c1549 100644
--- a/test/output/penguinCulmen.svg
+++ b/test/output/penguinCulmen.svg
@@ -134,13 +134,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
@@ -159,7 +247,6 @@
-
@@ -212,197 +299,110 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -483,156 +483,209 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -668,129 +721,76 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -832,225 +832,220 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -1064,7 +1059,6 @@
-
@@ -1085,7 +1079,6 @@
-
@@ -1105,17 +1098,24 @@
-
-
+
+
+
+
+
+
+
+
+
@@ -1185,10 +1185,6 @@
-
-
-
-
@@ -1206,7 +1202,6 @@
-
@@ -1260,196 +1255,201 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -1530,157 +1530,79 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -1716,128 +1638,206 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -1879,224 +1879,112 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -2111,7 +1999,6 @@
-
@@ -2131,7 +2018,6 @@
-
@@ -2150,16 +2036,130 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2228,693 +2228,693 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/output/penguinCulmenMarkFacet.svg b/test/output/penguinCulmenMarkFacet.svg
new file mode 100644
index 0000000000..a6d9782f26
--- /dev/null
+++ b/test/output/penguinCulmenMarkFacet.svg
@@ -0,0 +1,2905 @@
+
\ No newline at end of file
diff --git a/test/output/penguinFacetAnnotated.svg b/test/output/penguinFacetAnnotated.svg
new file mode 100644
index 0000000000..22ea2d9faf
--- /dev/null
+++ b/test/output/penguinFacetAnnotated.svg
@@ -0,0 +1,112 @@
+
\ No newline at end of file
diff --git a/test/output/penguinFacetAnnotatedX.svg b/test/output/penguinFacetAnnotatedX.svg
new file mode 100644
index 0000000000..0544b94b53
--- /dev/null
+++ b/test/output/penguinFacetAnnotatedX.svg
@@ -0,0 +1,100 @@
+
\ No newline at end of file
diff --git a/test/plots/index.js b/test/plots/index.js
index 9b4a043bf8..2813db680b 100644
--- a/test/plots/index.js
+++ b/test/plots/index.js
@@ -139,6 +139,7 @@ export {default as morleyBoxplot} from "./morley-boxplot.js";
export {default as moviesProfitByGenre} from "./movies-profit-by-genre.js";
export {default as moviesRatingByGenre} from "./movies-rating-by-genre.js";
export {default as musicRevenue} from "./music-revenue.js";
+export {default as multiplicationTable} from "./multiplication-table.js";
export {default as ordinalBar} from "./ordinal-bar.js";
export {default as penguinAnnotated} from "./penguin-annotated.js";
export {default as penguinCulmen} from "./penguin-culmen.js";
@@ -146,6 +147,7 @@ export {default as penguinCulmenArray} from "./penguin-culmen-array.js";
export {default as penguinCulmenDelaunay} from "./penguin-culmen-delaunay.js";
export {default as penguinCulmenDelaunayMesh} from "./penguin-culmen-delaunay-mesh.js";
export {default as penguinCulmenDelaunaySpecies} from "./penguin-culmen-delaunay-species.js";
+export {default as penguinCulmenMarkFacet} from "./penguin-culmen-mark-facet.js";
export {default as penguinCulmenVoronoi} from "./penguin-culmen-voronoi.js";
export {default as penguinVoronoi1D} from "./penguin-voronoi-1d.js";
export {default as penguinDensity} from "./penguin-density.js";
@@ -154,6 +156,8 @@ export {default as penguinDensityZ} from "./penguin-density-z.js";
export {default as penguinDodge} from "./penguin-dodge.js";
export {default as penguinDodgeHexbin} from "./penguin-dodge-hexbin.js";
export {default as penguinDodgeVoronoi} from "./penguin-dodge-voronoi.js";
+export {default as penguinFacetAnnotated} from "./penguins-facet-annotated.js";
+export {default as penguinFacetAnnotatedX} from "./penguins-facet-annotated-x.js";
export {default as penguinFacetDodge} from "./penguin-facet-dodge.js";
export {default as penguinFacetDodgeIdentity} from "./penguin-facet-dodge-identity.js";
export {default as penguinFacetDodgeIsland} from "./penguin-facet-dodge-island.js";
diff --git a/test/plots/multiplication-table.js b/test/plots/multiplication-table.js
new file mode 100644
index 0000000000..23a227c590
--- /dev/null
+++ b/test/plots/multiplication-table.js
@@ -0,0 +1,44 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function () {
+ const numbers = d3.range(2, 10);
+ return Plot.plot({
+ height: 450,
+ width: 450,
+ padding: 0,
+ color: {type: "categorical"},
+ fx: {axis: "top", tickSize: 6},
+ fy: {tickSize: 6},
+ marks: [
+ // This rect is faceted by y and repeated across x, and hence all rects in
+ // a row have the same fill. With rect, the default definitions of x1, x2,
+ // y1, and y2 will fill the entire frame, similar to Plot.frame.
+ Plot.rect(numbers, {
+ fy: numbers,
+ fill: numbers,
+ inset: 1
+ }),
+ // This dot is faceted by x and repeated across y, and hence all dots in a
+ // column have the same fill. With dot, the default definitions of x and y
+ // would assume that the data is a tuple [x, y], so we set the frameAnchor
+ // to middle to draw one dot in the center of each frame.
+ Plot.dot(numbers, {
+ frameAnchor: "middle",
+ r: 19,
+ fx: numbers,
+ fill: numbers,
+ stroke: "white"
+ }),
+ // This text is faceted by x and y, and hence we need the cross product of
+ // the numbers. Again there is just one text mark per facet.
+ Plot.text(d3.cross(numbers, numbers), {
+ frameAnchor: "middle",
+ text: ([x, y]) => x * y,
+ fill: "white",
+ fx: ([x]) => x,
+ fy: ([, y]) => y
+ })
+ ]
+ });
+}
diff --git a/test/plots/penguin-culmen-mark-facet.js b/test/plots/penguin-culmen-mark-facet.js
new file mode 100644
index 0000000000..37b1d8e47a
--- /dev/null
+++ b/test/plots/penguin-culmen-mark-facet.js
@@ -0,0 +1,28 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function () {
+ const data = await d3.csv("data/penguins.csv", d3.autoType);
+ return Plot.plot({
+ height: 600,
+ facet: {marginRight: 80},
+ marks: [
+ Plot.frame(),
+ Plot.dot(data, {
+ fx: "sex",
+ fy: "species",
+ facet: "exclude",
+ x: "culmen_depth_mm",
+ y: "culmen_length_mm",
+ r: 2,
+ fill: "#ddd"
+ }),
+ Plot.dot(data, {
+ fx: "sex",
+ fy: "species",
+ x: "culmen_depth_mm",
+ y: "culmen_length_mm"
+ })
+ ]
+ });
+}
diff --git a/test/plots/penguins-facet-annotated-x.js b/test/plots/penguins-facet-annotated-x.js
new file mode 100644
index 0000000000..f7d1ddb2e2
--- /dev/null
+++ b/test/plots/penguins-facet-annotated-x.js
@@ -0,0 +1,21 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function () {
+ const penguins = await d3.csv("data/penguins.csv", d3.autoType);
+ return Plot.plot({
+ marginLeft: 75,
+ x: {insetRight: 10},
+ marks: [
+ Plot.frame(),
+ Plot.barX(penguins, Plot.groupY({x: "count"}, {fx: "island", y: "species", fill: "sex"})),
+ Plot.text(["Torgersen Island only has Adelie penguins!"], {
+ fx: ["Torgersen"],
+ frameAnchor: "top-right",
+ dy: 4,
+ dx: -4,
+ lineWidth: 10
+ })
+ ]
+ });
+}
diff --git a/test/plots/penguins-facet-annotated.js b/test/plots/penguins-facet-annotated.js
new file mode 100644
index 0000000000..3f9bea5b21
--- /dev/null
+++ b/test/plots/penguins-facet-annotated.js
@@ -0,0 +1,22 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export default async function () {
+ const penguins = await d3.csv("data/penguins.csv", d3.autoType);
+ return Plot.plot({
+ marginLeft: 75,
+ marginRight: 70,
+ x: {insetRight: 10},
+ facet: {marginRight: 70},
+ marks: [
+ Plot.frame(),
+ Plot.barX(penguins, Plot.groupY({x: "count"}, {fy: "island", y: "species", fill: "sex"})),
+ Plot.text(["Torgersen Island only has Adelie penguins!"], {
+ fy: ["Torgersen"],
+ frameAnchor: "top-right",
+ dy: 4,
+ dx: -4
+ })
+ ]
+ });
+}