@mailwoman/corpus 10.4.0 → 10.5.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 (96) hide show
  1. package/data/PROVENANCE.md +14 -0
  2. package/data/address-source-register.json +32 -4
  3. package/data/source-resolutions.json +35 -0
  4. package/data/training-manifests/v0.7.2-address-systems.effective.json +1169 -0
  5. package/data/training-manifests/v0.7.2-address-systems.json +550 -0
  6. package/lib/adapters/cordis/adapter.ts +1 -1
  7. package/lib/adapters.ts +2 -0
  8. package/lib/be/adapters/brussels/adapter.ts +1 -1
  9. package/lib/be/adapters/wallonie/adapter.ts +1 -1
  10. package/lib/build/align.ts +1 -1
  11. package/lib/cz/adapters/cuzk/adapter.ts +1 -1
  12. package/lib/cz/tools/fetch/cuzk.ts +2 -2
  13. package/lib/es/tools/fetch/bizkaia.ts +1 -1
  14. package/lib/gb/adapters/os-open/adapter.ts +453 -0
  15. package/lib/gb/os-open/nearest-link.ts +169 -0
  16. package/lib/gb/os-open/open-names.ts +180 -0
  17. package/lib/gb/os-open/open-roads.ts +145 -0
  18. package/lib/gb/tools/fetch/os-open.ts +251 -0
  19. package/lib/nl/tools/fetch/kadaster.ts +1 -1
  20. package/lib/registers.ts +7 -0
  21. package/lib/source-register/effective-manifest.ts +12 -6
  22. package/lib/source-register/training-manifest.ts +30 -2
  23. package/lib/source-register.ts +1 -0
  24. package/lib/subvenue/surfaces.ts +1 -1
  25. package/lib/tools/fetch/gleif.ts +1 -1
  26. package/lib/tools/fetch.ts +2 -0
  27. package/lib/tools/lint/recipe-output.ts +1 -1
  28. package/lib/tools/migrate/source-names.ts +1 -1
  29. package/lib/us/adapters/usgov/nad/adapter.ts +1 -1
  30. package/lib/us/adapters/usgov/nppes/adapter.ts +1 -1
  31. package/out/adapters/cordis/adapter.js +1 -1
  32. package/out/adapters.d.ts.map +1 -1
  33. package/out/adapters.js +2 -0
  34. package/out/adapters.js.map +1 -1
  35. package/out/be/adapters/brussels/adapter.d.ts +1 -1
  36. package/out/be/adapters/brussels/adapter.js +1 -1
  37. package/out/be/adapters/wallonie/adapter.d.ts +1 -1
  38. package/out/be/adapters/wallonie/adapter.js +1 -1
  39. package/out/build/align.d.ts +1 -1
  40. package/out/cz/adapters/cuzk/adapter.d.ts +1 -1
  41. package/out/cz/adapters/cuzk/adapter.js +1 -1
  42. package/out/cz/tools/fetch/cuzk.js +2 -2
  43. package/out/cz/tools/fetch/cuzk.js.map +1 -1
  44. package/out/es/tools/fetch/bizkaia.js +1 -1
  45. package/out/es/tools/fetch/bizkaia.js.map +1 -1
  46. package/out/gb/adapters/os-open/adapter.d.ts +165 -0
  47. package/out/gb/adapters/os-open/adapter.d.ts.map +1 -0
  48. package/out/gb/adapters/os-open/adapter.js +298 -0
  49. package/out/gb/adapters/os-open/adapter.js.map +1 -0
  50. package/out/gb/os-open/nearest-link.d.ts +73 -0
  51. package/out/gb/os-open/nearest-link.d.ts.map +1 -0
  52. package/out/gb/os-open/nearest-link.js +133 -0
  53. package/out/gb/os-open/nearest-link.js.map +1 -0
  54. package/out/gb/os-open/open-names.d.ts +92 -0
  55. package/out/gb/os-open/open-names.d.ts.map +1 -0
  56. package/out/gb/os-open/open-names.js +107 -0
  57. package/out/gb/os-open/open-names.js.map +1 -0
  58. package/out/gb/os-open/open-roads.d.ts +80 -0
  59. package/out/gb/os-open/open-roads.d.ts.map +1 -0
  60. package/out/gb/os-open/open-roads.js +79 -0
  61. package/out/gb/os-open/open-roads.js.map +1 -0
  62. package/out/gb/tools/fetch/os-open.d.ts +91 -0
  63. package/out/gb/tools/fetch/os-open.d.ts.map +1 -0
  64. package/out/gb/tools/fetch/os-open.js +166 -0
  65. package/out/gb/tools/fetch/os-open.js.map +1 -0
  66. package/out/nl/tools/fetch/kadaster.d.ts +1 -1
  67. package/out/nl/tools/fetch/kadaster.js +1 -1
  68. package/out/registers.d.ts +7 -0
  69. package/out/registers.d.ts.map +1 -1
  70. package/out/registers.js +7 -0
  71. package/out/registers.js.map +1 -1
  72. package/out/source-register/effective-manifest.d.ts +9 -3
  73. package/out/source-register/effective-manifest.d.ts.map +1 -1
  74. package/out/source-register/effective-manifest.js +9 -4
  75. package/out/source-register/effective-manifest.js.map +1 -1
  76. package/out/source-register/training-manifest.d.ts +27 -0
  77. package/out/source-register/training-manifest.d.ts.map +1 -1
  78. package/out/source-register/training-manifest.js +13 -1
  79. package/out/source-register/training-manifest.js.map +1 -1
  80. package/out/source-register.d.ts +1 -1
  81. package/out/source-register.d.ts.map +1 -1
  82. package/out/source-register.js +1 -1
  83. package/out/source-register.js.map +1 -1
  84. package/out/subvenue/surfaces.d.ts +1 -1
  85. package/out/tools/fetch/gleif.d.ts +1 -1
  86. package/out/tools/fetch.d.ts +2 -0
  87. package/out/tools/fetch.d.ts.map +1 -1
  88. package/out/tools/fetch.js +2 -0
  89. package/out/tools/fetch.js.map +1 -1
  90. package/out/tools/lint/recipe-output.d.ts +1 -1
  91. package/out/tools/migrate/source-names.d.ts +1 -1
  92. package/out/us/adapters/usgov/nad/adapter.d.ts +1 -1
  93. package/out/us/adapters/usgov/nad/adapter.js +1 -1
  94. package/out/us/adapters/usgov/nppes/adapter.d.ts +1 -1
  95. package/out/us/adapters/usgov/nppes/adapter.js +1 -1
  96. package/package.json +14 -13
@@ -0,0 +1,453 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `gb-os-open`: Great Britain street addresses joined from three OS OpenData products, each under
7
+ * the Open Government Licence v3.0.
8
+ *
9
+ * - **Code-Point Open** gives every unit postcode in England, Scotland and Wales a grid point.
10
+ * - **OS Open Roads** gives each named road link a centreline and the roadNameTOID of its name.
11
+ * - **OS Open Names** gives each roadNameTOID its `NAME1`, `POSTCODE_DISTRICT` and `POPULATED_PLACE`.
12
+ *
13
+ * Each postcode is joined to the nearest named road link within 100 m, measured point-to-segment in
14
+ * EPSG:27700 meters. The street is the Open Names `NAME1` of that link's TOID, the locality is its
15
+ * `POPULATED_PLACE`. A row carries no region, because the GB layout prints none. Every
16
+ * component is OS data except `house_number`, which no open GB product publishes and which this
17
+ * adapter **draws** from local bands seeded by the postcode and TOID, so each row carries a number the
18
+ * source never stated.
19
+ *
20
+ * A postcode is dropped when:
21
+ *
22
+ * - its Code-Point coordinate has no position, `Positional_quality_indicator` 90;
23
+ * - its coordinate is shared with another postcode, so the point does not locate this postcode's street;
24
+ * - no named link lies within 100 m;
25
+ * - the joined road's `POSTCODE_DISTRICT` differs from the postcode's own outward code.
26
+ *
27
+ * `inputPath` is a directory holding the three products unzipped, each in its own subdirectory:
28
+ * `code-point-open/` (with `Data/CSV/*.csv`), `open-names/` (with `Data/*.csv`) and `open-roads/`
29
+ * (with the `.gml` tiles anywhere beneath it). The Code-Point rows are held in memory, the road
30
+ * tiles stream past them once, and Open Names is read once for the roadNameTOID values the join chose.
31
+ */
32
+
33
+ import { formatAddressRow } from "@mailwoman/codex/address/format"
34
+ import { titleCaseGB } from "@mailwoman/codex/gb/place-name"
35
+ import { outwardCode } from "@mailwoman/codex/gb/postcode"
36
+ import { pathExists } from "@mailwoman/core/fs/readers/stat"
37
+ import { stringifyJSON } from "@mailwoman/core/json"
38
+ import { mulberry32 } from "@mailwoman/core/random"
39
+ import { createCodePointParseStats, PQI_NO_COORDINATE, readCodePointCSV } from "@mailwoman/spatial/gb/codepoint"
40
+ import { PathBuilder, type PathBuilderLike } from "path-ts"
41
+ import { Globerator } from "spliterator/node/fs"
42
+
43
+ import { UnsupportedCountryError } from "#adapters/errors"
44
+ import { stableSourceIDFromParts } from "#adapters/source-id"
45
+ import { NearestLinkJoin } from "#gb/os-open/nearest-link"
46
+ import {
47
+ createOpenNamesReadStats,
48
+ type OpenNamesRoad,
49
+ populatedPlaceName1,
50
+ readOpenNamesRoads,
51
+ } from "#gb/os-open/open-names"
52
+ import { createOpenRoadsReadStats, readOpenRoadsLinks } from "#gb/os-open/open-roads"
53
+ import { SourceRegister } from "#registers"
54
+ import { tieredNumber, type TieredNumberBand } from "#synthesizers/utils"
55
+ import { AddressRole, type AdapterOptions, type CanonicalRow, type CorpusAdapter, SurfaceOrigin } from "#types"
56
+
57
+ /**
58
+ * Registry id for this adapter.
59
+ *
60
+ * Stamped into every row it emits, so a corpus record can be traced back to the dataset it came from.
61
+ */
62
+ export const GB_OS_OPEN_ADAPTER_ID = "gb-os-open"
63
+
64
+ /**
65
+ * The license on all three products, and on every row.
66
+ */
67
+ export const GB_OS_OPEN_LICENSE = "OGL-UK-3.0"
68
+
69
+ /**
70
+ * The jurisdictions the adapter emits.
71
+ *
72
+ * Code-Point Open covers Great Britain and carries no `BT` postcode.
73
+ */
74
+ export const GB_OS_OPEN_COUNTRIES: readonly string[] = ["GB"]
75
+
76
+ /**
77
+ * The farthest a road link may lie from a postcode's point and still name its street, in EPSG:27700 meters.
78
+ */
79
+ export const GB_OS_OPEN_JOIN_RADIUS_M = 100
80
+
81
+ /**
82
+ * The subdirectory of `inputPath` that holds each product.
83
+ */
84
+ export const GB_OS_OPEN_PRODUCT_DIRECTORIES = {
85
+ codePointOpen: "code-point-open",
86
+ openNames: "open-names",
87
+ openRoads: "open-roads",
88
+ } as const
89
+
90
+ /**
91
+ * The bands the drawn house number comes from.
92
+ *
93
+ * Seven in ten draws fall in 1–60, a quarter in 61–200, and the rest in 201–400.
94
+ */
95
+ export const GB_OS_OPEN_HOUSE_NUMBER_BANDS: readonly TieredNumberBand[] = [
96
+ { cutoff: 0.7, base: 1, span: 60 },
97
+ { cutoff: 0.95, base: 61, span: 140 },
98
+ { base: 201, span: 200 },
99
+ ]
100
+
101
+ /**
102
+ * What one run read, dropped and emitted.
103
+ */
104
+ export interface GBOSOpenSummary {
105
+ /**
106
+ * Code-Point rows read, including rows with no coordinate.
107
+ */
108
+ codePointRows: number
109
+ /**
110
+ * Code-Point rows that failed the parser's shape checks.
111
+ */
112
+ codePointMalformed: number
113
+ /**
114
+ * Rows per `Positional_quality_indicator` value, including 90.
115
+ */
116
+ pqi: Record<string, number>
117
+ /**
118
+ * Postcodes with a coordinate, the denominator of the join.
119
+ */
120
+ postcodesWithCoordinate: number
121
+ /**
122
+ * Postcodes with a named link within the radius, counted before any drop.
123
+ */
124
+ joined: number
125
+ dropped: {
126
+ /**
127
+ * Positional quality 90: the row has no coordinate.
128
+ */
129
+ noCoordinate: number
130
+ /**
131
+ * The coordinate is shared with another postcode.
132
+ */
133
+ sharedCoordinate: number
134
+ /**
135
+ * No named link within the radius.
136
+ */
137
+ noLinkWithinRadius: number
138
+ /**
139
+ * The link's TOID has no road row in Open Names.
140
+ */
141
+ noOpenNamesRow: number
142
+ /**
143
+ * The road's `POSTCODE_DISTRICT` is not the postcode's outward code.
144
+ */
145
+ districtMismatch: number
146
+ }
147
+ /**
148
+ * Rows emitted.
149
+ */
150
+ emitted: number
151
+ /**
152
+ * Emitted streets that OS wrote with no lowercase letter and that were title-cased.
153
+ */
154
+ titleCasedStreets: number
155
+ /**
156
+ * Emitted rows with no `POPULATED_PLACE`, so no locality.
157
+ */
158
+ noLocality: number
159
+ /**
160
+ * Emitted rows whose `POPULATED_PLACE` was bilingual, of which the first name was kept.
161
+ */
162
+ bilingualLocality: number
163
+ roads: ReturnType<typeof createOpenRoadsReadStats>
164
+ names: ReturnType<typeof createOpenNamesReadStats>
165
+ }
166
+
167
+ /**
168
+ * Construction options.
169
+ */
170
+ export interface GBOSOpenAdapterOptions {
171
+ /**
172
+ * Receives the run's counts once the rows are exhausted.
173
+ */
174
+ onSummary?: (summary: GBOSOpenSummary) => void
175
+ }
176
+
177
+ /**
178
+ * The three product directories under `inputPath`.
179
+ *
180
+ * @throws Naming the missing path, when a product's subdirectory is absent.
181
+ */
182
+ export async function gbOSOpenProductPaths(
183
+ inputPath: PathBuilderLike
184
+ ): Promise<Record<keyof typeof GB_OS_OPEN_PRODUCT_DIRECTORIES, PathBuilder>> {
185
+ const root = PathBuilder.from(inputPath)
186
+
187
+ const paths = {
188
+ codePointOpen: root(GB_OS_OPEN_PRODUCT_DIRECTORIES.codePointOpen),
189
+ openNames: root(GB_OS_OPEN_PRODUCT_DIRECTORIES.openNames),
190
+ openRoads: root(GB_OS_OPEN_PRODUCT_DIRECTORIES.openRoads),
191
+ }
192
+
193
+ for (const path of Object.values(paths)) {
194
+ if (!(await pathExists(path))) {
195
+ throw new Error(
196
+ `${GB_OS_OPEN_ADAPTER_ID} adapter: ${path.toString()} is absent; inputPath must hold ${Object.values(GB_OS_OPEN_PRODUCT_DIRECTORIES).join("/, ")}/`
197
+ )
198
+ }
199
+ }
200
+
201
+ return paths
202
+ }
203
+
204
+ /**
205
+ * The files matching `pattern` under `directory`, sorted so a run reads them in one order.
206
+ *
207
+ * @throws When none match, so a wrong layout reports itself rather than reading as an empty product.
208
+ */
209
+ async function productFiles(directory: PathBuilder, pattern: string): Promise<string[]> {
210
+ const names = await Globerator.from(pattern, { cwd: directory.toString(), absolute: false }).toSorted()
211
+
212
+ if (!names.length) {
213
+ throw new Error(`${GB_OS_OPEN_ADAPTER_ID} adapter: ${directory.toString()} holds no file matching ${pattern}`)
214
+ }
215
+
216
+ return names.map((name) => directory(name).toString())
217
+ }
218
+
219
+ /**
220
+ * The house number drawn for one postcode and road, from {@linkcode GB_OS_OPEN_HOUSE_NUMBER_BANDS}.
221
+ *
222
+ * The seed is the row's source id, so a rerun draws the same number.
223
+ */
224
+ export function drawGBHouseNumber(sourceID: string): string {
225
+ const seed = Number.parseInt(sourceID.slice(-8), 16)
226
+
227
+ return tieredNumber(mulberry32(seed), GB_OS_OPEN_HOUSE_NUMBER_BANDS)
228
+ }
229
+
230
+ /**
231
+ * The street name as the row prints it.
232
+ *
233
+ * OS writes nearly every road name in mixed case.
234
+ * A name with no lowercase letter is title-cased.
235
+ */
236
+ function streetName(name1: string): { street: string; titleCased: boolean } {
237
+ if (/\p{Ll}/u.test(name1)) return { street: name1, titleCased: false }
238
+
239
+ return { street: titleCaseGB(name1), titleCased: true }
240
+ }
241
+
242
+ /**
243
+ * A road link's identity, as the join keeps it.
244
+ */
245
+ interface JoinedLink {
246
+ roadNameTOID: string
247
+ }
248
+
249
+ /**
250
+ * Creates the OS OpenData adapter.
251
+ */
252
+ export function createGBOSOpenAdapter(options: GBOSOpenAdapterOptions = {}): CorpusAdapter {
253
+ return {
254
+ id: GB_OS_OPEN_ADAPTER_ID,
255
+ defaultLicense: GB_OS_OPEN_LICENSE,
256
+ addressRole: AddressRole.Premise,
257
+ register: SourceRegister.OrdnanceSurveyOpenData,
258
+ surface: SurfaceOrigin.Rendered,
259
+ description:
260
+ "OS OpenData (Code-Point Open, OS Open Roads, OS Open Names): GB postcodes joined to their nearest named road, with a drawn house number.",
261
+
262
+ async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
263
+ if (opts.country && !GB_OS_OPEN_COUNTRIES.includes(opts.country)) {
264
+ throw new UnsupportedCountryError(GB_OS_OPEN_ADAPTER_ID, GB_OS_OPEN_COUNTRIES, opts.country)
265
+ }
266
+
267
+ const paths = await gbOSOpenProductPaths(opts.inputPath)
268
+ const codePointFiles = await productFiles(paths.codePointOpen, "Data/CSV/*.csv")
269
+ const roadFiles = await productFiles(paths.openRoads, "**/*.gml")
270
+ const nameFiles = await productFiles(paths.openNames, "Data/*.csv")
271
+
272
+ const summary: GBOSOpenSummary = {
273
+ codePointRows: 0,
274
+ codePointMalformed: 0,
275
+ pqi: {},
276
+ postcodesWithCoordinate: 0,
277
+ joined: 0,
278
+ dropped: {
279
+ noCoordinate: 0,
280
+ sharedCoordinate: 0,
281
+ noLinkWithinRadius: 0,
282
+ noOpenNamesRow: 0,
283
+ districtMismatch: 0,
284
+ },
285
+ emitted: 0,
286
+ titleCasedStreets: 0,
287
+ noLocality: 0,
288
+ bilingualLocality: 0,
289
+ roads: createOpenRoadsReadStats(),
290
+ names: createOpenNamesReadStats(),
291
+ }
292
+
293
+ // Code-Point: every postcode with a coordinate, and how many postcodes share each coordinate.
294
+ const codePointStats = createCodePointParseStats()
295
+ const postcodes: string[] = []
296
+ const xs: number[] = []
297
+ const ys: number[] = []
298
+ const pointCounts = new Map<string, number>()
299
+
300
+ for (const file of codePointFiles) {
301
+ for await (const record of readCodePointCSV(file, codePointStats)) {
302
+ postcodes.push(record.postcode)
303
+ xs.push(record.easting)
304
+ ys.push(record.northing)
305
+
306
+ const key = `${record.easting},${record.northing}`
307
+
308
+ pointCounts.set(key, (pointCounts.get(key) ?? 0) + 1)
309
+ summary.pqi[record.quality] = (summary.pqi[record.quality] ?? 0) + 1
310
+ }
311
+ }
312
+
313
+ summary.codePointRows = codePointStats.read
314
+ summary.codePointMalformed = codePointStats.skippedMalformed
315
+ summary.dropped.noCoordinate = codePointStats.skippedNoCoordinate
316
+ summary.postcodesWithCoordinate = postcodes.length
317
+
318
+ if (codePointStats.skippedNoCoordinate > 0) {
319
+ summary.pqi[PQI_NO_COORDINATE] = codePointStats.skippedNoCoordinate
320
+ }
321
+
322
+ // Open Roads: stream every named link past the postcodes once.
323
+ const join = new NearestLinkJoin<JoinedLink>(
324
+ { xs: Float64Array.from(xs), ys: Float64Array.from(ys) },
325
+ GB_OS_OPEN_JOIN_RADIUS_M
326
+ )
327
+
328
+ for (const file of roadFiles) {
329
+ for await (const link of readOpenRoadsLinks(file, summary.roads, { signal: opts.signal })) {
330
+ join.offer({ roadNameTOID: link.roadNameTOID }, link.vertices)
331
+ }
332
+ }
333
+
334
+ const wanted = new Set<string>()
335
+
336
+ for (let i = 0; i < postcodes.length; i++) {
337
+ const match = join.match(i)
338
+
339
+ if (!match) continue
340
+
341
+ summary.joined++
342
+ wanted.add(match.link.roadNameTOID)
343
+ }
344
+
345
+ // Open Names: only the roads the join chose.
346
+ const roads = new Map<string, OpenNamesRoad>()
347
+
348
+ for (const file of nameFiles) {
349
+ for await (const road of readOpenNamesRoads(file, summary.names, wanted)) {
350
+ roads.set(road.id, road)
351
+ }
352
+ }
353
+
354
+ try {
355
+ for (let i = 0; i < postcodes.length; i++) {
356
+ if (opts.signal?.aborted) break
357
+
358
+ if (opts.limit !== undefined && summary.emitted >= opts.limit) break
359
+
360
+ const postcode = postcodes[i]!
361
+
362
+ if ((pointCounts.get(`${xs[i]},${ys[i]}`) ?? 0) > 1) {
363
+ summary.dropped.sharedCoordinate++
364
+
365
+ continue
366
+ }
367
+
368
+ const match = join.match(i)
369
+
370
+ if (!match) {
371
+ summary.dropped.noLinkWithinRadius++
372
+
373
+ continue
374
+ }
375
+
376
+ const road = roads.get(match.link.roadNameTOID)
377
+
378
+ if (!road) {
379
+ summary.dropped.noOpenNamesRow++
380
+
381
+ continue
382
+ }
383
+
384
+ if (road.postcodeDistrict !== outwardCode(postcode)) {
385
+ summary.dropped.districtMismatch++
386
+
387
+ continue
388
+ }
389
+
390
+ const { street, titleCased } = streetName(road.name1)
391
+ const locality = populatedPlaceName1(road.populatedPlace)
392
+
393
+ const sourceID = stableSourceIDFromParts(GB_OS_OPEN_ADAPTER_ID, {
394
+ postcode,
395
+ roadNameTOID: road.id,
396
+ })
397
+
398
+ const components: CanonicalRow["components"] = {
399
+ house_number: drawGBHouseNumber(sourceID),
400
+ street,
401
+ }
402
+
403
+ if (locality) {
404
+ components.locality = locality
405
+ }
406
+
407
+ components.postcode = postcode
408
+
409
+ const rendered = formatAddressRow(components, "GB", { singleLine: true })
410
+
411
+ if (!rendered) continue
412
+
413
+ if (titleCased) {
414
+ summary.titleCasedStreets++
415
+ }
416
+
417
+ if (!locality) {
418
+ summary.noLocality++
419
+ }
420
+
421
+ if (road.populatedPlace.includes(" / ")) {
422
+ summary.bilingualLocality++
423
+ }
424
+
425
+ yield {
426
+ raw: rendered.raw,
427
+ components: rendered.components,
428
+ country: "GB",
429
+ locale: "en-GB",
430
+ source: GB_OS_OPEN_ADAPTER_ID,
431
+ source_id: sourceID,
432
+ corpus_version: "",
433
+ license: GB_OS_OPEN_LICENSE,
434
+ }
435
+
436
+ summary.emitted++
437
+ }
438
+ } finally {
439
+ options.onSummary?.(summary)
440
+
441
+ process.stderr.write(
442
+ ` ${GB_OS_OPEN_ADAPTER_ID}: ${summary.emitted} emitted of ${summary.codePointRows} Code-Point rows; ` +
443
+ `dropped ${stringifyJSON(summary.dropped)}\n`
444
+ )
445
+ }
446
+ },
447
+ }
448
+ }
449
+
450
+ /**
451
+ * The configured adapter instance registered with the corpus builder.
452
+ */
453
+ export const gbOSOpenAdapter = createGBOSOpenAdapter()
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The nearest line feature to each of a set of points, measured point-to-segment in a planar
7
+ * projection.
8
+ *
9
+ * The points sit in a bucket grid and the lines stream past it. Each segment of each line asks the
10
+ * grid for the points inside its bounding box widened by the search radius, and each of those
11
+ * points keeps the nearest line it has been offered. Memory is bounded by the point count rather
12
+ * than by the line count, so a whole country's road network can be read once from disk.
13
+ *
14
+ * Distances are Euclidean in the projection's own units. For EPSG:27700, the British National
15
+ * Grid, those are meters, and the grid's scale error stays under 0.04% across Great Britain.
16
+ */
17
+
18
+ import { planarSegmentDistanceSquared } from "@mailwoman/spatial/distance"
19
+
20
+ /**
21
+ * The multiplier that packs a cell's column and row into one numeric key.
22
+ *
23
+ * A row index must stay below it.
24
+ * With 100 m cells, 2^21 rows span 209,715 km, so a projected coordinate in meters cannot reach it.
25
+ */
26
+ const CELL_ROW_SPAN = 2 ** 21
27
+
28
+ /**
29
+ * A uniform grid of point indices, keyed by cell.
30
+ */
31
+ export class PointBucketIndex {
32
+ readonly cellSize: number
33
+
34
+ readonly #cells = new Map<number, number[]>()
35
+
36
+ constructor(cellSize: number) {
37
+ if (!(cellSize > 0)) throw new RangeError(`PointBucketIndex: cell size must be positive, got ${cellSize}`)
38
+
39
+ this.cellSize = cellSize
40
+ }
41
+
42
+ #key(column: number, row: number): number {
43
+ return column * CELL_ROW_SPAN + row
44
+ }
45
+
46
+ /**
47
+ * Files the point `index` under the cell holding `(x, y)`.
48
+ */
49
+ add(index: number, x: number, y: number): void {
50
+ const key = this.#key(Math.floor(x / this.cellSize), Math.floor(y / this.cellSize))
51
+ const bucket = this.#cells.get(key)
52
+
53
+ if (bucket) {
54
+ bucket.push(index)
55
+ } else {
56
+ this.#cells.set(key, [index])
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Every point index filed in a cell that the box touches.
62
+ *
63
+ * The answer holds every point of each touched cell, including points up to one cell outside the box.
64
+ * A caller measures each one.
65
+ */
66
+ *within(minX: number, minY: number, maxX: number, maxY: number): Generator<number> {
67
+ const firstColumn = Math.floor(minX / this.cellSize)
68
+ const lastColumn = Math.floor(maxX / this.cellSize)
69
+ const firstRow = Math.floor(minY / this.cellSize)
70
+ const lastRow = Math.floor(maxY / this.cellSize)
71
+
72
+ for (let column = firstColumn; column <= lastColumn; column++) {
73
+ for (let row = firstRow; row <= lastRow; row++) {
74
+ const bucket = this.#cells.get(this.#key(column, row))
75
+
76
+ if (bucket) {
77
+ yield* bucket
78
+ }
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The line a point was joined to, and how far away it lies.
86
+ */
87
+ export interface NearestLinkMatch<T> {
88
+ link: T
89
+ /**
90
+ * The point-to-segment distance in projection units.
91
+ */
92
+ distance: number
93
+ }
94
+
95
+ /**
96
+ * Joins each point to the nearest line offered to it within `radius`.
97
+ *
98
+ * A line offered at exactly the same distance as the current nearest does not replace it,
99
+ * so the first-offered line wins a tie and the answer follows the order the lines are read in.
100
+ */
101
+ export class NearestLinkJoin<T> {
102
+ readonly radius: number
103
+
104
+ readonly #xs: Float64Array
105
+ readonly #ys: Float64Array
106
+ readonly #bestSquared: Float64Array
107
+ readonly #best: (T | undefined)[]
108
+ readonly #index: PointBucketIndex
109
+
110
+ /**
111
+ * @param points The points to join, as parallel coordinate arrays.
112
+ * A point's position in the arrays is its index in every answer.
113
+ * @param radius The largest distance at which a line joins, in projection units.
114
+ */
115
+ constructor(points: { xs: Float64Array; ys: Float64Array }, radius: number) {
116
+ if (points.xs.length !== points.ys.length) {
117
+ throw new RangeError(`NearestLinkJoin: ${points.xs.length} x values against ${points.ys.length} y values`)
118
+ }
119
+
120
+ this.radius = radius
121
+ this.#xs = points.xs
122
+ this.#ys = points.ys
123
+ this.#bestSquared = new Float64Array(points.xs.length).fill(Number.POSITIVE_INFINITY)
124
+ this.#best = Array.from({ length: points.xs.length })
125
+ this.#index = new PointBucketIndex(radius)
126
+
127
+ for (let i = 0; i < points.xs.length; i++) {
128
+ this.#index.add(i, points.xs[i]!, points.ys[i]!)
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Offers one line, as a flat `[x0, y0, x1, y1, …]` vertex array, to every point within reach of it.
134
+ */
135
+ offer(link: T, vertices: ArrayLike<number>): void {
136
+ const radius = this.radius
137
+ const radiusSquared = radius * radius
138
+
139
+ for (let v = 0; v + 3 < vertices.length; v += 2) {
140
+ const ax = vertices[v]!
141
+ const ay = vertices[v + 1]!
142
+ const bx = vertices[v + 2]!
143
+ const by = vertices[v + 3]!
144
+
145
+ for (const i of this.#index.within(
146
+ Math.min(ax, bx) - radius,
147
+ Math.min(ay, by) - radius,
148
+ Math.max(ax, bx) + radius,
149
+ Math.max(ay, by) + radius
150
+ )) {
151
+ const squared = planarSegmentDistanceSquared(this.#xs[i]!, this.#ys[i]!, ax, ay, bx, by)
152
+
153
+ if (squared <= radiusSquared && squared < this.#bestSquared[i]!) {
154
+ this.#bestSquared[i] = squared
155
+ this.#best[i] = link
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * The nearest line offered to point `index`, or null when none came within the radius.
163
+ */
164
+ match(index: number): NearestLinkMatch<T> | null {
165
+ const link = this.#best[index]
166
+
167
+ return link === undefined ? null : { link, distance: Math.sqrt(this.#bestSquared[index]!) }
168
+ }
169
+ }