@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/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
+ }
@@ -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
+ }