@osmix/shared 0.0.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @osmix/shared
2
+
3
+ ## 0.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Initial release
package/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @osmix/shared
2
+
3
+ `@osmix/shared` exposes small geometry helpers that packages across the Osmix workspace rely on.
4
+
5
+ ## Highlights
6
+
7
+ - `haversineDistance` – Compute great-circle distances between lon/lat pairs in meters.
8
+ - `clipPolyline` – Re-export of [`lineclip`](https://github.com/mourner/lineclip) for clipping projected polylines to axis-aligned bounding boxes with types added.
9
+
10
+ ## Installation
11
+
12
+ ```sh
13
+ npm install @osmix/shared
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```ts
19
+ import { haversineDistance, clipPolyline } from "@osmix/shared"
20
+
21
+ const meters = haversineDistance([-122.33, 47.61], [-122.30, 47.63])
22
+
23
+ const segments = clipPolyline(
24
+ [
25
+ [-122.33, 47.61],
26
+ [-122.31, 47.62],
27
+ ],
28
+ [-122.40, 47.50, -122.20, 47.70],
29
+ )
30
+ ```
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package",
3
+ "name": "@osmix/shared",
4
+ "type": "module",
5
+ "version": "0.0.1",
6
+ "description": "Shared utilities for Osmix",
7
+ "exports": {
8
+ "./*": "./src/*.ts",
9
+ "./tsconfig/base.json": "./tsconfig/base.json",
10
+ "./tsconfig/test.json": "./tsconfig/test.json",
11
+ "./tsconfig/app.json": "./tsconfig/app.json",
12
+ "./test/*": "./src/test/*.ts"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public",
16
+ "exports": {
17
+ "./*": {
18
+ "import": "./dist/*.js",
19
+ "types": "./dist/*.d.ts"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md",
25
+ "LICENSE"
26
+ ]
27
+ },
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/conveyal/osmix.git"
32
+ },
33
+ "homepage": "https://github.com/conveyal/osmix#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/conveyal/osmix/issues"
36
+ },
37
+ "sideEffects": false,
38
+ "scripts": {
39
+ "build": "tsc",
40
+ "bench": "vitest bench",
41
+ "test": "vitest",
42
+ "typecheck": "tsc --noEmit"
43
+ },
44
+ "devDependencies": {
45
+ "typescript": "catalog:",
46
+ "vitest": "catalog:"
47
+ },
48
+ "dependencies": {
49
+ "@mapbox/sphericalmercator": "catalog:",
50
+ "lineclip": "^2.0.0"
51
+ }
52
+ }
package/src/assert.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Short utility for checking index access.
3
+ */
4
+ export function assertValue<T>(
5
+ value?: T,
6
+ message?: string,
7
+ ): asserts value is NonNullable<T> {
8
+ if (value === undefined || value === null) {
9
+ throw Error(message ?? "Value is undefined or null")
10
+ }
11
+ }
@@ -0,0 +1,8 @@
1
+ export function bytesToStream(bytes: Uint8Array<ArrayBuffer>) {
2
+ return new ReadableStream<Uint8Array<ArrayBuffer>>({
3
+ start(controller) {
4
+ controller.enqueue(bytes)
5
+ controller.close()
6
+ },
7
+ })
8
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Concatenates multiple `Uint8Array` segments into a contiguous array.
3
+ */
4
+ export function concatBytes(
5
+ parts: Uint8Array<ArrayBuffer>[],
6
+ ): Uint8Array<ArrayBuffer> {
7
+ const total = parts.reduce((n, p) => n + p.length, 0)
8
+ const out = new Uint8Array<ArrayBuffer>(new ArrayBuffer(total))
9
+ let offset = 0
10
+ for (const p of parts) {
11
+ out.set(p, offset)
12
+ offset += p.length
13
+ }
14
+ return out
15
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Calculate the haversine distance between two LonLat points.
3
+ * @param p1 - The first point
4
+ * @param p2 - The second point
5
+ * @returns The haversine distance in meters
6
+ */
7
+ export function haversineDistance(
8
+ p1: [number, number],
9
+ p2: [number, number],
10
+ ): number {
11
+ const R = 6371008.8 // Earth's radius in meters
12
+ const dLat = (p2[1] - p1[1]) * (Math.PI / 180)
13
+ const dLon = (p2[0] - p1[0]) * (Math.PI / 180)
14
+ const lat1 = p1[1] * (Math.PI / 180)
15
+ const lat2 = p2[1] * (Math.PI / 180)
16
+ const a =
17
+ Math.sin(dLat / 2) ** 2 +
18
+ Math.sin(dLon / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2)
19
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
20
+ return R * c
21
+ }
@@ -0,0 +1,11 @@
1
+ declare module "lineclip" {
2
+ export function clipPolyline(
3
+ points: [number, number][],
4
+ bbox: [number, number, number, number],
5
+ ): [number, number][][]
6
+
7
+ export function clipPolygon(
8
+ points: [number, number][],
9
+ bbox: [number, number, number, number],
10
+ ): [number, number][]
11
+ }
@@ -0,0 +1,2 @@
1
+ /// <reference path="./lineclip.d.ts" />
2
+ export { clipPolygon, clipPolyline } from "lineclip"
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import SphericalMercatorTile from "./spherical-mercator"
3
+ import type { Tile } from "./types"
4
+
5
+ function lonLatForPixel(
6
+ merc: SphericalMercatorTile,
7
+ tileIndex: Tile,
8
+ tileSize: number,
9
+ px: number,
10
+ py: number,
11
+ ): [number, number] {
12
+ const [x, y, z] = tileIndex
13
+ return merc.ll([x * tileSize + px, y * tileSize + py], z) as [number, number]
14
+ }
15
+
16
+ describe("SphericalMercatorTile", () => {
17
+ it("projects lon/lat to tile-local pixels", () => {
18
+ const tile: Tile = [300, 300, 10]
19
+ const [tx, ty, tz] = tile
20
+ const tileSize = 256
21
+ const merc = new SphericalMercatorTile({ size: tileSize, tile })
22
+
23
+ const insideLonLat = lonLatForPixel(merc, tile, tileSize, 32, 16)
24
+ expect(merc.llToTilePx(insideLonLat)).toEqual([32, 16])
25
+
26
+ const outsideTopLeft = merc.ll(
27
+ [tx * tileSize - 10, ty * tileSize - 10],
28
+ tz,
29
+ ) as [number, number]
30
+ expect(outsideTopLeft).toEqual([-74.54498291015625, 59.54128017205441])
31
+ expect(merc.llToTilePx(outsideTopLeft)).toEqual([-10, -10])
32
+
33
+ const outsideBottomRight = merc.ll(
34
+ [(tx + 1) * tileSize + 10, (ty + 1) * tileSize + 10],
35
+ tz,
36
+ ) as [number, number]
37
+ expect(merc.llToTilePx(outsideBottomRight, tile)).toEqual([
38
+ tileSize + 10,
39
+ tileSize + 10,
40
+ ])
41
+ })
42
+ })
@@ -0,0 +1,42 @@
1
+ import { SphericalMercator } from "@mapbox/sphericalmercator"
2
+ import type { GeoBbox2D, LonLat, Tile, XY } from "./types"
3
+
4
+ /**
5
+ * Extends the SphericalMercator class to provide tile-local pixel coordinate calculations and clamping.
6
+ */
7
+ export default class SphericalMercatorTile extends SphericalMercator {
8
+ tileSize: number
9
+ tile?: Tile
10
+ constructor(
11
+ options: ConstructorParameters<typeof SphericalMercator>[0] & {
12
+ tile?: Tile
13
+ },
14
+ ) {
15
+ super(options)
16
+ this.tile = options?.tile
17
+ this.tileSize = options?.size ?? 256
18
+ }
19
+
20
+ llToTilePx(ll: LonLat, tile?: Tile): XY {
21
+ if (tile == null && this.tile == null)
22
+ throw Error("Tile must be set on construction or passed as an argument.")
23
+ const [tx, ty, tz] = (tile ?? this.tile)!
24
+ const merc = this.px(ll, tz)
25
+ const x = merc[0] - tx * this.tileSize
26
+ const y = merc[1] - ty * this.tileSize
27
+ return [x, y]
28
+ }
29
+
30
+ clampAndRoundPx(px: XY, bbox?: GeoBbox2D): XY {
31
+ const [minX, minY, maxX, maxY] = bbox ?? [
32
+ 0,
33
+ 0,
34
+ this.tileSize,
35
+ this.tileSize,
36
+ ]
37
+ return [
38
+ Math.max(minX, Math.min(maxX, Math.round(px[0]))),
39
+ Math.max(minY, Math.min(maxY, Math.round(px[1]))),
40
+ ]
41
+ }
42
+ }
@@ -0,0 +1,20 @@
1
+ import { concatBytes } from "./concat-bytes"
2
+
3
+ export async function streamToBytes(
4
+ stream: ReadableStream<Uint8Array<ArrayBuffer>>,
5
+ ): Promise<Uint8Array<ArrayBuffer>> {
6
+ const reader = stream.getReader()
7
+ const chunks: Uint8Array<ArrayBuffer>[] = []
8
+
9
+ while (true) {
10
+ const { done, value } = await reader.read()
11
+
12
+ if (done) {
13
+ break
14
+ }
15
+
16
+ chunks.push(value)
17
+ }
18
+
19
+ return concatBytes(chunks)
20
+ }
@@ -0,0 +1,198 @@
1
+ import { createReadStream, createWriteStream } from "node:fs"
2
+ import { readFile, writeFile } from "node:fs/promises"
3
+ import { dirname, join, resolve } from "node:path"
4
+ import { Readable, Writable } from "node:stream"
5
+ import { fileURLToPath } from "node:url"
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url))
8
+ const ROOT_DIR = resolve(__dirname, "../../../../")
9
+ const FIXTURES_DIR = resolve(ROOT_DIR, "fixtures")
10
+
11
+ export function getFixturePath(url: string) {
12
+ if (url.startsWith("http")) {
13
+ const fileName = url.split("/").pop()
14
+ if (!fileName) throw new Error("Invalid URL")
15
+ return join(FIXTURES_DIR, fileName)
16
+ }
17
+ return join(FIXTURES_DIR, url)
18
+ }
19
+
20
+ /**
21
+ * Get file from the cache folder or download it from the URL
22
+ */
23
+ export async function getFixtureFile(url: string): Promise<ArrayBuffer> {
24
+ const filePath = getFixturePath(url)
25
+ try {
26
+ const file = await readFile(filePath)
27
+ return file.buffer as ArrayBuffer
28
+ } catch (_error) {
29
+ const response = await fetch(url)
30
+ const buffer = await response.arrayBuffer()
31
+ await writeFile(filePath, new Uint8Array(buffer))
32
+ return buffer
33
+ }
34
+ }
35
+
36
+ export function getFixtureFileReadStream(url: string) {
37
+ return Readable.toWeb(
38
+ createReadStream(getFixturePath(url)),
39
+ ) as unknown as ReadableStream<ArrayBuffer>
40
+ }
41
+
42
+ export function getFixtureFileWriteStream(url: string) {
43
+ return Writable.toWeb(
44
+ createWriteStream(getFixturePath(url)),
45
+ ) as unknown as WritableStream<Uint8Array>
46
+ }
47
+
48
+ export type PbfFixture = {
49
+ url: string
50
+ bbox: {
51
+ bottom: number
52
+ top: number
53
+ left: number
54
+ right: number
55
+ }
56
+ nodesWithTags: number
57
+ nodes: number
58
+ ways: number
59
+ relations: number
60
+ node0: {
61
+ lat: number
62
+ lon: number
63
+ id: number
64
+ }
65
+ way0: number
66
+ relation0: number
67
+ uniqueStrings: number
68
+ primitiveGroups: number
69
+ }
70
+
71
+ /**
72
+ * List of PBFs and their metadata used for testing. Cached locally in the top level fixtures directory so they can
73
+ * be used across packages and apps.
74
+ *
75
+ * `monaco.pbf` is checked into the repository so it can be used in CI without causing repeated downloads.
76
+ *
77
+ * Below, we export a subset of the PBFs that we want to use for current tests.
78
+ */
79
+ const monacoPbfFixture: PbfFixture = {
80
+ url: "monaco.pbf",
81
+ bbox: {
82
+ bottom: 43.7232244,
83
+ top: 43.7543687,
84
+ left: 7.4053929,
85
+ right: 7.4447259,
86
+ },
87
+ nodesWithTags: 1_254,
88
+ nodes: 14_286,
89
+ ways: 3_346,
90
+ relations: 46,
91
+ node0: {
92
+ lat: 43.7371175,
93
+ lon: 7.4229093,
94
+ id: 21911883,
95
+ },
96
+ way0: 4097656,
97
+ relation0: 3410831,
98
+ uniqueStrings: 1_060,
99
+ primitiveGroups: 7,
100
+ }
101
+
102
+ export const AllPBFs: Record<string, PbfFixture> = {
103
+ monaco: monacoPbfFixture,
104
+ montenegro: {
105
+ url: "https://download.geofabrik.de/europe/montenegro-250101.osm.pbf",
106
+ bbox: {
107
+ bottom: 41.61621,
108
+ top: 43.562169,
109
+ left: 18.17282,
110
+ right: 20.358827,
111
+ },
112
+ nodesWithTags: 63_321,
113
+ nodes: 3_915_383,
114
+ ways: 321_330,
115
+ relations: 5_501,
116
+ node0: {
117
+ lat: 42.1982436,
118
+ lon: 18.9656482,
119
+ id: 26860768,
120
+ },
121
+ way0: 123,
122
+ relation0: 123,
123
+ uniqueStrings: 55_071,
124
+ primitiveGroups: 532,
125
+ },
126
+ croatia: {
127
+ url: "https://download.geofabrik.de/europe/croatia-250101.osm.pbf",
128
+ bbox: {
129
+ bottom: 42.16483,
130
+ top: 46.557562,
131
+ left: 13.08916,
132
+ right: 19.459968,
133
+ },
134
+ nodesWithTags: 481_613,
135
+ nodes: 23_063_621,
136
+ ways: 2_315_247,
137
+ relations: 39_098,
138
+ primitiveGroups: 3_178,
139
+ node0: {
140
+ lat: 42.9738772,
141
+ lon: 17.021989,
142
+ id: 4_511_653,
143
+ },
144
+ way0: 123,
145
+ relation0: 123,
146
+ uniqueStrings: 269_315,
147
+ },
148
+ italy: {
149
+ url: "https://download.geofabrik.de/europe/italy-250101.osm.pbf",
150
+ bbox: {
151
+ bottom: 35.07638,
152
+ left: 6.602696,
153
+ right: 19.12499,
154
+ top: 47.100045,
155
+ },
156
+ nodesWithTags: 1_513_303,
157
+ nodes: 250_818_620,
158
+ ways: 27_837_987,
159
+ relations: 100_000,
160
+ primitiveGroups: 34_901,
161
+ node0: {
162
+ lat: 41.9033,
163
+ lon: 12.4534,
164
+ id: 1,
165
+ },
166
+ way0: 123,
167
+ relation0: 123,
168
+ uniqueStrings: 3190,
169
+ },
170
+ washington: {
171
+ url: "https://download.geofabrik.de/north-america/us/washington-250101.osm.pbf",
172
+ bbox: {
173
+ bottom: 45.53882,
174
+ top: 49.00708,
175
+ left: -126.7423,
176
+ right: -116.911526,
177
+ },
178
+ nodesWithTags: 1_513_303,
179
+ nodes: 43_032_447,
180
+ ways: 4_541_651,
181
+ relations: 44_373,
182
+ node0: {
183
+ lat: 47.64248,
184
+ lon: -122.3196898,
185
+ id: 29445653,
186
+ },
187
+ way0: 123,
188
+ relation0: 123,
189
+ uniqueStrings: 598_993,
190
+ primitiveGroups: 34_901,
191
+ },
192
+ } as const
193
+
194
+ /**
195
+ * A subset of the PBFs that we want to use for current tests. Do not check in changes to this list as it will cause CI to
196
+ * attempt to download PBFs that are not checked into the repository.
197
+ */
198
+ export const PBFs: Record<string, PbfFixture> = { monaco: monacoPbfFixture }
@@ -0,0 +1,12 @@
1
+ import { bytesToStream } from "./bytes-to-stream"
2
+ import { streamToBytes } from "./stream-to-bytes"
3
+
4
+ export async function transformBytes(
5
+ bytes: Uint8Array<ArrayBuffer>,
6
+ transformStream: TransformStream<
7
+ Uint8Array<ArrayBuffer>,
8
+ Uint8Array<ArrayBuffer>
9
+ >,
10
+ ): Promise<Uint8Array<ArrayBuffer>> {
11
+ return streamToBytes(bytesToStream(bytes).pipeThrough(transformStream))
12
+ }
package/src/types.ts ADDED
@@ -0,0 +1,27 @@
1
+ export type LonLat = [lon: number, lat: number]
2
+ export type XY = [x: number, y: number]
3
+ export interface ILonLat {
4
+ lon: number
5
+ lat: number
6
+ }
7
+ export type Tile = [x: number, y: number, z: number]
8
+
9
+ /**
10
+ * Project LonLat to pixels
11
+ */
12
+ export type LonLatToPixel = (ll: LonLat, zoom: number) => XY
13
+
14
+ export type LonLatToTilePixel = (ll: LonLat, z: number, extent: number) => XY
15
+
16
+ export type Rgba = [number, number, number, number] | Uint8ClampedArray
17
+
18
+ /**
19
+ * A bounding box in the format [minLon, minLat, maxLon, maxLat].
20
+ * GeoJSON.BBox allows for 3D bounding boxes, but we use tools that expect 2D bounding boxes.
21
+ */
22
+ export type GeoBbox2D = [
23
+ minLon: number,
24
+ minLat: number,
25
+ maxLon: number,
26
+ maxLat: number,
27
+ ]
@@ -0,0 +1,8 @@
1
+ import { assert, test } from "vitest"
2
+ import { haversineDistance } from "../src/haversine-distance"
3
+
4
+ test("haversineDistance", () => {
5
+ const p1: [number, number] = [-75.343, 39.984]
6
+ const p2: [number, number] = [-75.534, 39.123]
7
+ assert.closeTo(haversineDistance(p1, p2), 97129.2211, 0.0001)
8
+ })
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "./base.json",
4
+ "compilerOptions": {
5
+ "noEmit": true,
6
+ "lib": [
7
+ "ESNext",
8
+ "ESNext.AsyncIterable",
9
+ "ESNext.Array",
10
+ "ESNext.Intl",
11
+ "ESNext.Symbol",
12
+ "DOM",
13
+ "DOM.Iterable"
14
+ ],
15
+ "target": "es2024",
16
+ "jsx": "react-jsx",
17
+ "allowImportingTsExtensions": true,
18
+
19
+ "noUnusedLocals": false,
20
+ "noUnusedParameters": false,
21
+ "noPropertyAccessFromIndexSignature": false,
22
+ "noUncheckedIndexedAccess": false
23
+ }
24
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "display": "@osmix/tsconfig/base",
4
+ "compilerOptions": {
5
+ "declaration": true,
6
+ "declarationMap": true,
7
+ "sourceMap": true,
8
+ "noEmitOnError": true,
9
+
10
+ "lib": ["ESNext"],
11
+ "target": "es2024",
12
+ "module": "preserve",
13
+ "moduleDetection": "force",
14
+
15
+ "moduleResolution": "bundler",
16
+ "verbatimModuleSyntax": true,
17
+ "isolatedModules": true,
18
+ "erasableSyntaxOnly": true,
19
+ "skipLibCheck": true,
20
+
21
+ "strict": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noUncheckedIndexedAccess": true,
24
+ "noUnusedLocals": true,
25
+ "noUnusedParameters": true,
26
+ "noPropertyAccessFromIndexSignature": true
27
+ }
28
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "display": "@osmix/tsconfig/test",
4
+ "extends": "./base.json",
5
+ "compilerOptions": {
6
+ "noEmit": true,
7
+
8
+ "noUncheckedIndexedAccess": false,
9
+ "noUnusedLocals": false,
10
+ "noUnusedParameters": false,
11
+ "noPropertyAccessFromIndexSignature": false
12
+ }
13
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "./tsconfig/base.json",
4
+ "include": ["src", "test"],
5
+ "exclude": ["node_modules", "dist"],
6
+ "compilerOptions": {
7
+ "outDir": "./dist"
8
+ }
9
+ }