@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.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Shared protocol constants and types for the bridge between the Zig
3
+ * `MapView` core and the ts-maps runtime.
4
+ *
5
+ * @module @craft-native/ts-maps/bridge-protocol
6
+ */
7
+
8
+ import type {
9
+ Coordinate,
10
+ MapCamera,
11
+ MapCircle,
12
+ MapConfiguration,
13
+ MapMarker,
14
+ MapPolygon,
15
+ MapPolyline,
16
+ MapRegion,
17
+ } from './types'
18
+
19
+ /**
20
+ * Namespace used when emitting and listening for bridge messages. Matches
21
+ * the Zig enum tag for `MapProvider.ts_maps`.
22
+ */
23
+ export const BRIDGE_NAMESPACE = 'ts_maps' as const
24
+
25
+ /** Method names the Zig core can invoke on the TypeScript side. */
26
+ export type MapBridgeMethod =
27
+ | 'setCamera'
28
+ | 'setRegion'
29
+ | 'addMarker'
30
+ | 'removeMarker'
31
+ | 'addPolyline'
32
+ | 'addPolygon'
33
+ | 'addCircle'
34
+ | 'fitToMarkers'
35
+ | 'selectMarker'
36
+ | 'deselectAll'
37
+ | 'setConfiguration'
38
+
39
+ /**
40
+ * Typed request envelope. The `method` discriminates the `params` shape.
41
+ * The union below gives per-method type narrowing.
42
+ */
43
+ export interface MapBridgeRequest {
44
+ method: MapBridgeMethod
45
+ params: Record<string, unknown>
46
+ }
47
+
48
+ /** Strongly typed request variants for each supported method. */
49
+ export type TypedMapBridgeRequest =
50
+ | { method: 'setCamera', params: { camera: MapCamera, animated?: boolean } }
51
+ | { method: 'setRegion', params: { region: MapRegion, animated?: boolean } }
52
+ | { method: 'addMarker', params: { marker: MapMarker } }
53
+ | { method: 'removeMarker', params: { id: number } }
54
+ | { method: 'addPolyline', params: { polyline: MapPolyline } }
55
+ | { method: 'addPolygon', params: { polygon: MapPolygon } }
56
+ | { method: 'addCircle', params: { circle: MapCircle } }
57
+ | { method: 'fitToMarkers', params: { padding?: number } }
58
+ | { method: 'selectMarker', params: { id: number } }
59
+ | { method: 'deselectAll', params: Record<string, never> }
60
+ | { method: 'setConfiguration', params: { configuration: MapConfiguration } }
61
+
62
+ /** Events emitted by the ts-maps runtime back to the Zig core. */
63
+ export type MapBridgeEvent =
64
+ | { type: 'tap', coordinate: Coordinate }
65
+ | { type: 'markerTap', markerId: number, coordinate: Coordinate }
66
+ | { type: 'regionChanged', region: MapRegion }
67
+ | { type: 'cameraChanged', camera: MapCamera }
68
+
69
+ /**
70
+ * Build the full event name used on the bridge (e.g. `map:tap`).
71
+ * Centralized so both sides agree on the exact string.
72
+ */
73
+ export function mapEventName(type: MapBridgeEvent['type']): string {
74
+ return `map:${type}`
75
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Runtime capability probe for Craft apps hosting `ts-maps` inside a
3
+ * WebView. The map renderer prefers WebGL2 and falls back to Canvas2D
4
+ * when it isn't available; this helper exposes the probe so the host
5
+ * app can pick the right renderer up front (and surface the decision to
6
+ * users on older devices).
7
+ *
8
+ * All checks are side-effect-free and safe to call during render: the
9
+ * probe creates a throw-away canvas, asks for the relevant context, and
10
+ * discards it.
11
+ *
12
+ * @module @craft-native/ts-maps/capabilities
13
+ */
14
+
15
+ export interface MapRendererCapabilities {
16
+ /** WebGL2 context is obtainable. */
17
+ webgl2: boolean
18
+ /** WebGL1 context is obtainable (fallback). */
19
+ webgl1: boolean
20
+ /** `OffscreenCanvas` exists on the global scope. */
21
+ offscreenCanvas: boolean
22
+ /** `navigator.hardwareConcurrency` — tile-worker parallelism hint. */
23
+ hardwareConcurrency: number
24
+ /** `window.devicePixelRatio` — retina-like displays report >1. */
25
+ devicePixelRatio: number
26
+ /** Pointer events are supported (required for the two-finger gestures). */
27
+ pointerEvents: boolean
28
+ /** Touch events are supported (used as a pointer-event fallback). */
29
+ touchEvents: boolean
30
+ /** Best renderer given the probe result. */
31
+ preferredRenderer: 'webgl' | 'canvas2d'
32
+ }
33
+
34
+ /**
35
+ * Probe the host runtime. Safe to call in any environment — when neither
36
+ * `window` nor `document` is available (Node / SSR) the probe returns a
37
+ * conservative zero-capabilities object.
38
+ */
39
+ export function probeCapabilities(): MapRendererCapabilities {
40
+ if (typeof document === 'undefined' || typeof window === 'undefined') {
41
+ return {
42
+ webgl2: false,
43
+ webgl1: false,
44
+ offscreenCanvas: false,
45
+ hardwareConcurrency: 1,
46
+ devicePixelRatio: 1,
47
+ pointerEvents: false,
48
+ touchEvents: false,
49
+ preferredRenderer: 'canvas2d',
50
+ }
51
+ }
52
+
53
+ const canvas = document.createElement('canvas')
54
+ // Narrow-scope try/catch per context kind — some browsers throw
55
+ // `SecurityError` when a context type is blocked by a permission policy.
56
+ let webgl2 = false
57
+ let webgl1 = false
58
+ try {
59
+ const gl2 = canvas.getContext('webgl2')
60
+ webgl2 = gl2 !== null && gl2 !== undefined
61
+ }
62
+ catch { /* webgl2 unavailable */ }
63
+ if (!webgl2) {
64
+ try {
65
+ const gl1 = canvas.getContext('webgl') ?? canvas.getContext('experimental-webgl')
66
+ webgl1 = gl1 !== null && gl1 !== undefined
67
+ }
68
+ catch { /* webgl1 unavailable */ }
69
+ }
70
+
71
+ const offscreenCanvas = typeof (globalThis as any).OffscreenCanvas === 'function'
72
+ const nav = (globalThis as any).navigator as Navigator | undefined
73
+ const hardwareConcurrency = Math.max(1, Math.floor(nav?.hardwareConcurrency ?? 1))
74
+ const devicePixelRatio = Math.max(1, (window as any).devicePixelRatio ?? 1)
75
+ const pointerEvents = typeof (globalThis as any).PointerEvent === 'function'
76
+ const touchEvents = 'ontouchstart' in window
77
+ || (nav !== undefined && (nav as any).maxTouchPoints > 0)
78
+
79
+ return {
80
+ webgl2,
81
+ webgl1,
82
+ offscreenCanvas,
83
+ hardwareConcurrency,
84
+ devicePixelRatio,
85
+ pointerEvents,
86
+ touchEvents,
87
+ preferredRenderer: webgl2 ? 'webgl' : 'canvas2d',
88
+ }
89
+ }
90
+
91
+ /**
92
+ * True when the probe thinks this host can run the WebGL-backed renderer
93
+ * comfortably. Callers typically use this to gate advanced features like
94
+ * fill-extrusion or hillshade on cheap older phones.
95
+ */
96
+ export function supportsAdvancedRendering(caps?: MapRendererCapabilities): boolean {
97
+ const probe = caps ?? probeCapabilities()
98
+ return probe.webgl2 && probe.hardwareConcurrency >= 2 && probe.pointerEvents
99
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Smoke tests for `@craft-native/ts-maps`. Runs under Bun's `bun:test`
3
+ * with `very-happy-dom` providing a DOM shim (see `test/preload.ts`).
4
+ */
5
+
6
+ import { describe, expect, it } from 'bun:test'
7
+ import {
8
+ cameraToCamera,
9
+ coordinateFromLatLng,
10
+ craftCoordToLatLng,
11
+ latLngFromCoordinate,
12
+ latLngToCraftCoord,
13
+ regionFromBounds,
14
+ tsMapsProvider,
15
+ } from './index'
16
+ import { LatLng } from 'ts-maps'
17
+
18
+ describe('tsMapsProvider', () => {
19
+ it('matches the Zig MapProvider.ts_maps tag', () => {
20
+ expect(tsMapsProvider).toBe('ts_maps')
21
+ })
22
+ })
23
+
24
+ describe('adapters.craftCoordToLatLng', () => {
25
+ it('produces a LatLng at the right coordinates', () => {
26
+ const ll = craftCoordToLatLng({ latitude: 40, longitude: -74 })
27
+ expect(ll).toBeInstanceOf(LatLng)
28
+ expect(ll.lat).toBe(40)
29
+ expect(ll.lng).toBe(-74)
30
+ })
31
+
32
+ it('round-trips losslessly via latLngToCraftCoord', () => {
33
+ const coord = { latitude: 37.7749, longitude: -122.4194 }
34
+ const back = latLngToCraftCoord(craftCoordToLatLng(coord))
35
+ expect(back.latitude).toBeCloseTo(coord.latitude, 10)
36
+ expect(back.longitude).toBeCloseTo(coord.longitude, 10)
37
+ })
38
+ })
39
+
40
+ describe('spec-named aliases', () => {
41
+ it('latLngFromCoordinate is an alias for craftCoordToLatLng', () => {
42
+ const ll = latLngFromCoordinate({ latitude: 10, longitude: 20 })
43
+ expect(ll.lat).toBe(10)
44
+ expect(ll.lng).toBe(20)
45
+ })
46
+
47
+ it('coordinateFromLatLng is an alias for latLngToCraftCoord', () => {
48
+ const coord = coordinateFromLatLng(new LatLng(1, 2))
49
+ expect(coord.latitude).toBe(1)
50
+ expect(coord.longitude).toBe(2)
51
+ })
52
+ })
53
+
54
+ describe('adapters.cameraToCamera', () => {
55
+ it('converts camera fields and re-maps heading -> bearing', () => {
56
+ const opts = cameraToCamera({
57
+ center: { latitude: 10, longitude: 20 },
58
+ zoom: 12,
59
+ pitch: 45,
60
+ heading: 90,
61
+ })
62
+ expect(opts.zoom).toBe(12)
63
+ expect(opts.pitch).toBe(45)
64
+ expect(opts.bearing).toBe(90)
65
+ expect(opts.center.lat).toBe(10)
66
+ expect(opts.center.lng).toBe(20)
67
+ })
68
+ })
69
+
70
+ describe('regionFromBounds', () => {
71
+ it('returns null for an empty list', () => {
72
+ expect(regionFromBounds([])).toBeNull()
73
+ })
74
+
75
+ it('centers on the midpoint and spans the delta', () => {
76
+ const region = regionFromBounds([
77
+ { latitude: 0, longitude: 0 },
78
+ { latitude: 10, longitude: 10 },
79
+ ])
80
+ expect(region).not.toBeNull()
81
+ expect(region!.center.latitude).toBe(5)
82
+ expect(region!.center.longitude).toBe(5)
83
+ expect(region!.span.latitude_delta).toBe(10)
84
+ expect(region!.span.longitude_delta).toBe(10)
85
+ })
86
+ })
87
+
88
+ describe('createMapView', () => {
89
+ // The DOM shim occasionally has loading issues (see preload.ts); if
90
+ // `document` isn't available we skip this block rather than fail.
91
+ const hasDom = typeof document !== 'undefined'
92
+ const maybe = hasDom ? it : it.skip
93
+
94
+ maybe('returns an instance with id, container, and map', async () => {
95
+ // Dynamic import keeps the module from being evaluated when the DOM
96
+ // shim couldn't register, which avoids noisy module-load errors in
97
+ // environments where `very-happy-dom` fails to bootstrap.
98
+ const { createMapView } = await import('./MapView')
99
+ const instance = createMapView()
100
+ expect(typeof instance.id).toBe('string')
101
+ // `very-happy-dom` returns a `VirtualElement` proxying `HTMLDivElement`,
102
+ // so `instanceof HTMLDivElement` fails. Feature-check the shape instead.
103
+ expect(instance.container).toBeDefined()
104
+ expect(typeof instance.container.appendChild).toBe('function')
105
+ expect(instance.container.tagName).toBe('DIV')
106
+ expect(instance.map).toBeDefined()
107
+ instance.destroy()
108
+ })
109
+ })
package/src/index.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `@craft-native/ts-maps` — SDK binding that lets Craft apps render
3
+ * interactive maps via the in-house `ts-maps` runtime. Pairs with the
4
+ * `MapProvider.ts_maps` variant in `packages/zig/src/maps.zig`.
5
+ *
6
+ * Re-exports the full ts-maps public API plus Craft-native helpers
7
+ * (`createMapView`, type mirrors, adapters, bridge protocol).
8
+ *
9
+ * @module @craft-native/ts-maps
10
+ */
11
+
12
+ // Re-export the full ts-maps runtime so consumers only need to install a
13
+ // single package. Consumers can `import { TsMap, marker } from '@craft-native/ts-maps'`.
14
+ export * from 'ts-maps'
15
+
16
+ // Craft-native MapView component.
17
+ export {
18
+ createMapView,
19
+ type ComponentProps,
20
+ type MapViewInstance,
21
+ type MapViewProps,
22
+ } from './MapView'
23
+
24
+ // Type mirrors of the Zig structs.
25
+ export {
26
+ defaultMapCamera,
27
+ defaultMapConfiguration,
28
+ markerColorHex,
29
+ strokePatternDashes,
30
+ type BoundingBox,
31
+ type Coordinate,
32
+ type CoordinateSpan,
33
+ type MapCamera,
34
+ type MapCircle,
35
+ type MapConfiguration,
36
+ type MapEvent,
37
+ type MapMarker,
38
+ type MapPolygon,
39
+ type MapPolyline,
40
+ type MapProvider,
41
+ type MapRegion,
42
+ type MapType,
43
+ type MarkerColor,
44
+ type StrokePattern,
45
+ type UserTrackingMode,
46
+ } from './types'
47
+
48
+ // Adapters (pure fns) for round-tripping between Craft and ts-maps types.
49
+ export {
50
+ boundsToCraftRegion,
51
+ cameraToCamera,
52
+ craftBoundingBoxToBounds,
53
+ craftCameraToOptions,
54
+ craftCircleToCircle,
55
+ craftCoordToLatLng,
56
+ craftMarkerToMarker,
57
+ craftPolygonToPolygon,
58
+ craftPolylineToPolyline,
59
+ craftRegionToBounds,
60
+ latLngToCraftCoord,
61
+ regionFromBounds,
62
+ type TsMapsCameraOptions,
63
+ } from './adapters'
64
+
65
+ // Bridge protocol constants/types.
66
+ export {
67
+ BRIDGE_NAMESPACE,
68
+ mapEventName,
69
+ type MapBridgeEvent,
70
+ type MapBridgeMethod,
71
+ type MapBridgeRequest,
72
+ type TypedMapBridgeRequest,
73
+ } from './bridge-protocol'
74
+
75
+ // Sandbox-filesystem tile persistence + offline region helpers.
76
+ export {
77
+ createFilesystemBackend,
78
+ type FilesystemBackendOptions,
79
+ } from './FilesystemTileBackend'
80
+ export {
81
+ createFilesystemTileCache,
82
+ saveOfflineRegionToFilesystem,
83
+ type FilesystemOfflineRegionOptions,
84
+ } from './offlineRegion'
85
+
86
+ // Runtime capability probe — tells the host app which renderer the
87
+ // device can realistically drive.
88
+ export {
89
+ probeCapabilities,
90
+ supportsAdvancedRendering,
91
+ type MapRendererCapabilities,
92
+ } from './capabilities'
93
+
94
+ // createMap is the underlying ts-maps factory; re-export under its original
95
+ // name for parity with the spec which mentions `createMap` as a Craft export.
96
+ export { createMap } from 'ts-maps'
97
+
98
+ /**
99
+ * Provider tag matching `MapProvider.ts_maps` in the Zig core. Useful when
100
+ * building a `MapConfiguration` programmatically without magic strings.
101
+ */
102
+ export const tsMapsProvider = 'ts_maps' as const
103
+
104
+ /**
105
+ * Alias preserved for callers that imported the two helpers by spec name.
106
+ * `coordinateFromLatLng(LatLng) -> Coordinate`, `latLngFromCoordinate(Coordinate) -> LatLng`.
107
+ */
108
+ export { latLngToCraftCoord as coordinateFromLatLng } from './adapters'
109
+ export { craftCoordToLatLng as latLngFromCoordinate } from './adapters'
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Thin Craft-specific wrapper around ts-maps' `saveOfflineRegion` that
3
+ * writes tile bytes to the app's sandboxed filesystem via the
4
+ * {@link createFilesystemBackend} adapter.
5
+ *
6
+ * Typical usage from a Craft app:
7
+ *
8
+ * ```ts
9
+ * import { saveOfflineRegionToFilesystem } from '@craft-native/ts-maps'
10
+ * import { app } from 'craft-native'
11
+ *
12
+ * await saveOfflineRegionToFilesystem({
13
+ * baseDir: `${await app.dataDir()}/tiles`,
14
+ * tileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
15
+ * bounds: mapViewBounds,
16
+ * zoomRange: [10, 14],
17
+ * })
18
+ * ```
19
+ *
20
+ * The download runs with bounded concurrency and respects `AbortSignal`,
21
+ * so a user canceling the region pre-cache tears the job down cleanly.
22
+ *
23
+ * @module @craft-native/ts-maps/offlineRegion
24
+ */
25
+
26
+ import type {
27
+ OfflineRegionResult,
28
+ TileCacheOptions,
29
+ } from 'ts-maps'
30
+ import { saveOfflineRegion, TileCache } from 'ts-maps'
31
+ import { createFilesystemBackend } from './FilesystemTileBackend'
32
+
33
+ export type OfflineBoundsLike =
34
+ | { west: number, south: number, east: number, north: number }
35
+ | readonly [west: number, south: number, east: number, north: number]
36
+
37
+ export interface OfflineProgressEmitter {
38
+ fire: (type: string, data?: Record<string, unknown>) => unknown
39
+ }
40
+
41
+ export interface FilesystemOfflineRegionOptions {
42
+ /** Sandbox directory for tile files. Created on first write. */
43
+ baseDir: string
44
+ /** Tile URL template — `{z}/{x}/{y}` placeholders are substituted. */
45
+ tileUrl: string
46
+ bounds: OfflineBoundsLike
47
+ zoomRange: [minZ: number, maxZ: number]
48
+ concurrency?: number
49
+ signal?: AbortSignal
50
+ /** Optional overrides for the `TileCache` wrapping the filesystem backend. */
51
+ cacheOptions?: Omit<TileCacheOptions, 'backend'>
52
+ }
53
+
54
+ /**
55
+ * Pre-download every tile inside `bounds` across `zoomRange` and persist
56
+ * it to disk under `baseDir`. Returns counts of saved / cached-already /
57
+ * failed tiles.
58
+ */
59
+ export function saveOfflineRegionToFilesystem(
60
+ opts: FilesystemOfflineRegionOptions,
61
+ emitter?: OfflineProgressEmitter,
62
+ ): Promise<OfflineRegionResult> {
63
+ const cache = new TileCache({
64
+ ...opts.cacheOptions,
65
+ backend: createFilesystemBackend({ baseDir: opts.baseDir }),
66
+ })
67
+ return saveOfflineRegion(
68
+ {
69
+ bounds: opts.bounds,
70
+ zoomRange: opts.zoomRange,
71
+ tileUrl: opts.tileUrl,
72
+ concurrency: opts.concurrency,
73
+ signal: opts.signal,
74
+ cache,
75
+ },
76
+ emitter,
77
+ )
78
+ }
79
+
80
+ /**
81
+ * Build a standalone {@link TileCache} whose persistence layer is the
82
+ * Craft sandbox filesystem. Useful when a map instance needs to serve
83
+ * pre-saved tiles at runtime (not just pre-download them).
84
+ */
85
+ export function createFilesystemTileCache(
86
+ baseDir: string,
87
+ cacheOptions?: Omit<TileCacheOptions, 'backend'>,
88
+ ): TileCache {
89
+ return new TileCache({
90
+ ...cacheOptions,
91
+ backend: createFilesystemBackend({ baseDir }),
92
+ })
93
+ }