@craft-native/ts-maps 0.0.37
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/LICENSE.md +21 -0
- package/README.md +76 -0
- package/package.json +61 -0
- package/src/FilesystemTileBackend.ts +243 -0
- package/src/MapView.ts +428 -0
- package/src/adapters.ts +221 -0
- package/src/bridge-protocol.ts +75 -0
- package/src/capabilities.ts +99 -0
- package/src/index.test.ts +109 -0
- package/src/index.ts +109 -0
- package/src/offlineRegion.ts +93 -0
- package/src/types.ts +237 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Stacks.js
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# @craft-native/ts-maps
|
|
2
|
+
|
|
3
|
+
Craft SDK bindings for the [ts-maps](https://github.com/stacksjs/ts-maps) interactive map runtime.
|
|
4
|
+
|
|
5
|
+
This package is the TypeScript side of `MapProvider.ts_maps` in Craft's Zig core. It ships a `MapView` component you can drop into any Craft app (desktop, mobile, WebView) to get a cross-platform interactive map with zero API keys.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @craft-native/ts-maps
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createMapView, defaultMapConfiguration, tsMapsProvider } from '@craft-native/ts-maps'
|
|
17
|
+
|
|
18
|
+
const view = createMapView({
|
|
19
|
+
configuration: { ...defaultMapConfiguration, provider: tsMapsProvider },
|
|
20
|
+
initialCamera: {
|
|
21
|
+
center: { latitude: 37.7749, longitude: -122.4194 },
|
|
22
|
+
zoom: 12,
|
|
23
|
+
pitch: 0,
|
|
24
|
+
heading: 0,
|
|
25
|
+
},
|
|
26
|
+
markers: [
|
|
27
|
+
{
|
|
28
|
+
id: 1,
|
|
29
|
+
coordinate: { latitude: 37.7749, longitude: -122.4194 },
|
|
30
|
+
title: 'San Francisco',
|
|
31
|
+
subtitle: null,
|
|
32
|
+
color: 'red',
|
|
33
|
+
is_draggable: false,
|
|
34
|
+
is_selected: false,
|
|
35
|
+
custom_icon: null,
|
|
36
|
+
anchor_x: 0.5,
|
|
37
|
+
anchor_y: 1,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
onTap: (coord) => console.log('tapped', coord),
|
|
41
|
+
onMarkerTap: (id, coord) => console.log('marker', id, coord),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
document.body.appendChild(view.container)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## API overview
|
|
48
|
+
|
|
49
|
+
| Export | What it is |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `createMapView(props)` | Factory returning a `MapViewInstance` with `id`, `container`, `map`, and imperative methods (`setCamera`, `addMarker`, `fitToMarkers`, …). |
|
|
52
|
+
| `MapViewProps` / `MapViewInstance` | Types for the component. |
|
|
53
|
+
| `Coordinate`, `MapRegion`, `MapCamera`, `MapMarker`, … | TypeScript mirrors of the structs in `packages/zig/src/maps.zig`. Field names match exactly so JSON round-trips are free. |
|
|
54
|
+
| `craftCoordToLatLng`, `latLngToCraftCoord`, `craftRegionToBounds`, `craftCameraToOptions`, `craftMarkerToMarker`, … | Pure adapter functions between Craft and ts-maps types. |
|
|
55
|
+
| `tsMapsProvider` | Constant `'ts_maps'` that matches the Zig enum tag. |
|
|
56
|
+
| Everything from `ts-maps` | `TsMap`, `LatLng`, `Marker`, `Polyline`, `marker()`, `tileLayer()`, etc. are re-exported so you only need one import. |
|
|
57
|
+
|
|
58
|
+
## Bridge protocol
|
|
59
|
+
|
|
60
|
+
When running inside a Craft WebView, `createMapView` registers itself with `getBridge()` and listens for the methods declared in `./bridge-protocol`:
|
|
61
|
+
|
|
62
|
+
- `setCamera`, `setRegion`, `addMarker`, `removeMarker`, `addPolyline`, `addPolygon`, `addCircle`, `fitToMarkers`, `selectMarker`, `deselectAll`, `setConfiguration`.
|
|
63
|
+
|
|
64
|
+
It emits these events back:
|
|
65
|
+
|
|
66
|
+
- `map:tap`, `map:markerTap`, `map:regionChanged`, `map:cameraChanged`.
|
|
67
|
+
|
|
68
|
+
If no bridge is available (browser-only, tests, etc.) the component silently falls back to local-only behavior — event callbacks passed via props still fire.
|
|
69
|
+
|
|
70
|
+
## Development
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
bun run typecheck
|
|
74
|
+
bun test
|
|
75
|
+
bunx --bun pickier .
|
|
76
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@craft-native/ts-maps",
|
|
3
|
+
"version": "0.0.37",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Craft SDK bindings for the ts-maps interactive map runtime. Provides a cross-platform MapView component backed by the MapProvider.ts_maps variant in the Zig core.",
|
|
7
|
+
"author": "Chris Breuer <chris@stacksjs.org>",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"homepage": "https://github.com/home-lang/craft#readme",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/home-lang/craft.git",
|
|
13
|
+
"directory": "packages/ts-maps"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"craft",
|
|
17
|
+
"ts-maps",
|
|
18
|
+
"maps",
|
|
19
|
+
"webview",
|
|
20
|
+
"cross-platform",
|
|
21
|
+
"native"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./adapters": {
|
|
29
|
+
"types": "./dist/adapters.d.ts",
|
|
30
|
+
"import": "./dist/adapters.js"
|
|
31
|
+
},
|
|
32
|
+
"./types": {
|
|
33
|
+
"types": "./dist/types.d.ts",
|
|
34
|
+
"import": "./dist/types.js"
|
|
35
|
+
},
|
|
36
|
+
"./bridge-protocol": {
|
|
37
|
+
"types": "./dist/bridge-protocol.d.ts",
|
|
38
|
+
"import": "./dist/bridge-protocol.js"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"module": "./dist/index.js",
|
|
42
|
+
"types": "./dist/index.d.ts",
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"src",
|
|
46
|
+
"README.md"
|
|
47
|
+
],
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "bun -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && bun build src/index.ts src/adapters.ts src/types.ts src/bridge-protocol.ts --outdir dist --format esm --target browser && bun run build:types",
|
|
50
|
+
"build:types": "bun --bun tsc -p tsconfig.build.json && bun scripts/finalize-types.ts",
|
|
51
|
+
"lint": "bunx --bun pickier lint . --config ../../pickier.config.ts --max-warnings 9999",
|
|
52
|
+
"lint:fix": "bunx --bun pickier lint . --fix --config ../../pickier.config.ts --max-warnings 9999",
|
|
53
|
+
"test": "bun test",
|
|
54
|
+
"typecheck": "bun --bun tsc --noEmit"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/bun": "^1.2.0",
|
|
58
|
+
"ts-maps": "^0.2.7",
|
|
59
|
+
"very-happy-dom": "^0.1.4"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem-backed tile persistence for `ts-maps` `TileCache`.
|
|
3
|
+
*
|
|
4
|
+
* Wraps Craft's `fs` bridge (which transparently falls back to `node:fs`
|
|
5
|
+
* when running outside a Craft WebView) so that tile bytes for an
|
|
6
|
+
* offline region are written to an app-sandboxed directory rather than
|
|
7
|
+
* IndexedDB. Returned objects plug straight into `TileCache` via its
|
|
8
|
+
* `backend` option.
|
|
9
|
+
*
|
|
10
|
+
* Each cached tile is serialised as a single `.tsm` file containing:
|
|
11
|
+
*
|
|
12
|
+
* magic 4 bytes "TSMT"
|
|
13
|
+
* version 1 byte 0x01
|
|
14
|
+
* keyLen 4 bytes little-endian uint32
|
|
15
|
+
* mimeLen 4 bytes little-endian uint32
|
|
16
|
+
* reservedLen 4 bytes little-endian uint32 (0)
|
|
17
|
+
* addedAt 8 bytes little-endian float64 (ms epoch)
|
|
18
|
+
* reserved 8 bytes little-endian float64 (0)
|
|
19
|
+
* dataLen 4 bytes little-endian uint32
|
|
20
|
+
* key keyLen bytes UTF-8
|
|
21
|
+
* mime mimeLen bytes UTF-8
|
|
22
|
+
* data dataLen bytes payload
|
|
23
|
+
*
|
|
24
|
+
* Filenames are `<hash>.tsm` where `<hash>` is a 64-bit FNV-1a of the
|
|
25
|
+
* URL (two 32-bit passes with different seeds) rendered in base36. That
|
|
26
|
+
* keeps filenames short, URL-safe, and collision-resistant enough for
|
|
27
|
+
* the tile counts an offline region realistically produces.
|
|
28
|
+
*
|
|
29
|
+
* @module @craft-native/ts-maps/FilesystemTileBackend
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { Tile, TileCacheBackend } from 'ts-maps'
|
|
33
|
+
// Import the fs helpers directly from their source module rather than from
|
|
34
|
+
// the `craft-native` package root, matching how `MapView` talks to
|
|
35
|
+
// the bridge — keeps the type graph narrow.
|
|
36
|
+
import { fs, readBinaryFile, writeBinaryFile } from '../../typescript/src/api/fs'
|
|
37
|
+
|
|
38
|
+
export interface FilesystemBackendOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Directory where tile files are stored. Created on first write if it
|
|
41
|
+
* doesn't exist. On Craft apps this is typically something like
|
|
42
|
+
* `app.dataDir + '/tiles'` — the caller resolves the platform-specific
|
|
43
|
+
* path and passes it in.
|
|
44
|
+
*/
|
|
45
|
+
baseDir: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const MAGIC = [0x54, 0x53, 0x4D, 0x54] // "TSMT"
|
|
49
|
+
const VERSION = 0x01
|
|
50
|
+
const FILE_SUFFIX = '.tsm'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Construct a {@link TileCacheBackend} that persists tiles under `baseDir`.
|
|
54
|
+
* Pass the returned object to `new TileCache({ backend })` (or to the
|
|
55
|
+
* cache used by `saveOfflineRegion`).
|
|
56
|
+
*/
|
|
57
|
+
export function createFilesystemBackend(opts: FilesystemBackendOptions): TileCacheBackend {
|
|
58
|
+
const baseDir = stripTrailingSlash(opts.baseDir)
|
|
59
|
+
let ensured = false
|
|
60
|
+
|
|
61
|
+
async function ensureDir(): Promise<void> {
|
|
62
|
+
if (ensured)
|
|
63
|
+
return
|
|
64
|
+
if (!(await fs.exists(baseDir)))
|
|
65
|
+
await fs.mkdir(baseDir)
|
|
66
|
+
ensured = true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function filePath(key: string): string {
|
|
70
|
+
return `${baseDir}/${hashKey(key)}${FILE_SUFFIX}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
async get(key: string): Promise<Tile | undefined> {
|
|
75
|
+
const path = filePath(key)
|
|
76
|
+
if (!(await fs.exists(path)))
|
|
77
|
+
return undefined
|
|
78
|
+
try {
|
|
79
|
+
const bytes = await readBinaryFile(path)
|
|
80
|
+
const decoded = decodeTile(bytes)
|
|
81
|
+
// Guard against FNV collisions: if the stored key doesn't match
|
|
82
|
+
// the requested URL we treat it as a miss rather than serving the
|
|
83
|
+
// wrong payload.
|
|
84
|
+
if (decoded.key !== key)
|
|
85
|
+
return undefined
|
|
86
|
+
return decoded
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
async put(tile: Tile): Promise<void> {
|
|
94
|
+
await ensureDir()
|
|
95
|
+
const payload = encodeTile(tile)
|
|
96
|
+
await writeBinaryFile(filePath(tile.key), payload)
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async delete(key: string): Promise<void> {
|
|
100
|
+
const path = filePath(key)
|
|
101
|
+
try {
|
|
102
|
+
if (await fs.exists(path))
|
|
103
|
+
await fs.remove(path)
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// swallow: a missing tile is not an error for our caller
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
async clear(): Promise<void> {
|
|
111
|
+
try {
|
|
112
|
+
if (!(await fs.exists(baseDir)))
|
|
113
|
+
return
|
|
114
|
+
const entries = await fs.readDir(baseDir)
|
|
115
|
+
for (const name of entries) {
|
|
116
|
+
if (!name.endsWith(FILE_SUFFIX))
|
|
117
|
+
continue
|
|
118
|
+
try {
|
|
119
|
+
await fs.remove(`${baseDir}/${name}`)
|
|
120
|
+
}
|
|
121
|
+
catch { /* ignore per-entry failure */ }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch { /* ignore */ }
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async all(): Promise<Tile[]> {
|
|
128
|
+
if (!(await fs.exists(baseDir)))
|
|
129
|
+
return []
|
|
130
|
+
let entries: string[]
|
|
131
|
+
try {
|
|
132
|
+
entries = await fs.readDir(baseDir)
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return []
|
|
136
|
+
}
|
|
137
|
+
const out: Tile[] = []
|
|
138
|
+
for (const name of entries) {
|
|
139
|
+
if (!name.endsWith(FILE_SUFFIX))
|
|
140
|
+
continue
|
|
141
|
+
try {
|
|
142
|
+
const bytes = await readBinaryFile(`${baseDir}/${name}`)
|
|
143
|
+
out.push(decodeTile(bytes))
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// skip unreadable / corrupt entries
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return out
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Encoding
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
function encodeTile(tile: Tile): Uint8Array {
|
|
159
|
+
const enc = new TextEncoder()
|
|
160
|
+
const key = enc.encode(tile.key)
|
|
161
|
+
const mime = enc.encode(tile.mime)
|
|
162
|
+
const reserved = new Uint8Array()
|
|
163
|
+
|
|
164
|
+
const headerLen = 4 + 1 + 4 + 4 + 4 + 8 + 8 + 4
|
|
165
|
+
const total = headerLen + key.length + mime.length + reserved.length + tile.data.length
|
|
166
|
+
const buf = new Uint8Array(total)
|
|
167
|
+
const view = new DataView(buf.buffer)
|
|
168
|
+
|
|
169
|
+
let o = 0
|
|
170
|
+
buf[o++] = MAGIC[0]
|
|
171
|
+
buf[o++] = MAGIC[1]
|
|
172
|
+
buf[o++] = MAGIC[2]
|
|
173
|
+
buf[o++] = MAGIC[3]
|
|
174
|
+
buf[o++] = VERSION
|
|
175
|
+
view.setUint32(o, key.length, true); o += 4
|
|
176
|
+
view.setUint32(o, mime.length, true); o += 4
|
|
177
|
+
view.setUint32(o, reserved.length, true); o += 4
|
|
178
|
+
view.setFloat64(o, tile.addedAt, true); o += 8
|
|
179
|
+
view.setFloat64(o, 0, true); o += 8
|
|
180
|
+
view.setUint32(o, tile.data.length, true); o += 4
|
|
181
|
+
|
|
182
|
+
buf.set(key, o); o += key.length
|
|
183
|
+
buf.set(mime, o); o += mime.length
|
|
184
|
+
buf.set(reserved, o); o += reserved.length
|
|
185
|
+
buf.set(tile.data, o)
|
|
186
|
+
|
|
187
|
+
return buf
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function decodeTile(bytes: Uint8Array): Tile {
|
|
191
|
+
if (bytes.length < 37)
|
|
192
|
+
throw new Error('truncated tile record')
|
|
193
|
+
if (bytes[0] !== MAGIC[0] || bytes[1] !== MAGIC[1] || bytes[2] !== MAGIC[2] || bytes[3] !== MAGIC[3])
|
|
194
|
+
throw new Error('bad tile magic')
|
|
195
|
+
if (bytes[4] !== VERSION)
|
|
196
|
+
throw new Error(`unsupported tile version ${bytes[4]}`)
|
|
197
|
+
|
|
198
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
199
|
+
let o = 5
|
|
200
|
+
const keyLen = view.getUint32(o, true); o += 4
|
|
201
|
+
const mimeLen = view.getUint32(o, true); o += 4
|
|
202
|
+
const reservedLen = view.getUint32(o, true); o += 4
|
|
203
|
+
const addedAt = view.getFloat64(o, true); o += 8
|
|
204
|
+
o += 8
|
|
205
|
+
const dataLen = view.getUint32(o, true); o += 4
|
|
206
|
+
|
|
207
|
+
const dec = new TextDecoder()
|
|
208
|
+
const key = dec.decode(bytes.subarray(o, o + keyLen)); o += keyLen
|
|
209
|
+
const mime = dec.decode(bytes.subarray(o, o + mimeLen)); o += mimeLen
|
|
210
|
+
o += reservedLen
|
|
211
|
+
const data = bytes.subarray(o, o + dataLen).slice()
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
key,
|
|
215
|
+
data,
|
|
216
|
+
mime,
|
|
217
|
+
addedAt,
|
|
218
|
+
bytes: data.length,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Hash. Two FNV-1a-32 passes with different seeds — effectively a 64-bit
|
|
224
|
+
// hash. Good enough for tile-scale workloads; `get()` re-verifies the
|
|
225
|
+
// stored key against the request to catch the collision case.
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
function hashKey(key: string): string {
|
|
229
|
+
let a = 0x811C9DC5 >>> 0
|
|
230
|
+
let b = 0xCBF29CE4 >>> 0
|
|
231
|
+
for (let i = 0; i < key.length; i++) {
|
|
232
|
+
const c = key.charCodeAt(i)
|
|
233
|
+
a ^= c
|
|
234
|
+
a = Math.imul(a, 0x01000193) >>> 0
|
|
235
|
+
b ^= c
|
|
236
|
+
b = Math.imul(b, 0x00000105) >>> 0
|
|
237
|
+
}
|
|
238
|
+
return `${a.toString(36)}${b.toString(36)}`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function stripTrailingSlash(path: string): string {
|
|
242
|
+
return path.replace(/\/+$/, '')
|
|
243
|
+
}
|