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