coastlines 0.0.6-alpha.0 → 0.0.7-alpha.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.
- package/lib/fetch-retry.js +37 -0
- package/lib/provider.js +202 -0
- package/package.json +2 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// A download that tries again on a network error, a body cut off mid-stream, or
|
|
2
|
+
// an HTTP 5xx; never on a 4xx. Three attempts, waiting 2 s and then 6 s between
|
|
3
|
+
// them. The caller's sha256 check stays the only trust: a retry changes how
|
|
4
|
+
// bytes arrive, not whether they are believed.
|
|
5
|
+
export const WAITS_MS = [2000, 6000];
|
|
6
|
+
|
|
7
|
+
const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
|
|
9
|
+
/** One attempt: the bytes, or a failure, with `retry` false when trying again cannot help. */
|
|
10
|
+
async function attempt(url, fetchImpl) {
|
|
11
|
+
let response;
|
|
12
|
+
try {
|
|
13
|
+
response = await fetchImpl(url);
|
|
14
|
+
if (response.ok) return { bytes: new Uint8Array(await response.arrayBuffer()) };
|
|
15
|
+
} catch (error) {
|
|
16
|
+
return { failure: error, retry: true };
|
|
17
|
+
}
|
|
18
|
+
await response.body?.cancel();
|
|
19
|
+
return {
|
|
20
|
+
failure: new Error(`${url}: HTTP ${response.status}`),
|
|
21
|
+
retry: response.status >= 500,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The bytes at `url`; throws on a 4xx at once, or with the last failure once the waits run out. */
|
|
26
|
+
export async function fetchBytesWithRetry(
|
|
27
|
+
url,
|
|
28
|
+
{ fetchImpl = fetch, sleep = sleepFor, waits = WAITS_MS, onRetry = () => {} } = {},
|
|
29
|
+
) {
|
|
30
|
+
for (let tried = 0; ; tried++) {
|
|
31
|
+
const { bytes, failure, retry } = await attempt(url, fetchImpl);
|
|
32
|
+
if (bytes) return bytes;
|
|
33
|
+
if (!retry || tried >= waits.length) throw failure;
|
|
34
|
+
onRetry(failure, tried + 1, waits.length + 1, waits[tried]);
|
|
35
|
+
await sleep(waits[tried]);
|
|
36
|
+
}
|
|
37
|
+
}
|
package/lib/provider.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// The scene's data side: which band a view draws from, which of its cells, and what it falls back
|
|
2
|
+
// to. A drawing library pulls rings from here through its port, an async iterable of rings, so
|
|
3
|
+
// neither package imports the other; the caller owns the projection and hands over a view.
|
|
4
|
+
//
|
|
5
|
+
// A view is the lon/lat frame a caller paints and its scale at the centre:
|
|
6
|
+
// `{ lat, metresPerPixel, lonMin, lonMax, latMin, latMax }`. The frame may run past the
|
|
7
|
+
// antimeridian. The catalogue keys bands by web-mercator zoom (a 512 px world at zoom 0), so the
|
|
8
|
+
// scale is turned into that zoom at the view's latitude to choose one.
|
|
9
|
+
//
|
|
10
|
+
// A band of cells keeps `<dir>/<xi>_<yi>.json`, `grid.cellDeg` degrees square, each
|
|
11
|
+
// `{ land, coast }`, and `<dir>/index.json`, `{ cell, cells }`, listing the cells that hold
|
|
12
|
+
// anything, under its `cells` source. A view draws from the finest band whose zoom it has passed,
|
|
13
|
+
// over the cells within `grid.marginDeg` of its frame. For that view, a band whose index cannot be
|
|
14
|
+
// read is skipped, and a band with a cell that cannot be read gives way to the band below it; the
|
|
15
|
+
// last of these is the whole file. Both are read again for the next view.
|
|
16
|
+
// The JSON itself, not lib/bands.js, so the provider runs in a browser too.
|
|
17
|
+
import catalogue from '../catalogue.json' with { type: 'json' };
|
|
18
|
+
|
|
19
|
+
const EARTH_R = 6378137; // metres, web mercator's sphere
|
|
20
|
+
const WORLD_PX = 512; // the width of the world at zoom 0, as the catalogue's zooms count it
|
|
21
|
+
const RAD = Math.PI / 180;
|
|
22
|
+
|
|
23
|
+
/** The web-mercator zoom of a view's scale at its centre. */
|
|
24
|
+
// Rounded, so a view at a band's edge zoom, turned into a scale and back, does not pass it.
|
|
25
|
+
export const zoomOf = (view) =>
|
|
26
|
+
Math.round(
|
|
27
|
+
1e9 *
|
|
28
|
+
Math.log2(
|
|
29
|
+
(2 * Math.PI * EARTH_R * Math.cos(view.lat * RAD)) / (WORLD_PX * view.metresPerPixel),
|
|
30
|
+
),
|
|
31
|
+
) / 1e9;
|
|
32
|
+
|
|
33
|
+
// What each kind of ring is read from: the bands are cut from both, and a cell carries both.
|
|
34
|
+
const KINDS = {
|
|
35
|
+
coast: { layer: 'land', part: 'coast', whole: 'ne-coastline-50m' },
|
|
36
|
+
land: { layer: 'land', part: 'land', whole: 'ne-land-50m' },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// { id, dir, zoom, inclusive, cell, margin, bytesPerCell } of a cut record: `dir` is its `cells`
|
|
40
|
+
// source's url, `zoom` the zoom it draws past (or from, when `inclusive`), `cell` and `margin` its
|
|
41
|
+
// grid in degrees.
|
|
42
|
+
const bandOf = (r) => ({
|
|
43
|
+
id: r.id,
|
|
44
|
+
dir: r.sources.find((s) => s.kind === 'cells').url.replace(/\/$/, ''),
|
|
45
|
+
zoom: r.zoom.min,
|
|
46
|
+
inclusive: r.zoom.minInclusive,
|
|
47
|
+
cell: r.grid.cellDeg,
|
|
48
|
+
margin: r.grid.marginDeg,
|
|
49
|
+
bytesPerCell: r.bytesPerCell,
|
|
50
|
+
});
|
|
51
|
+
const passed = (zoom, b) => (b.inclusive ? zoom >= b.zoom : zoom > b.zoom);
|
|
52
|
+
|
|
53
|
+
const cellKey = (xi, yi) => `${xi}_${yi}`;
|
|
54
|
+
|
|
55
|
+
// The band's cells, '<dir>/<xi>_<yi>', that meet the view's frame widened by its margin and that
|
|
56
|
+
// the band's index (`known`) lists.
|
|
57
|
+
function cellsAround(b, view, known) {
|
|
58
|
+
const deg = b.cell,
|
|
59
|
+
n = 360 / deg;
|
|
60
|
+
const lonMin = view.lonMin - b.margin,
|
|
61
|
+
lonMax = view.lonMax + b.margin;
|
|
62
|
+
const latMin = view.latMin - b.margin,
|
|
63
|
+
latMax = view.latMax + b.margin;
|
|
64
|
+
const keys = new Set();
|
|
65
|
+
for (let xi = Math.floor(lonMin / deg); xi <= Math.floor(lonMax / deg); xi++) {
|
|
66
|
+
const wx = ((((xi + n / 2) % n) + n) % n) - n / 2; // wrapped across the antimeridian
|
|
67
|
+
for (
|
|
68
|
+
let yi = Math.max(-90 / deg, Math.floor(latMin / deg));
|
|
69
|
+
yi <= Math.min(90 / deg - 1, Math.floor(latMax / deg));
|
|
70
|
+
yi++
|
|
71
|
+
) {
|
|
72
|
+
if (known.has(cellKey(wx, yi))) keys.add(`${b.dir}/${cellKey(wx, yi)}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return [...keys];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The rings of a GeoJSON object: a line's points, a polygon's rings.
|
|
79
|
+
function geojsonRings(geo) {
|
|
80
|
+
const of = (g) => {
|
|
81
|
+
if (!g) return [];
|
|
82
|
+
if (g.type === 'FeatureCollection') return g.features.flatMap(of);
|
|
83
|
+
if (g.type === 'Feature') return of(g.geometry);
|
|
84
|
+
if (g.type === 'GeometryCollection') return g.geometries.flatMap(of);
|
|
85
|
+
if (g.type === 'LineString') return [g.coordinates];
|
|
86
|
+
if (g.type === 'MultiLineString' || g.type === 'Polygon') return g.coordinates;
|
|
87
|
+
if (g.type === 'MultiPolygon') return g.coordinates.flat();
|
|
88
|
+
return [];
|
|
89
|
+
};
|
|
90
|
+
return of(geo);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A provider of rings for views. `read(path)` resolves to the parsed JSON at a catalogue source
|
|
95
|
+
* path (`geo/h/12_45.json`, `data/ne-coastline-50m.geojson`) and rejects when it cannot. `kind`
|
|
96
|
+
* is `coast`, the coastline's lines, or `land`, the land polygons' rings.
|
|
97
|
+
*
|
|
98
|
+
* `rings(view)` yields the view's rings; `held(view)` says whether they are all in hand, so
|
|
99
|
+
* drawing them fetches nothing; `cost(view)` is what drawing it would fetch: the band, the cells
|
|
100
|
+
* not yet held, and their bytes where the catalogue knows them.
|
|
101
|
+
*/
|
|
102
|
+
export function provider({ read, kind = 'coast' }) {
|
|
103
|
+
const { layer, part, whole } = KINDS[kind] ?? {};
|
|
104
|
+
if (!layer) throw new Error(`unknown kind ${kind}`);
|
|
105
|
+
const bands = catalogue
|
|
106
|
+
.filter((r) => r.layer === layer && r.grid)
|
|
107
|
+
.map(bandOf)
|
|
108
|
+
.sort((a, b) => a.zoom - b.zoom);
|
|
109
|
+
const file = catalogue.find((r) => r.id === whole);
|
|
110
|
+
const wholePath = file.sources.find((s) => s.kind === 'file').url;
|
|
111
|
+
|
|
112
|
+
const index = new Map(); // dir -> promise of its Set of cell keys, or null when unreadable
|
|
113
|
+
const loadIndex = (b) => {
|
|
114
|
+
if (!index.has(b.dir))
|
|
115
|
+
index.set(
|
|
116
|
+
b.dir,
|
|
117
|
+
read(`${b.dir}/index.json`).then(
|
|
118
|
+
(i) => new Set(i.cells),
|
|
119
|
+
() => {
|
|
120
|
+
index.delete(b.dir); // skipped for this view, tried again for the next
|
|
121
|
+
return null;
|
|
122
|
+
},
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
return index.get(b.dir);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const pending = new Map(); // path -> promise of its rings
|
|
129
|
+
const got = new Map(); // path -> its rings, once read
|
|
130
|
+
const fetch = (path, ringsOf) => {
|
|
131
|
+
if (!pending.has(path))
|
|
132
|
+
pending.set(
|
|
133
|
+
path,
|
|
134
|
+
read(path).then(
|
|
135
|
+
(json) => {
|
|
136
|
+
const rings = ringsOf(json);
|
|
137
|
+
got.set(path, rings);
|
|
138
|
+
return rings;
|
|
139
|
+
},
|
|
140
|
+
(e) => {
|
|
141
|
+
pending.delete(path); // a failed read is tried again for the next view
|
|
142
|
+
throw e;
|
|
143
|
+
},
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
return pending.get(path);
|
|
147
|
+
};
|
|
148
|
+
const cellRings = (cell) => (part === 'land' ? cell.land.flat() : cell.coast);
|
|
149
|
+
|
|
150
|
+
// The bands the view may draw from, finest first, each with its index: passed, with an index.
|
|
151
|
+
async function candidates(view) {
|
|
152
|
+
const zoom = zoomOf(view);
|
|
153
|
+
const all = bands.filter((b) => passed(zoom, b));
|
|
154
|
+
const known = await Promise.all(all.map(loadIndex));
|
|
155
|
+
return all
|
|
156
|
+
.map((b, i) => ({ b, known: known[i] }))
|
|
157
|
+
.filter((c) => c.known !== null)
|
|
158
|
+
.reverse();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// What the view draws from: the finest candidate band with every cell it needs read, else the
|
|
162
|
+
// whole file.
|
|
163
|
+
async function draw(view) {
|
|
164
|
+
for (const { b, known } of await candidates(view)) {
|
|
165
|
+
const keys = cellsAround(b, view, known);
|
|
166
|
+
try {
|
|
167
|
+
const parts = await Promise.all(keys.map((k) => fetch(`${k}.json`, cellRings)));
|
|
168
|
+
return { band: b.id, parts };
|
|
169
|
+
} catch {
|
|
170
|
+
// a cell it lacks: the band below draws the whole view, rather than leave a hole
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { band: file.id, parts: [await fetch(wholePath, geojsonRings)] };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The band the view would ask for first, and the paths it needs.
|
|
177
|
+
async function wants(view) {
|
|
178
|
+
const [first] = await candidates(view);
|
|
179
|
+
if (!first) return { band: file.id, paths: [wholePath], bytes: () => file.bytes };
|
|
180
|
+
const { b, known } = first;
|
|
181
|
+
const paths = cellsAround(b, view, known).map((k) => `${k}.json`);
|
|
182
|
+
return {
|
|
183
|
+
band: b.id,
|
|
184
|
+
paths,
|
|
185
|
+
bytes: (n) => (b.bytesPerCell == null ? null : n * b.bytesPerCell),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
async *rings(view) {
|
|
191
|
+
for (const p of (await draw(view)).parts) yield* p;
|
|
192
|
+
},
|
|
193
|
+
async held(view) {
|
|
194
|
+
return (await wants(view)).paths.every((p) => got.has(p));
|
|
195
|
+
},
|
|
196
|
+
async cost(view) {
|
|
197
|
+
const { band, paths, bytes } = await wants(view);
|
|
198
|
+
const cells = paths.filter((p) => !got.has(p)).length;
|
|
199
|
+
return { band, cells, bytes: cells ? bytes(cells) : 0 };
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coastlines",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7-alpha.0",
|
|
4
4
|
"description": "Natural Earth geography on a boat, installed rather than fetched: the marine catalogue at three scales, byte-for-byte as published, sha256-pinned, as plain GeoJSON today and a Signal K resource provider next",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"./charts.json": "./charts.json",
|
|
11
11
|
"./catalogue": "./lib/bands.js",
|
|
12
12
|
"./catalogue.json": "./catalogue.json",
|
|
13
|
+
"./provider": "./lib/provider.js",
|
|
13
14
|
"./data/*": "./data/*.geojson",
|
|
14
15
|
"./data/*.shp": "./data/*.shp",
|
|
15
16
|
"./data/*.txt": "./data/*.txt",
|