coastlines 0.0.7-alpha.0 → 0.0.9-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/README.md +27 -1
- package/catalogue.json +36 -18
- package/lib/acquire.js +45 -0
- package/lib/bands.js +27 -3
- package/lib/cut.js +140 -0
- package/lib/detail-cells.js +313 -0
- package/lib/island.js +51 -0
- package/lib/provider.js +46 -16
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -111,6 +111,32 @@ the GSHHG zip. `coastlines/manifest.json` is the machine-readable form of this
|
|
|
111
111
|
table, with the bytes and sha256 of each file and, under `sources`, of the
|
|
112
112
|
zip itself.
|
|
113
113
|
|
|
114
|
+
### Installed, built, or cached
|
|
115
|
+
|
|
116
|
+
| Where | What | Size | How it reaches you |
|
|
117
|
+
| --- | --- | ---: | --- |
|
|
118
|
+
| `data/` | the 36 files above | 78 MB, 29 MB packed | installed with the package |
|
|
119
|
+
| `geo/` | six cut bands, land and water in square cells | 549 MB | built by `npm run sync`; not shipped |
|
|
120
|
+
| `cache/` | the GSHHG zip, six Natural Earth 10m water files | 167 MB | fetched once by sync, kept |
|
|
121
|
+
|
|
122
|
+
Each cut band is a record in `catalogue.json`: grid, inputs and pins, cutter
|
|
123
|
+
and build date. Cells and size are measured from the build (file content;
|
|
124
|
+
4 KB disk blocks bring `geo/` to about 900 MB on disk). `cache/` holds the
|
|
125
|
+
inputs those builds draw on, pinned but never shipped.
|
|
126
|
+
|
|
127
|
+
<!-- cut-bands:start -->
|
|
128
|
+
| Band | Layer | Zoom | Cell | Cells | Size | Source |
|
|
129
|
+
| --- | --- | --- | --- | ---: | ---: | --- |
|
|
130
|
+
| `ne-10m-land` | land | 9–10 | 5° (+2°) | 1,456 | 18.0 MB | Natural Earth v5.1.2, public domain |
|
|
131
|
+
| `gshhg-h-land` | land | 10–12 | 1° (+0.5°) | 19,273 | 68.3 MB | GSHHG 2.3.7, LGPL-3.0-or-later |
|
|
132
|
+
| `gshhg-f-land` | land | 12–∞ | 0.5° (+0.25°) | 70,692 | 383.8 MB | GSHHG 2.3.7, LGPL-3.0-or-later |
|
|
133
|
+
| `ne-10m-water` | water | 7–∞ | 5° (+2°) | 712 | 31.0 MB | Natural Earth v5.1.2, public domain |
|
|
134
|
+
| `gshhg-h-lakes` | lakes | 10–12 | 1° (+0.5°) | 4,068 | 11.4 MB | GSHHG 2.3.7, LGPL-3.0-or-later |
|
|
135
|
+
| `gshhg-f-lakes` | lakes | 12–∞ | 0.5° (+0.25°) | 8,699 | 36.6 MB | GSHHG 2.3.7, LGPL-3.0-or-later |
|
|
136
|
+
<!-- cut-bands:end -->
|
|
137
|
+
|
|
138
|
+
How cells reach a boat that installs the package is open ([#17](https://github.com/mark-brannan/coastlines/issues/17)).
|
|
139
|
+
|
|
114
140
|
### Sources and licences
|
|
115
141
|
|
|
116
142
|
Natural Earth is public domain. GSHHG (Wessel & Smith, the Global
|
|
@@ -146,7 +172,7 @@ release and refuses any byte that disagrees with `manifest.json`; `npm pack`
|
|
|
146
172
|
runs it first, so a tarball never ships a file the manifest did not promise.
|
|
147
173
|
|
|
148
174
|
```shell
|
|
149
|
-
npm run sync # fetch what is missing, verify everything (
|
|
175
|
+
npm run sync # fetch what is missing, verify everything, cut the cells (~2 min, 6 GB heap)
|
|
150
176
|
npm run sync:check # verify only, no network
|
|
151
177
|
npm run sync:refresh # move the pins: refetch all and rewrite bytes and sha256
|
|
152
178
|
```
|
package/catalogue.json
CHANGED
|
@@ -1046,10 +1046,13 @@
|
|
|
1046
1046
|
"licence": "public domain",
|
|
1047
1047
|
"version": "v5.1.2",
|
|
1048
1048
|
"date": "2022-05-13",
|
|
1049
|
-
"caveat": "Holds to about z12 but has no narrow channels"
|
|
1049
|
+
"caveat": "Holds to about z12 but has no narrow channels",
|
|
1050
|
+
"cutter": "coastlines/lib/cut.js",
|
|
1051
|
+
"built": "2026-10-15"
|
|
1050
1052
|
},
|
|
1051
|
-
"
|
|
1052
|
-
"
|
|
1053
|
+
"cells": 1456,
|
|
1054
|
+
"bytesPerCell": 12954,
|
|
1055
|
+
"bytes": 18861693,
|
|
1053
1056
|
"from": ["ne-land-10m", "ne-coastline-10m", "ne-minor-islands-10m"],
|
|
1054
1057
|
"inputs": [],
|
|
1055
1058
|
"sources": [
|
|
@@ -1086,10 +1089,13 @@
|
|
|
1086
1089
|
"licence": "LGPL-3.0-or-later",
|
|
1087
1090
|
"version": "2.3.7",
|
|
1088
1091
|
"date": "2017-06-15",
|
|
1089
|
-
"caveat": "Holds to about z13; draws the Seattle ship canal. Its licence files are owned here and travel with every GSHHG band"
|
|
1092
|
+
"caveat": "Holds to about z13; draws the Seattle ship canal. Its licence files are owned here and travel with every GSHHG band",
|
|
1093
|
+
"cutter": "coastlines/lib/cut.js",
|
|
1094
|
+
"built": "2026-10-15"
|
|
1090
1095
|
},
|
|
1091
|
-
"
|
|
1092
|
-
"
|
|
1096
|
+
"cells": 19273,
|
|
1097
|
+
"bytesPerCell": 3717,
|
|
1098
|
+
"bytes": 71636830,
|
|
1093
1099
|
"from": [],
|
|
1094
1100
|
"inputs": ["gshhg-land-h", "gshhg-licence", "gshhg-copying-lesser"],
|
|
1095
1101
|
"sources": [
|
|
@@ -1118,10 +1124,13 @@
|
|
|
1118
1124
|
"licence": "LGPL-3.0-or-later",
|
|
1119
1125
|
"version": "2.3.7",
|
|
1120
1126
|
"date": "2017-06-15",
|
|
1121
|
-
"caveat": "Full resolution; about 70,000 cells"
|
|
1127
|
+
"caveat": "Full resolution; about 70,000 cells",
|
|
1128
|
+
"cutter": "coastlines/lib/cut.js",
|
|
1129
|
+
"built": "2026-10-15"
|
|
1122
1130
|
},
|
|
1123
|
-
"
|
|
1124
|
-
"
|
|
1131
|
+
"cells": 70692,
|
|
1132
|
+
"bytesPerCell": 5693,
|
|
1133
|
+
"bytes": 402415178,
|
|
1125
1134
|
"from": [],
|
|
1126
1135
|
"inputs": [
|
|
1127
1136
|
{
|
|
@@ -1169,10 +1178,13 @@
|
|
|
1169
1178
|
"licence": "public domain",
|
|
1170
1179
|
"version": "v5.1.2",
|
|
1171
1180
|
"date": "2022-05-13",
|
|
1172
|
-
"caveat": "Rivers and lakes with their regional supplements; whole features go into every cell they reach. Not in the manifest, so each file is pinned here"
|
|
1181
|
+
"caveat": "Rivers and lakes with their regional supplements; whole features go into every cell they reach. Not in the manifest, so each file is pinned here",
|
|
1182
|
+
"cutter": "coastlines/lib/cut.js",
|
|
1183
|
+
"built": "2026-10-15"
|
|
1173
1184
|
},
|
|
1174
|
-
"
|
|
1175
|
-
"
|
|
1185
|
+
"cells": 712,
|
|
1186
|
+
"bytesPerCell": 45613,
|
|
1187
|
+
"bytes": 32476615,
|
|
1176
1188
|
"from": [],
|
|
1177
1189
|
"inputs": [
|
|
1178
1190
|
{
|
|
@@ -1247,10 +1259,13 @@
|
|
|
1247
1259
|
"licence": "LGPL-3.0-or-later",
|
|
1248
1260
|
"version": "2.3.7",
|
|
1249
1261
|
"date": "2017-06-15",
|
|
1250
|
-
"caveat": "Lakes no Natural Earth file has (Dian Chi, Lago Mascardi); levels 2 and 3 with river-lakes"
|
|
1262
|
+
"caveat": "Lakes no Natural Earth file has (Dian Chi, Lago Mascardi); levels 2 and 3 with river-lakes",
|
|
1263
|
+
"cutter": "coastlines/lib/cut.js",
|
|
1264
|
+
"built": "2026-10-15"
|
|
1251
1265
|
},
|
|
1252
|
-
"
|
|
1253
|
-
"
|
|
1266
|
+
"cells": 4068,
|
|
1267
|
+
"bytesPerCell": 2935,
|
|
1268
|
+
"bytes": 11939267,
|
|
1254
1269
|
"from": [],
|
|
1255
1270
|
"inputs": [
|
|
1256
1271
|
{
|
|
@@ -1311,10 +1326,13 @@
|
|
|
1311
1326
|
"licence": "LGPL-3.0-or-later",
|
|
1312
1327
|
"version": "2.3.7",
|
|
1313
1328
|
"date": "2017-06-15",
|
|
1314
|
-
"caveat": "As gshhg-h-lakes at full resolution"
|
|
1329
|
+
"caveat": "As gshhg-h-lakes at full resolution",
|
|
1330
|
+
"cutter": "coastlines/lib/cut.js",
|
|
1331
|
+
"built": "2026-10-15"
|
|
1315
1332
|
},
|
|
1316
|
-
"
|
|
1317
|
-
"
|
|
1333
|
+
"cells": 8699,
|
|
1334
|
+
"bytesPerCell": 4417,
|
|
1335
|
+
"bytes": 38424112,
|
|
1318
1336
|
"from": [],
|
|
1319
1337
|
"inputs": [
|
|
1320
1338
|
{
|
package/lib/acquire.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Pinned-source acquisition: give it `{url, sha256}` (a manifest pin, or any
|
|
2
|
+
// source pinned the same way) and get back verified bytes from a local cache.
|
|
3
|
+
// Cache hit: no fetch. Miss, or a cached file that no longer matches: fetch
|
|
4
|
+
// with retry, check, keep. A wrong sha256 throws and nothing is stored or
|
|
5
|
+
// retried -- retry changes how bytes arrive, never whether they are believed.
|
|
6
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { digest } from './verify.js';
|
|
9
|
+
import { fetchBytesWithRetry } from './fetch-retry.js';
|
|
10
|
+
|
|
11
|
+
async function cached(file) {
|
|
12
|
+
try {
|
|
13
|
+
return await readFile(file);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error.code === 'ENOENT') return null;
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The bytes pinned by `{url, sha256, bytes?}`, from `cacheDir/<name>` when they are there and
|
|
22
|
+
* right, else fetched, checked and kept. `name` defaults to the url's last path segment (empty for a url ending in /) and must be a file name, not a
|
|
23
|
+
* path. `fetchImpl`, `sleep`, `waits` and `onRetry` go to the retry; `onFetch(url)` is told before a download.
|
|
24
|
+
*/
|
|
25
|
+
export async function acquire(
|
|
26
|
+
{ url, sha256, bytes: length },
|
|
27
|
+
{ cacheDir, name = new URL(url).pathname.split('/').pop(), onFetch, ...retry },
|
|
28
|
+
) {
|
|
29
|
+
if (!name || name !== name.split(/[/\\]/).pop() || name === '..' || name === '.')
|
|
30
|
+
throw new Error(`${name}: a cache name is a file name, not a path`);
|
|
31
|
+
const file = join(cacheDir, name);
|
|
32
|
+
const held = await cached(file);
|
|
33
|
+
if (held && digest(held) === sha256) return held;
|
|
34
|
+
onFetch?.(url);
|
|
35
|
+
const bytes = await fetchBytesWithRetry(url, retry);
|
|
36
|
+
const actual = digest(bytes);
|
|
37
|
+
if (actual !== sha256) throw new Error(`${name}: ${actual}, pinned ${sha256}`);
|
|
38
|
+
if (length !== undefined && bytes.byteLength !== length)
|
|
39
|
+
throw new Error(`${name}: ${bytes.byteLength} bytes, pinned ${length}`);
|
|
40
|
+
await mkdir(cacheDir, { recursive: true });
|
|
41
|
+
// Sibling then rename: an interrupted run leaves no truncated file to fail every later one.
|
|
42
|
+
await writeFile(`${file}.partial`, bytes);
|
|
43
|
+
await rename(`${file}.partial`, file);
|
|
44
|
+
return bytes;
|
|
45
|
+
}
|
package/lib/bands.js
CHANGED
|
@@ -16,6 +16,9 @@ import { charts } from './charts.js';
|
|
|
16
16
|
|
|
17
17
|
const require = createRequire(import.meta.url);
|
|
18
18
|
|
|
19
|
+
/** What a cut band's `provenance.cutter` names: the module that builds its cells. */
|
|
20
|
+
export const CUTTER = 'coastlines/lib/cut.js';
|
|
21
|
+
|
|
19
22
|
/** The parsed catalogue.json: one record per band, whole Natural Earth files first. */
|
|
20
23
|
export const catalogue = require('../catalogue.json');
|
|
21
24
|
|
|
@@ -46,6 +49,14 @@ export function inputsOf(record) {
|
|
|
46
49
|
return [...own, ...inherited];
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
/** The source keys a record's geometry is built from; a geometry file must name exactly one. */
|
|
53
|
+
export const sourcesOf = (record) =>
|
|
54
|
+
new Set(
|
|
55
|
+
inputsOf(record).map((input) =>
|
|
56
|
+
typeof input === 'string' ? find(input).source : input.source,
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
|
|
49
60
|
/** The zoom range charts.json draws a whole file at, across every chart that lists it; nulls when none does. */
|
|
50
61
|
function chartZoom(id) {
|
|
51
62
|
const layers = charts.flatMap((chart) => chart.layers).filter((layer) => layer.id === id);
|
|
@@ -72,6 +83,16 @@ function wholeFileDrift(record) {
|
|
|
72
83
|
return problems;
|
|
73
84
|
}
|
|
74
85
|
|
|
86
|
+
/** A cut band names its cutter and the day it was built. */
|
|
87
|
+
function cutDrift(record) {
|
|
88
|
+
const problems = [];
|
|
89
|
+
const { cutter, built } = record.provenance;
|
|
90
|
+
if (cutter !== CUTTER) problems.push(`${record.id}: cutter ${cutter} is not ${CUTTER}`);
|
|
91
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(built ?? ''))
|
|
92
|
+
problems.push(`${record.id}: built ${built} is not a date`);
|
|
93
|
+
return problems;
|
|
94
|
+
}
|
|
95
|
+
|
|
75
96
|
/**
|
|
76
97
|
* Where the catalogue and the manifest disagree, as sentences; empty when they agree. Every
|
|
77
98
|
* record's upstream URLs, members and sha256s must be what its inputs pin, its licence, version
|
|
@@ -93,9 +114,11 @@ export function drift() {
|
|
|
93
114
|
if (JSON.stringify(record.provenance[field]) !== want)
|
|
94
115
|
problems.push(`${record.id}: ${field} differs from its inputs`);
|
|
95
116
|
}
|
|
96
|
-
const keys =
|
|
97
|
-
|
|
98
|
-
|
|
117
|
+
const keys = sourcesOf(record);
|
|
118
|
+
if (keys.size !== 1)
|
|
119
|
+
problems.push(
|
|
120
|
+
`${record.id}: built from ${keys.size} sources (${[...keys].join(', ')}), not one; each licence stays in its own file`,
|
|
121
|
+
);
|
|
99
122
|
for (const key of keys) {
|
|
100
123
|
const from = manifest.sources[key];
|
|
101
124
|
if (licence !== from.licence)
|
|
@@ -105,6 +128,7 @@ export function drift() {
|
|
|
105
128
|
if (date !== from.date) problems.push(`${record.id}: date ${date} is not ${key}'s`);
|
|
106
129
|
}
|
|
107
130
|
if (record.grid === null) problems.push(...wholeFileDrift(record));
|
|
131
|
+
else problems.push(...cutDrift(record));
|
|
108
132
|
} catch (error) {
|
|
109
133
|
problems.push(`${record.id}: ${error.message}`);
|
|
110
134
|
}
|
package/lib/cut.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Builds the catalogue's `cells` sources: each cut band's inputs, read through `read`, cut into
|
|
2
|
+
// square lon/lat cells under geo/<dir>/ (<xi>_<yi>.json and index.json). A band is cut from one
|
|
3
|
+
// upstream only, so no geometry file mixes sources.
|
|
4
|
+
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { band, CUTTER, inputsOf, sourcesOf } from './bands.js';
|
|
6
|
+
import { find } from './catalogue.js';
|
|
7
|
+
import { shapefilePolygons } from './shapefile.js';
|
|
8
|
+
import { cutCells, cutLakes, cutWater, dbfRecords, gshhgLakes, joinWater } from './detail-cells.js';
|
|
9
|
+
|
|
10
|
+
/** The folder under geo/ of a band's `cells` source: `geo/f/` is `f`. */
|
|
11
|
+
export const cellsDir = (record) =>
|
|
12
|
+
record.sources.find((source) => source.kind === 'cells').url.replace(/^geo\/|\/$/g, '');
|
|
13
|
+
|
|
14
|
+
const json = (bytes) => JSON.parse(new TextDecoder().decode(bytes));
|
|
15
|
+
|
|
16
|
+
function polygons(geojson) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const { geometry } of geojson.features) {
|
|
19
|
+
if (geometry?.type === 'Polygon') out.push(geometry.coordinates);
|
|
20
|
+
else if (geometry?.type === 'MultiPolygon') for (const p of geometry.coordinates) out.push(p);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function lines(geojson) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const { geometry } of geojson.features) {
|
|
28
|
+
if (geometry?.type === 'LineString') out.push(geometry.coordinates);
|
|
29
|
+
else if (geometry?.type === 'MultiLineString')
|
|
30
|
+
for (const l of geometry.coordinates) out.push(l);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The cells of a band: Map(key -> cell). `read(input)` returns the bytes of one input, as
|
|
37
|
+
* `inputsOf(record)` lists them.
|
|
38
|
+
*/
|
|
39
|
+
export async function cutBand(record, read) {
|
|
40
|
+
if (sourcesOf(record).size !== 1)
|
|
41
|
+
throw new Error(`${record.id}: inputs from more than one source`);
|
|
42
|
+
const size = record.grid.cellDeg;
|
|
43
|
+
const bytes = await Promise.all(inputsOf(record).map(read));
|
|
44
|
+
if (record.id === 'ne-10m-land') {
|
|
45
|
+
const [land, coast, minor] = bytes.map(json);
|
|
46
|
+
return cutCells(
|
|
47
|
+
{ land: polygons(land), coast: [...lines(coast), ...polygons(minor).map((p) => p[0])] },
|
|
48
|
+
size,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (record.layer === 'land') {
|
|
52
|
+
const land = shapefilePolygons(bytes[0]);
|
|
53
|
+
return cutCells({ land, coast: land.flat() }, size);
|
|
54
|
+
}
|
|
55
|
+
if (record.layer === 'water') {
|
|
56
|
+
const [rivers, lakes] = [bytes.slice(0, 3), bytes.slice(3)].map((half) =>
|
|
57
|
+
joinWater(half.map(json)),
|
|
58
|
+
);
|
|
59
|
+
return cutWater(rivers, lakes, { size });
|
|
60
|
+
}
|
|
61
|
+
if (record.layer === 'lakes') {
|
|
62
|
+
const [l2, d2, l3, d3] = bytes;
|
|
63
|
+
return cutLakes(
|
|
64
|
+
gshhgLakes(shapefilePolygons(l2), dbfRecords(d2), shapefilePolygons(l3), dbfRecords(d3)),
|
|
65
|
+
size,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`${record.id}: no cutter for layer ${record.layer}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Writes a band's cells to `root`/geo/<dir>/, complete or not at all; returns the cell count. */
|
|
72
|
+
export async function writeBand(record, cells, root) {
|
|
73
|
+
const final = new URL(`geo/${cellsDir(record)}/`, root);
|
|
74
|
+
const partial = new URL(`geo/${cellsDir(record)}.partial/`, root);
|
|
75
|
+
await rm(partial, { recursive: true, force: true });
|
|
76
|
+
await mkdir(partial, { recursive: true });
|
|
77
|
+
// in batches: "f" has some 70,000 cells, past an open-file limit if written at once
|
|
78
|
+
const all = [...cells];
|
|
79
|
+
for (let i = 0; i < all.length; i += 512)
|
|
80
|
+
await Promise.all(
|
|
81
|
+
all
|
|
82
|
+
.slice(i, i + 512)
|
|
83
|
+
.map(([k, c]) => writeFile(new URL(`${k}.json`, partial), JSON.stringify(c))),
|
|
84
|
+
);
|
|
85
|
+
await writeFile(
|
|
86
|
+
new URL('index.json', partial),
|
|
87
|
+
JSON.stringify({ cell: record.grid.cellDeg, cells: [...cells.keys()] }),
|
|
88
|
+
);
|
|
89
|
+
await rm(final, { recursive: true, force: true });
|
|
90
|
+
await rename(partial, final);
|
|
91
|
+
return cells.size;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* catalogue.json's text with one record's `provenance.built` set to `day`, every other byte as it
|
|
96
|
+
* was. Throws rather than touch the wrong record: the id must be there, and the `built` must sit
|
|
97
|
+
* in that record, before the next `"id":`.
|
|
98
|
+
*/
|
|
99
|
+
export function stampBuilt(text, id, day) {
|
|
100
|
+
const at = text.indexOf(`"id": "${id}"`);
|
|
101
|
+
if (at < 0) throw new Error(`${id}: not in catalogue.json`);
|
|
102
|
+
const end = text.indexOf('"id": "', at + 1);
|
|
103
|
+
const record = text.slice(at, end < 0 ? undefined : end);
|
|
104
|
+
const stamped = record.replace(/("built": )"[^"]*"/, `$1"${day}"`);
|
|
105
|
+
if (stamped === record && !record.includes(`"built": "${day}"`))
|
|
106
|
+
throw new Error(`${id}: no provenance.built to stamp`);
|
|
107
|
+
return text.slice(0, at) + stamped + (end < 0 ? '' : text.slice(end));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The cut bands whose inputs are GSHHG's, which the notice below covers. */
|
|
111
|
+
export const modifiedGshhg = (catalogue) =>
|
|
112
|
+
catalogue.filter(
|
|
113
|
+
(r) =>
|
|
114
|
+
r.grid &&
|
|
115
|
+
inputsOf(r).some((i) => (typeof i === 'string' ? find(i).source : i.source) === 'gshhg'),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
/** The text of geo/gshhg-modified.txt for the bands built, with the dates they were cut. */
|
|
119
|
+
export function modifiedNotice(records) {
|
|
120
|
+
const rows = records
|
|
121
|
+
.map((r) => ` geo/${cellsDir(r)}/ ${r.id}, cut ${r.provenance.built}`)
|
|
122
|
+
.join('\n');
|
|
123
|
+
return `GSHHG, modified
|
|
124
|
+
===============
|
|
125
|
+
|
|
126
|
+
The cell files below are modified GSHHG ${band(records[0].id).provenance.version} (Wessel & Smith), not the
|
|
127
|
+
published shapefiles. They were made by ${CUTTER} from the pinned zip: polygons
|
|
128
|
+
clipped to square lon/lat cells, shorelines split where they pass from one cell to the next,
|
|
129
|
+
coordinates rounded to 5 decimals, and each land polygon annotated with the area and colour of the
|
|
130
|
+
island it is a piece of.
|
|
131
|
+
|
|
132
|
+
${rows}
|
|
133
|
+
|
|
134
|
+
Licence: GNU Lesser General Public License version 3 or later, conveyed under section 5(a) of the
|
|
135
|
+
GNU General Public License version 3, which asks for a notice that the work was modified and the
|
|
136
|
+
date of the modification: this file. The unmodified originals are fetched, byte for byte, by
|
|
137
|
+
\`npm run sync\`; their copyright and permission notice and the licence text are in
|
|
138
|
+
data/gshhg-licence.txt and data/gshhg-copying-lesser.txt and apply to the cells too.
|
|
139
|
+
`;
|
|
140
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Cuts Natural Earth 10m land and coast into square lon/lat cells, so a close view fetches only the
|
|
2
|
+
// few cells around it rather than the whole 10m world. Land polygons are clipped to each cell (fill
|
|
3
|
+
// is even-odd, so the cut edges vanish; each cell's `m` holds, for its land polygons in order, the
|
|
4
|
+
// area in km2 and colour of the island each is a piece of, worked out before the cut); coast lines are split where they pass from one cell to the
|
|
5
|
+
// next, each piece keeping the shared point so the line stays joined. Coordinates are rounded to
|
|
6
|
+
// `digits` decimals (5 is about a metre). Cell keys are `${xi}_${yi}`, xi = floor(lon / size).
|
|
7
|
+
|
|
8
|
+
import { islandInfo } from './island.js';
|
|
9
|
+
|
|
10
|
+
const key = (xi, yi) => `${xi}_${yi}`;
|
|
11
|
+
|
|
12
|
+
// Sutherland-Hodgman against one axis-aligned half-plane: keep points with p[axis] <= v (keep < 0)
|
|
13
|
+
// or >= v (keep > 0). `ring` is open (last point not repeated).
|
|
14
|
+
function clipHalf(ring, axis, v, keep) {
|
|
15
|
+
const out = [],
|
|
16
|
+
inside = (p) => (keep < 0 ? p[axis] <= v : p[axis] >= v);
|
|
17
|
+
for (let i = 0; i < ring.length; i++) {
|
|
18
|
+
const a = ring[i],
|
|
19
|
+
b = ring[(i + 1) % ring.length],
|
|
20
|
+
ia = inside(a),
|
|
21
|
+
ib = inside(b);
|
|
22
|
+
if (ia) out.push(a);
|
|
23
|
+
if (ia !== ib) {
|
|
24
|
+
const t = (v - a[axis]) / (b[axis] - a[axis]);
|
|
25
|
+
const p = [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])];
|
|
26
|
+
p[axis] = v;
|
|
27
|
+
out.push(p);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ringBox(ring) {
|
|
34
|
+
let x0 = Infinity,
|
|
35
|
+
y0 = Infinity,
|
|
36
|
+
x1 = -Infinity,
|
|
37
|
+
y1 = -Infinity;
|
|
38
|
+
for (const [x, y] of ring) {
|
|
39
|
+
if (x < x0) x0 = x;
|
|
40
|
+
if (x > x1) x1 = x;
|
|
41
|
+
if (y < y0) y0 = y;
|
|
42
|
+
if (y > y1) y1 = y;
|
|
43
|
+
}
|
|
44
|
+
return [x0, y0, x1, y1];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Polygons ([[outer, ...holes]], open rings) into cells by recursive halving along cell lines, so
|
|
48
|
+
// each point is clipped about log2(cells) times rather than once per cell.
|
|
49
|
+
function splitPolygons(polys, box, size, emit) {
|
|
50
|
+
if (!polys.length) return;
|
|
51
|
+
const [x0, y0, x1, y1] = box,
|
|
52
|
+
nx = Math.round((x1 - x0) / size),
|
|
53
|
+
ny = Math.round((y1 - y0) / size);
|
|
54
|
+
if (nx <= 1 && ny <= 1) {
|
|
55
|
+
emit(Math.round(x0 / size), Math.round(y0 / size), polys);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const axis = nx >= ny ? 0 : 1,
|
|
59
|
+
mid = axis === 0 ? x0 + Math.floor(nx / 2) * size : y0 + Math.floor(ny / 2) * size;
|
|
60
|
+
const lo = [],
|
|
61
|
+
hi = [];
|
|
62
|
+
for (const poly of polys) {
|
|
63
|
+
const b = ringBox(poly[0]);
|
|
64
|
+
if (b[axis + 2] <= mid) {
|
|
65
|
+
lo.push(poly);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (b[axis] >= mid) {
|
|
69
|
+
hi.push(poly);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
for (const [side, keep] of [
|
|
73
|
+
[lo, -1],
|
|
74
|
+
[hi, 1],
|
|
75
|
+
]) {
|
|
76
|
+
const [outer, ...holes] = poly.map((r) => clipHalf(r, axis, mid, keep));
|
|
77
|
+
if (outer.length >= 3)
|
|
78
|
+
side.push(Object.assign([outer, ...holes.filter((r) => r.length >= 3)], { isl: poly.isl }));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const loBox = axis === 0 ? [x0, y0, mid, y1] : [x0, y0, x1, mid],
|
|
82
|
+
hiBox = axis === 0 ? [mid, y0, x1, y1] : [x0, mid, x1, y1];
|
|
83
|
+
splitPolygons(lo, loBox, size, emit);
|
|
84
|
+
splitPolygons(hi, hiBox, size, emit);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// `land`: GeoJSON polygons as [[outer, ...holes]] of closed [lon, lat] rings; `coast`: lines of
|
|
88
|
+
// [lon, lat]. Returns Map(key -> { land, coast, m }), rings closed again, coordinates rounded; `m`
|
|
89
|
+
// holds `meta` of the whole polygon each land piece was cut from (by default its island's area and colour).
|
|
90
|
+
const islandMeta = (poly) => {
|
|
91
|
+
const i = islandInfo(poly);
|
|
92
|
+
return [i.km2, i.hue];
|
|
93
|
+
};
|
|
94
|
+
export function cutCells({ land, coast }, size = 5, digits = 5, meta = islandMeta) {
|
|
95
|
+
const f = 10 ** digits,
|
|
96
|
+
round = (p) => [Math.round(p[0] * f) / f, Math.round(p[1] * f) / f];
|
|
97
|
+
const tidy = (pts) =>
|
|
98
|
+
pts.map(round).filter((p, i, a) => i === 0 || p[0] !== a[i - 1][0] || p[1] !== a[i - 1][1]);
|
|
99
|
+
const cells = new Map(),
|
|
100
|
+
cell = (k) => cells.get(k) ?? cells.set(k, { land: [], coast: [], m: [] }).get(k);
|
|
101
|
+
const ids = coast.some((l) => l.id !== undefined); // and if lines carry an `id`, `cm` holds each piece's
|
|
102
|
+
|
|
103
|
+
const open = (r) =>
|
|
104
|
+
r.length > 1 && r[0][0] === r[r.length - 1][0] && r[0][1] === r[r.length - 1][1]
|
|
105
|
+
? r.slice(0, -1)
|
|
106
|
+
: r;
|
|
107
|
+
splitPolygons(
|
|
108
|
+
land.map((poly) => Object.assign(poly.map(open), { isl: meta(poly) })),
|
|
109
|
+
[-180, -90, 180, 90],
|
|
110
|
+
size,
|
|
111
|
+
(xi, yi, polys) => {
|
|
112
|
+
const c = cell(key(xi, yi)),
|
|
113
|
+
out = c.land;
|
|
114
|
+
for (const poly of polys) {
|
|
115
|
+
const rings = poly
|
|
116
|
+
.map(tidy)
|
|
117
|
+
.filter((r) => r.length >= 3)
|
|
118
|
+
.map((r) => [...r, r[0]]);
|
|
119
|
+
if (rings.length) {
|
|
120
|
+
out.push(rings);
|
|
121
|
+
c.m.push(poly.isl);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// A segment belongs to the cell holding its midpoint; a run of segments in one cell is one piece.
|
|
128
|
+
const cellOf = (a, b) =>
|
|
129
|
+
key(Math.floor((a[0] + b[0]) / 2 / size), Math.floor((a[1] + b[1]) / 2 / size));
|
|
130
|
+
const put = (k, piece, id) => {
|
|
131
|
+
const c = cell(k);
|
|
132
|
+
c.coast.push(piece);
|
|
133
|
+
if (ids) (c.cm ??= []).push(id);
|
|
134
|
+
};
|
|
135
|
+
for (const line of coast) {
|
|
136
|
+
let piece = null,
|
|
137
|
+
at = null;
|
|
138
|
+
for (let i = 0; i < line.length - 1; i++) {
|
|
139
|
+
const k = cellOf(line[i], line[i + 1]);
|
|
140
|
+
if (k !== at) {
|
|
141
|
+
if (piece) put(at, piece, line.id);
|
|
142
|
+
piece = [line[i]];
|
|
143
|
+
at = k;
|
|
144
|
+
}
|
|
145
|
+
piece.push(line[i + 1]);
|
|
146
|
+
}
|
|
147
|
+
if (piece) put(at, piece, line.id);
|
|
148
|
+
}
|
|
149
|
+
for (const c of cells.values()) {
|
|
150
|
+
const lines = c.coast.map(tidy),
|
|
151
|
+
keep = lines.map((l) => l.length > 1);
|
|
152
|
+
c.coast = lines.filter((_, i) => keep[i]);
|
|
153
|
+
if (c.cm) c.cm = c.cm.filter((_, i) => keep[i]);
|
|
154
|
+
}
|
|
155
|
+
return cells;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The Natural Earth 10m water downloads, the world file first and then the regional supplements,
|
|
159
|
+
// which hold what the world file leaves out (Lake Washington, Como, the Snohomish) and repeat none of
|
|
160
|
+
// it. joinWater makes one collection of several, in this order, so cutWater's ids stay unique.
|
|
161
|
+
export const WATER_FILES = {
|
|
162
|
+
rivers: [
|
|
163
|
+
'ne_10m_rivers_lake_centerlines.geojson',
|
|
164
|
+
'ne_10m_rivers_north_america.geojson',
|
|
165
|
+
'ne_10m_rivers_europe.geojson',
|
|
166
|
+
],
|
|
167
|
+
lakes: [
|
|
168
|
+
'ne_10m_lakes.geojson',
|
|
169
|
+
'ne_10m_lakes_north_america.geojson',
|
|
170
|
+
'ne_10m_lakes_europe.geojson',
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
export const joinWater = (geos) => ({
|
|
174
|
+
type: 'FeatureCollection',
|
|
175
|
+
features: geos.flatMap((g) => g?.features ?? []),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Natural Earth rivers and lakes into the same square cells, for scripts/water.mjs past WATER_ZOOM.
|
|
179
|
+
// A feature goes whole into every cell it reaches (a river, each cell its segments' midpoints and
|
|
180
|
+
// vertices fall in; a lake, each cell its box meets), so a river keeps one wiggle and a lake one
|
|
181
|
+
// outline wherever the view is cut; its index in the source file is its GeoJSON `id`, by which
|
|
182
|
+
// the scene keeps one copy of a feature fetched from several cells. Properties are cut to `keep`.
|
|
183
|
+
// Returns Map(key -> { rivers, lakes }), each a FeatureCollection.
|
|
184
|
+
export function cutWater(
|
|
185
|
+
riversGeo,
|
|
186
|
+
lakesGeo,
|
|
187
|
+
{
|
|
188
|
+
keep = { rivers: ['name', 'featurecla', 'min_zoom'], lakes: ['name', 'min_zoom'] },
|
|
189
|
+
size = 5,
|
|
190
|
+
digits = 5,
|
|
191
|
+
} = {},
|
|
192
|
+
) {
|
|
193
|
+
const f = 10 ** digits,
|
|
194
|
+
round = (c) =>
|
|
195
|
+
typeof c[0] === 'number'
|
|
196
|
+
? [Math.round(c[0] * f) / f, Math.round(c[1] * f) / f]
|
|
197
|
+
: c.map(round);
|
|
198
|
+
const cells = new Map(),
|
|
199
|
+
cell = (k) => cells.get(k) ?? cells.set(k, { rivers: [], lakes: [] }).get(k);
|
|
200
|
+
// 180 east and 90 north fall in the last cell, not one past it
|
|
201
|
+
const xiOf = (lon) => Math.min(180 / size - 1, Math.floor(lon / size)),
|
|
202
|
+
yiOf = (lat) => Math.min(90 / size - 1, Math.floor(lat / size));
|
|
203
|
+
const at = (lon, lat) => key(xiOf(lon), yiOf(lat));
|
|
204
|
+
const lines = (g) =>
|
|
205
|
+
g.type === 'LineString'
|
|
206
|
+
? [g.coordinates]
|
|
207
|
+
: g.type === 'MultiLineString'
|
|
208
|
+
? g.coordinates
|
|
209
|
+
: g.type === 'Polygon'
|
|
210
|
+
? g.coordinates
|
|
211
|
+
: g.type === 'MultiPolygon'
|
|
212
|
+
? g.coordinates.flat()
|
|
213
|
+
: [];
|
|
214
|
+
const reach = {
|
|
215
|
+
rivers: (g) => {
|
|
216
|
+
const ks = new Set();
|
|
217
|
+
for (const l of lines(g))
|
|
218
|
+
l.forEach((p, i) => {
|
|
219
|
+
ks.add(at(p[0], p[1]));
|
|
220
|
+
if (i) ks.add(at((p[0] + l[i - 1][0]) / 2, (p[1] + l[i - 1][1]) / 2));
|
|
221
|
+
});
|
|
222
|
+
return ks;
|
|
223
|
+
},
|
|
224
|
+
lakes: (g) => {
|
|
225
|
+
const [x0, y0, x1, y1] = ringBox(lines(g).flat()),
|
|
226
|
+
ks = new Set();
|
|
227
|
+
for (let xi = xiOf(x0); xi <= xiOf(x1); xi++)
|
|
228
|
+
for (let yi = yiOf(y0); yi <= yiOf(y1); yi++) ks.add(key(xi, yi));
|
|
229
|
+
return ks;
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
for (const [kind, geo] of [
|
|
233
|
+
['rivers', riversGeo],
|
|
234
|
+
['lakes', lakesGeo],
|
|
235
|
+
]) {
|
|
236
|
+
(geo?.features ?? []).forEach((ft, id) => {
|
|
237
|
+
if (!ft.geometry) return;
|
|
238
|
+
const slim = {
|
|
239
|
+
type: 'Feature',
|
|
240
|
+
id,
|
|
241
|
+
properties: Object.fromEntries(keep[kind].map((k) => [k, ft.properties[k]])),
|
|
242
|
+
geometry: { type: ft.geometry.type, coordinates: round(ft.geometry.coordinates) },
|
|
243
|
+
};
|
|
244
|
+
for (const k of reach[kind](ft.geometry)) cell(k)[kind].push(slim);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
for (const [k, c] of cells)
|
|
248
|
+
cells.set(k, {
|
|
249
|
+
rivers: { type: 'FeatureCollection', features: c.rivers },
|
|
250
|
+
lakes: { type: 'FeatureCollection', features: c.lakes },
|
|
251
|
+
});
|
|
252
|
+
return cells;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// GSHHG lakes (level 2, river-lakes among them) with their islands (level 3) as holes, from the
|
|
256
|
+
// shapefiles' polygons and dBASE records, for cutLakes. Each lake carries its GSHHG id and its
|
|
257
|
+
// whole lon/lat box, which its cut pieces keep: the scene drops a 10m lake inside one.
|
|
258
|
+
export function dbfRecords(bytes) {
|
|
259
|
+
const b = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
260
|
+
const n = b.readUInt32LE(4),
|
|
261
|
+
headLength = b.readUInt16LE(8),
|
|
262
|
+
recordLength = b.readUInt16LE(10),
|
|
263
|
+
fields = [];
|
|
264
|
+
for (let at = 32, o = 1; b[at] !== 0x0d; at += 32) {
|
|
265
|
+
const name = b.toString('latin1', at, at + 11).replace(/\0.*/s, ''),
|
|
266
|
+
length = b[at + 16];
|
|
267
|
+
fields.push([name, o, length]);
|
|
268
|
+
o += length;
|
|
269
|
+
}
|
|
270
|
+
return Array.from({ length: n }, (_, i) =>
|
|
271
|
+
Object.fromEntries(
|
|
272
|
+
fields.map(([k, o, l]) => [
|
|
273
|
+
k,
|
|
274
|
+
b
|
|
275
|
+
.toString(
|
|
276
|
+
'latin1',
|
|
277
|
+
headLength + i * recordLength + o,
|
|
278
|
+
headLength + i * recordLength + o + l,
|
|
279
|
+
)
|
|
280
|
+
.trim(),
|
|
281
|
+
]),
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
export function gshhgLakes(lakes, lakeRecords, islands, islandRecords) {
|
|
286
|
+
const byId = new Map(
|
|
287
|
+
lakes.map((poly, i) => [
|
|
288
|
+
lakeRecords[i].id,
|
|
289
|
+
Object.assign([...poly], { id: +lakeRecords[i].id }),
|
|
290
|
+
]),
|
|
291
|
+
);
|
|
292
|
+
islands.forEach((poly, i) => byId.get(islandRecords[i].parent_id)?.push(poly[0]));
|
|
293
|
+
return [...byId.values()];
|
|
294
|
+
}
|
|
295
|
+
// Lakes into cells as land is (cutCells): pieces clipped to each cell to fill, and the shores split
|
|
296
|
+
// at cell edges to ink, so a cut edge is never drawn. `m` holds, per piece, [id, x0, y0, x1, y1]: the
|
|
297
|
+
// lake's id and whole box, rounded out to `digits`; `cm` each shore piece's lake id.
|
|
298
|
+
export function cutLakes(lakes, size, digits = 5) {
|
|
299
|
+
const f = 10 ** digits;
|
|
300
|
+
const shores = lakes.flatMap((lake) =>
|
|
301
|
+
lake.map((ring) => Object.assign([...ring], { id: lake.id })),
|
|
302
|
+
);
|
|
303
|
+
return cutCells({ land: lakes, coast: shores }, size, digits, (poly) => {
|
|
304
|
+
const [x0, y0, x1, y1] = ringBox(poly[0]);
|
|
305
|
+
return [
|
|
306
|
+
poly.id,
|
|
307
|
+
Math.floor(x0 * f) / f,
|
|
308
|
+
Math.floor(y0 * f) / f,
|
|
309
|
+
Math.ceil(x1 * f) / f,
|
|
310
|
+
Math.ceil(y1 * f) / f,
|
|
311
|
+
];
|
|
312
|
+
});
|
|
313
|
+
}
|
package/lib/island.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// What the land treatments need to know about an island: its area in km2 and a colour that stays
|
|
2
|
+
// with it. Computed once per land polygon, from the whole polygon (so before lib/detail-cells.js
|
|
3
|
+
// cuts it into cells, which carry the result along), and shared by the scene and the cutter.
|
|
4
|
+
|
|
5
|
+
export const ISLAND_KM2 = 5000; // coloured islands are the land polygons smaller than this
|
|
6
|
+
export const ISLAND_HUES = 3; // red, green, gold
|
|
7
|
+
|
|
8
|
+
const R_KM = 6371.0088,
|
|
9
|
+
RAD = Math.PI / 180;
|
|
10
|
+
|
|
11
|
+
// Area of a [lon, lat] ring (open or closed) in km2: equirectangular about the ring's own latitude,
|
|
12
|
+
// good to a percent or two at this size.
|
|
13
|
+
function ringKm2(ring) {
|
|
14
|
+
const lat0 = ring.reduce((s, p) => s + p[1], 0) / ring.length,
|
|
15
|
+
lon0 = ring[0][0],
|
|
16
|
+
c = Math.cos(lat0 * RAD);
|
|
17
|
+
const pt = ([lon, lat]) => [(((lon - lon0 + 540) % 360) - 180) * c, lat];
|
|
18
|
+
let s = 0;
|
|
19
|
+
for (let i = 0; i < ring.length; i++) {
|
|
20
|
+
const [x1, y1] = pt(ring[i]),
|
|
21
|
+
[x2, y2] = pt(ring[(i + 1) % ring.length]);
|
|
22
|
+
s += x1 * y2 - x2 * y1;
|
|
23
|
+
}
|
|
24
|
+
return (Math.abs(s) / 2) * (R_KM * RAD) ** 2;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// { km2, hue } of a polygon [outer, ...holes]. The hue comes from the centre of the outer ring's
|
|
28
|
+
// box, to a tenth of a degree: the same island has the same colour in every view and, to within a
|
|
29
|
+
// tenth of a degree of disagreement between data sets, at every zoom.
|
|
30
|
+
export function islandInfo(poly) {
|
|
31
|
+
const km2 = Math.round(ringKm2(poly[0]) - poly.slice(1).reduce((s, r) => s + ringKm2(r), 0));
|
|
32
|
+
let x0 = Infinity,
|
|
33
|
+
x1 = -Infinity,
|
|
34
|
+
y0 = Infinity,
|
|
35
|
+
y1 = -Infinity;
|
|
36
|
+
const lon0 = poly[0][0][0];
|
|
37
|
+
for (const [lon, lat] of poly[0]) {
|
|
38
|
+
const x = ((lon - lon0 + 540) % 360) - 180;
|
|
39
|
+
if (x < x0) x0 = x;
|
|
40
|
+
if (x > x1) x1 = x;
|
|
41
|
+
if (lat < y0) y0 = lat;
|
|
42
|
+
if (lat > y1) y1 = lat;
|
|
43
|
+
}
|
|
44
|
+
let h =
|
|
45
|
+
Math.imul(Math.round((lon0 + (x0 + x1) / 2) * 10), 0x9e3779b1) ^
|
|
46
|
+
Math.imul(Math.round((y0 + y1) * 5), 0x85ebca6b);
|
|
47
|
+
h ^= h >>> 15;
|
|
48
|
+
h = Math.imul(h, 0x2c1b3c6d);
|
|
49
|
+
h ^= h >>> 13;
|
|
50
|
+
return { km2, hue: (h >>> 0) % ISLAND_HUES };
|
|
51
|
+
}
|
package/lib/provider.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// neither package imports the other; the caller owns the projection and hands over a view.
|
|
4
4
|
//
|
|
5
5
|
// A view is the lon/lat frame a caller paints and its scale at the centre:
|
|
6
|
-
// `{ lat,
|
|
6
|
+
// `{ lat, resolution, lonMin, lonMax, latMin, latMax }`, `resolution` in metres per pixel (the old
|
|
7
|
+
// name, `metresPerPixel`, still reads, deprecated). The frame may run past the
|
|
7
8
|
// antimeridian. The catalogue keys bands by web-mercator zoom (a 512 px world at zoom 0), so the
|
|
8
9
|
// scale is turned into that zoom at the view's latitude to choose one.
|
|
9
10
|
//
|
|
@@ -20,13 +21,20 @@ const EARTH_R = 6378137; // metres, web mercator's sphere
|
|
|
20
21
|
const WORLD_PX = 512; // the width of the world at zoom 0, as the catalogue's zooms count it
|
|
21
22
|
const RAD = Math.PI / 180;
|
|
22
23
|
|
|
24
|
+
// A view's metres per pixel: `resolution`, or the deprecated `metresPerPixel` when that is all it has.
|
|
25
|
+
const resolutionOf = (view) => {
|
|
26
|
+
if (view.resolution === undefined && view.metresPerPixel !== undefined)
|
|
27
|
+
warnOnce('metresPerPixel', 'resolution');
|
|
28
|
+
return view.resolution ?? view.metresPerPixel;
|
|
29
|
+
};
|
|
30
|
+
|
|
23
31
|
/** The web-mercator zoom of a view's scale at its centre. */
|
|
24
32
|
// Rounded, so a view at a band's edge zoom, turned into a scale and back, does not pass it.
|
|
25
33
|
export const zoomOf = (view) =>
|
|
26
34
|
Math.round(
|
|
27
35
|
1e9 *
|
|
28
36
|
Math.log2(
|
|
29
|
-
(2 * Math.PI * EARTH_R * Math.cos(view.lat * RAD)) / (WORLD_PX * view
|
|
37
|
+
(2 * Math.PI * EARTH_R * Math.cos(view.lat * RAD)) / (WORLD_PX * resolutionOf(view)),
|
|
30
38
|
),
|
|
31
39
|
) / 1e9;
|
|
32
40
|
|
|
@@ -95,9 +103,10 @@ function geojsonRings(geo) {
|
|
|
95
103
|
* path (`geo/h/12_45.json`, `data/ne-coastline-50m.geojson`) and rejects when it cannot. `kind`
|
|
96
104
|
* is `coast`, the coastline's lines, or `land`, the land polygons' rings.
|
|
97
105
|
*
|
|
98
|
-
* `rings(view)` yields the view's rings; `
|
|
99
|
-
* drawing them fetches nothing; `
|
|
100
|
-
* not yet
|
|
106
|
+
* `rings(view)` yields the view's rings; `loaded(view)` says whether they are all in hand, so
|
|
107
|
+
* drawing them fetches nothing; `pending(view)` is what drawing it would fetch: the band, the cells
|
|
108
|
+
* not yet loaded, and their bytes where the catalogue knows them. `held` and `cost` are the old
|
|
109
|
+
* names of those two.
|
|
101
110
|
*/
|
|
102
111
|
export function provider({ read, kind = 'coast' }) {
|
|
103
112
|
const { layer, part, whole } = KINDS[kind] ?? {};
|
|
@@ -125,11 +134,11 @@ export function provider({ read, kind = 'coast' }) {
|
|
|
125
134
|
return index.get(b.dir);
|
|
126
135
|
};
|
|
127
136
|
|
|
128
|
-
const
|
|
137
|
+
const inflight = new Map(); // path -> promise of its rings
|
|
129
138
|
const got = new Map(); // path -> its rings, once read
|
|
130
139
|
const fetch = (path, ringsOf) => {
|
|
131
|
-
if (!
|
|
132
|
-
|
|
140
|
+
if (!inflight.has(path))
|
|
141
|
+
inflight.set(
|
|
133
142
|
path,
|
|
134
143
|
read(path).then(
|
|
135
144
|
(json) => {
|
|
@@ -138,12 +147,12 @@ export function provider({ read, kind = 'coast' }) {
|
|
|
138
147
|
return rings;
|
|
139
148
|
},
|
|
140
149
|
(e) => {
|
|
141
|
-
|
|
150
|
+
inflight.delete(path); // a failed read is tried again for the next view
|
|
142
151
|
throw e;
|
|
143
152
|
},
|
|
144
153
|
),
|
|
145
154
|
);
|
|
146
|
-
return
|
|
155
|
+
return inflight.get(path);
|
|
147
156
|
};
|
|
148
157
|
const cellRings = (cell) => (part === 'land' ? cell.land.flat() : cell.coast);
|
|
149
158
|
|
|
@@ -186,17 +195,38 @@ export function provider({ read, kind = 'coast' }) {
|
|
|
186
195
|
};
|
|
187
196
|
}
|
|
188
197
|
|
|
198
|
+
async function loaded(view) {
|
|
199
|
+
return (await wants(view)).paths.every((p) => got.has(p));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function pending(view) {
|
|
203
|
+
const { band, paths, bytes } = await wants(view);
|
|
204
|
+
const cells = paths.filter((p) => !got.has(p)).length;
|
|
205
|
+
return { band, cells, bytes: cells ? bytes(cells) : 0 };
|
|
206
|
+
}
|
|
207
|
+
|
|
189
208
|
return {
|
|
190
209
|
async *rings(view) {
|
|
191
210
|
for (const p of (await draw(view)).parts) yield* p;
|
|
192
211
|
},
|
|
193
|
-
|
|
194
|
-
|
|
212
|
+
loaded,
|
|
213
|
+
pending,
|
|
214
|
+
/** @deprecated Use `loaded`. */
|
|
215
|
+
held(view) {
|
|
216
|
+
warnOnce('held', 'loaded');
|
|
217
|
+
return loaded(view);
|
|
195
218
|
},
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
return
|
|
219
|
+
/** @deprecated Use `pending`. */
|
|
220
|
+
cost(view) {
|
|
221
|
+
warnOnce('cost', 'pending');
|
|
222
|
+
return pending(view);
|
|
200
223
|
},
|
|
201
224
|
};
|
|
202
225
|
}
|
|
226
|
+
|
|
227
|
+
const warned = new Set();
|
|
228
|
+
function warnOnce(old, now) {
|
|
229
|
+
if (warned.has(old)) return;
|
|
230
|
+
warned.add(old);
|
|
231
|
+
console.warn(`coastlines provider: \`${old}\` is deprecated, use \`${now}\``);
|
|
232
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coastlines",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9-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",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"./catalogue": "./lib/bands.js",
|
|
12
12
|
"./catalogue.json": "./catalogue.json",
|
|
13
13
|
"./provider": "./lib/provider.js",
|
|
14
|
+
"./acquire": "./lib/acquire.js",
|
|
14
15
|
"./data/*": "./data/*.geojson",
|
|
15
16
|
"./data/*.shp": "./data/*.shp",
|
|
16
17
|
"./data/*.txt": "./data/*.txt",
|
|
@@ -19,10 +20,10 @@
|
|
|
19
20
|
},
|
|
20
21
|
"scripts": {
|
|
21
22
|
"test": "node --test",
|
|
22
|
-
"sync": "node scripts/sync.mjs",
|
|
23
|
-
"sync:check": "node scripts/sync.mjs --check",
|
|
24
|
-
"sync:refresh": "node scripts/sync.mjs --refresh",
|
|
25
|
-
"prepack": "node scripts/sync.mjs",
|
|
23
|
+
"sync": "node --max-old-space-size=6144 scripts/sync.mjs",
|
|
24
|
+
"sync:check": "node --max-old-space-size=6144 scripts/sync.mjs --check",
|
|
25
|
+
"sync:refresh": "node --max-old-space-size=6144 scripts/sync.mjs --refresh",
|
|
26
|
+
"prepack": "node scripts/sync.mjs --check --no-cells",
|
|
26
27
|
"build:sheet": "node scripts/render-sheet.mjs",
|
|
27
28
|
"build:readme": "node scripts/catalogue-table.mjs",
|
|
28
29
|
"build:pages": "node scripts/build-pages.mjs",
|