coastlines 0.0.8-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 CHANGED
@@ -116,11 +116,25 @@ zip itself.
116
116
  | Where | What | Size | How it reaches you |
117
117
  | --- | --- | ---: | --- |
118
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 | 896 MB | built by `npm run sync`; not shipped |
119
+ | `geo/` | six cut bands, land and water in square cells | 549 MB | built by `npm run sync`; not shipped |
120
120
  | `cache/` | the GSHHG zip, six Natural Earth 10m water files | 167 MB | fetched once by sync, kept |
121
121
 
122
- The cut bands are Natural Earth 10m and GSHHG h and f, land and lakes, each
123
- a record in `catalogue.json`: grid, inputs and pins, cutter and build date.
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
+
124
138
  How cells reach a boat that installs the package is open ([#17](https://github.com/mark-brannan/coastlines/issues/17)).
125
139
 
126
140
  ### Sources and licences
package/catalogue.json CHANGED
@@ -1050,8 +1050,9 @@
1050
1050
  "cutter": "coastlines/lib/cut.js",
1051
1051
  "built": "2026-10-15"
1052
1052
  },
1053
- "bytesPerCell": null,
1054
- "bytes": null,
1053
+ "cells": 1456,
1054
+ "bytesPerCell": 12954,
1055
+ "bytes": 18861693,
1055
1056
  "from": ["ne-land-10m", "ne-coastline-10m", "ne-minor-islands-10m"],
1056
1057
  "inputs": [],
1057
1058
  "sources": [
@@ -1092,8 +1093,9 @@
1092
1093
  "cutter": "coastlines/lib/cut.js",
1093
1094
  "built": "2026-10-15"
1094
1095
  },
1095
- "bytesPerCell": null,
1096
- "bytes": null,
1096
+ "cells": 19273,
1097
+ "bytesPerCell": 3717,
1098
+ "bytes": 71636830,
1097
1099
  "from": [],
1098
1100
  "inputs": ["gshhg-land-h", "gshhg-licence", "gshhg-copying-lesser"],
1099
1101
  "sources": [
@@ -1126,8 +1128,9 @@
1126
1128
  "cutter": "coastlines/lib/cut.js",
1127
1129
  "built": "2026-10-15"
1128
1130
  },
1129
- "bytesPerCell": null,
1130
- "bytes": null,
1131
+ "cells": 70692,
1132
+ "bytesPerCell": 5693,
1133
+ "bytes": 402415178,
1131
1134
  "from": [],
1132
1135
  "inputs": [
1133
1136
  {
@@ -1179,8 +1182,9 @@
1179
1182
  "cutter": "coastlines/lib/cut.js",
1180
1183
  "built": "2026-10-15"
1181
1184
  },
1182
- "bytesPerCell": null,
1183
- "bytes": null,
1185
+ "cells": 712,
1186
+ "bytesPerCell": 45613,
1187
+ "bytes": 32476615,
1184
1188
  "from": [],
1185
1189
  "inputs": [
1186
1190
  {
@@ -1259,8 +1263,9 @@
1259
1263
  "cutter": "coastlines/lib/cut.js",
1260
1264
  "built": "2026-10-15"
1261
1265
  },
1262
- "bytesPerCell": null,
1263
- "bytes": null,
1266
+ "cells": 4068,
1267
+ "bytesPerCell": 2935,
1268
+ "bytes": 11939267,
1264
1269
  "from": [],
1265
1270
  "inputs": [
1266
1271
  {
@@ -1325,8 +1330,9 @@
1325
1330
  "cutter": "coastlines/lib/cut.js",
1326
1331
  "built": "2026-10-15"
1327
1332
  },
1328
- "bytesPerCell": null,
1329
- "bytes": null,
1333
+ "cells": 8699,
1334
+ "bytesPerCell": 4417,
1335
+ "bytes": 38424112,
1330
1336
  "from": [],
1331
1337
  "inputs": [
1332
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
@@ -49,6 +49,14 @@ export function inputsOf(record) {
49
49
  return [...own, ...inherited];
50
50
  }
51
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
+
52
60
  /** The zoom range charts.json draws a whole file at, across every chart that lists it; nulls when none does. */
53
61
  function chartZoom(id) {
54
62
  const layers = charts.flatMap((chart) => chart.layers).filter((layer) => layer.id === id);
@@ -75,17 +83,13 @@ function wholeFileDrift(record) {
75
83
  return problems;
76
84
  }
77
85
 
78
- /** A cut band names its cutter and the day it was built, and draws on one upstream only. */
86
+ /** A cut band names its cutter and the day it was built. */
79
87
  function cutDrift(record) {
80
88
  const problems = [];
81
89
  const { cutter, built } = record.provenance;
82
90
  if (cutter !== CUTTER) problems.push(`${record.id}: cutter ${cutter} is not ${CUTTER}`);
83
91
  if (!/^\d{4}-\d{2}-\d{2}$/.test(built ?? ''))
84
92
  problems.push(`${record.id}: built ${built} is not a date`);
85
- const sources = new Set(
86
- inputsOf(record).map((i) => (typeof i === 'string' ? find(i).source : i.source)),
87
- );
88
- if (sources.size !== 1) problems.push(`${record.id}: mixes sources ${[...sources].join(', ')}`);
89
93
  return problems;
90
94
  }
91
95
 
@@ -110,9 +114,11 @@ export function drift() {
110
114
  if (JSON.stringify(record.provenance[field]) !== want)
111
115
  problems.push(`${record.id}: ${field} differs from its inputs`);
112
116
  }
113
- const keys = new Set(
114
- inputs.map((input) => (typeof input === 'string' ? find(input).source : input.source)),
115
- );
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
+ );
116
122
  for (const key of keys) {
117
123
  const from = manifest.sources[key];
118
124
  if (licence !== from.licence)
package/lib/cut.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // square lon/lat cells under geo/<dir>/ (<xi>_<yi>.json and index.json). A band is cut from one
3
3
  // upstream only, so no geometry file mixes sources.
4
4
  import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
5
- import { band, CUTTER, inputsOf } from './bands.js';
5
+ import { band, CUTTER, inputsOf, sourcesOf } from './bands.js';
6
6
  import { find } from './catalogue.js';
7
7
  import { shapefilePolygons } from './shapefile.js';
8
8
  import { cutCells, cutLakes, cutWater, dbfRecords, gshhgLakes, joinWater } from './detail-cells.js';
@@ -37,6 +37,8 @@ function lines(geojson) {
37
37
  * `inputsOf(record)` lists them.
38
38
  */
39
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`);
40
42
  const size = record.grid.cellDeg;
41
43
  const bytes = await Promise.all(inputsOf(record).map(read));
42
44
  if (record.id === 'ne-10m-land') {
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, metresPerPixel, lonMin, lonMax, latMin, latMax }`. The frame may run past the
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.metresPerPixel),
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; `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.
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 pending = new Map(); // path -> promise of its rings
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 (!pending.has(path))
132
- pending.set(
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
- pending.delete(path); // a failed read is tried again for the next view
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 pending.get(path);
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
- async held(view) {
194
- return (await wants(view)).paths.every((p) => got.has(p));
212
+ loaded,
213
+ pending,
214
+ /** @deprecated Use `loaded`. */
215
+ held(view) {
216
+ warnOnce('held', 'loaded');
217
+ return loaded(view);
195
218
  },
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 };
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.8-alpha.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",
@@ -22,7 +23,7 @@
22
23
  "sync": "node --max-old-space-size=6144 scripts/sync.mjs",
23
24
  "sync:check": "node --max-old-space-size=6144 scripts/sync.mjs --check",
24
25
  "sync:refresh": "node --max-old-space-size=6144 scripts/sync.mjs --refresh",
25
- "prepack": "node scripts/sync.mjs --no-cells",
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",