@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +21 -0
  3. package/README.md +194 -2
  4. package/dist/cells.d.ts +84 -0
  5. package/dist/cells.js +179 -0
  6. package/dist/chunks.d.ts +29 -0
  7. package/dist/chunks.js +49 -0
  8. package/dist/dataset.d.ts +72 -0
  9. package/dist/dataset.js +115 -0
  10. package/dist/engine.d.ts +72 -0
  11. package/dist/engine.js +154 -0
  12. package/dist/erddap.d.ts +117 -0
  13. package/dist/erddap.js +224 -0
  14. package/dist/extent.d.ts +64 -0
  15. package/dist/extent.js +151 -0
  16. package/dist/fetchRetry.d.ts +33 -0
  17. package/dist/fetchRetry.js +76 -0
  18. package/dist/index.d.ts +24 -0
  19. package/dist/index.js +16 -0
  20. package/dist/lobes.d.ts +24 -0
  21. package/dist/lobes.js +55 -0
  22. package/dist/mask.d.ts +71 -0
  23. package/dist/mask.js +363 -0
  24. package/dist/preview.d.ts +121 -0
  25. package/dist/preview.js +205 -0
  26. package/dist/sql.d.ts +10 -0
  27. package/dist/sql.js +33 -0
  28. package/dist/sqlTemplates.d.ts +1 -0
  29. package/dist/sqlTemplates.js +8 -0
  30. package/dist/wkb.d.ts +5 -0
  31. package/dist/wkb.js +80 -0
  32. package/package.json +71 -4
  33. package/sql/last_step.sql +31 -0
  34. package/sql/points_tabledap.sql +21 -0
  35. package/sql/stats_categorical.sql +36 -0
  36. package/sql/stats_daily.sql +27 -0
  37. package/sql/stats_tabledap.sql +36 -0
  38. package/src/cells.ts +190 -0
  39. package/src/chunks.ts +58 -0
  40. package/src/dataset.ts +160 -0
  41. package/src/engine.ts +190 -0
  42. package/src/erddap.ts +271 -0
  43. package/src/extent.ts +172 -0
  44. package/src/fetchRetry.ts +71 -0
  45. package/src/index.ts +37 -0
  46. package/src/lobes.ts +66 -0
  47. package/src/mask.ts +341 -0
  48. package/src/preview.ts +282 -0
  49. package/src/sql.ts +36 -0
  50. package/src/sqlTemplates.ts +8 -0
  51. package/src/wkb.ts +81 -0
package/src/mask.ts ADDED
@@ -0,0 +1,341 @@
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
+ import type { Feature, FeatureCollection, Geometry, Polygon } from 'geojson'
13
+
14
+ export type GeoInput = FeatureCollection | Feature | Geometry
15
+ export type MaskMethod = 'auto' | 'scan' | 'turf'
16
+
17
+ export interface MaskCell { lon: number; lat: number; weight: number }
18
+ export interface MaskResult {
19
+ cells : MaskCell[] // one row per included cell (centre inside, or boundary-touching when weighted)
20
+ nCandidates : number // cells whose centre falls in some part's bbox
21
+ nInside : number // cells whose centre is inside the polygon
22
+ method : 'scan' | 'turf'
23
+ ms : number
24
+ }
25
+ export interface MaskOptions { weights?: boolean; method?: MaskMethod }
26
+
27
+ // ── axis geometry ─────────────────────────────────────────────────────────────
28
+ /** cell edges for a monotonic axis: midpoints between neighbours, ends extrapolated. */
29
+ export function axisEdges(v: number[]): number[] {
30
+ const n = v.length
31
+ if (n === 0) return []
32
+ if (n === 1) return [v[0] - 0.5, v[0] + 0.5] // unknown spacing: assume 1
33
+ const e = new Array<number>(n + 1)
34
+ for (let i = 1; i < n; i++) e[i] = (v[i - 1] + v[i]) / 2
35
+ e[0] = v[0] - (v[1] - v[0]) / 2
36
+ e[n] = v[n - 1] + (v[n - 1] - v[n - 2]) / 2
37
+ return e
38
+ }
39
+
40
+ /** an axis prepared for lookup: ascending values + edges, plus the map back to the caller's order. */
41
+ interface Axis {
42
+ val : number[] // ascending values
43
+ lo : number[] // ascending lower edges
44
+ hi : number[] // ascending upper edges
45
+ uniform: number // spacing when uniform, else 0
46
+ min : number
47
+ max : number
48
+ }
49
+ function prepAxis(v: number[]): Axis {
50
+ const desc = v.length > 1 && v[1] < v[0]
51
+ const val = desc ? [...v].reverse() : [...v]
52
+ const e = axisEdges(val)
53
+ const lo = e.slice(0, -1), hi = e.slice(1)
54
+ let uniform = val.length > 1 ? val[1] - val[0] : 0
55
+ for (let i = 2; i < val.length; i++)
56
+ if (Math.abs(val[i] - val[i - 1] - uniform) > Math.abs(uniform) * 1e-6) { uniform = 0; break }
57
+ return { val, lo, hi, uniform, min: e[0], max: e[e.length - 1] }
58
+ }
59
+ /** index of the cell containing x, or -1. uniform axes short-circuit; others binary-search. */
60
+ function cellIndex(a: Axis, x: number): number {
61
+ if (x < a.min || x > a.max) return -1
62
+ if (a.uniform) {
63
+ const i = Math.floor((x - a.min) / a.uniform)
64
+ return i < 0 ? 0 : i >= a.val.length ? a.val.length - 1 : i
65
+ }
66
+ let lo = 0, hi = a.val.length - 1
67
+ while (lo < hi) { const m = (lo + hi) >> 1; if (x > a.hi[m]) lo = m + 1; else hi = m }
68
+ return lo
69
+ }
70
+ /** smallest index with hi[i] >= x (the first cell that could contain x or anything above it). */
71
+ function lowerIndex(a: Axis, x: number): number {
72
+ if (x <= a.min) return 0
73
+ if (x > a.max) return a.val.length
74
+ return cellIndex(a, x)
75
+ }
76
+ function upperIndex(a: Axis, x: number): number {
77
+ if (x >= a.max) return a.val.length - 1
78
+ if (x < a.min) return -1
79
+ return cellIndex(a, x)
80
+ }
81
+
82
+ // ── polygon parts ─────────────────────────────────────────────────────────────
83
+ interface Part { rings: number[][][]; bbox: [number, number, number, number]; feat: Feature<Polygon> }
84
+ export function polygonParts(gj: GeoInput): Part[] {
85
+ const feats: Feature[] =
86
+ (gj as FeatureCollection).type === 'FeatureCollection' ? (gj as FeatureCollection).features
87
+ : (gj as Feature).type === 'Feature' ? [gj as Feature]
88
+ : [{ type: 'Feature', properties: {}, geometry: gj as Geometry }]
89
+ const out: Part[] = []
90
+ for (const f of feats) {
91
+ const g = f.geometry as Geometry
92
+ if (!g) continue
93
+ let polys: number[][][][]
94
+ if (g.type === 'Polygon') polys = [g.coordinates as number[][][]]
95
+ else if (g.type === 'MultiPolygon') polys = g.coordinates as number[][][][]
96
+ else if (g.type === 'GeometryCollection')
97
+ { for (const sub of g.geometries) out.push(...polygonParts(sub)); continue }
98
+ else continue
99
+ for (const rings of polys) {
100
+ let xmin = Infinity, xmax = -Infinity, ymin = Infinity, ymax = -Infinity
101
+ for (const [x, y] of rings[0]) {
102
+ if (x < xmin) xmin = x; if (x > xmax) xmax = x
103
+ if (y < ymin) ymin = y; if (y > ymax) ymax = y
104
+ }
105
+ out.push({ rings, bbox: [xmin, ymin, xmax, ymax], feat: tPolygon(rings) })
106
+ }
107
+ }
108
+ return out
109
+ }
110
+ export function countVertices(ps: Part[]): number {
111
+ return ps.reduce((s, p) => s + p.rings.reduce((t, r) => t + r.length, 0), 0)
112
+ }
113
+
114
+ // ── masking ───────────────────────────────────────────────────────────────────
115
+ const key = (i: number, j: number) => i * 1e7 + j
116
+
117
+ /** candidate cells = union over parts of the cells whose centre lies in that part's bbox. */
118
+ function candidates(ps: Part[], ax: Axis, ay: Axis): Set<number> {
119
+ const set = new Set<number>()
120
+ for (const p of ps) {
121
+ const [x0, y0, x1, y1] = p.bbox
122
+ const i0 = lowerIndex(ax, x0), i1 = upperIndex(ax, x1)
123
+ const j0 = lowerIndex(ay, y0), j1 = upperIndex(ay, y1)
124
+ for (let i = i0; i <= i1; i++) {
125
+ if (i < 0 || i >= ax.val.length || ax.val[i] < x0 || ax.val[i] > x1) 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) continue
128
+ set.add(key(i, j))
129
+ }
130
+ }
131
+ }
132
+ return set
133
+ }
134
+
135
+ /** A) turf point-in-polygon per candidate cell centre, with a part-bbox prefilter. */
136
+ function maskTurf(ps: Part[], cands: Set<number>, ax: Axis, ay: Axis): Set<number> {
137
+ const m = new Set<number>()
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) continue
144
+ if (booleanPointInPolygon([x, y], p.feat)) { m.add(k); break }
145
+ }
146
+ }
147
+ return m
148
+ }
149
+
150
+ /** B) scanline fill: for each grid row, x-crossings of every ring, fill the columns between pairs. */
151
+ function maskScan(ps: Part[], ax: Axis, ay: Axis): Set<number> {
152
+ const m = new Set<number>()
153
+ for (const p of ps) {
154
+ const [, y0, , y1] = p.bbox
155
+ const j0 = Math.max(0, lowerIndex(ay, y0)), j1 = Math.min(ay.val.length - 1, upperIndex(ay, y1))
156
+ for (let j = j0; j <= j1; j++) {
157
+ const y = ay.val[j]
158
+ if (y < y0 || y > y1) continue
159
+ const xs: number[] = []
160
+ for (const ring of p.rings) {
161
+ for (let k = 0, n = ring.length - 1; k < n; k++) {
162
+ const [axx, ayy] = ring[k], [bx, by] = ring[k + 1]
163
+ if ((ayy > y) !== (by > y)) xs.push(axx + (y - ayy) * (bx - axx) / (by - ayy))
164
+ }
165
+ }
166
+ xs.sort((a, b) => a - b)
167
+ for (let k = 0; k + 1 < xs.length; k += 2) {
168
+ // cells whose centre lies in [xs[k], xs[k+1]]
169
+ let i0 = lowerIndex(ax, xs[k]); if (i0 < ax.val.length && ax.val[i0] < xs[k]) i0++
170
+ let i1 = upperIndex(ax, xs[k + 1]); if (i1 >= 0 && ax.val[i1] > xs[k + 1]) i1--
171
+ for (let i = Math.max(0, i0); i <= Math.min(ax.val.length - 1, i1); i++) m.add(key(i, j))
172
+ }
173
+ }
174
+ }
175
+ return m
176
+ }
177
+
178
+ // ── clipping a big ring to one cell ──────────────────────────────────────────
179
+ // clipping a polygon to a cell costs O(vertices), and a boundary-heavy mask clips thousands of cells: a
180
+ // 79,000-vertex monument took 54 s (and froze the tab) where FKNMS's 39,645 vertices in 13 parts take 0.2 s.
181
+ // A vertex whose two edges both lie wholly beyond the SAME side of the rectangle cannot change the clipped
182
+ // area (Cohen–Sutherland's trivial reject), so it is dropped first. Done once per grid row (band) and then
183
+ // per cell, the work per cell is the few hundred vertices that are near it.
184
+ type Rect = [number, number, number, number]
185
+ const outCode = (x: number, y: number, r: Rect) => (x < r[0] ? 1 : x > r[2] ? 2 : 0) | (y < r[1] ? 4 : y > r[3] ? 8 : 0)
186
+
187
+ /**
188
+ * The ring with every run of vertices that stays beyond ONE side of `r` collapsed to its two ends; null when
189
+ * nothing of it can reach `r`. A vertex is dropped when it and both of its neighbours (in the reduced ring so
190
+ * far) lie beyond the same side: the path through it and the shortcut past it are both inside that half-plane,
191
+ * so the polygon's overlap with `r` is the same. (Judging every vertex against its ORIGINAL neighbours would
192
+ * be wrong: a ring that surrounds the rectangle would lose every vertex.) The first vertex is always kept.
193
+ */
194
+ export function reduceRing(ring: number[][], r: Rect): number[][] | null {
195
+ const n = ring.length - 1 // closed ring: the last position repeats the first
196
+ if (n < 8) return ring
197
+ const out: number[][] = [ring[0]], oc: number[] = [outCode(ring[0][0], ring[0][1], r)]
198
+ for (let i = 1; i < n; i++) {
199
+ const c = outCode(ring[i][0], ring[i][1], r)
200
+ while (out.length >= 2 && (oc[oc.length - 2] & oc[oc.length - 1] & c)) { out.pop(); oc.pop() }
201
+ out.push(ring[i]); oc.push(c)
202
+ }
203
+ // the ring is a cycle: the tail can still collapse into the (kept) first vertex
204
+ while (out.length >= 2 && (oc[oc.length - 2] & oc[oc.length - 1] & oc[0])) { out.pop(); oc.pop() }
205
+ if (out.length < 3) return null
206
+ out.push(out[0])
207
+ return out
208
+ }
209
+ /** a part's rings reduced to `r`; null when its outer ring cannot reach `r` (a hole that cannot is dropped). */
210
+ export function reduceRings(rings: number[][][], r: Rect): number[][][] | null {
211
+ const outer = reduceRing(rings[0], r)
212
+ if (!outer) return null
213
+ const out = [outer]
214
+ for (let k = 1; k < rings.length; k++) { const h = reduceRing(rings[k], r); if (h) out.push(h) }
215
+ return out
216
+ }
217
+
218
+ /** C) partial-cell area weights: clip each part to the cell rectangle and divide by the cell area. */
219
+ function cellWeights(ps: Part[], inside: Set<number>, ax: Axis, ay: Axis): Map<number, number> {
220
+ const w = new Map<number, number>()
221
+ const all = new Set<number>(inside)
222
+ // also the 8-neighbours: cells whose centre is outside but whose square the polygon touches
223
+ for (const k of inside) {
224
+ const i = Math.floor(k / 1e7), j = k - i * 1e7
225
+ for (let di = -1; di <= 1; di++) for (let dj = -1; dj <= 1; dj++) {
226
+ const ii = i + di, jj = j + dj
227
+ if (ii >= 0 && ii < ax.val.length && jj >= 0 && jj < ay.val.length) all.add(key(ii, jj))
228
+ }
229
+ }
230
+ // a polygon smaller than a cell can miss every cell centre (Gray's Reef on a 0.25° grid): then
231
+ // seed with the cells whose square overlaps a part's bbox, so it still gets its partial cells
232
+ if (!inside.size) for (const p of ps) {
233
+ const [px0, py0, px1, py1] = p.bbox
234
+ for (let i = 0; i < ax.val.length; i++) {
235
+ if (ax.hi[i] < px0 || ax.lo[i] > px1) continue
236
+ for (let j = 0; j < ay.val.length; j++) if (!(ay.hi[j] < py0 || ay.lo[j] > py1)) all.add(key(i, j))
237
+ }
238
+ }
239
+ // by grid row, so a part's rings are reduced to the row's band once and to each cell from that
240
+ const cells = [...all].sort((p, q) => (p % 1e7) - (q % 1e7) || p - q)
241
+ let bandJ = -1
242
+ let band: (number[][][] | null)[] = []
243
+ for (const k of cells) {
244
+ const i = Math.floor(k / 1e7), j = k - i * 1e7
245
+ const x0 = ax.lo[i], x1 = ax.hi[i], y0 = ay.lo[j], y1 = ay.hi[j]
246
+ const cell = tPolygon([[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]])
247
+ const cellArea = area(cell)
248
+ if (!(cellArea > 0)) continue
249
+ if (j !== bandJ) {
250
+ bandJ = j
251
+ band = ps.map((p) => (p.rings[0].length > 8 ? reduceRings(p.rings, [-Infinity, y0, Infinity, y1]) : p.rings))
252
+ }
253
+ let a = 0
254
+ for (const [pi, p] of ps.entries()) {
255
+ const [px0, py0, px1, py1] = p.bbox
256
+ if (x1 < px0 || x0 > px1 || y1 < py0 || y0 > py1) continue
257
+ const rings = band[pi] && p.rings[0].length > 8 ? reduceRings(band[pi]!, [x0, y0, x1, y1]) : band[pi]
258
+ if (!rings) continue
259
+ a += area(bboxClip(rings === p.rings ? p.feat : tPolygon(rings), [x0, y0, x1, y1]) as Feature<any>)
260
+ }
261
+ const f = a / cellArea
262
+ if (f > 0) w.set(k, Math.min(f, 1))
263
+ }
264
+ return w
265
+ }
266
+
267
+ /**
268
+ * Grid cells of the (lon, lat) lattice covered by `geojson`.
269
+ *
270
+ * `method: 'auto'` uses turf point-in-polygon for small problems (< 10,000 candidate cells and
271
+ * < 5,000 vertices) and the scanline fill otherwise; both are exact and agree.
272
+ * `weights: true` (the default) adds the partial-cell area fraction: 1 for interior cells,
273
+ * fractional for boundary cells, and cells whose centre is outside but whose square the polygon
274
+ * clips are included with their fraction too.
275
+ */
276
+ export function gridMask(geojson: GeoInput, lon: number[], lat: number[], opts: MaskOptions = {}): MaskResult {
277
+ const t0 = (globalThis.performance ?? Date).now()
278
+ const weights = opts.weights !== false
279
+ const ps = polygonParts(geojson)
280
+ const ax = prepAxis(lon), ay = prepAxis(lat)
281
+ if (!ps.length || !ax.val.length || !ay.val.length)
282
+ return { cells: [], nCandidates: 0, nInside: 0, method: 'scan', ms: 0 }
283
+
284
+ const cands = candidates(ps, ax, ay)
285
+ const nv = countVertices(ps)
286
+ const method: 'scan' | 'turf' =
287
+ opts.method === 'scan' || opts.method === 'turf' ? opts.method
288
+ : (cands.size < 10_000 && nv < 5_000) ? 'turf' : 'scan'
289
+
290
+ const inside = method === 'turf' ? maskTurf(ps, cands, ax, ay) : maskScan(ps, ax, ay)
291
+ const w = weights ? cellWeights(ps, inside, ax, ay) : null
292
+
293
+ const cells: MaskCell[] = []
294
+ const emit = (k: number, weight: number) => {
295
+ const i = Math.floor(k / 1e7), j = k - i * 1e7
296
+ cells.push({ lon: ax.val[i], lat: ay.val[j], weight })
297
+ }
298
+ if (w) for (const [k, f] of w) emit(k, f)
299
+ else for (const k of inside) emit(k, 1)
300
+ cells.sort((a, b) => a.lat - b.lat || a.lon - b.lon)
301
+
302
+ return { cells, nCandidates: cands.size, nInside: inside.size, method, ms: (globalThis.performance ?? Date).now() - t0 }
303
+ }
304
+
305
+ // ── point masking (tabledap) ──────────────────────────────────────────────────
306
+ /** a sampling position, as it comes out of a tabledap slab. */
307
+ export interface MaskPoint { lon: number; lat: number }
308
+ export interface PointMaskResult { cells: MaskCell[]; nCandidates: number; nInside: number; ms: number }
309
+
310
+ /**
311
+ * The positions inside a place polygon, for a **tabledap** (point) dataset.
312
+ *
313
+ * There is no lattice and no partial cell here: a sample is in or out, so every kept position gets
314
+ * weight 1 and the caller can feed the result straight into the same `mask` table the grid path
315
+ * uses. Duplicate positions (one station, many casts and depths) are collapsed, which is what makes
316
+ * this cheap: a two-degree CalCOFI box over five years is tens of stations, not thousands of rows.
317
+ * The bbox of each polygon part prefilters, exactly as `maskTurf` does.
318
+ */
319
+ export function pointMask(geojson: GeoInput, points: MaskPoint[]): PointMaskResult {
320
+ const t0 = (globalThis.performance ?? Date).now()
321
+ const ps = polygonParts(geojson)
322
+ const seen = new Map<string, MaskPoint>()
323
+ for (const p of points) {
324
+ if (!Number.isFinite(p.lon) || !Number.isFinite(p.lat)) continue
325
+ const k = `${p.lon},${p.lat}`
326
+ if (!seen.has(k)) seen.set(k, p)
327
+ }
328
+ const cells: MaskCell[] = []
329
+ for (const p of seen.values()) {
330
+ for (const part of ps) {
331
+ const [x0, y0, x1, y1] = part.bbox
332
+ if (p.lon < x0 || p.lon > x1 || p.lat < y0 || p.lat > y1) continue
333
+ if (booleanPointInPolygon([p.lon, p.lat], part.feat)) { cells.push({ lon: p.lon, lat: p.lat, weight: 1 }); break }
334
+ }
335
+ }
336
+ cells.sort((a, b) => a.lat - b.lat || a.lon - b.lon)
337
+ return {
338
+ cells, nCandidates: seen.size, nInside: cells.length,
339
+ ms: (globalThis.performance ?? Date).now() - t0,
340
+ }
341
+ }
package/src/preview.ts ADDED
@@ -0,0 +1,282 @@
1
+ // the live preview: a series for any polygon x ERDDAP grid, in the browser, with the precompute's own chunking. a long
2
+ // window is a chain of griddap requests (at most 90 steps and 100,000 values each, `chunkDaysFor`), and each chunk's
3
+ // statistics are handed to the caller as they land, oldest first, so a chart draws while the rest is still coming.
4
+ // the per-lobe masking (the 0..360 shift included) is the precompute's `maskLobe` and the App's run(); the slab of a
5
+ // chunk is `(SELECT DISTINCT * FROM read_parquet([...one file per lobe]))` and the SQL is `stats_daily` or
6
+ // `stats_categorical`, exactly as `precompute/src/stats.ts` `computeOne` runs them. `sliceSource` is the map's half: one
7
+ // step's `last_step` rows for `@oceanmetrics/map`'s grid slot.
8
+ import { chunkDaysFor, chunks, type ChunkLimits } from './chunks.js'
9
+ import { lobeLonSpan, statsTemplate, toDatasetLon, valueExpr, type CubeVariable, type Dataset } from './dataset.js'
10
+ import type { Engine, Row } from './engine.js'
11
+ import { AXIS_TIMEOUT_MS, SLICE_TIMEOUT_MS, fetchAxis, fetchSlab, griddapSliceUrl, griddapUrl, isParquet, noonZ, type Format } from './erddap.js'
12
+ import { timeInstant, type TimeExtent } from './extent.js'
13
+ import type { RetryOpts } from './fetchRetry.js'
14
+ import type { Lobe } from './lobes.js'
15
+ import { gridMask, type MaskCell } from './mask.js'
16
+
17
+ /** what a preview reads of a dataset (a `toDataset()` result has all of it) */
18
+ export type PreviewDataset = Pick<Dataset, 'baseUrl' | 'datasetId' | 'latDescending' | 'lonRange'> &
19
+ Partial<Pick<Dataset, 'depth' | 'protocol'>> & { format?: Format }
20
+
21
+ /** one lobe's axis vectors as the server returns them: its own order, its own longitude frame */
22
+ export interface LobeAxes { lon: number[]; lat: number[] }
23
+
24
+ type FetchFn = typeof fetch
25
+
26
+ // ---- the chunk rule ----
27
+
28
+ /**
29
+ * Days per request for a window over `cells` axis cells per step (the largest lobe's lon x lat) of a grid whose step
30
+ * is `stepDays`: the precompute's `chunkDaysFor`, at most `maxDays` steps and `maxValues` values a request.
31
+ */
32
+ export function previewWindow(cells: number, stepDays = 1, { maxValues = 100_000, maxDays = 90 }: ChunkLimits = {}): number {
33
+ return chunkDaysFor(cells, stepDays, { maxValues, maxDays })
34
+ }
35
+
36
+ // ---- the mask ----
37
+
38
+ /**
39
+ * 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
40
+ * -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
41
+ * largest lobe (what sets the chunk size).
42
+ */
43
+ export function lobeMasks(dataset: Pick<Dataset, 'lonRange'>, lobes: Lobe[], axes: LobeAxes[]): { cells: MaskCell[]; cellsPerStep: number } {
44
+ if (lobes.length !== axes.length) throw new Error(`lobeMasks: ${lobes.length} lobe(s) but ${axes.length} axis pair(s)`)
45
+ const shifted = dataset.lonRange[1] > 180
46
+ const toPoly = (x: number) => (shifted && x > 180 ? x - 360 : x)
47
+ const cells: MaskCell[] = []
48
+ for (const [i, lobe] of lobes.entries()) {
49
+ const m = gridMask(lobe.geojson, axes[i].lon.map(toPoly), axes[i].lat)
50
+ for (const c of m.cells) cells.push(shifted ? { ...c, lon: toDatasetLon(c.lon, dataset.lonRange) } : c)
51
+ }
52
+ return { cells, cellsPerStep: Math.max(0, ...axes.map((a) => a.lon.length * a.lat.length)) }
53
+ }
54
+
55
+ /** each lobe's axis vectors from the server (`fetchAxis`, the lobe's longitude span in the dataset's frame) */
56
+ export async function lobeAxes(dataset: Pick<Dataset, 'baseUrl' | 'datasetId' | 'latDescending' | 'lonRange'>, lobes: Lobe[],
57
+ { fetch: fetchFn, signal, retry = {} }: { fetch?: FetchFn; signal?: AbortSignal; retry?: Omit<RetryOpts, 'signal' | 'fetchFn'> } = {}): Promise<LobeAxes[]> {
58
+ const r = { timeoutMs: AXIS_TIMEOUT_MS, ...retry, ...(fetchFn ? { fetchFn } : {}) }
59
+ return Promise.all(lobes.map(async (lobe) => {
60
+ const [lo, hi] = lobeLonSpan(lobe.bbox, dataset.lonRange)
61
+ const [lon, lat] = await Promise.all([
62
+ fetchAxis(dataset.baseUrl, dataset.datasetId, 'longitude', lo, hi, false, signal, r),
63
+ fetchAxis(dataset.baseUrl, dataset.datasetId, 'latitude', lobe.bbox[1], lobe.bbox[3], dataset.latDescending, signal, r),
64
+ ])
65
+ return { lon, lat }
66
+ }))
67
+ }
68
+
69
+ // ---- shared ----
70
+
71
+ let seq = 0
72
+ const uid = (what: string) => `${what}_${++seq}`
73
+ const aborted = (signal?: AbortSignal) => { if (signal?.aborted) throw signal.reason ?? new DOMException('aborted', 'AbortError') }
74
+
75
+ /** a yyyy-mm-dd UTC day from what DuckDB-WASM hands back for a DATE (epoch ms, a Date, or a string) */
76
+ export function isoDay(v: unknown): string {
77
+ if (v instanceof Date) return v.toISOString().slice(0, 10)
78
+ if (typeof v === 'number' || typeof v === 'bigint') return new Date(Number(v)).toISOString().slice(0, 10)
79
+ return String(v).slice(0, 10)
80
+ }
81
+
82
+ function slabUrl(dataset: PreviewDataset, variable: string, ax: LobeAxes, time: [string, string] | { at: string }): string {
83
+ const grid = {
84
+ base: dataset.baseUrl, datasetId: dataset.datasetId, variable,
85
+ lat : [ax.lat[ax.lat.length - 1], ax.lat[0]] as [number, number], lon: [ax.lon[0], ax.lon[ax.lon.length - 1]] as [number, number],
86
+ latDescending: dataset.latDescending, depth: dataset.depth, format: dataset.format ?? 'parquet',
87
+ }
88
+ return Array.isArray(time) ? griddapUrl({ ...grid, time }) : griddapSliceUrl({ ...grid, at: time.at })
89
+ }
90
+
91
+ function parquetFormat(dataset: PreviewDataset): Format {
92
+ if (dataset.protocol === 'tabledap') throw new Error(`${dataset.datasetId}: the preview is for griddap datasets (tabledap has no lattice)`)
93
+ const f = dataset.format ?? 'parquet'
94
+ if (!isParquet(f)) throw new Error(`${dataset.datasetId}: the preview reads Parquet slabs, and this server offers .${f}`)
95
+ return f
96
+ }
97
+
98
+ /** fetch every lobe's slab for one request, register each under a fresh name; the read_parquet() list of them */
99
+ async function loadSlabs(engine: Engine, urls: string[], format: Format, signal: AbortSignal | undefined,
100
+ retry: Omit<RetryOpts, 'signal'>): Promise<string[]> {
101
+ const slabs = await Promise.all(urls.map((u) => fetchSlab(u, format, undefined, signal, retry)))
102
+ aborted(signal)
103
+ const files: string[] = []
104
+ for (const s of slabs) {
105
+ const f = `${uid('slab')}.parquet`
106
+ await engine.registerBuffer(f, s.buffer!)
107
+ files.push(f)
108
+ }
109
+ return files
110
+ }
111
+ const readList = (files: string[]) => `read_parquet([${files.map((f) => `'${f}'`).join(', ')}])`
112
+ // the lobes' slabs as one relation without a row twice: where two places' boxes overlap (Channel Islands and Chumash)
113
+ // both slabs hold the cells of the overlap, and the join over the mask would return each of them twice
114
+ const distinctSlab = (files: string[]) => `(SELECT DISTINCT * FROM ${readList(files)})`
115
+
116
+ // ---- the preview ----
117
+
118
+ export interface PreviewChunk {
119
+ /** 0-based, in time order */
120
+ index: number
121
+ count: number
122
+ /** the chunk's first and last day, yyyy-mm-dd */
123
+ start: string
124
+ end : string
125
+ /** one griddap request per lobe */
126
+ urls : string[]
127
+ }
128
+
129
+ export interface PreviewOptions {
130
+ dataset : PreviewDataset
131
+ variable : CubeVariable
132
+ /** the place's lobes (`placeLobes`); not needed when `mask` is given */
133
+ lobes ?: Lobe[]
134
+ /** each lobe's axis vectors (`lobeAxes`, or the provenance's `axes`): they bound the requests and set the chunk size */
135
+ axes : LobeAxes[]
136
+ /** a mask already computed (`lobeMasks(...).cells`), so a preview and a slice share one masking */
137
+ mask ?: MaskCell[]
138
+ /** the window, yyyy-mm-dd UTC days, both included */
139
+ t0 : string
140
+ t1 : string
141
+ engine : Engine
142
+ fetch ?: FetchFn
143
+ /** the rows of each chunk as it lands, in time order; a date already given by an earlier chunk is not repeated */
144
+ onChunk ?: (rows: Row[], chunk: PreviewChunk) => void
145
+ signal ?: AbortSignal
146
+ /** the live extent: its step sets the chunk days, its endpoints the exact instants of the first and last request */
147
+ extent ?: TimeExtent | null
148
+ /** the grid's step in days when there is no extent (default 1) */
149
+ stepDays?: number
150
+ limits ?: ChunkLimits
151
+ /** requests in flight: the next chunk is fetched while this one is aggregated (default 2, the precompute's CONCURRENCY) */
152
+ concurrency?: number
153
+ retry ?: Omit<RetryOpts, 'signal' | 'fetchFn'>
154
+ }
155
+
156
+ export interface PreviewResult {
157
+ /** every chunk's rows, in time order, `date` as yyyy-mm-dd */
158
+ rows : Row[]
159
+ chunks : PreviewChunk[]
160
+ chunkDays: number
161
+ template : 'stats_daily' | 'stats_categorical'
162
+ maskCells: number
163
+ }
164
+
165
+ /**
166
+ * The statistics of `variable` over the place for [t0, t1], chunk by chunk. Each chunk's slab (one Parquet request per
167
+ * lobe) is registered, aggregated with the template over the mask and dropped; `onChunk` gets its rows as soon as they
168
+ * are computed, in time order. `signal` stops the chain: the in-flight requests are cancelled, nothing after the abort
169
+ * reaches `onChunk`, and the promise rejects with the abort reason. The mask table and the slabs are dropped either way.
170
+ */
171
+ export async function runPreview(o: PreviewOptions): Promise<PreviewResult> {
172
+ const { dataset, variable, axes, engine, signal } = o
173
+ const format = parquetFormat(dataset)
174
+ aborted(signal)
175
+ const masked = o.mask
176
+ ? { cells: o.mask, cellsPerStep: Math.max(0, ...axes.map((a) => a.lon.length * a.lat.length)) }
177
+ : lobeMasks(dataset, o.lobes ?? [], axes)
178
+ const name = statsTemplate(variable) as 'stats_daily' | 'stats_categorical'
179
+ const chunkDays = previewWindow(masked.cellsPerStep, o.stepDays ?? o.extent?.stepDays ?? 1, o.limits)
180
+ const at = (d: string) => timeInstant(d, o.extent ?? null, noonZ)
181
+ const list: PreviewChunk[] = chunks(o.t0, o.t1, chunkDays).map(([s, e], index, all) => ({
182
+ index, count: all.length, start: s, end: e,
183
+ urls: axes.map((ax) => slabUrl(dataset, variable.name, ax, [at(s), at(e)])),
184
+ }))
185
+ const result: PreviewResult = { rows: [], chunks: list, chunkDays, template: name, maskCells: masked.cells.length }
186
+ if (!masked.cells.length || !list.length) return result
187
+
188
+ const retry = { ...o.retry, ...(o.fetch ? { fetchFn: o.fetch } : {}) }
189
+ const mask = uid('preview_mask')
190
+ const expr = valueExpr(variable)
191
+ const seen = new Set<string>()
192
+ const inFlight: Array<Promise<Array<Awaited<ReturnType<typeof fetchSlab>>>>> = []
193
+ // the fetch half of a chunk: started up to `concurrency` chunks ahead, consumed in order
194
+ const start = (k: number) => {
195
+ if (k < list.length && !inFlight[k]) {
196
+ inFlight[k] = Promise.all(list[k].urls.map((u) => fetchSlab(u, format, undefined, signal, retry)))
197
+ inFlight[k].catch(() => {}) // a later chunk's failure surfaces when it is awaited, or never (after an abort)
198
+ }
199
+ }
200
+ const ahead = Math.max(1, o.concurrency ?? 2)
201
+ try {
202
+ await engine.insertMask(masked.cells, mask)
203
+ for (const chunk of list) {
204
+ for (let k = chunk.index; k < chunk.index + ahead; k++) start(k)
205
+ const slabs = await inFlight[chunk.index]
206
+ aborted(signal)
207
+ const files: string[] = []
208
+ try {
209
+ for (const s of slabs) { const f = `${uid('slab')}.parquet`; await engine.registerBuffer(f, s.buffer!); files.push(f) }
210
+ // DISTINCT: a coarse axis (the 8-day grid) can snap a chunk's two endpoints onto one step
211
+ const slab = distinctSlab(files)
212
+ const out = await engine.runTemplate(name, { expr, slab, mask })
213
+ aborted(signal)
214
+ // two chunks snapped onto the same step of a coarse axis give that step twice, identical: keep the first
215
+ const rows = out.map((r) => ({ ...r, date: isoDay(r.date) })).filter((r) => !seen.has(r.date))
216
+ for (const r of rows) seen.add(r.date)
217
+ result.rows.push(...rows)
218
+ o.onChunk?.(rows, chunk)
219
+ } finally {
220
+ for (const f of files) await engine.dropFile(f).catch(() => {})
221
+ }
222
+ }
223
+ } finally {
224
+ await engine.exec(`DROP TABLE IF EXISTS ${mask}`, 'drop').catch(() => {})
225
+ }
226
+ return result
227
+ }
228
+
229
+ // ---- the map's slice ----
230
+
231
+ /** one cell of a step, as `@oceanmetrics/map`'s `SliceCell` */
232
+ export interface SliceCell { lat: number; lon: number; value: number | null; weight: number }
233
+ /** `@oceanmetrics/map`'s `SliceSource`, structurally: the grid slot's data */
234
+ export interface SliceSource { slice(t: string | null, signal?: AbortSignal): Promise<SliceCell[]> }
235
+
236
+ export interface SliceSourceOptions {
237
+ dataset : PreviewDataset
238
+ variable: CubeVariable
239
+ lobes ?: Lobe[]
240
+ axes : LobeAxes[]
241
+ mask ?: MaskCell[]
242
+ engine : Engine
243
+ fetch ?: FetchFn
244
+ /** a null `t` is the extent's last step (ERDDAP's `last` when there is no extent) */
245
+ extent ?: TimeExtent | null
246
+ retry ?: Omit<RetryOpts, 'signal' | 'fetchFn'>
247
+ }
248
+
249
+ /**
250
+ * A `SliceSource` for the grid slot: `slice(t)` fetches the one step `t` (a yyyy-mm-dd day or an ISO instant; null for
251
+ * the newest) of every lobe and runs `last_step` over the mask: the masked cells with their own lat/lon, the area
252
+ * weight and the value (null where the step has no data). The mask goes into the engine once, at the first slice.
253
+ */
254
+ export function sliceSource(o: SliceSourceOptions): SliceSource {
255
+ const format = parquetFormat(o.dataset)
256
+ const cells = o.mask ?? lobeMasks(o.dataset, o.lobes ?? [], o.axes).cells
257
+ const mask = uid('slice_mask')
258
+ let inserted: Promise<void> | null = null
259
+ const retry = { timeoutMs: SLICE_TIMEOUT_MS, ...o.retry, ...(o.fetch ? { fetchFn: o.fetch } : {}) }
260
+ return {
261
+ async slice(t, signal) {
262
+ aborted(signal)
263
+ if (!cells.length) return []
264
+ const at = t === null ? (o.extent ? o.extent.end : 'last')
265
+ : t.length <= 10 ? timeInstant(t, o.extent ?? null, noonZ) : t
266
+ inserted ??= o.engine.insertMask(cells, mask).catch((e) => { inserted = null; throw e })
267
+ const urls = o.axes.map((ax) => slabUrl(o.dataset, o.variable.name, ax, { at }))
268
+ const files = await loadSlabs(o.engine, urls, format, signal, retry)
269
+ try {
270
+ await inserted
271
+ aborted(signal)
272
+ const rows = await o.engine.runTemplate('last_step', { expr: valueExpr(o.variable), slab: distinctSlab(files), mask })
273
+ return rows.map((r) => ({
274
+ lat: Number(r.latitude), lon: Number(r.longitude), weight: Number(r.weight),
275
+ value: r.value === null || r.value === undefined ? null : Number(r.value),
276
+ }))
277
+ } finally {
278
+ for (const f of files) await o.engine.dropFile(f).catch(() => {})
279
+ }
280
+ },
281
+ }
282
+ }
package/src/sql.ts ADDED
@@ -0,0 +1,36 @@
1
+ // the {{named}}-parameter SQL templates of sql/, rendered without DuckDB. moved from erddap-places `app/src/lib/sql.ts`:
2
+ // the templates come from the generated sqlTemplates.ts (built from sql/*.sql by scripts/sql-templates.mjs) instead of
3
+ // Vite's import.meta.glob; `lit` and `render` are unchanged, so the browser, the precompute and om_extract (which
4
+ // carries byte-identical copies of the same .sql files) run the same text.
5
+ import { TEMPLATES } from './sqlTemplates.js'
6
+
7
+ /** the names of the shipped templates */
8
+ export type TemplateName = 'stats_daily' | 'stats_categorical' | 'last_step' | 'stats_tabledap' | 'points_tabledap'
9
+
10
+ export function template(name: string): string {
11
+ const t = TEMPLATES[name]
12
+ if (t === undefined) throw new Error(`no SQL template ${name} (have: ${Object.keys(TEMPLATES).join(', ')})`)
13
+ return t
14
+ }
15
+ export function templateNames(): string[] {
16
+ return Object.keys(TEMPLATES).sort()
17
+ }
18
+
19
+ export type Param = string | number | boolean | null
20
+ export interface Params { [k: string]: Param }
21
+
22
+ /** params spliced literally (identifiers and expressions: table/column names); everything else quoted. */
23
+ const RAW = new Set(['var', 'expr', 'slab', 'mask', 'src'])
24
+ export function lit(v: Param): string {
25
+ if (v === null || v === undefined) return 'NULL'
26
+ if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'NULL'
27
+ if (typeof v === 'boolean') return v ? 'TRUE' : 'FALSE'
28
+ return `'${String(v).replace(/'/g, "''")}'`
29
+ }
30
+ export function render(name: string, params: Params): string {
31
+ const body = template(name)
32
+ return body.replace(/\{\{(\w+)\}\}/g, (_, k: string) => {
33
+ if (!(k in params)) throw new Error(`template ${name}: missing param ${k}`)
34
+ return RAW.has(k) ? String(params[k]) : lit(params[k])
35
+ }).trim()
36
+ }