@oceanmetrics/engine 0.0.0-stage → 0.1.0

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 +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +192 -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 +202 -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 +279 -0
  49. package/src/sql.ts +36 -0
  50. package/src/sqlTemplates.ts +8 -0
  51. package/src/wkb.ts +81 -0
package/src/cells.ts ADDED
@@ -0,0 +1,190 @@
1
+ // the masked grid, as map geometry: one GeoJSON square per mask cell, sized from the dataset's own
2
+ // axis spacing (lon/lat +- half a step), carrying the cell's value and area weight.
3
+ //
4
+ // antimeridian: the mask cells of a place like PMNM arrive on both sides of +-180. drawn naively in
5
+ // the (-180, 180] frame, the two halves sit at opposite edges of the map and any square straddling
6
+ // the line wraps the whole globe. so the whole set is put in one continuous frame first: when the
7
+ // cells span more than 180 degrees of longitude, they are expressed in [0, 360) ("centred at 180")
8
+ // and the squares keep going past 180 (179.95 -> 180.05) instead of wrapping. MapLibre accepts
9
+ // longitudes outside [-180, 180] and draws them across the seam.
10
+ import type { Feature, FeatureCollection, Point, Polygon } from 'geojson'
11
+
12
+ /** a mask cell with the value of one time step (null when the grid has no data there). */
13
+ export interface ValueCell { lon: number; lat: number; weight: number; value?: number | null }
14
+ /** what each square carries into the map (hover shows `value` and `weight`). */
15
+ export interface CellProps { lon: number; lat: number; weight: number; value: number | null }
16
+
17
+ export type LonCenter = 0 | 180
18
+ export interface CellSquaresOptions {
19
+ /** cell width in degrees; inferred from the distinct cell longitudes when omitted. */
20
+ lonSpacing?: number
21
+ /** cell height in degrees; inferred from the distinct cell latitudes when omitted. */
22
+ latSpacing?: number
23
+ /** longitude frame: (-180, 180] for 0, [0, 360) for 180, or 'auto' by the cells' own span. */
24
+ center?: LonCenter | 'auto'
25
+ }
26
+ export interface CellSquares {
27
+ geojson : FeatureCollection<Polygon, CellProps>
28
+ lonSpacing: number
29
+ latSpacing: number
30
+ center : LonCenter
31
+ /** [west, south, east, north] of the squares, in the chosen frame (east may exceed 180). */
32
+ bbox : [number, number, number, number]
33
+ /** value range over the cells that have one, for the colour ramp ([0, 0] when there are none). */
34
+ range : [number, number]
35
+ }
36
+
37
+ /** a longitude in the frame `center` names: (-180, 180] for 0, [0, 360) for 180. */
38
+ export function normalizeLon(lon: number, center: LonCenter = 0): number {
39
+ let x = ((((lon + 180) % 360) + 360) % 360) - 180 // (-180, 180]
40
+ if (x === -180) x = 180
41
+ return center === 180 && x < 0 ? x + 360 : x
42
+ }
43
+
44
+ /**
45
+ * The step of an axis given only the values that were used: the **median** positive gap between
46
+ * distinct sorted values, so gaps left by cells outside the mask (2x, 3x the step) cannot inflate
47
+ * it. Returns 0 when there is nothing to measure (fewer than two distinct values).
48
+ */
49
+ export function axisSpacing(values: number[]): number {
50
+ const v = [...new Set(values.filter((x) => Number.isFinite(x)))].sort((a, b) => a - b)
51
+ if (v.length < 2) return 0
52
+ const gaps: number[] = []
53
+ for (let i = 1; i < v.length; i++) { const d = v[i] - v[i - 1]; if (d > 0) gaps.push(d) }
54
+ if (!gaps.length) return 0
55
+ gaps.sort((a, b) => a - b)
56
+ const m = gaps.length >> 1
57
+ return gaps.length % 2 ? gaps[m] : (gaps[m - 1] + gaps[m]) / 2
58
+ }
59
+
60
+ /** the frame the cells fit in without wrapping: 180 when they span more than half the globe. */
61
+ export function pickCenter(lons: number[]): LonCenter {
62
+ if (!lons.length) return 0
63
+ const x = lons.map((l) => normalizeLon(l, 0))
64
+ return Math.max(...x) - Math.min(...x) > 180 ? 180 : 0
65
+ }
66
+
67
+ /**
68
+ * One square per mask cell: `lon +- lonSpacing/2`, `lat +- latSpacing/2` (latitude clamped to the
69
+ * poles), in a single continuous longitude frame, with `value`, `weight`, `lon` and `lat` as
70
+ * properties. Cells whose value is missing are kept (drawn as a gap) with `value: null`.
71
+ */
72
+ export function cellSquares(cells: ValueCell[], opts: CellSquaresOptions = {}): CellSquares {
73
+ const center = opts.center === undefined || opts.center === 'auto'
74
+ ? pickCenter(cells.map((c) => c.lon))
75
+ : opts.center
76
+ const lons = cells.map((c) => normalizeLon(c.lon, center))
77
+ let dx = opts.lonSpacing ?? axisSpacing(lons)
78
+ let dy = opts.latSpacing ?? axisSpacing(cells.map((c) => c.lat))
79
+ // a single row or column gives that axis no gap to measure: borrow the other axis's step
80
+ if (!dx) dx = dy
81
+ if (!dy) dy = dx
82
+ const hx = dx / 2, hy = dy / 2
83
+
84
+ let w = Infinity, s = Infinity, e = -Infinity, n = -Infinity
85
+ let vmin = Infinity, vmax = -Infinity
86
+ const features: Feature<Polygon, CellProps>[] = cells.map((c, i) => {
87
+ const x = lons[i], y = c.lat
88
+ const x0 = x - hx, x1 = x + hx
89
+ const y0 = Math.max(-90, y - hy), y1 = Math.min(90, y + hy)
90
+ const value = typeof c.value === 'number' && Number.isFinite(c.value) ? c.value : null
91
+ if (value !== null) { if (value < vmin) vmin = value; if (value > vmax) vmax = value }
92
+ if (x0 < w) w = x0; if (x1 > e) e = x1
93
+ if (y0 < s) s = y0; if (y1 > n) n = y1
94
+ return {
95
+ type : 'Feature',
96
+ id : i,
97
+ properties: { lon: x, lat: y, weight: c.weight, value },
98
+ geometry : { type: 'Polygon', coordinates: [[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]] },
99
+ }
100
+ })
101
+
102
+ return {
103
+ geojson : { type: 'FeatureCollection', features },
104
+ lonSpacing: dx, latSpacing: dy, center,
105
+ bbox : features.length ? [w, s, e, n] : [0, 0, 0, 0],
106
+ range : Number.isFinite(vmin) ? [vmin, vmax] : [0, 0],
107
+ }
108
+ }
109
+
110
+ // ── map bounds for a place ────────────────────────────────────────────────────
111
+ /**
112
+ * Map bounds for a place, antimeridian-aware.
113
+ *
114
+ * The gazetteer splits geometry at +-180, so a place that straddles the line (PMNM) has the useless
115
+ * stored bbox `[-180, .., 180, ..]`. Only in that case — width > 180 — is the geometry walked, in
116
+ * the [0, 360) frame, giving bounds like `[177.8, 19.2, 210.0, 31.8]` that MapLibre fits across the
117
+ * seam. Every other place returns its stored bbox untouched, so selecting a place still does no
118
+ * geometry work (the FKNMS/TBNMS vertex counts are the reason that matters).
119
+ */
120
+ export function placeMapBounds(
121
+ place: { bbox: [number, number, number, number]; geometry?: unknown },
122
+ ): [number, number, number, number] {
123
+ const [x0, y0, x1, y1] = place.bbox
124
+ if (x1 - x0 <= 180 || !place.geometry) return [x0, y0, x1, y1]
125
+ let w = Infinity, s = Infinity, e = -Infinity, n = -Infinity
126
+ const walk = (c: any) => {
127
+ if (typeof c?.[0] === 'number') {
128
+ const x = normalizeLon(c[0], 180), y = c[1]
129
+ if (x < w) w = x; if (x > e) e = x
130
+ if (y < s) s = y; if (y > n) n = y
131
+ } else if (Array.isArray(c)) for (const k of c) walk(k)
132
+ }
133
+ const g: any = place.geometry
134
+ if (g.type === 'GeometryCollection') for (const sub of g.geometries) walk(sub.coordinates)
135
+ else walk(g.coordinates)
136
+ return Number.isFinite(w) ? [w, s, e, n] : [x0, y0, x1, y1]
137
+ }
138
+
139
+ /**
140
+ * The box to fit for a place of the gazetteer index. The index unwraps a place cut at the antimeridian
141
+ * (NMS:PMNM is 177.8..199.0, `centroid_lon` 188), so its bbox fits directly. A few rows still read -180..180
142
+ * (the geometry is split but the bbox is not unwrapped): those need the geometry, and give null until it is
143
+ * loaded (the caller fits when it arrives).
144
+ */
145
+ export function indexBounds(
146
+ bbox: [number, number, number, number],
147
+ geometry?: unknown,
148
+ ): [number, number, number, number] | null {
149
+ if (bbox[2] - bbox[0] <= 180) return bbox
150
+ return geometry ? placeMapBounds({ bbox, geometry }) : null
151
+ }
152
+
153
+ // ── points (tabledap) ─────────────────────────────────────────────────────────
154
+ export interface CellPoints {
155
+ geojson: FeatureCollection<Point, CellProps>
156
+ center : LonCenter
157
+ bbox : [number, number, number, number]
158
+ range : [number, number]
159
+ }
160
+
161
+ /**
162
+ * One point per sample position, for a **tabledap** run: there is no cell to draw, so the map gets
163
+ * circles instead of squares. Same longitude framing as `cellSquares()`, so an antimeridian place
164
+ * stays in one piece, and the same properties, so the hover popup and the colour ramp are shared.
165
+ */
166
+ export function cellPoints(cells: ValueCell[], opts: { center?: LonCenter | 'auto' } = {}): CellPoints {
167
+ const center = opts.center === undefined || opts.center === 'auto'
168
+ ? pickCenter(cells.map((c) => c.lon))
169
+ : opts.center
170
+ let w = Infinity, s = Infinity, e = -Infinity, n = -Infinity
171
+ let vmin = Infinity, vmax = -Infinity
172
+ const features: Feature<Point, CellProps>[] = cells.map((c, i) => {
173
+ const x = normalizeLon(c.lon, center), y = c.lat
174
+ const value = typeof c.value === 'number' && Number.isFinite(c.value) ? c.value : null
175
+ if (value !== null) { if (value < vmin) vmin = value; if (value > vmax) vmax = value }
176
+ if (x < w) w = x; if (x > e) e = x
177
+ if (y < s) s = y; if (y > n) n = y
178
+ return {
179
+ type: 'Feature', id: i,
180
+ properties: { lon: x, lat: y, weight: c.weight, value },
181
+ geometry : { type: 'Point', coordinates: [x, y] },
182
+ }
183
+ })
184
+ return {
185
+ geojson: { type: 'FeatureCollection', features },
186
+ center,
187
+ bbox : features.length ? [w, s, e, n] : [0, 0, 0, 0],
188
+ range : Number.isFinite(vmin) ? [vmin, vmax] : [0, 0],
189
+ }
190
+ }
package/src/chunks.ts ADDED
@@ -0,0 +1,58 @@
1
+ // the time chunking of a long window: a window is cut into requests of at most 90 time steps and 100,000 values, so a
2
+ // server is asked many light questions instead of a few heavy ones. moved from erddap-places `precompute/src/stats.ts`
3
+ // (`window`, `chunks`, `chunkDaysFor`) with the two constants of `precompute/src/targets.ts`; the arithmetic is
4
+ // unchanged, and the two limits are now options (defaulting to the precompute's values) so the browser preview runs
5
+ // the same rule.
6
+ import { addDays, day, daysBetween, type TimeExtent } from './extent.js'
7
+
8
+ /** never ask ERDDAP for more than this many time steps in one request (90 days of a daily grid). */
9
+ export const MAX_CHUNK_DAYS = 90
10
+ /**
11
+ * and shrink the chunk further so a single request (one lobe) stays under this many values: the
12
+ * `n_max_vals_per_req = 100000` of marinebon/extractr `ed_extract()`, which keeps an ERDDAP server
13
+ * responsive with many light requests instead of a few heavy ones.
14
+ */
15
+ export const MAX_REQUEST_VALUES = 100_000
16
+
17
+ export interface ChunkLimits {
18
+ /** values per request (one lobe): time steps x axis cells */
19
+ maxValues?: number
20
+ /** time steps per request */
21
+ maxDays?: number
22
+ }
23
+
24
+ /** the last `days` days of the dataset, or its full extent when that is shorter (or `days` is 'all'). */
25
+ export function window(ext: TimeExtent, days: number | 'all' = 'all'): { start: string; end: string } {
26
+ const end = day(ext.end)
27
+ if (days === 'all' || !Number.isFinite(days)) return { start: day(ext.start), end }
28
+ let start = addDays(end, -(days - 1))
29
+ if (daysBetween(day(ext.start), start) < 0) start = day(ext.start)
30
+ return { start, end }
31
+ }
32
+
33
+ /** split [start, end] into consecutive, non-overlapping slices of at most `chunkDays` days. */
34
+ export function chunks(start: string, end: string, chunkDays: number): Array<[string, string]> {
35
+ const out: Array<[string, string]> = []
36
+ let s = start
37
+ while (daysBetween(s, end) >= 0) {
38
+ const e = addDays(s, chunkDays - 1)
39
+ out.push([s, daysBetween(e, end) < 0 ? end : e])
40
+ s = addDays(e, 1)
41
+ }
42
+ return out
43
+ }
44
+
45
+ /**
46
+ * days per request: `maxDays` time steps (90 days of a daily grid; a coarser grid's steps are
47
+ * counted in its own days, so a monthly product asks for up to 90 months at once), shrunk so one
48
+ * request stays under `maxValues` values (extractr's n_max_vals_per_req). `cellsPerStep` is the
49
+ * largest lobe's axis cells: each lobe is its own request. Never below one step.
50
+ */
51
+ export function chunkDaysFor(cellsPerStep: number, stepDays = 1,
52
+ { maxValues = MAX_REQUEST_VALUES, maxDays = MAX_CHUNK_DAYS }: ChunkLimits = {}): number {
53
+ const sd = Math.max(1, stepDays)
54
+ const cap = Math.floor(maxDays * sd)
55
+ if (!(cellsPerStep > 0)) return cap
56
+ const steps = Math.max(1, Math.floor(maxValues / cellsPerStep))
57
+ return Math.max(1, Math.min(cap, Math.floor(steps * sd)))
58
+ }
package/src/dataset.ts ADDED
@@ -0,0 +1,160 @@
1
+ // the STAC side of a dataset: one erddap/* Collection -> the fields a run needs (the griddap URL, the value expression,
2
+ // the template). moved from erddap-places `app/src/lib/catalog.ts`: the pure functions, unchanged; `loadDatasets` (it
3
+ // reads the gazetteer's catalog.json) and `linkMismatch` (the app's wording) stay in the app.
4
+ import type { Format } from './erddap.js'
5
+ import { pickFormat } from './erddap.js'
6
+ export interface CubeVariable {
7
+ name : string
8
+ unit : string | null
9
+ description: string
10
+ categorical: boolean
11
+ /** class value -> label, for categorical variables (from `erddap-places:classes`). */
12
+ classes ?: Record<string, string>
13
+ }
14
+
15
+ /** the long-format columns of a tabledap table: `measurement_type` / `measurement_value`. */
16
+ export interface LongFormat { typeColumn: string; valueColumn: string }
17
+
18
+ export interface Dataset {
19
+ id : string // erddap/dhw_5km
20
+ title : string
21
+ baseUrl : string
22
+ datasetId : string
23
+ protocol : 'griddap' | 'tabledap'
24
+ /** set when the table keeps its variables in a column (tabledap, long format) */
25
+ longFormat ?: LongFormat
26
+ version ?: string
27
+ cors : boolean
28
+ formats : string[]
29
+ lonRange : [number, number]
30
+ latDescending: boolean
31
+ variables : CubeVariable[]
32
+ timeStep ?: string // e.g. P1D, P8D
33
+ timeExtent ?: [string | null, string | null]
34
+ /** `erddap-places:depth`: the depth level (m) to slice on a grid with a depth axis (the surface) */
35
+ depth ?: number
36
+ /** `erddap-places:status`: 'pending' when the server does not serve it yet (list it, greyed out) */
37
+ status ?: string
38
+ format : Format // best format this server can serve us
39
+ collection : any // the raw STAC Collection
40
+ }
41
+
42
+ const KELVIN = /^(k|kelvin|degree_?k(elvin)?)$/i
43
+
44
+ /**
45
+ * The SQL expression for a variable's value, converting Kelvin to °C.
46
+ *
47
+ * For a long-format table the variable is not a column: the rows were already filtered to
48
+ * `measurement_type = '<name>'` by the request, so the expression is the value column.
49
+ */
50
+ export function valueExpr(v: CubeVariable, alias = 's', long?: LongFormat): string {
51
+ const col = long ? `${alias}."${long.valueColumn}"` : `${alias}."${v.name}"`
52
+ return KELVIN.test(v.unit ?? '') ? `(${col} - 273.15)` : col
53
+ }
54
+ /** the axis label for a variable, after any unit conversion. */
55
+ export function valueLabel(v: CubeVariable): string {
56
+ if (KELVIN.test(v.unit ?? '')) return '°C'
57
+ const u = (v.unit ?? '').trim()
58
+ if (!u || u === '1' || u.toLowerCase() === 'none') return v.name
59
+ if (/^(celsius|degree_?c(elsius)?)$/i.test(u)) return '°C'
60
+ return u
61
+ }
62
+
63
+ /** the `erddap-places:categorical` flag as published in the catalog (boolean, or the string). */
64
+ export function isCategorical(v: any): boolean {
65
+ const f = v?.['erddap-places:categorical']
66
+ return f === true || f === 'true'
67
+ }
68
+
69
+ /** `erddap-places:long_format` -> the two column names, or undefined. */
70
+ export function toLongFormat(f: any): LongFormat | undefined {
71
+ const t = f?.type_column, v = f?.value_column
72
+ return t && v ? { typeColumn: String(t), valueColumn: String(v) } : undefined
73
+ }
74
+
75
+ /**
76
+ * The SQL template a run is summarised with: point (tabledap) data rolls up by month, a flagged
77
+ * categorical grid gets per-class fractions, everything else is the daily grid statistics.
78
+ */
79
+ export function statsTemplate(
80
+ v: Pick<CubeVariable, 'categorical'> | null | undefined,
81
+ protocol: 'griddap' | 'tabledap' = 'griddap',
82
+ ): 'stats_daily' | 'stats_categorical' | 'stats_tabledap' {
83
+ if (protocol === 'tabledap') return 'stats_tabledap'
84
+ return v?.categorical === true ? 'stats_categorical' : 'stats_daily'
85
+ }
86
+
87
+ function toVariable(name: string, v: any): CubeVariable {
88
+ return {
89
+ name,
90
+ unit : v?.unit ?? null,
91
+ description: v?.description ?? name,
92
+ categorical: isCategorical(v),
93
+ classes : v?.['erddap-places:classes'] ?? undefined,
94
+ }
95
+ }
96
+
97
+ /** one STAC Collection -> the fields the app uses. */
98
+ export function toDataset(c: any): Dataset {
99
+ const vars = Object.entries(c['cube:variables'] ?? {})
100
+ .filter(([, v]: [string, any]) => (v?.type ?? 'data') === 'data')
101
+ .map(([k, v]) => toVariable(k, v))
102
+ const time = c['cube:dimensions']?.time ?? {}
103
+ return {
104
+ id : c.id,
105
+ title : c.title ?? c.id,
106
+ baseUrl : c['erddap:base_url'],
107
+ datasetId : c['erddap:dataset_id'],
108
+ protocol : c['erddap:protocol'] === 'tabledap' ? 'tabledap' : 'griddap',
109
+ longFormat : toLongFormat(c['erddap-places:long_format']),
110
+ version : c['erddap:version'],
111
+ cors : c['erddap:cors'] !== false,
112
+ formats : c['erddap:formats'] ?? [],
113
+ lonRange : c['erddap:lon_range'] ?? [-180, 180],
114
+ latDescending: c['erddap:lat_descending'] === true,
115
+ variables : vars,
116
+ timeStep : time.step,
117
+ timeExtent : time.extent,
118
+ depth : Number.isFinite(Number(c['erddap-places:depth'])) && c['erddap-places:depth'] != null
119
+ ? Number(c['erddap-places:depth']) : undefined,
120
+ status : c['erddap-places:status'] ?? undefined,
121
+ format : pickFormat({ erddap: { cors: c['erddap:cors'], formats: c['erddap:formats'] } }),
122
+ collection : c,
123
+ }
124
+ }
125
+
126
+ /**
127
+ * The dataset a link names. The app writes the STAC collection id (`erddap/dhw_5km`), but links written elsewhere name
128
+ * ERDDAP's own dataset id (`dhw_5km`, `IMERG_monthly_global_precip`), and one collection id differs from its dataset id
129
+ * in case (`erddap/imerg_monthly_global_precip`). Tried in turn, each ignoring case: the collection id, the collection
130
+ * id with `erddap/` before it, ERDDAP's dataset id. A pending dataset is never picked; null when nothing matches.
131
+ */
132
+ export function resolveDataset<D extends Pick<Dataset, 'id' | 'datasetId' | 'status'>>(datasets: D[], wanted: string): D | null {
133
+ const w = String(wanted ?? '').trim().toLowerCase()
134
+ if (!w) return null
135
+ const live = datasets.filter((d) => d.status !== 'pending')
136
+ const eq = (a: string | undefined, b: string) => (a ?? '').toLowerCase() === b
137
+ return live.find((d) => d.id === wanted)
138
+ ?? live.find((d) => eq(d.id, w))
139
+ ?? live.find((d) => eq(d.id, `erddap/${w}`))
140
+ ?? live.find((d) => eq(d.datasetId, w.replace(/^erddap\//, '')))
141
+ ?? null
142
+ }
143
+
144
+ /** ERDDAP wants a lon in the dataset's own range: [-180, 180] or [0, 360]. */
145
+ export function toDatasetLon(lon: number, range: [number, number]): number {
146
+ if (range[1] > 180 && lon < 0) return lon + 360
147
+ if (range[0] < 0 && lon > 180) return lon - 360
148
+ return lon
149
+ }
150
+
151
+ /**
152
+ * A lobe's longitude span in the dataset's own frame, [min, max], clamped to `erddap:lon_range`:
153
+ * a lobe that runs to 180 on a grid ending at 179.75 (CMEMS 0.25°) would otherwise ask for a
154
+ * longitude past the axis maximum, which ERDDAP refuses (404).
155
+ */
156
+ export function lobeLonSpan(bbox: [number, number, number, number] | number[], range: [number, number]): [number, number] {
157
+ const lo = toDatasetLon(bbox[0], range), hi = toDatasetLon(bbox[2], range)
158
+ const clamp = (x: number) => Math.min(Math.max(x, range[0]), range[1])
159
+ return [clamp(Math.min(lo, hi)), clamp(Math.max(lo, hi))]
160
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,190 @@
1
+ // DuckDB-WASM as the statistics engine: the slab goes in as a registered Parquet buffer, the mask as an Arrow table,
2
+ // and every statistic is a sql/ template rendered with {{named}} params. moved from erddap-places
3
+ // `app/src/lib/engine.ts`; the methods are unchanged, but the package never boots DuckDB itself: the app owns the
4
+ // bundles and the Web Worker (Vite `?url` imports, a CDN, or the node-blocking bundle in tests) and hands the Engine a
5
+ // `connect()` that resolves to `{ db, conn }`. `connectBrowser(duckdb, bundles)` is that boot for a browser app, with the
6
+ // duckdb-wasm module passed in (a peer dependency, never imported at run time here).
7
+ import { DataType, tableFromArrays, tableToIPC } from 'apache-arrow'
8
+ import type * as duckdbWasm from '@duckdb/duckdb-wasm'
9
+ import type { MaskCell } from './mask.js'
10
+ import { render } from './sql.js'
11
+ import type { Params } from './sql.js'
12
+
13
+ // ---- the database it runs on ----
14
+
15
+ /** the slice of a DuckDB-WASM database the engine uses: `AsyncDuckDB` in a browser, the node-blocking bindings in tests */
16
+ export interface DuckDbLike {
17
+ registerFileBuffer(name: string, buf: Uint8Array): unknown
18
+ copyFileToBuffer(name: string): Uint8Array | Promise<Uint8Array>
19
+ dropFile(name: string): unknown
20
+ }
21
+ /** the slice of a connection the engine uses: `AsyncDuckDBConnection`, or the blocking `DuckDBConnection` */
22
+ export interface DuckConnLike {
23
+ /** an Arrow table (or a promise of one): iterable rows, `schema.fields`, `numRows` */
24
+ query(sql: string): any
25
+ insertArrowFromIPCStream(buf: Uint8Array, opts: { name: string; create?: boolean }): unknown
26
+ }
27
+ export interface Duck { db: DuckDbLike; conn: DuckConnLike }
28
+
29
+ /**
30
+ * The browser boot erddap-places' engine did itself: pick the bundle (eh or mvp), start its Web Worker, instantiate,
31
+ * connect. `duckdb` is the app's `import * as duckdb from '@duckdb/duckdb-wasm'`, `bundles` the module and worker
32
+ * URLs it serves (see the README, "Worker setup").
33
+ */
34
+ export async function connectBrowser(duckdb: typeof duckdbWasm, bundles: duckdbWasm.DuckDBBundles,
35
+ logger: duckdbWasm.Logger = new duckdb.VoidLogger()): Promise<Duck> {
36
+ const bundle = await duckdb.selectBundle(bundles)
37
+ const worker = new Worker(bundle.mainWorker!)
38
+ const db = new duckdb.AsyncDuckDB(logger, worker)
39
+ await db.instantiate(bundle.mainModule, bundle.pthreadWorker)
40
+ return { db, conn: await db.connect() }
41
+ }
42
+
43
+ // ---- rows ----
44
+ export type Row = Record<string, any>
45
+ /**
46
+ * an Arrow result as plain rows: BIGINT as a number, and DECIMAL scaled. DuckDB-WASM hands a DECIMAL back as its
47
+ * unscaled integer (`1.0 AS weight` in points_tabledap.sql arrived as 10, a `VALUES (20.05, ...)` latitude as 2005);
48
+ * erddap-places' toRows passed it through, which no ERDDAP slab (float axes, float values) ever exercised.
49
+ */
50
+ export function toRows(t: any): Row[] {
51
+ const cols: string[] = t.schema.fields.map((f: any) => f.name)
52
+ const scale: number[] = t.schema.fields.map((f: any) => (DataType.isDecimal(f.type) ? 10 ** f.type.scale : 0))
53
+ const out: Row[] = []
54
+ for (const r of t) {
55
+ const o: Row = {}
56
+ cols.forEach((c, i) => {
57
+ const v = r[c]
58
+ o[c] = typeof v === 'bigint' ? Number(v) : scale[i] && v !== null && v !== undefined ? Number(v) / scale[i] : v
59
+ })
60
+ out.push(o)
61
+ }
62
+ return out
63
+ }
64
+
65
+ export interface EngineEvent { name: string; ms: number; note?: string }
66
+
67
+ const now = () => (globalThis.performance ?? Date).now()
68
+
69
+ export class Engine {
70
+ db!: DuckDbLike
71
+ conn!: DuckConnLike
72
+ ready: Promise<void>
73
+ marks: EngineEvent[] = []
74
+ lastSql = ''
75
+ private q: Promise<any> = Promise.resolve() // one connection: serialize everything
76
+
77
+ /** `connect` runs at once (construct the engine when the app first needs it) */
78
+ constructor(connect: () => Promise<Duck> | Duck) { this.ready = this.init(connect) }
79
+ private mark(name: string, ms: number, note?: string) { this.marks.push({ name, ms: Math.round(ms), note }) }
80
+
81
+ private async init(connect: () => Promise<Duck> | Duck) {
82
+ const t = now()
83
+ const { db, conn } = await connect()
84
+ this.db = db
85
+ this.conn = conn
86
+ this.mark('connect', now() - t)
87
+ }
88
+
89
+ /** register an already-fetched slab (parquet bytes) under a virtual file name. */
90
+ async registerBuffer(name: string, buf: Uint8Array) {
91
+ const run = async () => {
92
+ await this.ready
93
+ const t = now()
94
+ await this.db.registerFileBuffer(name, buf)
95
+ this.mark(`register:${name}`, now() - t, `${(buf.byteLength / 1e3).toFixed(0)} kB`)
96
+ }
97
+ this.q = this.q.then(run, run)
98
+ return this.q
99
+ }
100
+
101
+ /** forget a registered file (a slab whose statistics are done). */
102
+ async dropFile(name: string) {
103
+ const run = async () => {
104
+ await this.ready
105
+ await this.db.dropFile(name)
106
+ }
107
+ this.q = this.q.then(run, run)
108
+ return this.q
109
+ }
110
+
111
+ /** the gridMask cells as a DuckDB table (via Arrow IPC, so no giant VALUES list). */
112
+ async insertMask(cells: MaskCell[], table = 'mask') {
113
+ const run = async () => {
114
+ await this.ready
115
+ const t = now()
116
+ const arrow = tableFromArrays({
117
+ latitude : Float64Array.from(cells.map((c) => c.lat)),
118
+ longitude: Float64Array.from(cells.map((c) => c.lon)),
119
+ weight : Float64Array.from(cells.map((c) => c.weight)),
120
+ })
121
+ await this.conn.query(`DROP TABLE IF EXISTS ${table}`)
122
+ await this.conn.insertArrowFromIPCStream(tableToIPC(arrow, 'stream'), { name: table, create: true })
123
+ this.mark(`mask:${table}`, now() - t, `${cells.length} cells`)
124
+ }
125
+ this.q = this.q.then(run, run)
126
+ return this.q
127
+ }
128
+
129
+ /** rows from a non-Parquet slab (.csvp / .jsonp) as a table, so the same SQL works on them. */
130
+ async insertRows(rows: Record<string, unknown>[], table: string) {
131
+ const run = async () => {
132
+ await this.ready
133
+ const t = now()
134
+ const cols = Object.keys(rows[0] ?? {})
135
+ const arrays: Record<string, any> = {}
136
+ for (const c of cols) {
137
+ const vals = rows.map((r) => r[c])
138
+ arrays[c] = vals.every((v) => typeof v === 'number' || v === null)
139
+ ? Float64Array.from(vals.map((v) => (v === null ? NaN : (v as number))))
140
+ : vals.map((v) => (v === null || v === undefined ? null : String(v)))
141
+ }
142
+ await this.conn.query(`DROP TABLE IF EXISTS ${table}`)
143
+ await this.conn.insertArrowFromIPCStream(tableToIPC(tableFromArrays(arrays), 'stream'), { name: table, create: true })
144
+ this.mark(`rows:${table}`, now() - t, `${rows.length} rows`)
145
+ }
146
+ this.q = this.q.then(run, run)
147
+ return this.q
148
+ }
149
+
150
+ exec(sql: string, label = 'exec'): Promise<Row[]> {
151
+ const run = async () => {
152
+ await this.ready
153
+ const t = now()
154
+ this.lastSql = sql
155
+ const res = await this.conn.query(sql)
156
+ this.mark(`query:${label}`, now() - t, `${res.numRows} rows`)
157
+ return toRows(res)
158
+ }
159
+ this.q = this.q.then(run, run)
160
+ return this.q
161
+ }
162
+
163
+ /**
164
+ * The result table as Parquet bytes, written by DuckDB itself.
165
+ *
166
+ * The rows go back into a temporary table (Arrow IPC, as the mask does), `COPY … TO` writes a
167
+ * Parquet file into the WASM filesystem and `copyFileToBuffer` hands it back. Going through the
168
+ * rows rather than re-running the statistics keeps the download identical to what is on screen,
169
+ * whatever has happened to the registered slab since.
170
+ */
171
+ async toParquet(rows: Record<string, unknown>[], table = '_export', file = 'export.parquet'): Promise<Uint8Array> {
172
+ if (!rows.length) throw new Error('nothing to export')
173
+ await this.insertRows(rows, table)
174
+ const run = async () => {
175
+ await this.ready
176
+ const t = now()
177
+ await this.conn.query(`COPY (SELECT * FROM ${table}) TO '${file}' (FORMAT PARQUET)`)
178
+ const buf = await this.db.copyFileToBuffer(file)
179
+ await Promise.resolve(this.db.dropFile(file)).catch(() => {})
180
+ await this.conn.query(`DROP TABLE IF EXISTS ${table}`)
181
+ this.mark(`parquet:${file}`, now() - t, `${rows.length} rows, ${(buf.byteLength / 1e3).toFixed(0)} kB`)
182
+ return buf
183
+ }
184
+ this.q = this.q.then(run, run)
185
+ return this.q
186
+ }
187
+
188
+ /** render a `sql/<name>.sql` template with {{params}} and run it. */
189
+ runTemplate(name: string, params: Params): Promise<Row[]> { return this.exec(render(name, params), name) }
190
+ }