@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/dist/erddap.js ADDED
@@ -0,0 +1,224 @@
1
+ // ERDDAP griddap client. no proxy of ours in the path: the browser talks to the ERDDAP server.
2
+ //
3
+ // URL shape (griddap): {base}/griddap/{dataset}.{fmt}?{var}[(t0):1:(t1)][(lat0):1:(lat1)][(lon0):1:(lon1)]
4
+ // each constraint's square brackets are percent-encoded (%5B / %5D); the rest of the query is left
5
+ // literal, because ERDDAP will not accept a fully encoded query string. constraint values go in the
6
+ // axis's own direction (latitude descends on the CRW grid, so [(22.4):1:(18.8)]). a 4-D grid
7
+ // (time, depth, latitude, longitude) gets one more constraint, a single depth level, after time.
8
+ import { fetchRetry } from './fetchRetry.js';
9
+ /** the two Parquet rungs are handled identically once the bytes are in hand. */
10
+ export const isParquet = (f) => f === 'parquet' || f === 'parquetWMeta';
11
+ const enc = (s) => s.replace(/\[/g, '%5B').replace(/\]/g, '%5D');
12
+ const stripSlash = (s) => s.replace(/\/+$/, '');
13
+ /** one axis constraint, `[(lo):1:(hi)]` with the brackets encoded. */
14
+ export function constraint(lo, hi, stride = 1) {
15
+ return enc(`[(${lo}):${stride}:(${hi})]`);
16
+ }
17
+ /** an ISO instant at 12:00Z for a Date or a yyyy-mm-dd string (ERDDAP daily grids sit at noon). */
18
+ export function noonZ(d) {
19
+ const iso = typeof d === 'string' ? d.slice(0, 10) : d.toISOString().slice(0, 10);
20
+ return `${iso}T12:00:00Z`;
21
+ }
22
+ const EXT = {
23
+ parquet: '.parquet', parquetWMeta: '.parquetWMeta', csvp: '.csvp', jsonp: '.json',
24
+ };
25
+ /** the griddap request URL for one lobe. */
26
+ export function griddapUrl(s) {
27
+ const fmt = s.format ?? 'parquet';
28
+ const desc = s.latDescending ?? true;
29
+ const [y0, y1] = [Math.min(...s.lat), Math.max(...s.lat)];
30
+ const [x0, x1] = [Math.min(...s.lon), Math.max(...s.lon)];
31
+ const q = s.variable +
32
+ constraint(s.time[0], s.time[1]) +
33
+ (s.depth != null && Number.isFinite(s.depth) ? constraint(s.depth, s.depth) : '') +
34
+ (desc ? constraint(y1, y0) : constraint(y0, y1)) +
35
+ constraint(x0, x1);
36
+ const tail = fmt === 'jsonp' ? `&.jsonp=${s.callback ?? 'erddapCb'}` : '';
37
+ return `${stripSlash(s.base)}/griddap/${s.datasetId}${EXT[fmt]}?${q}${tail}`;
38
+ }
39
+ /**
40
+ * the griddap URL for ONE time step (`[(t):1:(t)]`), for the map when the statistics come from the
41
+ * precomputed file: the same axis order, longitude range and depth as griddapUrl(), but a single step
42
+ * (`at` is the ISO instant of that step, e.g. the dataset's last time).
43
+ */
44
+ export function griddapSliceUrl(s) {
45
+ const { at, ...rest } = s;
46
+ return griddapUrl({ ...rest, time: [at, at] });
47
+ }
48
+ /** one URL per polygon lobe (bbox = [lonMin, latMin, lonMax, latMax]). */
49
+ export function griddapUrls(s, bboxes) {
50
+ return bboxes.map((b) => griddapUrl({ ...s, lon: [b[0], b[2]], lat: [b[1], b[3]] }));
51
+ }
52
+ // only the comparison characters are encoded; `=` stays literal, as ERDDAP's own example URLs do
53
+ const OPS = {
54
+ '>=': '%3E=', '<=': '%3C=', '>': '%3E', '<': '%3C', '=': '=', '!=': '!=', '=~': '=~',
55
+ };
56
+ /** one tabledap constraint, `column%3E=value`. */
57
+ export function tableConstraint(c) {
58
+ return `${c.column}${OPS[c.op] ?? c.op}${c.value}`;
59
+ }
60
+ /** the tabledap request URL: the encoded column list, then one `&constraint` each. */
61
+ export function tabledapUrl(s) {
62
+ const fmt = s.format ?? 'parquetWMeta';
63
+ const q = [
64
+ s.columns.join('%2C'),
65
+ ...(s.constraints ?? []).map(tableConstraint),
66
+ ...(s.functions ?? []),
67
+ ];
68
+ const tail = fmt === 'jsonp' ? `&.jsonp=${s.callback ?? 'erddapCb'}` : '';
69
+ return `${stripSlash(s.base)}/tabledap/${s.datasetId}${EXT[fmt]}?${q.join('&')}${tail}`;
70
+ }
71
+ /**
72
+ * The constraints for one place lobe and one time window: a bbox and a half-open-ish time range,
73
+ * plus the long-format row filter when the dataset keeps its variables in a `measurement_type`
74
+ * column (`erddap-places:long_format` in the STAC Collection).
75
+ */
76
+ export function tabledapPlaceConstraints(o) {
77
+ const iso = (d, end = false) => (d.length > 10 ? d : `${d}T${end ? '23:59:59' : '00:00:00'}Z`);
78
+ const cs = [
79
+ { column: 'time', op: '>=', value: iso(o.from) },
80
+ { column: 'time', op: '<=', value: iso(o.to, true) },
81
+ { column: 'longitude', op: '>=', value: o.bbox[0] },
82
+ { column: 'longitude', op: '<=', value: o.bbox[2] },
83
+ { column: 'latitude', op: '>=', value: o.bbox[1] },
84
+ { column: 'latitude', op: '<=', value: o.bbox[3] },
85
+ ];
86
+ // a string value is quoted, and the quotes percent-encoded (%22), as ERDDAP requires
87
+ if (o.typeColumn && o.typeValue)
88
+ cs.push({ column: o.typeColumn, op: '=', value: `%22${o.typeValue}%22` });
89
+ return cs;
90
+ }
91
+ // ── axis vectors ──────────────────────────────────────────────────────────────
92
+ /**
93
+ * per-attempt timeout of an axis request. small, but a busy server is slow whatever the size: PacIOOS answered
94
+ * `dhw_5km` latitude requests in 13–19 s on 2026-10-20, past the old 15 s, so a live run failed while the server
95
+ * was up ("did not answer (no response within 15 s, 2 tries)").
96
+ */
97
+ export const AXIS_TIMEOUT_MS = 45_000;
98
+ /** per-attempt timeout of the map's one-step slice: as small, and as slow on a busy server (PacIOOS took 27–31 s for 45 days of one pixel) */
99
+ export const SLICE_TIMEOUT_MS = 45_000;
100
+ /**
101
+ * `dataset.json?latitude[(min):1:(max)]` -> the axis values, in the server's own order. `AXIS_TIMEOUT_MS` per
102
+ * attempt and one retry by default (`retry` overrides them; the precompute waits longer).
103
+ */
104
+ export async function fetchAxis(base, datasetId, axis, min, max, descending = false, signal, retry = {}) {
105
+ const c = descending ? constraint(max, min) : constraint(min, max);
106
+ const url = `${stripSlash(base)}/griddap/${datasetId}.json?${axis}${c}`;
107
+ return fetchRetry(url, { timeoutMs: AXIS_TIMEOUT_MS, ...retry, signal }, async (res) => {
108
+ if (!res.ok)
109
+ throw new Error(`${url}: ${res.status} ${res.statusText}`);
110
+ const j = await res.json();
111
+ return j.table.rows.map((r) => Number(r[0]));
112
+ });
113
+ }
114
+ // ── format rung ───────────────────────────────────────────────────────────────
115
+ /**
116
+ * Best format this server can serve us: Parquet -> CSV (`.csvp`) -> JSONP.
117
+ * CORS off means only JSONP (a `<script>` tag is not subject to CORS).
118
+ */
119
+ export function pickFormat(collection) {
120
+ const e = collection?.erddap ?? {};
121
+ const fmts = (e.formats ?? []).map((f) => f.replace(/^\./, '').toLowerCase());
122
+ if (e.cors === false)
123
+ return 'jsonp';
124
+ if (fmts.length === 0)
125
+ return 'parquet';
126
+ // parquetWMeta is plain Parquet with ERDDAP's metadata in the file footer: prefer it when offered
127
+ if (fmts.includes('parquetwmeta'))
128
+ return 'parquetWMeta';
129
+ if (fmts.includes('parquet'))
130
+ return 'parquet';
131
+ if (fmts.includes('csvp') || fmts.includes('csv'))
132
+ return 'csvp';
133
+ return 'jsonp';
134
+ }
135
+ /** load a `.json?...&.jsonp=cb` URL through a <script> tag (browser only; no CORS needed). */
136
+ export function fetchJsonp(url, cbName, timeoutMs = 120_000, signal) {
137
+ return new Promise((resolve, reject) => {
138
+ if (typeof document === 'undefined')
139
+ return reject(new Error('jsonp needs a browser document'));
140
+ const done = (fn) => { clearTimeout(timer); delete window[cbName]; script.remove(); fn(); };
141
+ const timer = setTimeout(() => done(() => reject(new Error(`jsonp timeout: ${url}`))), timeoutMs);
142
+ window[cbName] = (data) => done(() => resolve(data));
143
+ // a <script> tag cannot be cancelled, but a superseded run must stop waiting on it
144
+ signal?.addEventListener('abort', () => done(() => reject(new DOMException('aborted', 'AbortError'))), { once: true });
145
+ const script = document.createElement('script');
146
+ script.src = url;
147
+ script.onerror = () => done(() => reject(new Error(`jsonp failed: ${url}`)));
148
+ document.head.appendChild(script);
149
+ });
150
+ }
151
+ /** `{table:{columnNames, rows}}` -> array of objects. */
152
+ export function tableToRows(t) {
153
+ const { columnNames, rows } = t.table;
154
+ return rows.map((r) => Object.fromEntries(columnNames.map((c, i) => [c, r[i]])));
155
+ }
156
+ /** minimal CSV parse for `.csvp` (ERDDAP quotes with " and doubles inner quotes). */
157
+ export function parseCsvp(text) {
158
+ const lines = text.split('\n').filter((l) => l.length);
159
+ if (!lines.length)
160
+ return [];
161
+ const split = (l) => {
162
+ const out = [];
163
+ let cur = '', q = false;
164
+ for (let i = 0; i < l.length; i++) {
165
+ const ch = l[i];
166
+ if (q) {
167
+ if (ch === '"') {
168
+ if (l[i + 1] === '"') {
169
+ cur += '"';
170
+ i++;
171
+ }
172
+ else
173
+ q = false;
174
+ }
175
+ else
176
+ cur += ch;
177
+ }
178
+ else if (ch === '"')
179
+ q = true;
180
+ else if (ch === ',') {
181
+ out.push(cur);
182
+ cur = '';
183
+ }
184
+ else if (ch !== '\r')
185
+ cur += ch;
186
+ }
187
+ out.push(cur);
188
+ return out;
189
+ };
190
+ const head = split(lines[0]).map((h) => h.replace(/\s*\(.*\)$/, '')); // strip the " (degree_C)" units suffix
191
+ return lines.slice(1).map((l) => {
192
+ const f = split(l);
193
+ return Object.fromEntries(head.map((h, i) => {
194
+ const v = f[i];
195
+ const n = v === '' || v === 'NaN' ? null : Number(v);
196
+ return [h, n === null || Number.isNaN(n) ? v : n];
197
+ }));
198
+ });
199
+ }
200
+ /**
201
+ * fetch one slab in the given format. Parquet comes back as bytes for `registerFileBuffer`. `retry`
202
+ * sets the per-attempt timeout and the tries (fetchRetry: 20 s and one retry unless the caller says
203
+ * otherwise: a whole live window can take longer than the one-step map slice).
204
+ */
205
+ export async function fetchSlab(url, format = 'parquet', callback = 'erddapCb', signal, retry = {}) {
206
+ const t0 = (globalThis.performance ?? Date).now();
207
+ if (format === 'jsonp') {
208
+ const data = await fetchJsonp(url, callback, retry.timeoutMs ?? 120_000, signal);
209
+ return { url, format, ms: (globalThis.performance ?? Date).now() - t0, rows: tableToRows(data) };
210
+ }
211
+ return fetchRetry(url, { ...retry, signal }, async (res) => {
212
+ if (!res.ok) {
213
+ const msg = (await res.text().catch(() => '')).slice(0, 400);
214
+ throw new Error(`${res.status} ${res.statusText} from ERDDAP: ${msg}`);
215
+ }
216
+ if (format === 'csvp') {
217
+ const text = await res.text();
218
+ return { url, format, ms: (globalThis.performance ?? Date).now() - t0, bytes: text.length, text, rows: parseCsvp(text) };
219
+ }
220
+ // parquet and parquetWMeta alike: raw bytes for registerFileBuffer
221
+ const buffer = new Uint8Array(await res.arrayBuffer());
222
+ return { url, format, ms: (globalThis.performance ?? Date).now() - t0, bytes: buffer.byteLength, buffer };
223
+ });
224
+ }
@@ -0,0 +1,64 @@
1
+ import { type RetryOpts } from './fetchRetry.js';
2
+ export interface TimeExtent {
3
+ start: string;
4
+ end: string;
5
+ stepDays?: number;
6
+ stepLabel?: string;
7
+ nValues?: number;
8
+ }
9
+ export interface InfoTable {
10
+ table: {
11
+ columnNames: string[];
12
+ rows: unknown[][];
13
+ };
14
+ }
15
+ /** the UTC calendar day (yyyy-mm-dd) of an ISO instant. */
16
+ export declare const day: (iso: string) => string;
17
+ /** yyyy-mm-dd, n days after `d` (n may be negative). */
18
+ export declare function addDays(d: string, n: number): string;
19
+ /** whole days from `a` to `b`, by UTC calendar day. */
20
+ export declare const daysBetween: (a: string, b: string) => number;
21
+ /** `8 days 0h 7m 57s` / `1 day` / `12h` -> days. */
22
+ export declare function parseSpacing(s: string): number | undefined;
23
+ /** a human label for a time step in days. */
24
+ export declare function stepLabel(days: number | undefined): string | undefined;
25
+ /** the ERDDAP `info/<id>/index.json` table -> the dataset's live time extent. */
26
+ export declare function parseInfoExtent(info: InfoTable, axis?: string): TimeExtent | null;
27
+ export interface Window {
28
+ start: string;
29
+ end: string;
30
+ }
31
+ export interface ClampResult extends Window {
32
+ snapped: boolean;
33
+ reason?: string;
34
+ }
35
+ export interface WindowOpts {
36
+ days?: number;
37
+ minSteps?: number;
38
+ maxDays?: number;
39
+ }
40
+ /** how many days a default window should span for a dataset of this step. */
41
+ export declare function spanDays(ext: TimeExtent | null, o?: WindowOpts): number;
42
+ /** the last `days` (or `minSteps` steps) ending at the dataset's last time step. */
43
+ export declare function defaultWindow(ext: TimeExtent | null, o?: WindowOpts): Window;
44
+ /**
45
+ * Keep a user's window inside the dataset's extent.
46
+ * A start after the last time step (or an end before the first) snaps the whole window back to the
47
+ * default one, with a `reason` for the status line, so nothing out-of-range is ever requested.
48
+ */
49
+ export declare function clampWindow(w: Window, ext: TimeExtent | null, o?: WindowOpts): ClampResult;
50
+ /**
51
+ * The ISO instant to put in a griddap time constraint for a calendar day. The extent's own endpoints
52
+ * are used verbatim, so a request never overshoots an axis whose steps are not at 12:00Z (MUR sits
53
+ * at 09:00Z); any interior day falls back to noon.
54
+ */
55
+ export declare function timeInstant(date: string, ext: TimeExtent | null, noon: (d: string) => string): string;
56
+ /**
57
+ * `<base>/info/<datasetID>/index.json` -> the live extent, memoised per base+dataset. The fetch is
58
+ * shared by every caller, so no caller's signal is passed to it (a superseded run must not cancel it
59
+ * for the next one); fetchRetry bounds it instead (`INFO_TIMEOUT_MS` per attempt, one retry). null on failure,
60
+ * and a failure is forgotten, so the next call asks again.
61
+ */
62
+ /** per-attempt timeout of the info request (PacIOOS took 4–9 s on 2026-10-20, and 20 s on 2026-10-09) */
63
+ export declare const INFO_TIMEOUT_MS = 30000;
64
+ export declare function fetchTimeExtent(base: string, datasetId: string, axis?: string, retry?: Omit<RetryOpts, 'signal'>): Promise<TimeExtent | null>;
package/dist/extent.js ADDED
@@ -0,0 +1,151 @@
1
+ // live time extent of an ERDDAP grid dataset, from `<base>/erddap/info/<datasetID>/index.json`.
2
+ //
3
+ // the STAC `cube:dimensions.time.extent` end is null (or stale) for near-real-time grids, and every
4
+ // dataset ends at a different instant: AOML Seascapes is an 8-day product months behind, CRW is a
5
+ // couple of days behind. asking the server means the default window always lands inside the data,
6
+ // instead of 404ing with `"Start" is greater than the axis maximum=…`.
7
+ //
8
+ // the parsing and clamping here are pure functions (see extent.test.ts); only fetchTimeExtent()
9
+ // touches the network.
10
+ import { fetchRetry } from './fetchRetry.js';
11
+ const isoZ = (secs) => new Date(secs * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
12
+ /** the UTC calendar day (yyyy-mm-dd) of an ISO instant. */
13
+ export const day = (iso) => iso.slice(0, 10);
14
+ const dayMs = 864e5;
15
+ /** yyyy-mm-dd, n days after `d` (n may be negative). */
16
+ export function addDays(d, n) {
17
+ return new Date(Date.parse(`${day(d)}T00:00:00Z`) + n * dayMs).toISOString().slice(0, 10);
18
+ }
19
+ /** whole days from `a` to `b`, by UTC calendar day. */
20
+ export const daysBetween = (a, b) => Math.round((Date.parse(`${day(b)}T00:00:00Z`) - Date.parse(`${day(a)}T00:00:00Z`)) / dayMs);
21
+ /** `8 days 0h 7m 57s` / `1 day` / `12h` -> days. */
22
+ export function parseSpacing(s) {
23
+ const m = s.match(/([\d.]+)\s*days?/i);
24
+ let days = m ? Number(m[1]) : 0;
25
+ const h = s.match(/(\d+)\s*h\b/), mn = s.match(/(\d+)\s*m\b/), sec = s.match(/(\d+)\s*s\b/);
26
+ days += (h ? Number(h[1]) / 24 : 0) + (mn ? Number(mn[1]) / 1440 : 0) + (sec ? Number(sec[1]) / 86400 : 0);
27
+ return days > 0 ? days : undefined;
28
+ }
29
+ /** a human label for a time step in days. */
30
+ export function stepLabel(days) {
31
+ if (!days || !Number.isFinite(days))
32
+ return undefined;
33
+ const d = Math.round(days);
34
+ if (days < 1)
35
+ return 'sub-daily';
36
+ if (d === 1)
37
+ return 'daily';
38
+ if (d === 7)
39
+ return 'weekly';
40
+ if (d >= 28 && d <= 31)
41
+ return 'monthly';
42
+ if (d >= 360 && d <= 366)
43
+ return 'annual';
44
+ return `${d}-day`;
45
+ }
46
+ /** the ERDDAP `info/<id>/index.json` table -> the dataset's live time extent. */
47
+ export function parseInfoExtent(info, axis = 'time') {
48
+ const rows = (info?.table?.rows ?? []).map((r) => r.map((c) => (c == null ? '' : String(c))));
49
+ const find = (type, varName, attr) => rows.find((r) => r[0] === type && r[1] === varName && r[2] === attr)?.[4];
50
+ let start, end;
51
+ const range = find('attribute', axis, 'actual_range');
52
+ if (range) {
53
+ const [a, b] = range.split(',').map((x) => Number(x.trim()));
54
+ if (Number.isFinite(a) && Number.isFinite(b)) {
55
+ const [lo, hi] = a <= b ? [a, b] : [b, a];
56
+ start = isoZ(lo);
57
+ end = isoZ(hi);
58
+ }
59
+ }
60
+ // fall back to the NC_GLOBAL coverage attributes (already ISO strings)
61
+ start ??= find('attribute', 'NC_GLOBAL', 'time_coverage_start');
62
+ end ??= find('attribute', 'NC_GLOBAL', 'time_coverage_end');
63
+ if (!start || !end)
64
+ return null;
65
+ const dim = rows.find((r) => r[0] === 'dimension' && r[1] === axis)?.[4] ?? '';
66
+ const step = parseSpacing((dim.match(/averageSpacing=([^,]+)/)?.[1] ?? '').trim());
67
+ const nVals = Number(dim.match(/nValues=(\d+)/)?.[1]);
68
+ return {
69
+ start, end,
70
+ stepDays: step,
71
+ stepLabel: stepLabel(step),
72
+ nValues: Number.isFinite(nVals) ? nVals : undefined,
73
+ };
74
+ }
75
+ /** how many days a default window should span for a dataset of this step. */
76
+ export function spanDays(ext, o = {}) {
77
+ const { days = 30, minSteps = 8, maxDays = 90 } = o;
78
+ const step = ext?.stepDays ?? 1;
79
+ return Math.min(maxDays, Math.max(days, step > 1 ? Math.ceil(minSteps * step) : days));
80
+ }
81
+ /** the last `days` (or `minSteps` steps) ending at the dataset's last time step. */
82
+ export function defaultWindow(ext, o = {}) {
83
+ const end = ext ? day(ext.end) : addDays(new Date().toISOString(), -2);
84
+ const span = spanDays(ext, o);
85
+ let start = addDays(end, -(span - 1));
86
+ if (ext && daysBetween(ext.start, start) < 0)
87
+ start = day(ext.start);
88
+ return { start, end };
89
+ }
90
+ /**
91
+ * Keep a user's window inside the dataset's extent.
92
+ * A start after the last time step (or an end before the first) snaps the whole window back to the
93
+ * default one, with a `reason` for the status line, so nothing out-of-range is ever requested.
94
+ */
95
+ export function clampWindow(w, ext, o = {}) {
96
+ if (!ext)
97
+ return { ...w, snapped: false };
98
+ const lo = day(ext.start), hi = day(ext.end);
99
+ if (daysBetween(w.start, hi) < 0 || daysBetween(lo, w.end) < 0) {
100
+ const d = defaultWindow(ext, o);
101
+ const why = daysBetween(w.start, hi) < 0
102
+ ? `the requested window starts after this dataset ends (${hi})`
103
+ : `the requested window ends before this dataset starts (${lo})`;
104
+ return { ...d, snapped: true, reason: `${why}; showing ${d.start} to ${d.end} instead` };
105
+ }
106
+ const start = daysBetween(lo, w.start) < 0 ? lo : w.start;
107
+ const end = daysBetween(w.end, hi) < 0 ? hi : w.end;
108
+ const snapped = start !== w.start || end !== w.end;
109
+ return {
110
+ start, end, snapped,
111
+ reason: snapped ? `clamped to the dataset extent (${lo} to ${hi})` : undefined,
112
+ };
113
+ }
114
+ /**
115
+ * The ISO instant to put in a griddap time constraint for a calendar day. The extent's own endpoints
116
+ * are used verbatim, so a request never overshoots an axis whose steps are not at 12:00Z (MUR sits
117
+ * at 09:00Z); any interior day falls back to noon.
118
+ */
119
+ export function timeInstant(date, ext, noon) {
120
+ if (ext) {
121
+ if (day(date) === day(ext.end))
122
+ return ext.end;
123
+ if (day(date) === day(ext.start))
124
+ return ext.start;
125
+ }
126
+ return noon(date);
127
+ }
128
+ // ── fetch ─────────────────────────────────────────────────────────────────────
129
+ const cache = new Map();
130
+ const stripSlash = (s) => s.replace(/\/+$/, '');
131
+ /**
132
+ * `<base>/info/<datasetID>/index.json` -> the live extent, memoised per base+dataset. The fetch is
133
+ * shared by every caller, so no caller's signal is passed to it (a superseded run must not cancel it
134
+ * for the next one); fetchRetry bounds it instead (`INFO_TIMEOUT_MS` per attempt, one retry). null on failure,
135
+ * and a failure is forgotten, so the next call asks again.
136
+ */
137
+ /** per-attempt timeout of the info request (PacIOOS took 4–9 s on 2026-10-20, and 20 s on 2026-10-09) */
138
+ export const INFO_TIMEOUT_MS = 30_000;
139
+ export function fetchTimeExtent(base, datasetId, axis = 'time', retry = {}) {
140
+ const url = `${stripSlash(base)}/info/${datasetId}/index.json`;
141
+ const hit = cache.get(url);
142
+ if (hit)
143
+ return hit;
144
+ const p = fetchRetry(url, { timeoutMs: INFO_TIMEOUT_MS, ...retry }, async (res) => {
145
+ if (!res.ok)
146
+ throw new Error(`${url}: ${res.status} ${res.statusText}`);
147
+ return parseInfoExtent(await res.json(), axis);
148
+ }).catch((e) => { cache.delete(url); console.warn('extent:', e); return null; });
149
+ cache.set(url, p);
150
+ return p;
151
+ }
@@ -0,0 +1,33 @@
1
+ export interface RetryOpts {
2
+ signal?: AbortSignal;
3
+ /** give up on one attempt after this long (ms) */
4
+ timeoutMs?: number;
5
+ /** tries in all (1 = no retry) */
6
+ attempts?: number;
7
+ /** pause before each retry (ms) */
8
+ backoffMs?: number;
9
+ /** for tests */
10
+ fetchFn?: typeof fetch;
11
+ wait?: (ms: number) => Promise<unknown>;
12
+ }
13
+ /** the last failure after every attempt: the host did not answer, or answered with a 5xx. */
14
+ export declare class ErddapUnreachable extends Error {
15
+ readonly host: string;
16
+ readonly reason: string;
17
+ readonly attempts: number;
18
+ readonly url: string;
19
+ constructor(host: string, reason: string, attempts: number, url: string);
20
+ }
21
+ export declare const hostOf: (url: string) => string;
22
+ /**
23
+ * fetch `url` and `read` the response (the default returns it unread), inside the same timeout: a
24
+ * stall while the body downloads is a timeout too. A network error (a TypeError, the browser's
25
+ * "Failed to fetch"), a timeout or a 5xx is retried; a 4xx goes to `read` (which may throw the
26
+ * server's message), and whatever `read` throws for its own reasons is not retried.
27
+ */
28
+ export declare function fetchRetry<T = Response>(url: string, o?: RetryOpts, read?: (res: Response) => Promise<T>): Promise<T>;
29
+ /**
30
+ * The toast's words when the live map step fails after the precomputed statistics are on screen:
31
+ * which host, what happened, and that the statistics are not affected.
32
+ */
33
+ export declare function mapFailMessage(e: unknown): string;
@@ -0,0 +1,76 @@
1
+ // fetch with a per-attempt timeout and a retry, for requests to a server that is not ours (ERDDAP).
2
+ //
3
+ // a browser fetch has no timeout of its own: a host that accepts the connection and then stalls holds
4
+ // a run for minutes, and a dropped connection surfaces as the bare "Failed to fetch" (PacIOOS, 2026-10-09:
5
+ // a 20 s info request, then a failed map slice). here every attempt gives up after `timeoutMs`, a network
6
+ // error, a timeout or a 5xx is retried after `backoffMs`, and the last failure is an ErddapUnreachable
7
+ // whose message names the host and what happened, so the toast can say something a person can act on.
8
+ // a 4xx is the server's answer (a bad constraint) and is not retried; the caller's own abort (a newer
9
+ // run) passes straight through as the AbortError it is.
10
+ /** the last failure after every attempt: the host did not answer, or answered with a 5xx. */
11
+ export class ErddapUnreachable extends Error {
12
+ host;
13
+ reason;
14
+ attempts;
15
+ url;
16
+ constructor(host, reason, attempts, url) {
17
+ super(`${host} did not answer (${reason}${attempts > 1 ? `, ${attempts} tries` : ''})`);
18
+ this.host = host;
19
+ this.reason = reason;
20
+ this.attempts = attempts;
21
+ this.url = url;
22
+ this.name = 'ErddapUnreachable';
23
+ }
24
+ }
25
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
26
+ export const hostOf = (url) => { try {
27
+ return new URL(url).host;
28
+ }
29
+ catch {
30
+ return url;
31
+ } };
32
+ /**
33
+ * fetch `url` and `read` the response (the default returns it unread), inside the same timeout: a
34
+ * stall while the body downloads is a timeout too. A network error (a TypeError, the browser's
35
+ * "Failed to fetch"), a timeout or a 5xx is retried; a 4xx goes to `read` (which may throw the
36
+ * server's message), and whatever `read` throws for its own reasons is not retried.
37
+ */
38
+ export async function fetchRetry(url, o = {}, read = async (r) => r) {
39
+ const { signal, timeoutMs = 20_000, attempts = 2, backoffMs = 1_500, fetchFn = fetch, wait = sleep } = o;
40
+ let reason = '';
41
+ for (let i = 0; i < attempts; i++) {
42
+ if (i > 0)
43
+ await wait(backoffMs);
44
+ if (signal?.aborted)
45
+ throw signal.reason ?? new DOMException('aborted', 'AbortError');
46
+ const timer = AbortSignal.timeout(timeoutMs);
47
+ const sig = signal ? AbortSignal.any([signal, timer]) : timer;
48
+ try {
49
+ const res = await fetchFn(url, { signal: sig });
50
+ if (res.status >= 500) {
51
+ reason = `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`;
52
+ continue;
53
+ }
54
+ return await read(res);
55
+ }
56
+ catch (e) {
57
+ if (signal?.aborted)
58
+ throw e; // superseded: not a failure
59
+ if (timer.aborted)
60
+ reason = `no response within ${Math.round(timeoutMs / 1000)} s`;
61
+ else if (e instanceof TypeError)
62
+ reason = `network error: ${e.message}`;
63
+ else
64
+ throw e; // read()'s own error, e.g. a 4xx message
65
+ }
66
+ }
67
+ throw new ErddapUnreachable(hostOf(url), reason, attempts, url);
68
+ }
69
+ /**
70
+ * The toast's words when the live map step fails after the precomputed statistics are on screen:
71
+ * which host, what happened, and that the statistics are not affected.
72
+ */
73
+ export function mapFailMessage(e) {
74
+ const why = e instanceof Error ? e.message : String(e);
75
+ return `The map's latest time step did not load: ${why}. The precomputed statistics are unaffected; retry the map from the Time strip.`;
76
+ }
@@ -0,0 +1,24 @@
1
+ export { axisEdges, countVertices, gridMask, pointMask, polygonParts, reduceRing, reduceRings } from './mask.js';
2
+ export type { GeoInput, MaskCell, MaskMethod, MaskOptions, MaskPoint, MaskResult, PointMaskResult } from './mask.js';
3
+ export { polygonCoordinates, wkbToGeoJSON } from './wkb.js';
4
+ export { placeLobes } from './lobes.js';
5
+ export type { Lobe, LobePlace } from './lobes.js';
6
+ export { AXIS_TIMEOUT_MS, SLICE_TIMEOUT_MS, constraint, fetchAxis, fetchJsonp, fetchSlab, griddapSliceUrl, griddapUrl, griddapUrls, isParquet, noonZ, parseCsvp, pickFormat, tableConstraint, tableToRows, tabledapPlaceConstraints, tabledapUrl, } from './erddap.js';
7
+ export type { Collection, ErddapMeta, Format, GriddapSpec, Slab, TableConstraint, TableOp, TabledapSpec } from './erddap.js';
8
+ export { INFO_TIMEOUT_MS, addDays, clampWindow, day, daysBetween, defaultWindow, fetchTimeExtent, parseInfoExtent, parseSpacing, spanDays, stepLabel, timeInstant, } from './extent.js';
9
+ export type { ClampResult, InfoTable, TimeExtent, Window, WindowOpts } from './extent.js';
10
+ export { ErddapUnreachable, fetchRetry, hostOf, mapFailMessage } from './fetchRetry.js';
11
+ export type { RetryOpts } from './fetchRetry.js';
12
+ export { axisSpacing, cellPoints, cellSquares, indexBounds, normalizeLon, pickCenter, placeMapBounds } from './cells.js';
13
+ export type { CellPoints, CellProps, CellSquares, CellSquaresOptions, LonCenter, ValueCell } from './cells.js';
14
+ export { isCategorical, lobeLonSpan, resolveDataset, statsTemplate, toDataset, toDatasetLon, toLongFormat, valueExpr, valueLabel, } from './dataset.js';
15
+ export type { CubeVariable, Dataset, LongFormat } from './dataset.js';
16
+ export { MAX_CHUNK_DAYS, MAX_REQUEST_VALUES, chunkDaysFor, chunks, window } from './chunks.js';
17
+ export type { ChunkLimits } from './chunks.js';
18
+ export { lit, render, template, templateNames } from './sql.js';
19
+ export type { Param, Params, TemplateName } from './sql.js';
20
+ export { TEMPLATES } from './sqlTemplates.js';
21
+ export { Engine, connectBrowser, toRows } from './engine.js';
22
+ export type { Duck, DuckConnLike, DuckDbLike, EngineEvent, Row } from './engine.js';
23
+ export { isoDay, lobeAxes, lobeMasks, previewWindow, runPreview, sliceSource } from './preview.js';
24
+ export type { LobeAxes, PreviewChunk, PreviewDataset, PreviewOptions, PreviewResult, SliceCell, SliceSource, SliceSourceOptions, } from './preview.js';
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ // @oceanmetrics/engine: the browser engine of Ocean Metrics. the mask, the lobes, the ERDDAP requests, the SQL templates
2
+ // shared with the Python engine (om_extract), DuckDB-WASM as the statistics engine, the chunked live preview and the
3
+ // map's grid slice. see the README for what moved from where.
4
+ export { axisEdges, countVertices, gridMask, pointMask, polygonParts, reduceRing, reduceRings } from './mask.js';
5
+ export { polygonCoordinates, wkbToGeoJSON } from './wkb.js';
6
+ export { placeLobes } from './lobes.js';
7
+ export { AXIS_TIMEOUT_MS, SLICE_TIMEOUT_MS, constraint, fetchAxis, fetchJsonp, fetchSlab, griddapSliceUrl, griddapUrl, griddapUrls, isParquet, noonZ, parseCsvp, pickFormat, tableConstraint, tableToRows, tabledapPlaceConstraints, tabledapUrl, } from './erddap.js';
8
+ export { INFO_TIMEOUT_MS, addDays, clampWindow, day, daysBetween, defaultWindow, fetchTimeExtent, parseInfoExtent, parseSpacing, spanDays, stepLabel, timeInstant, } from './extent.js';
9
+ export { ErddapUnreachable, fetchRetry, hostOf, mapFailMessage } from './fetchRetry.js';
10
+ export { axisSpacing, cellPoints, cellSquares, indexBounds, normalizeLon, pickCenter, placeMapBounds } from './cells.js';
11
+ export { isCategorical, lobeLonSpan, resolveDataset, statsTemplate, toDataset, toDatasetLon, toLongFormat, valueExpr, valueLabel, } from './dataset.js';
12
+ export { MAX_CHUNK_DAYS, MAX_REQUEST_VALUES, chunkDaysFor, chunks, window } from './chunks.js';
13
+ export { lit, render, template, templateNames } from './sql.js';
14
+ export { TEMPLATES } from './sqlTemplates.js';
15
+ export { Engine, connectBrowser, toRows } from './engine.js';
16
+ export { isoDay, lobeAxes, lobeMasks, previewWindow, runPreview, sliceSource } from './preview.js';
@@ -0,0 +1,24 @@
1
+ import type { FeatureCollection, Geometry } from 'geojson';
2
+ /** what placeLobes() reads of a place: its id and its (Multi)Polygon, already split at ±180 */
3
+ export interface LobePlace {
4
+ place_id: string;
5
+ geometry: Geometry;
6
+ }
7
+ type Place = LobePlace;
8
+ /** one griddap request's worth of a place: its own bbox and the polygons inside it. */
9
+ export interface Lobe {
10
+ id: string;
11
+ bbox: [number, number, number, number];
12
+ geojson: FeatureCollection;
13
+ nParts: number;
14
+ }
15
+ /**
16
+ * Split a place into the lobes that each get one griddap request.
17
+ *
18
+ * Parts are grouped by side of the antimeridian, and only when the place actually spans it (a
19
+ * lon extent wider than 180°, as for PMNM): a place wholly on one side stays a single request
20
+ * covering all its parts. The polygon parts themselves are never modified — the gazetteer has
21
+ * already split them at ±180.
22
+ */
23
+ export declare function placeLobes(place: Place): Lobe[];
24
+ export {};