@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/src/MapView.ts
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MapView` — Craft-native interactive map component backed by the
|
|
3
|
+
* ts-maps runtime. Instances are WebView-hosted; in native Craft apps
|
|
4
|
+
* the Zig core drives them via the `ts_maps` bridge namespace.
|
|
5
|
+
*
|
|
6
|
+
* @module @craft-native/ts-maps/MapView
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Import the bridge directly from its source module rather than the craft
|
|
10
|
+
// package root, so we don't pull the entire `craft-native` surface
|
|
11
|
+
// (including unrelated stx-templated sidebar files) through the type
|
|
12
|
+
// checker. The public API of the bridge is stable, so this is fine.
|
|
13
|
+
import type { NativeBridge } from '../../typescript/src/bridge/core'
|
|
14
|
+
import { getBridge } from '../../typescript/src/bridge/core'
|
|
15
|
+
import type { TsMap } from 'ts-maps'
|
|
16
|
+
import { createMap, tileLayer } from 'ts-maps'
|
|
17
|
+
import {
|
|
18
|
+
boundsToCraftRegion,
|
|
19
|
+
craftCameraToOptions,
|
|
20
|
+
craftCircleToCircle,
|
|
21
|
+
craftMarkerToMarker,
|
|
22
|
+
craftPolygonToPolygon,
|
|
23
|
+
craftPolylineToPolyline,
|
|
24
|
+
craftRegionToBounds,
|
|
25
|
+
latLngToCraftCoord,
|
|
26
|
+
} from './adapters'
|
|
27
|
+
import type {
|
|
28
|
+
MapBridgeEvent,
|
|
29
|
+
TypedMapBridgeRequest,
|
|
30
|
+
} from './bridge-protocol'
|
|
31
|
+
import { BRIDGE_NAMESPACE, mapEventName } from './bridge-protocol'
|
|
32
|
+
import type {
|
|
33
|
+
Coordinate,
|
|
34
|
+
MapCamera,
|
|
35
|
+
MapCircle,
|
|
36
|
+
MapConfiguration,
|
|
37
|
+
MapMarker,
|
|
38
|
+
MapPolygon,
|
|
39
|
+
MapPolyline,
|
|
40
|
+
MapRegion,
|
|
41
|
+
} from './types'
|
|
42
|
+
import { defaultMapCamera, defaultMapConfiguration } from './types'
|
|
43
|
+
|
|
44
|
+
// Monotonic counter — same pattern as `components/native.ts` in
|
|
45
|
+
// `craft-native` — so multiple map views created in the same tick
|
|
46
|
+
// still get unique ids.
|
|
47
|
+
let _mapViewIdCounter = 0
|
|
48
|
+
function nextMapViewId(): string {
|
|
49
|
+
_mapViewIdCounter += 1
|
|
50
|
+
return `mapview_${Date.now()}_${_mapViewIdCounter}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Public types
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/** Subset of Craft's `ComponentProps` duplicated here to keep runtime deps minimal. */
|
|
58
|
+
export interface ComponentProps {
|
|
59
|
+
/** Explicit component id — auto-generated if omitted. */
|
|
60
|
+
id?: string
|
|
61
|
+
/** CSS class applied to the container `<div>`. */
|
|
62
|
+
className?: string
|
|
63
|
+
/** Inline styles applied to the container. */
|
|
64
|
+
style?: Partial<CSSStyleDeclaration>
|
|
65
|
+
/** Hidden state. */
|
|
66
|
+
hidden?: boolean
|
|
67
|
+
/** Accessible tooltip. */
|
|
68
|
+
tooltip?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Props accepted by {@link createMapView}. */
|
|
72
|
+
export interface MapViewProps extends ComponentProps {
|
|
73
|
+
/** Map configuration; defaults to {@link defaultMapConfiguration}. */
|
|
74
|
+
configuration?: MapConfiguration
|
|
75
|
+
/** Initial camera position; defaults to {@link defaultMapCamera}. */
|
|
76
|
+
initialCamera?: MapCamera
|
|
77
|
+
/** Markers to render on first mount. */
|
|
78
|
+
markers?: MapMarker[]
|
|
79
|
+
/** Polylines to render on first mount. */
|
|
80
|
+
polylines?: MapPolyline[]
|
|
81
|
+
/** Polygons to render on first mount. */
|
|
82
|
+
polygons?: MapPolygon[]
|
|
83
|
+
/** Circles to render on first mount. */
|
|
84
|
+
circles?: MapCircle[]
|
|
85
|
+
/** Optional tile URL template. Defaults to OSM. */
|
|
86
|
+
tileUrl?: string
|
|
87
|
+
/** Optional tile attribution string. */
|
|
88
|
+
tileAttribution?: string
|
|
89
|
+
/** Called when the user taps the map background. */
|
|
90
|
+
onTap?: (coordinate: Coordinate) => void
|
|
91
|
+
/** Called when a marker is tapped. */
|
|
92
|
+
onMarkerTap?: (markerId: number, coordinate: Coordinate) => void
|
|
93
|
+
/** Called when the visible region changes (after the user pans/zooms). */
|
|
94
|
+
onRegionChanged?: (region: MapRegion) => void
|
|
95
|
+
/** Called when the camera changes (zoom / bearing / pitch). */
|
|
96
|
+
onCameraChanged?: (camera: MapCamera) => void
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Instance returned by {@link createMapView}. */
|
|
100
|
+
export interface MapViewInstance {
|
|
101
|
+
/** Unique id — also used as the bridge component id. */
|
|
102
|
+
id: string
|
|
103
|
+
/** DOM container hosting the ts-maps canvas. */
|
|
104
|
+
container: HTMLDivElement
|
|
105
|
+
/** Underlying ts-maps instance. */
|
|
106
|
+
map: TsMap
|
|
107
|
+
/** Current configuration. */
|
|
108
|
+
configuration: MapConfiguration
|
|
109
|
+
|
|
110
|
+
/** Move the camera. */
|
|
111
|
+
setCamera: (camera: MapCamera, animated?: boolean) => void
|
|
112
|
+
/** Fit the viewport to a region. */
|
|
113
|
+
setRegion: (region: MapRegion, animated?: boolean) => void
|
|
114
|
+
/** Add a marker; returns its id. */
|
|
115
|
+
addMarker: (marker: MapMarker) => number
|
|
116
|
+
/** Remove a marker by id. */
|
|
117
|
+
removeMarker: (id: number) => boolean
|
|
118
|
+
/** Add a polyline; returns its id. */
|
|
119
|
+
addPolyline: (polyline: MapPolyline) => number
|
|
120
|
+
/** Add a polygon; returns its id. */
|
|
121
|
+
addPolygon: (polygon: MapPolygon) => number
|
|
122
|
+
/** Add a circle; returns its id. */
|
|
123
|
+
addCircle: (circle: MapCircle) => number
|
|
124
|
+
/** Fit the map to all currently-rendered markers. */
|
|
125
|
+
fitToMarkers: (paddingPercent?: number) => void
|
|
126
|
+
/** Select a marker by id. */
|
|
127
|
+
selectMarker: (id: number) => void
|
|
128
|
+
/** Deselect all markers. */
|
|
129
|
+
deselectAll: () => void
|
|
130
|
+
/** Update the configuration. */
|
|
131
|
+
setConfiguration: (configuration: MapConfiguration) => void
|
|
132
|
+
/** Detach event listeners and remove the map from the DOM. */
|
|
133
|
+
destroy: () => void
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Safe bridge accessor
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* `getBridge()` can throw in non-browser or test environments. Callers of
|
|
142
|
+
* this helper treat a missing bridge as "native integration unavailable"
|
|
143
|
+
* and fall back to pure-DOM behavior.
|
|
144
|
+
*/
|
|
145
|
+
function tryGetBridge(): NativeBridge | null {
|
|
146
|
+
try {
|
|
147
|
+
return getBridge()
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Factory
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
const DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
|
|
159
|
+
const DEFAULT_TILE_ATTRIBUTION = '© OpenStreetMap contributors'
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Create a `MapView`. The returned {@link MapViewInstance} owns a DOM
|
|
163
|
+
* container — append it to the page yourself; this factory never mounts it
|
|
164
|
+
* for you so consumers stay in control of layout.
|
|
165
|
+
*/
|
|
166
|
+
export function createMapView(props: MapViewProps = {}): MapViewInstance {
|
|
167
|
+
const id = props.id ?? nextMapViewId()
|
|
168
|
+
const configuration = { ...defaultMapConfiguration, ...props.configuration }
|
|
169
|
+
const initialCamera = props.initialCamera ?? defaultMapCamera
|
|
170
|
+
|
|
171
|
+
// Container div -------------------------------------------------------------
|
|
172
|
+
const container = document.createElement('div')
|
|
173
|
+
container.id = id
|
|
174
|
+
container.className = ['tsmap-craft-view', props.className].filter(Boolean).join(' ')
|
|
175
|
+
// ts-maps requires a non-zero size; a sensible default keeps tests sane.
|
|
176
|
+
container.style.width = '100%'
|
|
177
|
+
container.style.height = '100%'
|
|
178
|
+
if (props.style)
|
|
179
|
+
Object.assign(container.style, props.style)
|
|
180
|
+
if (props.hidden)
|
|
181
|
+
container.style.display = 'none'
|
|
182
|
+
if (props.tooltip)
|
|
183
|
+
container.title = props.tooltip
|
|
184
|
+
|
|
185
|
+
// ts-maps instance ----------------------------------------------------------
|
|
186
|
+
const cameraOptions = craftCameraToOptions(initialCamera)
|
|
187
|
+
const map = createMap(container, {
|
|
188
|
+
center: cameraOptions.center as any,
|
|
189
|
+
zoom: cameraOptions.zoom,
|
|
190
|
+
minZoom: configuration.min_zoom,
|
|
191
|
+
maxZoom: configuration.max_zoom,
|
|
192
|
+
zoomControl: configuration.is_zoom_enabled,
|
|
193
|
+
dragging: configuration.is_scroll_enabled,
|
|
194
|
+
} as any)
|
|
195
|
+
|
|
196
|
+
// Always add a tile layer so the map actually renders something.
|
|
197
|
+
tileLayer(props.tileUrl ?? DEFAULT_TILE_URL, {
|
|
198
|
+
attribution: props.tileAttribution ?? DEFAULT_TILE_ATTRIBUTION,
|
|
199
|
+
maxZoom: configuration.max_zoom,
|
|
200
|
+
} as any).addTo(map as any)
|
|
201
|
+
|
|
202
|
+
// Runtime registries — track the ts-maps objects we created so we can
|
|
203
|
+
// remove/update them by Craft id later.
|
|
204
|
+
const markers = new Map<number, ReturnType<typeof craftMarkerToMarker>>()
|
|
205
|
+
const polylines = new Map<number, ReturnType<typeof craftPolylineToPolyline>>()
|
|
206
|
+
const polygons = new Map<number, ReturnType<typeof craftPolygonToPolygon>>()
|
|
207
|
+
const circles = new Map<number, ReturnType<typeof craftCircleToCircle>>()
|
|
208
|
+
|
|
209
|
+
// Bridge wiring -------------------------------------------------------------
|
|
210
|
+
const bridge = tryGetBridge()
|
|
211
|
+
if (bridge) {
|
|
212
|
+
// Some Craft versions expose a `registerComponent`. Call it defensively
|
|
213
|
+
// so we keep working against older bridges that lack it.
|
|
214
|
+
const maybeRegister = (bridge as unknown as {
|
|
215
|
+
registerComponent?: (type: string, componentId: string) => void
|
|
216
|
+
}).registerComponent
|
|
217
|
+
if (typeof maybeRegister === 'function')
|
|
218
|
+
maybeRegister.call(bridge, 'MapView', id)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const emitBridge = (event: MapBridgeEvent): void => {
|
|
222
|
+
if (!bridge)
|
|
223
|
+
return
|
|
224
|
+
bridge.emit(mapEventName(event.type), { ...event, viewId: id, namespace: BRIDGE_NAMESPACE })
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Event plumbing ------------------------------------------------------------
|
|
228
|
+
const handleTap = (e: any): void => {
|
|
229
|
+
const coord = e?.latlng ? latLngToCraftCoord(e.latlng) : { latitude: 0, longitude: 0 }
|
|
230
|
+
props.onTap?.(coord)
|
|
231
|
+
emitBridge({ type: 'tap', coordinate: coord })
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const readCamera = (): MapCamera => {
|
|
235
|
+
const center = (map as any).getCenter?.()
|
|
236
|
+
const zoom = (map as any).getZoom?.() ?? initialCamera.zoom
|
|
237
|
+
return {
|
|
238
|
+
center: center ? latLngToCraftCoord(center) : initialCamera.center,
|
|
239
|
+
zoom,
|
|
240
|
+
pitch: initialCamera.pitch,
|
|
241
|
+
heading: initialCamera.heading,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const handleMoveEnd = (): void => {
|
|
246
|
+
const bounds = (map as any).getBounds?.() as ReturnType<typeof craftRegionToBounds> | undefined
|
|
247
|
+
if (bounds) {
|
|
248
|
+
const region = boundsToCraftRegion(bounds as any)
|
|
249
|
+
props.onRegionChanged?.(region)
|
|
250
|
+
emitBridge({ type: 'regionChanged', region })
|
|
251
|
+
}
|
|
252
|
+
const camera = readCamera()
|
|
253
|
+
props.onCameraChanged?.(camera)
|
|
254
|
+
emitBridge({ type: 'cameraChanged', camera })
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
;(map as any).on?.('click', handleTap)
|
|
258
|
+
;(map as any).on?.('moveend', handleMoveEnd)
|
|
259
|
+
|
|
260
|
+
// Marker click delegation — attach a listener when adding, unwire on remove.
|
|
261
|
+
const wireMarker = (m: ReturnType<typeof craftMarkerToMarker>): void => {
|
|
262
|
+
;(m as any).on?.('click', () => {
|
|
263
|
+
const ll = (m as any).getLatLng?.()
|
|
264
|
+
const coord = ll ? latLngToCraftCoord(ll) : { latitude: 0, longitude: 0 }
|
|
265
|
+
const markerId = (m as any).__craftMarkerId as number
|
|
266
|
+
props.onMarkerTap?.(markerId, coord)
|
|
267
|
+
emitBridge({ type: 'markerTap', markerId, coordinate: coord })
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Imperative API ------------------------------------------------------------
|
|
272
|
+
const instance: MapViewInstance = {
|
|
273
|
+
id,
|
|
274
|
+
container,
|
|
275
|
+
map,
|
|
276
|
+
configuration,
|
|
277
|
+
|
|
278
|
+
setCamera(camera, animated = false) {
|
|
279
|
+
const opts = craftCameraToOptions(camera)
|
|
280
|
+
if (animated && typeof (map as any).flyTo === 'function')
|
|
281
|
+
(map as any).flyTo(opts.center, opts.zoom)
|
|
282
|
+
else
|
|
283
|
+
(map as any).setView?.(opts.center, opts.zoom)
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
setRegion(region, _animated = false) {
|
|
287
|
+
const bounds = craftRegionToBounds(region)
|
|
288
|
+
;(map as any).fitBounds?.(bounds)
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
addMarker(marker) {
|
|
292
|
+
const tsMarker = craftMarkerToMarker(marker)
|
|
293
|
+
;(tsMarker as any).addTo?.(map)
|
|
294
|
+
wireMarker(tsMarker)
|
|
295
|
+
markers.set(marker.id, tsMarker)
|
|
296
|
+
return marker.id
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
removeMarker(markerId) {
|
|
300
|
+
const existing = markers.get(markerId)
|
|
301
|
+
if (!existing)
|
|
302
|
+
return false
|
|
303
|
+
;(existing as any).remove?.()
|
|
304
|
+
markers.delete(markerId)
|
|
305
|
+
return true
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
addPolyline(polyline) {
|
|
309
|
+
const tsLine = craftPolylineToPolyline(polyline)
|
|
310
|
+
;(tsLine as any).addTo?.(map)
|
|
311
|
+
polylines.set(polyline.id, tsLine)
|
|
312
|
+
return polyline.id
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
addPolygon(polygon) {
|
|
316
|
+
const tsPolygon = craftPolygonToPolygon(polygon)
|
|
317
|
+
;(tsPolygon as any).addTo?.(map)
|
|
318
|
+
polygons.set(polygon.id, tsPolygon)
|
|
319
|
+
return polygon.id
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
addCircle(circle) {
|
|
323
|
+
const tsCircle = craftCircleToCircle(circle)
|
|
324
|
+
;(tsCircle as any).addTo?.(map)
|
|
325
|
+
circles.set(circle.id, tsCircle)
|
|
326
|
+
return circle.id
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
fitToMarkers(paddingPercent = 10) {
|
|
330
|
+
if (markers.size === 0)
|
|
331
|
+
return
|
|
332
|
+
const coords: Coordinate[] = []
|
|
333
|
+
for (const m of markers.values()) {
|
|
334
|
+
const ll = (m as any).getLatLng?.()
|
|
335
|
+
if (ll)
|
|
336
|
+
coords.push(latLngToCraftCoord(ll))
|
|
337
|
+
}
|
|
338
|
+
if (coords.length === 0)
|
|
339
|
+
return
|
|
340
|
+
// Expand span by `paddingPercent` so pins aren't flush with the viewport edges.
|
|
341
|
+
let minLat = 90, maxLat = -90, minLng = 180, maxLng = -180
|
|
342
|
+
for (const c of coords) {
|
|
343
|
+
if (c.latitude < minLat) minLat = c.latitude
|
|
344
|
+
if (c.latitude > maxLat) maxLat = c.latitude
|
|
345
|
+
if (c.longitude < minLng) minLng = c.longitude
|
|
346
|
+
if (c.longitude > maxLng) maxLng = c.longitude
|
|
347
|
+
}
|
|
348
|
+
const factor = 1 + paddingPercent / 100
|
|
349
|
+
const latSpan = (maxLat - minLat) * factor
|
|
350
|
+
const lngSpan = (maxLng - minLng) * factor
|
|
351
|
+
instance.setRegion({
|
|
352
|
+
center: {
|
|
353
|
+
latitude: (minLat + maxLat) / 2,
|
|
354
|
+
longitude: (minLng + maxLng) / 2,
|
|
355
|
+
},
|
|
356
|
+
span: { latitude_delta: latSpan, longitude_delta: lngSpan },
|
|
357
|
+
})
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
selectMarker(markerId) {
|
|
361
|
+
for (const [, m] of markers)
|
|
362
|
+
(m as any).__craftSelected = false
|
|
363
|
+
const target = markers.get(markerId)
|
|
364
|
+
if (target) {
|
|
365
|
+
;(target as any).__craftSelected = true
|
|
366
|
+
;(target as any).openPopup?.()
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
deselectAll() {
|
|
371
|
+
for (const m of markers.values()) {
|
|
372
|
+
;(m as any).__craftSelected = false
|
|
373
|
+
;(m as any).closePopup?.()
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
setConfiguration(next) {
|
|
378
|
+
instance.configuration = next
|
|
379
|
+
// Minimum/maximum zoom are the two cheap knobs we can re-apply live
|
|
380
|
+
// without tearing the map down.
|
|
381
|
+
;(map as any).setMinZoom?.(next.min_zoom)
|
|
382
|
+
;(map as any).setMaxZoom?.(next.max_zoom)
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
destroy() {
|
|
386
|
+
;(map as any).off?.('click', handleTap)
|
|
387
|
+
;(map as any).off?.('moveend', handleMoveEnd)
|
|
388
|
+
;(map as any).remove?.()
|
|
389
|
+
container.remove()
|
|
390
|
+
},
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Mount initial overlays ----------------------------------------------------
|
|
394
|
+
for (const m of props.markers ?? []) instance.addMarker(m)
|
|
395
|
+
for (const pl of props.polylines ?? []) instance.addPolyline(pl)
|
|
396
|
+
for (const pg of props.polygons ?? []) instance.addPolygon(pg)
|
|
397
|
+
for (const c of props.circles ?? []) instance.addCircle(c)
|
|
398
|
+
|
|
399
|
+
// Subscribe to bridge-originated requests ----------------------------------
|
|
400
|
+
if (bridge) {
|
|
401
|
+
const unbind: Array<() => void> = []
|
|
402
|
+
const on = (method: TypedMapBridgeRequest['method'], handler: (p: any) => void): void => {
|
|
403
|
+
const event = `${BRIDGE_NAMESPACE}:${method}:${id}`
|
|
404
|
+
bridge.on(event, handler)
|
|
405
|
+
unbind.push(() => bridge.off(event, handler))
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
on('setCamera', (p) => instance.setCamera(p.camera, p.animated))
|
|
409
|
+
on('setRegion', (p) => instance.setRegion(p.region, p.animated))
|
|
410
|
+
on('addMarker', (p) => instance.addMarker(p.marker))
|
|
411
|
+
on('removeMarker', (p) => instance.removeMarker(p.id))
|
|
412
|
+
on('addPolyline', (p) => instance.addPolyline(p.polyline))
|
|
413
|
+
on('addPolygon', (p) => instance.addPolygon(p.polygon))
|
|
414
|
+
on('addCircle', (p) => instance.addCircle(p.circle))
|
|
415
|
+
on('fitToMarkers', (p) => instance.fitToMarkers(p.padding))
|
|
416
|
+
on('selectMarker', (p) => instance.selectMarker(p.id))
|
|
417
|
+
on('deselectAll', () => instance.deselectAll())
|
|
418
|
+
on('setConfiguration', (p) => instance.setConfiguration(p.configuration))
|
|
419
|
+
|
|
420
|
+
const originalDestroy = instance.destroy
|
|
421
|
+
instance.destroy = () => {
|
|
422
|
+
for (const fn of unbind) fn()
|
|
423
|
+
originalDestroy()
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return instance
|
|
428
|
+
}
|
package/src/adapters.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure conversion functions between Craft's Zig-mirrored map types and
|
|
3
|
+
* the runtime objects used by the ts-maps library. These are deliberately
|
|
4
|
+
* side-effect-free so they can be unit-tested without a DOM.
|
|
5
|
+
*
|
|
6
|
+
* @module @craft-native/ts-maps/adapters
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Circle, Marker, Polygon, Polyline } from 'ts-maps'
|
|
10
|
+
import { LatLng, LatLngBounds } from 'ts-maps'
|
|
11
|
+
import type {
|
|
12
|
+
BoundingBox,
|
|
13
|
+
Coordinate,
|
|
14
|
+
MapCamera,
|
|
15
|
+
MapCircle,
|
|
16
|
+
MapMarker,
|
|
17
|
+
MapPolygon,
|
|
18
|
+
MapPolyline,
|
|
19
|
+
MapRegion,
|
|
20
|
+
} from './types'
|
|
21
|
+
import { markerColorHex, strokePatternDashes } from './types'
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Coordinates
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** Convert a Craft `Coordinate` to a ts-maps `LatLng`. */
|
|
28
|
+
export function craftCoordToLatLng(coord: Coordinate): LatLng {
|
|
29
|
+
return new LatLng(coord.latitude, coord.longitude)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Convert a ts-maps `LatLng` back to a Craft `Coordinate`. */
|
|
33
|
+
export function latLngToCraftCoord(ll: LatLng): Coordinate {
|
|
34
|
+
return { latitude: ll.lat, longitude: ll.lng }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Regions / bounds
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/** Convert a Craft `MapRegion` (center + span) to a ts-maps `LatLngBounds`. */
|
|
42
|
+
export function craftRegionToBounds(region: MapRegion): LatLngBounds {
|
|
43
|
+
const { center, span } = region
|
|
44
|
+
const halfLat = span.latitude_delta / 2
|
|
45
|
+
const halfLng = span.longitude_delta / 2
|
|
46
|
+
const sw = new LatLng(center.latitude - halfLat, center.longitude - halfLng)
|
|
47
|
+
const ne = new LatLng(center.latitude + halfLat, center.longitude + halfLng)
|
|
48
|
+
return new LatLngBounds(sw, ne)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Convert a Craft `BoundingBox` to a ts-maps `LatLngBounds`. */
|
|
52
|
+
export function craftBoundingBoxToBounds(box: BoundingBox): LatLngBounds {
|
|
53
|
+
return new LatLngBounds(
|
|
54
|
+
craftCoordToLatLng(box.south_west),
|
|
55
|
+
craftCoordToLatLng(box.north_east),
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Convert a ts-maps `LatLngBounds` back to a Craft `MapRegion`. Useful when
|
|
61
|
+
* translating `moveend` events into `regionChanged` bridge events.
|
|
62
|
+
*/
|
|
63
|
+
export function boundsToCraftRegion(bounds: LatLngBounds): MapRegion {
|
|
64
|
+
const sw = bounds._southWest
|
|
65
|
+
const ne = bounds._northEast
|
|
66
|
+
if (!sw || !ne) {
|
|
67
|
+
return {
|
|
68
|
+
center: { latitude: 0, longitude: 0 },
|
|
69
|
+
span: { latitude_delta: 0, longitude_delta: 0 },
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
center: {
|
|
74
|
+
latitude: (sw.lat + ne.lat) / 2,
|
|
75
|
+
longitude: (sw.lng + ne.lng) / 2,
|
|
76
|
+
},
|
|
77
|
+
span: {
|
|
78
|
+
latitude_delta: ne.lat - sw.lat,
|
|
79
|
+
longitude_delta: ne.lng - sw.lng,
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Camera
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* ts-maps `setView()` / `easeTo()` option shape. Pulled out so the MapView
|
|
90
|
+
* wrapper and consumers can pass the exact object through without guessing
|
|
91
|
+
* field names.
|
|
92
|
+
*/
|
|
93
|
+
export interface TsMapsCameraOptions {
|
|
94
|
+
center: LatLng
|
|
95
|
+
zoom: number
|
|
96
|
+
bearing: number
|
|
97
|
+
pitch: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Convert a Craft `MapCamera` to a ts-maps-style camera options object. */
|
|
101
|
+
export function craftCameraToOptions(cam: MapCamera): TsMapsCameraOptions {
|
|
102
|
+
return {
|
|
103
|
+
center: craftCoordToLatLng(cam.center),
|
|
104
|
+
zoom: cam.zoom,
|
|
105
|
+
bearing: cam.heading,
|
|
106
|
+
pitch: cam.pitch,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Alias matching the name requested in the SDK spec. Kept distinct from
|
|
112
|
+
* `craftCameraToOptions` in case the upstream runtime ever needs a
|
|
113
|
+
* different shape for `cameraTo…` vs `…ToOptions`.
|
|
114
|
+
*/
|
|
115
|
+
export function cameraToCamera(cam: MapCamera): TsMapsCameraOptions {
|
|
116
|
+
return craftCameraToOptions(cam)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Markers & overlays
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Convert a Craft `MapMarker` to a ts-maps `Marker`. The returned marker
|
|
125
|
+
* has not yet been added to a map — the caller is responsible for calling
|
|
126
|
+
* `marker.addTo(map)`.
|
|
127
|
+
*/
|
|
128
|
+
export function craftMarkerToMarker(m: MapMarker): Marker {
|
|
129
|
+
const marker = new Marker(craftCoordToLatLng(m.coordinate), {
|
|
130
|
+
title: m.title ?? undefined,
|
|
131
|
+
draggable: m.is_draggable,
|
|
132
|
+
// Custom icon path is passed through as a string — the runtime can
|
|
133
|
+
// resolve it to a DivIcon or Icon URL as appropriate.
|
|
134
|
+
icon: m.custom_icon ?? undefined,
|
|
135
|
+
} as any)
|
|
136
|
+
// Tag the marker with the Craft-side id so event handlers can round-trip
|
|
137
|
+
// identity back to the Zig core.
|
|
138
|
+
;(marker as any).__craftMarkerId = m.id
|
|
139
|
+
;(marker as any).__craftMarkerColor = m.color
|
|
140
|
+
if (m.is_selected)
|
|
141
|
+
(marker as any).__craftSelected = true
|
|
142
|
+
return marker
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Convert a Craft `MapPolyline` to a ts-maps `Polyline`. */
|
|
146
|
+
export function craftPolylineToPolyline(pl: MapPolyline): Polyline {
|
|
147
|
+
const latlngs = pl.coordinates.map(craftCoordToLatLng)
|
|
148
|
+
const polyline = new Polyline(latlngs as any, {
|
|
149
|
+
color: markerColorHex[pl.stroke_color],
|
|
150
|
+
weight: pl.stroke_width,
|
|
151
|
+
dashArray: strokePatternDashes[pl.stroke_pattern].join(',') || undefined,
|
|
152
|
+
} as any)
|
|
153
|
+
;(polyline as any).__craftPolylineId = pl.id
|
|
154
|
+
;(polyline as any).__craftZIndex = pl.z_index
|
|
155
|
+
return polyline
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Convert a Craft `MapPolygon` to a ts-maps `Polygon`. */
|
|
159
|
+
export function craftPolygonToPolygon(pg: MapPolygon): Polygon {
|
|
160
|
+
const rings: LatLng[][] = [pg.exterior_ring.map(craftCoordToLatLng)]
|
|
161
|
+
for (const ring of pg.interior_rings)
|
|
162
|
+
rings.push(ring.map(craftCoordToLatLng))
|
|
163
|
+
|
|
164
|
+
const polygon = new Polygon(rings as any, {
|
|
165
|
+
color: markerColorHex[pg.stroke_color],
|
|
166
|
+
weight: pg.stroke_width,
|
|
167
|
+
fillColor: markerColorHex[pg.fill_color],
|
|
168
|
+
fillOpacity: pg.fill_opacity,
|
|
169
|
+
} as any)
|
|
170
|
+
;(polygon as any).__craftPolygonId = pg.id
|
|
171
|
+
;(polygon as any).__craftZIndex = pg.z_index
|
|
172
|
+
return polygon
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Convert a Craft `MapCircle` to a ts-maps `Circle`. */
|
|
176
|
+
export function craftCircleToCircle(c: MapCircle): Circle {
|
|
177
|
+
const circle = new Circle(craftCoordToLatLng(c.center), {
|
|
178
|
+
radius: c.radius_meters,
|
|
179
|
+
color: markerColorHex[c.stroke_color],
|
|
180
|
+
weight: c.stroke_width,
|
|
181
|
+
fillColor: markerColorHex[c.fill_color],
|
|
182
|
+
fillOpacity: c.fill_opacity,
|
|
183
|
+
} as any)
|
|
184
|
+
;(circle as any).__craftCircleId = c.id
|
|
185
|
+
return circle
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Helpers
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Build a Craft `MapRegion` from the bounds of an array of coordinates.
|
|
194
|
+
* Mirrors `BoundingBox.fromCoordinates(...).toRegion()` in `maps.zig`.
|
|
195
|
+
*/
|
|
196
|
+
export function regionFromBounds(coords: Coordinate[]): MapRegion | null {
|
|
197
|
+
if (coords.length === 0)
|
|
198
|
+
return null
|
|
199
|
+
|
|
200
|
+
let minLat = 90
|
|
201
|
+
let maxLat = -90
|
|
202
|
+
let minLng = 180
|
|
203
|
+
let maxLng = -180
|
|
204
|
+
for (const c of coords) {
|
|
205
|
+
if (c.latitude < minLat) minLat = c.latitude
|
|
206
|
+
if (c.latitude > maxLat) maxLat = c.latitude
|
|
207
|
+
if (c.longitude < minLng) minLng = c.longitude
|
|
208
|
+
if (c.longitude > maxLng) maxLng = c.longitude
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
center: {
|
|
213
|
+
latitude: (minLat + maxLat) / 2,
|
|
214
|
+
longitude: (minLng + maxLng) / 2,
|
|
215
|
+
},
|
|
216
|
+
span: {
|
|
217
|
+
latitude_delta: maxLat - minLat,
|
|
218
|
+
longitude_delta: maxLng - minLng,
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
}
|