@oceanmetrics/engine 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/dist/cells.d.ts +84 -0
- package/dist/cells.js +179 -0
- package/dist/chunks.d.ts +29 -0
- package/dist/chunks.js +49 -0
- package/dist/dataset.d.ts +72 -0
- package/dist/dataset.js +115 -0
- package/dist/engine.d.ts +72 -0
- package/dist/engine.js +154 -0
- package/dist/erddap.d.ts +117 -0
- package/dist/erddap.js +224 -0
- package/dist/extent.d.ts +64 -0
- package/dist/extent.js +151 -0
- package/dist/fetchRetry.d.ts +33 -0
- package/dist/fetchRetry.js +76 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +16 -0
- package/dist/lobes.d.ts +24 -0
- package/dist/lobes.js +55 -0
- package/dist/mask.d.ts +71 -0
- package/dist/mask.js +363 -0
- package/dist/preview.d.ts +121 -0
- package/dist/preview.js +205 -0
- package/dist/sql.d.ts +10 -0
- package/dist/sql.js +33 -0
- package/dist/sqlTemplates.d.ts +1 -0
- package/dist/sqlTemplates.js +8 -0
- package/dist/wkb.d.ts +5 -0
- package/dist/wkb.js +80 -0
- package/package.json +71 -4
- package/sql/last_step.sql +31 -0
- package/sql/points_tabledap.sql +21 -0
- package/sql/stats_categorical.sql +36 -0
- package/sql/stats_daily.sql +27 -0
- package/sql/stats_tabledap.sql +36 -0
- package/src/cells.ts +190 -0
- package/src/chunks.ts +58 -0
- package/src/dataset.ts +160 -0
- package/src/engine.ts +190 -0
- package/src/erddap.ts +271 -0
- package/src/extent.ts +172 -0
- package/src/fetchRetry.ts +71 -0
- package/src/index.ts +37 -0
- package/src/lobes.ts +66 -0
- package/src/mask.ts +341 -0
- package/src/preview.ts +282 -0
- package/src/sql.ts +36 -0
- package/src/sqlTemplates.ts +8 -0
- package/src/wkb.ts +81 -0
package/dist/lobes.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// the lobe split of a place: one griddap request per lobe. moved from erddap-places `app/src/lib/gazetteer.ts`
|
|
2
|
+
// (`placeLobes`, `Lobe`, `partBbox`); the logic is unchanged (the Python twin `om_extract.geom.place_lobes` and the
|
|
3
|
+
// parity fixtures in extract/tests/fixtures/mask were generated from it). `LobePlace` is the slice of the gazetteer's
|
|
4
|
+
// `Place` the split reads.
|
|
5
|
+
import { polygonCoordinates } from './wkb.js';
|
|
6
|
+
const partBbox = (rings) => {
|
|
7
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
8
|
+
for (const [x, y] of rings[0]) {
|
|
9
|
+
if (x < x0)
|
|
10
|
+
x0 = x;
|
|
11
|
+
if (x > x1)
|
|
12
|
+
x1 = x;
|
|
13
|
+
if (y < y0)
|
|
14
|
+
y0 = y;
|
|
15
|
+
if (y > y1)
|
|
16
|
+
y1 = y;
|
|
17
|
+
}
|
|
18
|
+
return [x0, y0, x1, y1];
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Split a place into the lobes that each get one griddap request.
|
|
22
|
+
*
|
|
23
|
+
* Parts are grouped by side of the antimeridian, and only when the place actually spans it (a
|
|
24
|
+
* lon extent wider than 180°, as for PMNM): a place wholly on one side stays a single request
|
|
25
|
+
* covering all its parts. The polygon parts themselves are never modified — the gazetteer has
|
|
26
|
+
* already split them at ±180.
|
|
27
|
+
*/
|
|
28
|
+
export function placeLobes(place) {
|
|
29
|
+
const parts = polygonCoordinates(place.geometry);
|
|
30
|
+
if (!parts.length)
|
|
31
|
+
return [];
|
|
32
|
+
const boxes = parts.map(partBbox);
|
|
33
|
+
const x0 = Math.min(...boxes.map((b) => b[0])), x1 = Math.max(...boxes.map((b) => b[2]));
|
|
34
|
+
const split = x1 - x0 > 180;
|
|
35
|
+
const groups = new Map();
|
|
36
|
+
boxes.forEach((b, i) => {
|
|
37
|
+
const g = !split ? 'all' : (b[0] + b[2]) / 2 < 0 ? 'W' : 'E';
|
|
38
|
+
(groups.get(g) ?? groups.set(g, []).get(g)).push(i);
|
|
39
|
+
});
|
|
40
|
+
const lobes = [];
|
|
41
|
+
for (const [g, idx] of groups) {
|
|
42
|
+
const feats = idx.map((i) => ({
|
|
43
|
+
type: 'Feature', properties: { place_id: place.place_id },
|
|
44
|
+
geometry: { type: 'Polygon', coordinates: parts[i] },
|
|
45
|
+
}));
|
|
46
|
+
lobes.push({
|
|
47
|
+
id: `${place.place_id}${g === 'all' ? '' : `:${g}`}`,
|
|
48
|
+
bbox: [Math.min(...idx.map((i) => boxes[i][0])), Math.min(...idx.map((i) => boxes[i][1])),
|
|
49
|
+
Math.max(...idx.map((i) => boxes[i][2])), Math.max(...idx.map((i) => boxes[i][3]))],
|
|
50
|
+
geojson: { type: 'FeatureCollection', features: feats },
|
|
51
|
+
nParts: idx.length,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return lobes.sort((a, b) => a.bbox[0] - b.bbox[0]);
|
|
55
|
+
}
|
package/dist/mask.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Feature, FeatureCollection, Geometry, Polygon } from 'geojson';
|
|
2
|
+
export type GeoInput = FeatureCollection | Feature | Geometry;
|
|
3
|
+
export type MaskMethod = 'auto' | 'scan' | 'turf';
|
|
4
|
+
export interface MaskCell {
|
|
5
|
+
lon: number;
|
|
6
|
+
lat: number;
|
|
7
|
+
weight: number;
|
|
8
|
+
}
|
|
9
|
+
export interface MaskResult {
|
|
10
|
+
cells: MaskCell[];
|
|
11
|
+
nCandidates: number;
|
|
12
|
+
nInside: number;
|
|
13
|
+
method: 'scan' | 'turf';
|
|
14
|
+
ms: number;
|
|
15
|
+
}
|
|
16
|
+
export interface MaskOptions {
|
|
17
|
+
weights?: boolean;
|
|
18
|
+
method?: MaskMethod;
|
|
19
|
+
}
|
|
20
|
+
/** cell edges for a monotonic axis: midpoints between neighbours, ends extrapolated. */
|
|
21
|
+
export declare function axisEdges(v: number[]): number[];
|
|
22
|
+
interface Part {
|
|
23
|
+
rings: number[][][];
|
|
24
|
+
bbox: [number, number, number, number];
|
|
25
|
+
feat: Feature<Polygon>;
|
|
26
|
+
}
|
|
27
|
+
export declare function polygonParts(gj: GeoInput): Part[];
|
|
28
|
+
export declare function countVertices(ps: Part[]): number;
|
|
29
|
+
type Rect = [number, number, number, number];
|
|
30
|
+
/**
|
|
31
|
+
* The ring with every run of vertices that stays beyond ONE side of `r` collapsed to its two ends; null when
|
|
32
|
+
* nothing of it can reach `r`. A vertex is dropped when it and both of its neighbours (in the reduced ring so
|
|
33
|
+
* far) lie beyond the same side: the path through it and the shortcut past it are both inside that half-plane,
|
|
34
|
+
* so the polygon's overlap with `r` is the same. (Judging every vertex against its ORIGINAL neighbours would
|
|
35
|
+
* be wrong: a ring that surrounds the rectangle would lose every vertex.) The first vertex is always kept.
|
|
36
|
+
*/
|
|
37
|
+
export declare function reduceRing(ring: number[][], r: Rect): number[][] | null;
|
|
38
|
+
/** a part's rings reduced to `r`; null when its outer ring cannot reach `r` (a hole that cannot is dropped). */
|
|
39
|
+
export declare function reduceRings(rings: number[][][], r: Rect): number[][][] | null;
|
|
40
|
+
/**
|
|
41
|
+
* Grid cells of the (lon, lat) lattice covered by `geojson`.
|
|
42
|
+
*
|
|
43
|
+
* `method: 'auto'` uses turf point-in-polygon for small problems (< 10,000 candidate cells and
|
|
44
|
+
* < 5,000 vertices) and the scanline fill otherwise; both are exact and agree.
|
|
45
|
+
* `weights: true` (the default) adds the partial-cell area fraction: 1 for interior cells,
|
|
46
|
+
* fractional for boundary cells, and cells whose centre is outside but whose square the polygon
|
|
47
|
+
* clips are included with their fraction too.
|
|
48
|
+
*/
|
|
49
|
+
export declare function gridMask(geojson: GeoInput, lon: number[], lat: number[], opts?: MaskOptions): MaskResult;
|
|
50
|
+
/** a sampling position, as it comes out of a tabledap slab. */
|
|
51
|
+
export interface MaskPoint {
|
|
52
|
+
lon: number;
|
|
53
|
+
lat: number;
|
|
54
|
+
}
|
|
55
|
+
export interface PointMaskResult {
|
|
56
|
+
cells: MaskCell[];
|
|
57
|
+
nCandidates: number;
|
|
58
|
+
nInside: number;
|
|
59
|
+
ms: number;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The positions inside a place polygon, for a **tabledap** (point) dataset.
|
|
63
|
+
*
|
|
64
|
+
* There is no lattice and no partial cell here: a sample is in or out, so every kept position gets
|
|
65
|
+
* weight 1 and the caller can feed the result straight into the same `mask` table the grid path
|
|
66
|
+
* uses. Duplicate positions (one station, many casts and depths) are collapsed, which is what makes
|
|
67
|
+
* this cheap: a two-degree CalCOFI box over five years is tens of stations, not thousands of rows.
|
|
68
|
+
* The bbox of each polygon part prefilters, exactly as `maskTurf` does.
|
|
69
|
+
*/
|
|
70
|
+
export declare function pointMask(geojson: GeoInput, points: MaskPoint[]): PointMaskResult;
|
|
71
|
+
export {};
|
package/dist/mask.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// gridMask: mark the grid cells of an ERDDAP axis lattice that fall inside a place polygon, and
|
|
2
|
+
// give boundary cells a partial-area weight. no spatial extension, no server: plain JS + turf.
|
|
3
|
+
//
|
|
4
|
+
// axis vectors are the dataset's own axis values (any monotonic direction, any spacing). cell edges
|
|
5
|
+
// are the midpoints between neighbours, with the two ends extrapolated by half the end spacing.
|
|
6
|
+
// polygon lobes must already be split at +-180 (the gazetteer does that); each polygon part is
|
|
7
|
+
// handled on its own, so MULTIPOLYGONs work.
|
|
8
|
+
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
|
9
|
+
import bboxClip from '@turf/bbox-clip';
|
|
10
|
+
import area from '@turf/area';
|
|
11
|
+
import { polygon as tPolygon } from '@turf/helpers';
|
|
12
|
+
// ── axis geometry ─────────────────────────────────────────────────────────────
|
|
13
|
+
/** cell edges for a monotonic axis: midpoints between neighbours, ends extrapolated. */
|
|
14
|
+
export function axisEdges(v) {
|
|
15
|
+
const n = v.length;
|
|
16
|
+
if (n === 0)
|
|
17
|
+
return [];
|
|
18
|
+
if (n === 1)
|
|
19
|
+
return [v[0] - 0.5, v[0] + 0.5]; // unknown spacing: assume 1
|
|
20
|
+
const e = new Array(n + 1);
|
|
21
|
+
for (let i = 1; i < n; i++)
|
|
22
|
+
e[i] = (v[i - 1] + v[i]) / 2;
|
|
23
|
+
e[0] = v[0] - (v[1] - v[0]) / 2;
|
|
24
|
+
e[n] = v[n - 1] + (v[n - 1] - v[n - 2]) / 2;
|
|
25
|
+
return e;
|
|
26
|
+
}
|
|
27
|
+
function prepAxis(v) {
|
|
28
|
+
const desc = v.length > 1 && v[1] < v[0];
|
|
29
|
+
const val = desc ? [...v].reverse() : [...v];
|
|
30
|
+
const e = axisEdges(val);
|
|
31
|
+
const lo = e.slice(0, -1), hi = e.slice(1);
|
|
32
|
+
let uniform = val.length > 1 ? val[1] - val[0] : 0;
|
|
33
|
+
for (let i = 2; i < val.length; i++)
|
|
34
|
+
if (Math.abs(val[i] - val[i - 1] - uniform) > Math.abs(uniform) * 1e-6) {
|
|
35
|
+
uniform = 0;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
return { val, lo, hi, uniform, min: e[0], max: e[e.length - 1] };
|
|
39
|
+
}
|
|
40
|
+
/** index of the cell containing x, or -1. uniform axes short-circuit; others binary-search. */
|
|
41
|
+
function cellIndex(a, x) {
|
|
42
|
+
if (x < a.min || x > a.max)
|
|
43
|
+
return -1;
|
|
44
|
+
if (a.uniform) {
|
|
45
|
+
const i = Math.floor((x - a.min) / a.uniform);
|
|
46
|
+
return i < 0 ? 0 : i >= a.val.length ? a.val.length - 1 : i;
|
|
47
|
+
}
|
|
48
|
+
let lo = 0, hi = a.val.length - 1;
|
|
49
|
+
while (lo < hi) {
|
|
50
|
+
const m = (lo + hi) >> 1;
|
|
51
|
+
if (x > a.hi[m])
|
|
52
|
+
lo = m + 1;
|
|
53
|
+
else
|
|
54
|
+
hi = m;
|
|
55
|
+
}
|
|
56
|
+
return lo;
|
|
57
|
+
}
|
|
58
|
+
/** smallest index with hi[i] >= x (the first cell that could contain x or anything above it). */
|
|
59
|
+
function lowerIndex(a, x) {
|
|
60
|
+
if (x <= a.min)
|
|
61
|
+
return 0;
|
|
62
|
+
if (x > a.max)
|
|
63
|
+
return a.val.length;
|
|
64
|
+
return cellIndex(a, x);
|
|
65
|
+
}
|
|
66
|
+
function upperIndex(a, x) {
|
|
67
|
+
if (x >= a.max)
|
|
68
|
+
return a.val.length - 1;
|
|
69
|
+
if (x < a.min)
|
|
70
|
+
return -1;
|
|
71
|
+
return cellIndex(a, x);
|
|
72
|
+
}
|
|
73
|
+
export function polygonParts(gj) {
|
|
74
|
+
const feats = gj.type === 'FeatureCollection' ? gj.features
|
|
75
|
+
: gj.type === 'Feature' ? [gj]
|
|
76
|
+
: [{ type: 'Feature', properties: {}, geometry: gj }];
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const f of feats) {
|
|
79
|
+
const g = f.geometry;
|
|
80
|
+
if (!g)
|
|
81
|
+
continue;
|
|
82
|
+
let polys;
|
|
83
|
+
if (g.type === 'Polygon')
|
|
84
|
+
polys = [g.coordinates];
|
|
85
|
+
else if (g.type === 'MultiPolygon')
|
|
86
|
+
polys = g.coordinates;
|
|
87
|
+
else if (g.type === 'GeometryCollection') {
|
|
88
|
+
for (const sub of g.geometries)
|
|
89
|
+
out.push(...polygonParts(sub));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
continue;
|
|
94
|
+
for (const rings of polys) {
|
|
95
|
+
let xmin = Infinity, xmax = -Infinity, ymin = Infinity, ymax = -Infinity;
|
|
96
|
+
for (const [x, y] of rings[0]) {
|
|
97
|
+
if (x < xmin)
|
|
98
|
+
xmin = x;
|
|
99
|
+
if (x > xmax)
|
|
100
|
+
xmax = x;
|
|
101
|
+
if (y < ymin)
|
|
102
|
+
ymin = y;
|
|
103
|
+
if (y > ymax)
|
|
104
|
+
ymax = y;
|
|
105
|
+
}
|
|
106
|
+
out.push({ rings, bbox: [xmin, ymin, xmax, ymax], feat: tPolygon(rings) });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
export function countVertices(ps) {
|
|
112
|
+
return ps.reduce((s, p) => s + p.rings.reduce((t, r) => t + r.length, 0), 0);
|
|
113
|
+
}
|
|
114
|
+
// ── masking ───────────────────────────────────────────────────────────────────
|
|
115
|
+
const key = (i, j) => i * 1e7 + j;
|
|
116
|
+
/** candidate cells = union over parts of the cells whose centre lies in that part's bbox. */
|
|
117
|
+
function candidates(ps, ax, ay) {
|
|
118
|
+
const set = new Set();
|
|
119
|
+
for (const p of ps) {
|
|
120
|
+
const [x0, y0, x1, y1] = p.bbox;
|
|
121
|
+
const i0 = lowerIndex(ax, x0), i1 = upperIndex(ax, x1);
|
|
122
|
+
const j0 = lowerIndex(ay, y0), j1 = upperIndex(ay, y1);
|
|
123
|
+
for (let i = i0; i <= i1; i++) {
|
|
124
|
+
if (i < 0 || i >= ax.val.length || ax.val[i] < x0 || ax.val[i] > x1)
|
|
125
|
+
continue;
|
|
126
|
+
for (let j = j0; j <= j1; j++) {
|
|
127
|
+
if (j < 0 || j >= ay.val.length || ay.val[j] < y0 || ay.val[j] > y1)
|
|
128
|
+
continue;
|
|
129
|
+
set.add(key(i, j));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return set;
|
|
134
|
+
}
|
|
135
|
+
/** A) turf point-in-polygon per candidate cell centre, with a part-bbox prefilter. */
|
|
136
|
+
function maskTurf(ps, cands, ax, ay) {
|
|
137
|
+
const m = new Set();
|
|
138
|
+
for (const k of cands) {
|
|
139
|
+
const i = Math.floor(k / 1e7), j = k - i * 1e7;
|
|
140
|
+
const x = ax.val[i], y = ay.val[j];
|
|
141
|
+
for (const p of ps) {
|
|
142
|
+
const [x0, y0, x1, y1] = p.bbox;
|
|
143
|
+
if (x < x0 || x > x1 || y < y0 || y > y1)
|
|
144
|
+
continue;
|
|
145
|
+
if (booleanPointInPolygon([x, y], p.feat)) {
|
|
146
|
+
m.add(k);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return m;
|
|
152
|
+
}
|
|
153
|
+
/** B) scanline fill: for each grid row, x-crossings of every ring, fill the columns between pairs. */
|
|
154
|
+
function maskScan(ps, ax, ay) {
|
|
155
|
+
const m = new Set();
|
|
156
|
+
for (const p of ps) {
|
|
157
|
+
const [, y0, , y1] = p.bbox;
|
|
158
|
+
const j0 = Math.max(0, lowerIndex(ay, y0)), j1 = Math.min(ay.val.length - 1, upperIndex(ay, y1));
|
|
159
|
+
for (let j = j0; j <= j1; j++) {
|
|
160
|
+
const y = ay.val[j];
|
|
161
|
+
if (y < y0 || y > y1)
|
|
162
|
+
continue;
|
|
163
|
+
const xs = [];
|
|
164
|
+
for (const ring of p.rings) {
|
|
165
|
+
for (let k = 0, n = ring.length - 1; k < n; k++) {
|
|
166
|
+
const [axx, ayy] = ring[k], [bx, by] = ring[k + 1];
|
|
167
|
+
if ((ayy > y) !== (by > y))
|
|
168
|
+
xs.push(axx + (y - ayy) * (bx - axx) / (by - ayy));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
xs.sort((a, b) => a - b);
|
|
172
|
+
for (let k = 0; k + 1 < xs.length; k += 2) {
|
|
173
|
+
// cells whose centre lies in [xs[k], xs[k+1]]
|
|
174
|
+
let i0 = lowerIndex(ax, xs[k]);
|
|
175
|
+
if (i0 < ax.val.length && ax.val[i0] < xs[k])
|
|
176
|
+
i0++;
|
|
177
|
+
let i1 = upperIndex(ax, xs[k + 1]);
|
|
178
|
+
if (i1 >= 0 && ax.val[i1] > xs[k + 1])
|
|
179
|
+
i1--;
|
|
180
|
+
for (let i = Math.max(0, i0); i <= Math.min(ax.val.length - 1, i1); i++)
|
|
181
|
+
m.add(key(i, j));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return m;
|
|
186
|
+
}
|
|
187
|
+
const outCode = (x, y, r) => (x < r[0] ? 1 : x > r[2] ? 2 : 0) | (y < r[1] ? 4 : y > r[3] ? 8 : 0);
|
|
188
|
+
/**
|
|
189
|
+
* The ring with every run of vertices that stays beyond ONE side of `r` collapsed to its two ends; null when
|
|
190
|
+
* nothing of it can reach `r`. A vertex is dropped when it and both of its neighbours (in the reduced ring so
|
|
191
|
+
* far) lie beyond the same side: the path through it and the shortcut past it are both inside that half-plane,
|
|
192
|
+
* so the polygon's overlap with `r` is the same. (Judging every vertex against its ORIGINAL neighbours would
|
|
193
|
+
* be wrong: a ring that surrounds the rectangle would lose every vertex.) The first vertex is always kept.
|
|
194
|
+
*/
|
|
195
|
+
export function reduceRing(ring, r) {
|
|
196
|
+
const n = ring.length - 1; // closed ring: the last position repeats the first
|
|
197
|
+
if (n < 8)
|
|
198
|
+
return ring;
|
|
199
|
+
const out = [ring[0]], oc = [outCode(ring[0][0], ring[0][1], r)];
|
|
200
|
+
for (let i = 1; i < n; i++) {
|
|
201
|
+
const c = outCode(ring[i][0], ring[i][1], r);
|
|
202
|
+
while (out.length >= 2 && (oc[oc.length - 2] & oc[oc.length - 1] & c)) {
|
|
203
|
+
out.pop();
|
|
204
|
+
oc.pop();
|
|
205
|
+
}
|
|
206
|
+
out.push(ring[i]);
|
|
207
|
+
oc.push(c);
|
|
208
|
+
}
|
|
209
|
+
// the ring is a cycle: the tail can still collapse into the (kept) first vertex
|
|
210
|
+
while (out.length >= 2 && (oc[oc.length - 2] & oc[oc.length - 1] & oc[0])) {
|
|
211
|
+
out.pop();
|
|
212
|
+
oc.pop();
|
|
213
|
+
}
|
|
214
|
+
if (out.length < 3)
|
|
215
|
+
return null;
|
|
216
|
+
out.push(out[0]);
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
/** a part's rings reduced to `r`; null when its outer ring cannot reach `r` (a hole that cannot is dropped). */
|
|
220
|
+
export function reduceRings(rings, r) {
|
|
221
|
+
const outer = reduceRing(rings[0], r);
|
|
222
|
+
if (!outer)
|
|
223
|
+
return null;
|
|
224
|
+
const out = [outer];
|
|
225
|
+
for (let k = 1; k < rings.length; k++) {
|
|
226
|
+
const h = reduceRing(rings[k], r);
|
|
227
|
+
if (h)
|
|
228
|
+
out.push(h);
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
/** C) partial-cell area weights: clip each part to the cell rectangle and divide by the cell area. */
|
|
233
|
+
function cellWeights(ps, inside, ax, ay) {
|
|
234
|
+
const w = new Map();
|
|
235
|
+
const all = new Set(inside);
|
|
236
|
+
// also the 8-neighbours: cells whose centre is outside but whose square the polygon touches
|
|
237
|
+
for (const k of inside) {
|
|
238
|
+
const i = Math.floor(k / 1e7), j = k - i * 1e7;
|
|
239
|
+
for (let di = -1; di <= 1; di++)
|
|
240
|
+
for (let dj = -1; dj <= 1; dj++) {
|
|
241
|
+
const ii = i + di, jj = j + dj;
|
|
242
|
+
if (ii >= 0 && ii < ax.val.length && jj >= 0 && jj < ay.val.length)
|
|
243
|
+
all.add(key(ii, jj));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// a polygon smaller than a cell can miss every cell centre (Gray's Reef on a 0.25° grid): then
|
|
247
|
+
// seed with the cells whose square overlaps a part's bbox, so it still gets its partial cells
|
|
248
|
+
if (!inside.size)
|
|
249
|
+
for (const p of ps) {
|
|
250
|
+
const [px0, py0, px1, py1] = p.bbox;
|
|
251
|
+
for (let i = 0; i < ax.val.length; i++) {
|
|
252
|
+
if (ax.hi[i] < px0 || ax.lo[i] > px1)
|
|
253
|
+
continue;
|
|
254
|
+
for (let j = 0; j < ay.val.length; j++)
|
|
255
|
+
if (!(ay.hi[j] < py0 || ay.lo[j] > py1))
|
|
256
|
+
all.add(key(i, j));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// by grid row, so a part's rings are reduced to the row's band once and to each cell from that
|
|
260
|
+
const cells = [...all].sort((p, q) => (p % 1e7) - (q % 1e7) || p - q);
|
|
261
|
+
let bandJ = -1;
|
|
262
|
+
let band = [];
|
|
263
|
+
for (const k of cells) {
|
|
264
|
+
const i = Math.floor(k / 1e7), j = k - i * 1e7;
|
|
265
|
+
const x0 = ax.lo[i], x1 = ax.hi[i], y0 = ay.lo[j], y1 = ay.hi[j];
|
|
266
|
+
const cell = tPolygon([[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]]);
|
|
267
|
+
const cellArea = area(cell);
|
|
268
|
+
if (!(cellArea > 0))
|
|
269
|
+
continue;
|
|
270
|
+
if (j !== bandJ) {
|
|
271
|
+
bandJ = j;
|
|
272
|
+
band = ps.map((p) => (p.rings[0].length > 8 ? reduceRings(p.rings, [-Infinity, y0, Infinity, y1]) : p.rings));
|
|
273
|
+
}
|
|
274
|
+
let a = 0;
|
|
275
|
+
for (const [pi, p] of ps.entries()) {
|
|
276
|
+
const [px0, py0, px1, py1] = p.bbox;
|
|
277
|
+
if (x1 < px0 || x0 > px1 || y1 < py0 || y0 > py1)
|
|
278
|
+
continue;
|
|
279
|
+
const rings = band[pi] && p.rings[0].length > 8 ? reduceRings(band[pi], [x0, y0, x1, y1]) : band[pi];
|
|
280
|
+
if (!rings)
|
|
281
|
+
continue;
|
|
282
|
+
a += area(bboxClip(rings === p.rings ? p.feat : tPolygon(rings), [x0, y0, x1, y1]));
|
|
283
|
+
}
|
|
284
|
+
const f = a / cellArea;
|
|
285
|
+
if (f > 0)
|
|
286
|
+
w.set(k, Math.min(f, 1));
|
|
287
|
+
}
|
|
288
|
+
return w;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Grid cells of the (lon, lat) lattice covered by `geojson`.
|
|
292
|
+
*
|
|
293
|
+
* `method: 'auto'` uses turf point-in-polygon for small problems (< 10,000 candidate cells and
|
|
294
|
+
* < 5,000 vertices) and the scanline fill otherwise; both are exact and agree.
|
|
295
|
+
* `weights: true` (the default) adds the partial-cell area fraction: 1 for interior cells,
|
|
296
|
+
* fractional for boundary cells, and cells whose centre is outside but whose square the polygon
|
|
297
|
+
* clips are included with their fraction too.
|
|
298
|
+
*/
|
|
299
|
+
export function gridMask(geojson, lon, lat, opts = {}) {
|
|
300
|
+
const t0 = (globalThis.performance ?? Date).now();
|
|
301
|
+
const weights = opts.weights !== false;
|
|
302
|
+
const ps = polygonParts(geojson);
|
|
303
|
+
const ax = prepAxis(lon), ay = prepAxis(lat);
|
|
304
|
+
if (!ps.length || !ax.val.length || !ay.val.length)
|
|
305
|
+
return { cells: [], nCandidates: 0, nInside: 0, method: 'scan', ms: 0 };
|
|
306
|
+
const cands = candidates(ps, ax, ay);
|
|
307
|
+
const nv = countVertices(ps);
|
|
308
|
+
const method = opts.method === 'scan' || opts.method === 'turf' ? opts.method
|
|
309
|
+
: (cands.size < 10_000 && nv < 5_000) ? 'turf' : 'scan';
|
|
310
|
+
const inside = method === 'turf' ? maskTurf(ps, cands, ax, ay) : maskScan(ps, ax, ay);
|
|
311
|
+
const w = weights ? cellWeights(ps, inside, ax, ay) : null;
|
|
312
|
+
const cells = [];
|
|
313
|
+
const emit = (k, weight) => {
|
|
314
|
+
const i = Math.floor(k / 1e7), j = k - i * 1e7;
|
|
315
|
+
cells.push({ lon: ax.val[i], lat: ay.val[j], weight });
|
|
316
|
+
};
|
|
317
|
+
if (w)
|
|
318
|
+
for (const [k, f] of w)
|
|
319
|
+
emit(k, f);
|
|
320
|
+
else
|
|
321
|
+
for (const k of inside)
|
|
322
|
+
emit(k, 1);
|
|
323
|
+
cells.sort((a, b) => a.lat - b.lat || a.lon - b.lon);
|
|
324
|
+
return { cells, nCandidates: cands.size, nInside: inside.size, method, ms: (globalThis.performance ?? Date).now() - t0 };
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The positions inside a place polygon, for a **tabledap** (point) dataset.
|
|
328
|
+
*
|
|
329
|
+
* There is no lattice and no partial cell here: a sample is in or out, so every kept position gets
|
|
330
|
+
* weight 1 and the caller can feed the result straight into the same `mask` table the grid path
|
|
331
|
+
* uses. Duplicate positions (one station, many casts and depths) are collapsed, which is what makes
|
|
332
|
+
* this cheap: a two-degree CalCOFI box over five years is tens of stations, not thousands of rows.
|
|
333
|
+
* The bbox of each polygon part prefilters, exactly as `maskTurf` does.
|
|
334
|
+
*/
|
|
335
|
+
export function pointMask(geojson, points) {
|
|
336
|
+
const t0 = (globalThis.performance ?? Date).now();
|
|
337
|
+
const ps = polygonParts(geojson);
|
|
338
|
+
const seen = new Map();
|
|
339
|
+
for (const p of points) {
|
|
340
|
+
if (!Number.isFinite(p.lon) || !Number.isFinite(p.lat))
|
|
341
|
+
continue;
|
|
342
|
+
const k = `${p.lon},${p.lat}`;
|
|
343
|
+
if (!seen.has(k))
|
|
344
|
+
seen.set(k, p);
|
|
345
|
+
}
|
|
346
|
+
const cells = [];
|
|
347
|
+
for (const p of seen.values()) {
|
|
348
|
+
for (const part of ps) {
|
|
349
|
+
const [x0, y0, x1, y1] = part.bbox;
|
|
350
|
+
if (p.lon < x0 || p.lon > x1 || p.lat < y0 || p.lat > y1)
|
|
351
|
+
continue;
|
|
352
|
+
if (booleanPointInPolygon([p.lon, p.lat], part.feat)) {
|
|
353
|
+
cells.push({ lon: p.lon, lat: p.lat, weight: 1 });
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
cells.sort((a, b) => a.lat - b.lat || a.lon - b.lon);
|
|
359
|
+
return {
|
|
360
|
+
cells, nCandidates: seen.size, nInside: cells.length,
|
|
361
|
+
ms: (globalThis.performance ?? Date).now() - t0,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { type ChunkLimits } from './chunks.js';
|
|
2
|
+
import { type CubeVariable, type Dataset } from './dataset.js';
|
|
3
|
+
import type { Engine, Row } from './engine.js';
|
|
4
|
+
import { type Format } from './erddap.js';
|
|
5
|
+
import { type TimeExtent } from './extent.js';
|
|
6
|
+
import type { RetryOpts } from './fetchRetry.js';
|
|
7
|
+
import type { Lobe } from './lobes.js';
|
|
8
|
+
import { type MaskCell } from './mask.js';
|
|
9
|
+
/** what a preview reads of a dataset (a `toDataset()` result has all of it) */
|
|
10
|
+
export type PreviewDataset = Pick<Dataset, 'baseUrl' | 'datasetId' | 'latDescending' | 'lonRange'> & Partial<Pick<Dataset, 'depth' | 'protocol'>> & {
|
|
11
|
+
format?: Format;
|
|
12
|
+
};
|
|
13
|
+
/** one lobe's axis vectors as the server returns them: its own order, its own longitude frame */
|
|
14
|
+
export interface LobeAxes {
|
|
15
|
+
lon: number[];
|
|
16
|
+
lat: number[];
|
|
17
|
+
}
|
|
18
|
+
type FetchFn = typeof fetch;
|
|
19
|
+
/**
|
|
20
|
+
* Days per request for a window over `cells` axis cells per step (the largest lobe's lon x lat) of a grid whose step
|
|
21
|
+
* is `stepDays`: the precompute's `chunkDaysFor`, at most `maxDays` steps and `maxValues` values a request.
|
|
22
|
+
*/
|
|
23
|
+
export declare function previewWindow(cells: number, stepDays?: number, { maxValues, maxDays }?: ChunkLimits): number;
|
|
24
|
+
/**
|
|
25
|
+
* The mask of every lobe on its axes, in the dataset's own longitude frame (a 0..360 grid's axis goes to the polygon's
|
|
26
|
+
* -180..180 for the mask and its cells come back to 0..360, so they join the slab), and the cells per step of the
|
|
27
|
+
* largest lobe (what sets the chunk size).
|
|
28
|
+
*/
|
|
29
|
+
export declare function lobeMasks(dataset: Pick<Dataset, 'lonRange'>, lobes: Lobe[], axes: LobeAxes[]): {
|
|
30
|
+
cells: MaskCell[];
|
|
31
|
+
cellsPerStep: number;
|
|
32
|
+
};
|
|
33
|
+
/** each lobe's axis vectors from the server (`fetchAxis`, the lobe's longitude span in the dataset's frame) */
|
|
34
|
+
export declare function lobeAxes(dataset: Pick<Dataset, 'baseUrl' | 'datasetId' | 'latDescending' | 'lonRange'>, lobes: Lobe[], { fetch: fetchFn, signal, retry }?: {
|
|
35
|
+
fetch?: FetchFn;
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
retry?: Omit<RetryOpts, 'signal' | 'fetchFn'>;
|
|
38
|
+
}): Promise<LobeAxes[]>;
|
|
39
|
+
/** a yyyy-mm-dd UTC day from what DuckDB-WASM hands back for a DATE (epoch ms, a Date, or a string) */
|
|
40
|
+
export declare function isoDay(v: unknown): string;
|
|
41
|
+
export interface PreviewChunk {
|
|
42
|
+
/** 0-based, in time order */
|
|
43
|
+
index: number;
|
|
44
|
+
count: number;
|
|
45
|
+
/** the chunk's first and last day, yyyy-mm-dd */
|
|
46
|
+
start: string;
|
|
47
|
+
end: string;
|
|
48
|
+
/** one griddap request per lobe */
|
|
49
|
+
urls: string[];
|
|
50
|
+
}
|
|
51
|
+
export interface PreviewOptions {
|
|
52
|
+
dataset: PreviewDataset;
|
|
53
|
+
variable: CubeVariable;
|
|
54
|
+
/** the place's lobes (`placeLobes`); not needed when `mask` is given */
|
|
55
|
+
lobes?: Lobe[];
|
|
56
|
+
/** each lobe's axis vectors (`lobeAxes`, or the provenance's `axes`): they bound the requests and set the chunk size */
|
|
57
|
+
axes: LobeAxes[];
|
|
58
|
+
/** a mask already computed (`lobeMasks(...).cells`), so a preview and a slice share one masking */
|
|
59
|
+
mask?: MaskCell[];
|
|
60
|
+
/** the window, yyyy-mm-dd UTC days, both included */
|
|
61
|
+
t0: string;
|
|
62
|
+
t1: string;
|
|
63
|
+
engine: Engine;
|
|
64
|
+
fetch?: FetchFn;
|
|
65
|
+
/** the rows of each chunk as it lands, in time order; a date already given by an earlier chunk is not repeated */
|
|
66
|
+
onChunk?: (rows: Row[], chunk: PreviewChunk) => void;
|
|
67
|
+
signal?: AbortSignal;
|
|
68
|
+
/** the live extent: its step sets the chunk days, its endpoints the exact instants of the first and last request */
|
|
69
|
+
extent?: TimeExtent | null;
|
|
70
|
+
/** the grid's step in days when there is no extent (default 1) */
|
|
71
|
+
stepDays?: number;
|
|
72
|
+
limits?: ChunkLimits;
|
|
73
|
+
/** requests in flight: the next chunk is fetched while this one is aggregated (default 2, the precompute's CONCURRENCY) */
|
|
74
|
+
concurrency?: number;
|
|
75
|
+
retry?: Omit<RetryOpts, 'signal' | 'fetchFn'>;
|
|
76
|
+
}
|
|
77
|
+
export interface PreviewResult {
|
|
78
|
+
/** every chunk's rows, in time order, `date` as yyyy-mm-dd */
|
|
79
|
+
rows: Row[];
|
|
80
|
+
chunks: PreviewChunk[];
|
|
81
|
+
chunkDays: number;
|
|
82
|
+
template: 'stats_daily' | 'stats_categorical';
|
|
83
|
+
maskCells: number;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The statistics of `variable` over the place for [t0, t1], chunk by chunk. Each chunk's slab (one Parquet request per
|
|
87
|
+
* lobe) is registered, aggregated with the template over the mask and dropped; `onChunk` gets its rows as soon as they
|
|
88
|
+
* are computed, in time order. `signal` stops the chain: the in-flight requests are cancelled, nothing after the abort
|
|
89
|
+
* reaches `onChunk`, and the promise rejects with the abort reason. The mask table and the slabs are dropped either way.
|
|
90
|
+
*/
|
|
91
|
+
export declare function runPreview(o: PreviewOptions): Promise<PreviewResult>;
|
|
92
|
+
/** one cell of a step, as `@oceanmetrics/map`'s `SliceCell` */
|
|
93
|
+
export interface SliceCell {
|
|
94
|
+
lat: number;
|
|
95
|
+
lon: number;
|
|
96
|
+
value: number | null;
|
|
97
|
+
weight: number;
|
|
98
|
+
}
|
|
99
|
+
/** `@oceanmetrics/map`'s `SliceSource`, structurally: the grid slot's data */
|
|
100
|
+
export interface SliceSource {
|
|
101
|
+
slice(t: string | null, signal?: AbortSignal): Promise<SliceCell[]>;
|
|
102
|
+
}
|
|
103
|
+
export interface SliceSourceOptions {
|
|
104
|
+
dataset: PreviewDataset;
|
|
105
|
+
variable: CubeVariable;
|
|
106
|
+
lobes?: Lobe[];
|
|
107
|
+
axes: LobeAxes[];
|
|
108
|
+
mask?: MaskCell[];
|
|
109
|
+
engine: Engine;
|
|
110
|
+
fetch?: FetchFn;
|
|
111
|
+
/** a null `t` is the extent's last step (ERDDAP's `last` when there is no extent) */
|
|
112
|
+
extent?: TimeExtent | null;
|
|
113
|
+
retry?: Omit<RetryOpts, 'signal' | 'fetchFn'>;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* A `SliceSource` for the grid slot: `slice(t)` fetches the one step `t` (a yyyy-mm-dd day or an ISO instant; null for
|
|
117
|
+
* the newest) of every lobe and runs `last_step` over the mask: the masked cells with their own lat/lon, the area
|
|
118
|
+
* weight and the value (null where the step has no data). The mask goes into the engine once, at the first slice.
|
|
119
|
+
*/
|
|
120
|
+
export declare function sliceSource(o: SliceSourceOptions): SliceSource;
|
|
121
|
+
export {};
|