@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/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
|
+
}
|