Skip to content

Commit 44b4a1d

Browse files
mbostockFil
andauthored
raster mark (observablehq#1196)
* image data mark * PreTtiER * handle invalid data; stride, offset * handle flipped images * archive test failure artifacts * skip image data tests, for now * PreTtiER * only ignore generated images in CI * only ignore large generated images * fillOpacity * tweak * fix formula * PreTtiER * volcano * more idiomatic heatmap * fill as f(x, y) * pixel midpoints * PreTtiER * not pixelated, again * PreTtiER * raster * pixelRatio * fix aria-label; comments * Goldstein–Price * tentative documentation for Plot.raster * fix partial coverage of sample fill * raster fillOpacity * require x1, y1, x2, y2 * validate width, height * fix for sparse samples * better error on missing scales * document * floor rounded (or floored?) * exploration for a "nearest" raster interpolate method * barycentric interpolation see https://observablehq.com/@visionscarto/igrf-90 * raster tuple shorthand * barycentric interpolate and extrapolate * only maybeTuple if isTuples * allow marks to apply scales selectively (like we do with projections) * interpolate on values * 3 interpolation methods for the nearest neighbor: voronoi renderCell, quadree.find, delaunay.find. This is completely gratuitous since they all run in less than 1ms… It's even hard to know which one is the fastest, because if I loop on 100s of them the browser starts to thrash (allocating so much memory for images it immediately discards, I guess…) * barycentric walmart * fold mark.project into mark.scale * fix barycentric extrapolation * materialize fewer arrays * use channel names * don’t pass {r, g, b, a} * don’t overload x & y channels * fix inverted x or y; simplify example * simpler * fix grid orientation * only stroke if opaque * optional x1, y1, x2, y2 * shorten * fix order * const * rasterize * The performance measurements I had done were just rubbish (I forgot to await on the promises!). Measuring the three methods on the ca55 dataset I see this order: voronoi cellRender (180ms), delaunay find (220ms), quadtree (500ms). * rasterize * tolerance for points that are on a triangle's edge * use a symbol for values that need extrapolation, simplify and fix a few issues, use a mixing function for categorical interpolation * rasterize with walk on spheres * document rasterize * pixelSize * default to full frame * remove ignored options * reformat options * fix the ca55 tests (the coordinates represent a planar projection) * caveat about webkit/safari * remove console.log * more built-in rasterizers * fix walk-on-spheres implementation; remove blur * port fixes to wos * adaptive extrapolation * fillOpacity fixes * renames walk-on-spheres to random-walk; documents the rasterize option rationale for the renaming: "random-walk" is more commonly known, and expresses well enough what's happening. Walk on spheres converges much faster than a basic random walk would, and makes it feasible, but it is a question of implementation. * a constant fillOpacity informs the opacity property on the g element, not the opacity of each pixel * fix bug with projection clip in indirectStyles * performance optimizations for randow-walk: 1. use rasterizeNull to boot; if we have more samples (and a costlier delaunay), at least we have less pixels to impute. 2. cache more aggressively the result of delaunay.find: at the beginning of each line, for each pixel, and for each step of the walk On actual tests it can be up to 2x faster. * sample pixel centroids * fix handling of undefined values * use transform for equirectangular coordinates * don’t delete * stroke if constant fillOpacity * fix test snapshots * fix typo in test name * note potential bias caused by stroke * rename tests * don’t bootstrap random-walk with none * terminate walk when minimum distance is reached * comment re. opacity * comment re. none order bias * contour mark * dense grid contours * consolidate code * more code consolidation * cleaner * cleaner deferred channels * interpolate, not rasterize * blur * cleaner * use typed array when possible * optimize barycentric interpolation * nicer contours for ca55 with barycentric+blur 3; support raster blur Contour blurring is unchanged, and blurs the abstract data (with a linear interpolation). Raster blurring is made with d3.blurImage. Two consequences: * we can now blur “categorical” colors, if we want to smooth out the image and give it a polished look in the higher variance regions. (This works very well when we have two colors, but with more categories there is a risk of hiding the components of a color, making the image more difficult to understand. Anyway, it’s available as an option to play with.) * for quantitative data, and with a color scale with continuous scheme and linear transform, this is very close to linear interpolation; but if the underlying data is better rendered with a log color scale, the color interpolation takes this into account (which IMO is better). * ignore negative blur * cleaner tests * for contours, filter points with missing X and Y before calling the interpolate function, and ignore x and y filters on geometries * fix barycentric interpolate for filtered points note: the penguins dataset is full of surprises since some points are occluded by others of a different species… * contour shorthands * fix contour filtering * filter value, too * materialize x and y when needed * default to nearest * comment * remove obsolete opacity trick * better contour thresholds; fix test * nullish instead of undefined * renderBounds * fix circular import * a hand-written Peters projection seemed more fun than the sqrt scale; tests the same thing * update raster documentation with interpolate; document contour * document Plot.identity * peters axes * symmetric Peters * style tweak * NaN instead of null * avoid error when empty quantile domain * faceted sampler raster * fix test snapshot * faceted contour; fix dense faceted raster … and fix default contour thresholds * expose spatial interpolators * pass x, y, step * error when data undefined, but not null * d3 7.8.1 Co-authored-by: Philippe Rivière <[email protected]>
1 parent e44f007 commit 44b4a1d

57 files changed

Lines changed: 9124 additions & 560 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/node.js.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,9 @@ jobs:
2727
echo ::add-matcher::.github/eslint.json
2828
yarn run eslint . --format=compact
2929
- run: yarn test
30+
- name: Test artifacts
31+
uses: actions/upload-artifact@v3
32+
if: failure()
33+
with:
34+
name: test-output-changes
35+
path: test/output/*-changed.*

README.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,6 +1112,66 @@ Equivalent to [Plot.cell](#plotcelldata-options), except that if the **y** optio
11121112
11131113
<!-- jsdocEnd cellY -->
11141114
1115+
1116+
### Contour
1117+
1118+
[Source](./src/marks/contour.js) · [Examples](https://observablehq.com/@observablehq/plot-contour) · Renders contour polygons from two-dimensional samples.
1119+
1120+
#### Plot.contour(*data*, *options*)
1121+
1122+
<!-- jsdoc contour -->
1123+
1124+
Returns a new contour mark with the given *data* and *options*. The *data* represents a discrete set of samples in abstract coordinates, bound to the scales *x* and *y*, and a **value** channel.
1125+
1126+
Most of the options are identical to the [raster](#raster) mark’s options, which is used internally to compute a rectangular grid of numeric values. Marching squares are then applied to derive the contour polygons for each threshold value.
1127+
1128+
The following options define the value channel and the aesthetics of the contours:
1129+
* **value** - the sample’s value (a channel); as a shorthand notation, it can be defined by setting either fill, fillOpacity or stroke
1130+
* **fill** - the contour’s fill color; if a channel, bound to the *color* scale
1131+
* **fillOpacity** - the contour’s opacity; if a channel, bound to the *opacity* scale
1132+
* **stroke** - the contour’s stroke color; if a channel, bound to the *color* scale; defaults to currentColor
1133+
* **strokeOpacity** - the (constant or variable) contour’s stroke opacity; if a channel, bound to the *opacity* scale; defaults to 1
1134+
* **strokeWidth** - the (constant or variable) contour’s stroke width; defaults to 1
1135+
* **thresholds** - the thresholds — an array of threshold values; if a *count* is specified instead of an array of thresholds, then the input values’ extent will be uniformly divided into approximately *count* bins. Defaults to [Sturges’s formula](https://github.com/d3/d3-contour/blob/main/README.md#contours_thresholds).
1136+
* **x** and **y** - the sample’s coordinates.
1137+
* **interpolate** - the interpolate method (see [raster](#raster) for details).
1138+
* **blur** - the blur radius, a non-negative number of pixels, that defaults to 0.
1139+
1140+
Each sample is projected onto the coordinate system of a rectangle with dimensions that may be specified directly with the following options:
1141+
1142+
* **width** - the number of pixels on each horizontal line
1143+
* **height** - the number of lines; a positive integer
1144+
1145+
Alternatively, the width and height of the raster can be imputed from the starting and ending positions for x and y, and a pixel size:
1146+
1147+
* **x1** - the starting horizontal position; bound to the *x* scale
1148+
* **x2** - the ending horizontal position; bound to the *x* scale
1149+
* **y1** - the starting vertical position; bound to the *y* scale
1150+
* **y2** - the ending vertical position; bound to the *y* scale
1151+
* **pixelSize** - the density of the raster image; defaults to 1
1152+
1153+
If a width has been specified, x1 defaults to 0 and x2 defaults to width; similarly, if a height has been specified, y1 defaults to 0 and y2 defaults to height. Otherwise, if data has been specified, x1, y1, x2, and y2 respectively default to the frame’s left, top, right, and bottom, coordinates. Lastly, if no data has been specified, and fill is a function of x and y, you must specify all of x1, x2, y1 and y2 to define the domain (see below).
1154+
1155+
The defaults for this mark make it convenient to draw thresholds from a flat array of values representing a rectangular matrix:
1156+
1157+
```js
1158+
Plot.contour(volcano.values, {width: volcano.width, height: volcano.height, fill: volcano.values, thresholds: 5})
1159+
```
1160+
1161+
When *data* is not specified and *value* is a function, a sample is taken for every pixel of the raster, which allows to draw contours from a function and a two-dimensional domain:
1162+
1163+
```js
1164+
Plot.contour({
1165+
fill: (x, y) => x * y * Math.sin(x) * Math.sin(y),
1166+
x1: 0,
1167+
x2: 2 * Math.PI,
1168+
y1: 0,
1169+
y2: 2 * Math.PI
1170+
})
1171+
```
1172+
1173+
<!-- jsdocEnd contour -->
1174+
11151175
### Delaunay
11161176
11171177
[<img src="./img/voronoi.png" width="320" height="198" alt="a Voronoi diagram of penguin culmens, showing the length and depth of several species">](https://observablehq.com/@observablehq/plot-delaunay)
@@ -1504,6 +1564,63 @@ Returns a new link with the given *data* and *options*.
15041564
15051565
<!-- jsdocEnd link -->
15061566
1567+
### Raster
1568+
1569+
[Source](./src/marks/raster.js) · [Examples](https://observablehq.com/@observablehq/plot-raster) · Fills a raster image with color samples.
1570+
1571+
#### Plot.raster(*data*, *options*)
1572+
1573+
<!-- jsdoc raster -->
1574+
1575+
Returns a new raster mark with the given *data* and *options*. The *data* represents a discrete set of samples in abstract coordinates, bound to the scales *x* and *y*, a **fill** channel bound to the *color* scale, and a **fillOpacity** channel bound to the *opacity* scale.
1576+
1577+
Each sample is drawn on a rectangular raster image with dimensions that may be specified directly with the following options:
1578+
1579+
* **width** - the number of pixels on each horizontal line
1580+
* **height** - the number of lines; a positive integer
1581+
1582+
Alternatively, the width and height of the raster can be imputed from the starting and ending positions for x and y, and a pixel size:
1583+
1584+
* **x1** - the starting horizontal position; bound to the *x* scale
1585+
* **x2** - the ending horizontal position; bound to the *x* scale
1586+
* **y1** - the starting vertical position; bound to the *y* scale
1587+
* **y2** - the ending vertical position; bound to the *y* scale
1588+
* **pixelSize** - the density of the raster image; defaults to 1
1589+
1590+
If a width has been specified, x1 defaults to 0 and x2 defaults to width; similarly, if a height has been specified, y1 defaults to 0 and y2 defaults to height. Otherwise, if data has been specified, x1, y1, x2, and y2 respectively default to the frame’s left, top, right, and bottom, coordinates. Lastly, if no data has been specified, and fill is a function of x and y, you must specify all of x1, x2, y1 and y2 to define the domain (see below).
1591+
1592+
1593+
The following options are supported:
1594+
1595+
* **fill** - the sample’s color; if a channel, bound to the *color* scale
1596+
* **fillOpacity** - the sample’s opacity; if a channel, bound to the *opacity* scale
1597+
* **x** and **y** - the sample’s coordinates
1598+
* **imageRendering** - the [image-rendering](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/image-rendering) attribute of the image; defaults to auto, which blends neighboring samples with bilinear interpolation. A typical setting is pixelated, that asks the browser to render each pixel as a solid rectangle (unfortunately not supported by Webkit).
1599+
* **interpolate** - the interpolate method.
1600+
* **blur** - the blur radius, a non-negative number of pixels, that defaults to 0.
1601+
1602+
The interpolate option supports the following settings:
1603+
* none - default if the *x* and *y* options are not null: assigns the value to the pixel under the (floor rounded) coordinates of each sample—if inside the raster
1604+
* dense - default otherwise; assumes that the data describes every pixel on the raster of dimensions width × height, starting from the top left, in row-major order
1605+
* nearest - evaluates each pixel with the closest sample, resulting in Voronoi cells
1606+
* barycentric - does a Delaunay triangulation of the samples, then evaluates each triangle’s interior with a mix of the values of its vertices, weighted by the distance to each of the vertices; points outside the convex hull are extrapolated
1607+
* random-walk - evaluates a pixel by simulating a random walk, and picking the value of the first sample reached
1608+
* a function that receives a sample index, width and height of the raster, the *x* and *y* positions of the samples (in the coordinate system of the raster), and an array of (unscaled) values, and must return a dense array of width * height values, organized in row-major order.
1609+
1610+
The defaults for this mark make it convenient to draw an image from a flat array of values representing a rectangular matrix:
1611+
1612+
```js
1613+
Plot.raster(volcano.values, {width: volcano.width, height: volcano.height, fill: volcano.values})
1614+
```
1615+
1616+
When *data* is not specified and *fill* or *fillOpacity* is a function, a sample is taken for every pixel of the raster, which allows to fill an image from a function and a two-dimensional domain:
1617+
1618+
```js
1619+
Plot.raster({x1: -1, x2: 1, y1: -1, y2: 1, fill: (x, y) => Math.atan2(y, x)})
1620+
```
1621+
1622+
<!-- jsdocEnd raster -->
1623+
15071624
### Rect
15081625
15091626
[<img src="./img/rect.png" width="320" height="198" alt="a histogram">](https://observablehq.com/@observablehq/plot-rect)
@@ -2771,6 +2888,18 @@ Plot.column is typically used by options transforms to define new channels; the
27712888
27722889
<!-- jsdocEnd column -->
27732890
2891+
#### Plot.identity
2892+
2893+
<!-- jsdoc identity -->
2894+
2895+
This channel helper returns a source array as-is, avoiding an extra copy when defining a channel as being equal to the data:
2896+
2897+
```js
2898+
Plot.raster(await readValues(), {width: 300, height: 200, fill: Plot.identity})
2899+
```
2900+
2901+
<!-- jsdocEnd identity -->
2902+
27742903
## Initializers
27752904
27762905
Initializers can be used to transform and derive new channels prior to rendering. Unlike transforms which operate in abstract data space, initializers can operate in screen space such as pixel coordinates and colors. For example, initializers can modify a marks’ positions to avoid occlusion. Initializers are invoked *after* the initial scales are constructed and can modify the channels or derive new channels; these in turn may (or may not, as desired) be passed to scales.

src/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export {Arrow, arrow} from "./marks/arrow.js";
44
export {BarX, BarY, barX, barY} from "./marks/bar.js";
55
export {boxX, boxY} from "./marks/box.js";
66
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
7+
export {Contour, contour} from "./marks/contour.js";
78
export {delaunayLink, delaunayMesh, hull, voronoi, voronoiMesh} from "./marks/delaunay.js";
89
export {Density, density} from "./marks/density.js";
910
export {Dot, dot, dotX, dotY, circle, hexagon} from "./marks/dot.js";
@@ -14,13 +15,15 @@ export {Image, image} from "./marks/image.js";
1415
export {Line, line, lineX, lineY} from "./marks/line.js";
1516
export {linearRegressionX, linearRegressionY} from "./marks/linearRegression.js";
1617
export {Link, link} from "./marks/link.js";
18+
export {Raster, raster} from "./marks/raster.js";
19+
export {interpolateNone, interpolatorBarycentric, interpolateNearest, interpolatorRandomWalk} from "./marks/raster.js";
1720
export {Rect, rect, rectX, rectY} from "./marks/rect.js";
1821
export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js";
1922
export {Text, text, textX, textY} from "./marks/text.js";
2023
export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
2124
export {tree, cluster} from "./marks/tree.js";
2225
export {Vector, vector, vectorX, vectorY, spike} from "./marks/vector.js";
23-
export {valueof, column} from "./options.js";
26+
export {valueof, column, identity} from "./options.js";
2427
export {filter, reverse, sort, shuffle, basic as transform, initializer} from "./transforms/basic.js";
2528
export {bin, binX, binY} from "./transforms/bin.js";
2629
export {centroid, geoCentroid} from "./transforms/centroid.js";

src/marks/contour.js

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import {blur2, contours, geoPath, map, max, min, range, thresholdSturges} from "d3";
2+
import {Channels} from "../channel.js";
3+
import {create} from "../context.js";
4+
import {labelof, identity} from "../options.js";
5+
import {Position} from "../projection.js";
6+
import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, styles} from "../style.js";
7+
import {initializer} from "../transforms/basic.js";
8+
import {maybeThresholds} from "../transforms/bin.js";
9+
import {AbstractRaster, maybeTuples, rasterBounds, sampler} from "./raster.js";
10+
11+
const defaults = {
12+
ariaLabel: "contour",
13+
fill: "none",
14+
stroke: "currentColor",
15+
strokeMiterlimit: 1,
16+
pixelSize: 2
17+
};
18+
19+
export class Contour extends AbstractRaster {
20+
constructor(data, {value, ...options} = {}) {
21+
const channels = styles({}, options, defaults);
22+
23+
// If value is not specified explicitly, look for a channel to promote. If
24+
// more than one channel is present, throw an error. (To disambiguate,
25+
// specify the value option explicitly.)
26+
if (value === undefined) {
27+
for (const key in channels) {
28+
if (channels[key].value != null) {
29+
if (value !== undefined) throw new Error("ambiguous contour value");
30+
value = options[key];
31+
options[key] = "value";
32+
}
33+
}
34+
}
35+
36+
// For any channel specified as the literal (contour threshold) "value"
37+
// (maybe because of the promotion above), propagate the label from the
38+
// original value definition.
39+
if (value != null) {
40+
const v = {transform: (D) => D.map((d) => d.value), label: labelof(value)};
41+
for (const key in channels) {
42+
if (options[key] === "value") {
43+
options[key] = v;
44+
}
45+
}
46+
}
47+
48+
// If the data is null, then we’ll construct the raster grid by evaluating a
49+
// function for each point in a dense grid. The value channel is populated
50+
// by the sampler initializer, and hence is not passed to super to avoid
51+
// computing it before there’s data.
52+
if (data == null) {
53+
if (typeof value !== "function") throw new Error("invalid contour value");
54+
options = sampler("value", {value, ...options});
55+
value = null;
56+
}
57+
58+
// Otherwise if data was provided, it represents a discrete set of spatial
59+
// samples (often a grid, but not necessarily). If no interpolation method
60+
// was specified, default to nearest.
61+
else {
62+
let {interpolate} = options;
63+
if (value === undefined) value = identity;
64+
if (interpolate === undefined) options.interpolate = "nearest";
65+
}
66+
67+
// Wrap the options in our initializer that computes the contour geometries;
68+
// this runs after any other initializers (and transforms).
69+
super(data, {value: {value, optional: true}}, contourGeometry(options), defaults);
70+
71+
// With the exception of the x, y, x1, y1, x2, y2, and value channels, this
72+
// mark’s channels are not evaluated on the initial data but rather on the
73+
// contour multipolygons generated in the initializer.
74+
const contourChannels = {geometry: {value: identity}};
75+
for (const key in this.channels) {
76+
const channel = this.channels[key];
77+
const {scale} = channel;
78+
if (scale === "x" || scale === "y" || key === "value") continue;
79+
contourChannels[key] = channel;
80+
delete this.channels[key];
81+
}
82+
this.contourChannels = contourChannels;
83+
}
84+
filter(index, {x, y, value, ...channels}, values) {
85+
// Only filter channels constructed by the contourGeometry initializer; the
86+
// x, y, and value channels must be filtered by the initializer itself.
87+
return super.filter(index, channels, values);
88+
}
89+
render(index, scales, channels, dimensions, context) {
90+
const {geometry: G} = channels;
91+
const path = geoPath();
92+
return create("svg:g", context)
93+
.call(applyIndirectStyles, this, dimensions, context)
94+
.call(applyTransform, this, scales)
95+
.call((g) => {
96+
g.selectAll()
97+
.data(index)
98+
.enter()
99+
.append("path")
100+
.call(applyDirectStyles, this)
101+
.attr("d", (i) => path(G[i]))
102+
.call(applyChannelStyles, this, channels);
103+
})
104+
.node();
105+
}
106+
}
107+
108+
function contourGeometry({thresholds, interval, ...options}) {
109+
thresholds = maybeThresholds(thresholds, interval, thresholdSturges);
110+
return initializer(options, function (data, facets, channels, scales, dimensions, context) {
111+
const [x1, y1, x2, y2] = rasterBounds(channels, scales, dimensions, context);
112+
const dx = x2 - x1;
113+
const dy = y2 - y1;
114+
const {pixelSize: k, width: w = Math.round(Math.abs(dx) / k), height: h = Math.round(Math.abs(dy) / k)} = this;
115+
const kx = w / dx;
116+
const ky = h / dy;
117+
const V = channels.value.value;
118+
const VV = []; // V per facet
119+
120+
// Interpolate the raster grid, as needed.
121+
if (this.interpolate) {
122+
const {x: X, y: Y} = Position(channels, scales, context);
123+
// Convert scaled (screen) coordinates to grid (canvas) coordinates.
124+
const IX = map(X, (x) => (x - x1) * kx, Float64Array);
125+
const IY = map(Y, (y) => (y - y1) * ky, Float64Array);
126+
// The contour mark normally skips filtering on x, y, and value, so here
127+
// we’re careful to use different names (0, 1, 2) when filtering.
128+
const ichannels = [channels.x, channels.y, channels.value];
129+
const ivalues = [IX, IY, V];
130+
for (const facet of facets) {
131+
const index = this.filter(facet, ichannels, ivalues);
132+
VV.push(this.interpolate(index, w, h, IX, IY, V));
133+
}
134+
}
135+
136+
// Otherwise, chop up the existing dense raster grid into facets, if needed.
137+
// V must be a dense grid in projected coordinates; if there are multiple
138+
// facets, then V must be laid out vertically as facet 0, 1, 2… etc.
139+
else if (facets) {
140+
const n = w * h;
141+
const m = facets.length;
142+
for (let i = 0; i < m; ++i) VV.push(V.slice(i * n, i * n + n));
143+
} else {
144+
VV.push(V);
145+
}
146+
147+
// Blur the raster grid, if desired.
148+
if (this.blur > 0) for (const V of VV) blur2({data: V, width: w, height: h}, this.blur);
149+
150+
// Compute the contour thresholds; d3-contour unlike d3-array doesn’t pass
151+
// the min and max automatically, so we do that here to normalize, and also
152+
// so we can share consistent thresholds across facets. When an interval is
153+
// used, note that the lowest threshold should be below (or equal) to the
154+
// lowest value, or else some data will be missing.
155+
const T =
156+
typeof thresholds?.range === "function"
157+
? thresholds.range(...(([min, max]) => [thresholds.floor(min), max])(finiteExtent(VV)))
158+
: typeof thresholds === "function"
159+
? thresholds(V, ...finiteExtent(VV))
160+
: thresholds;
161+
162+
// Compute the (maybe faceted) contours.
163+
const contour = contours().thresholds(T).size([w, h]);
164+
const contourData = [];
165+
const contourFacets = [];
166+
for (const V of VV) contourFacets.push(range(contourData.length, contourData.push(...contour(V))));
167+
168+
// Rescale the contour multipolygon from grid to screen coordinates.
169+
for (const {coordinates} of contourData) {
170+
for (const rings of coordinates) {
171+
for (const ring of rings) {
172+
for (const point of ring) {
173+
point[0] = point[0] / kx + x1;
174+
point[1] = point[1] / ky + y1;
175+
}
176+
}
177+
}
178+
}
179+
180+
// Compute the deferred channels.
181+
return {
182+
data: contourData,
183+
facets: contourFacets,
184+
channels: Channels(this.contourChannels, contourData)
185+
};
186+
});
187+
}
188+
189+
export function contour() {
190+
return new Contour(...maybeTuples(...arguments));
191+
}
192+
193+
function finiteExtent(VV) {
194+
return [min(VV, (V) => min(V, finite)), max(VV, (V) => max(V, finite))];
195+
}
196+
197+
function finite(x) {
198+
return isFinite(x) ? x : NaN;
199+
}

0 commit comments

Comments
 (0)