lecodes-sdk 0.19.1 → 0.20.0

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,396 @@
1
+ // MapView — a vector map (maplibre-native on iOS/Android) as a Presentable. A typed wrapper over
2
+ // the `NativeView("map")` capability (docs/map-plugin-plan.md): push it (`Router.push(map)`),
3
+ // open it fullscreen (`map.open()`), or embed it among a screen's children where the host builds
4
+ // the "native" layout node. The style is a URL, a bundled `asset()`, a read file or the style
5
+ // object (see MapStyle). Data goes in as named layers (`map.markers(name)`, `map.line(name)`)
6
+ // whose `set()` replaces their content; the camera is driven with `flyTo` / `fitPoints`; the user
7
+ // puck is fed by the app (`setUserLocation`) — the plugin never touches the location hardware.
8
+ //
9
+ // Host-OPTIONAL: it exists where the host registers a "map" view (the `map` plugin from
10
+ // lecodes-plugins) — gate on `MapView.isSupported`. Everything here is sugar over ONE JSON channel
11
+ // (`call` / `on`); the wire contract is the method/event table in docs/map-plugin-plan.md.
12
+ //
13
+ // Ordering: the native map exists only once presented and its style loaded — the plugin emits
14
+ // `ready` then. Data and camera calls issued before that are queued and replayed in order, so an
15
+ // app can build the map declaratively and push it later without a ready-dance of its own.
16
+
17
+ import { NativeView, NativeViewElement } from "../ui/NativeView"
18
+ import { _channelOn } from "../runtime/channel"
19
+ import type { FetchResponse } from "../runtime/fetch"
20
+
21
+ /** The ready-made styles a map falls back on — [OpenFreeMap](https://openfreemap.org): OSM data,
22
+ * no key, no registration, no request limits, commercial use allowed, and the whole stack is
23
+ * open-source if you'd rather self-host. `liberty` is the default. Credit them (and OSM) in your
24
+ * app: "© OpenFreeMap © OpenMapTiles, data from OpenStreetMap". A public free instance is a
25
+ * donation-funded service with no SLA — for a product with real traffic, run your own tiles and
26
+ * pass that style's URL instead. */
27
+ export type MapStyleName = "liberty" | "bright" | "positron" | "dark" | "fiord"
28
+
29
+ const NAMED_STYLES: Record<string, string> = {
30
+ liberty: "https://tiles.openfreemap.org/styles/liberty",
31
+ bright: "https://tiles.openfreemap.org/styles/bright",
32
+ positron: "https://tiles.openfreemap.org/styles/positron",
33
+ dark: "https://tiles.openfreemap.org/styles/dark",
34
+ fiord: "https://tiles.openfreemap.org/styles/fiord",
35
+ }
36
+
37
+ /**
38
+ * Where the map's style comes from:
39
+ *
40
+ * - a **name** — `"liberty"` (the default), `"positron"`, … see {@link MapStyleName}: a ready-made
41
+ * style on a free public tile server, so `MapView()` alone already draws a world map;
42
+ * - a **URL** — `"https://tiles.example.com/styles/city/style.json"`, the map fetches it;
43
+ * - a **bundled style** — `asset("./map/style.json")`: the file ships inside the app and the
44
+ * wrapper hands its text to the map, so the style itself needs no server (tiles, sprites and
45
+ * glyphs are still fetched from whatever urls it names);
46
+ * - an **already-read file** — a `FetchResponse` from `fetchLocal("style.json")` or
47
+ * `await fetch(url)` (a style downloaded once and cached in `files`);
48
+ * - the **style object** itself — the natural way to substitute a tile-server address at runtime:
49
+ * `{ ...style, sources: { openmaptiles: { type: "vector", url: `${server}/data/v3.json` } } }`.
50
+ *
51
+ * Whichever form: **every url INSIDE the style (`sources[].url`, `sprite`, `glyphs`) must be
52
+ * absolute.** maplibre-native, unlike maplibre-gl-js, resolves no relative ones — a style with
53
+ * them loads to an empty basemap (your layers still draw). A tileserver-gl instance emits
54
+ * relative urls until its `publicUrl` is configured.
55
+ */
56
+ // `string & {}` keeps the named suggestions in the editor while still accepting any URL.
57
+ export type MapStyle = MapStyleName | (string & {}) | FetchResponse | object
58
+
59
+ /** `[longitude, latitude]` — GeoJSON order, the same as the style, the tiles and your data. */
60
+ export type LngLat = [number, number]
61
+
62
+ export interface MapCamera {
63
+ center: LngLat
64
+ zoom: number
65
+ /** Degrees clockwise from north. */
66
+ bearing: number
67
+ /** Degrees from the vertical. */
68
+ pitch: number
69
+ }
70
+
71
+ export interface MapOptions {
72
+ /** The MapLibre style: a ready-made name (`"liberty"` — the default, `"positron"`, …), a URL,
73
+ * a style bundled with the app (`asset("./style.json")`), an already-read file
74
+ * (`fetchLocal("style.json")`, `await fetch(url)`) or the style object itself — see
75
+ * {@link MapStyle}. */
76
+ style?: MapStyle
77
+ center?: LngLat
78
+ zoom?: number
79
+ minZoom?: number
80
+ maxZoom?: number
81
+ bearing?: number
82
+ pitch?: number
83
+ /** Two-finger rotate gesture (default true). */
84
+ rotate?: boolean
85
+ /** Two-finger tilt gesture (default false — most city maps stay flat). */
86
+ tilt?: boolean
87
+ }
88
+
89
+ /** A tap on the map itself — not on a feature of a managed layer. */
90
+ export interface MapTap {
91
+ lngLat: LngLat
92
+ /** View-space point, px. */
93
+ point: [number, number]
94
+ }
95
+
96
+ export interface CameraMove {
97
+ zoom?: number
98
+ bearing?: number
99
+ pitch?: number
100
+ /** Animation length, ms (flyTo only; default 600). */
101
+ duration?: number
102
+ }
103
+
104
+ export interface MapPaddingValues {
105
+ top?: number | string
106
+ left?: number | string
107
+ bottom?: number | string
108
+ right?: number | string
109
+ }
110
+
111
+ export interface FitOptions {
112
+ /** Px around the points, or per edge. Added to the view padding set by `setPadding`. */
113
+ padding?: number | MapPaddingValues
114
+ maxZoom?: number
115
+ /** Default true. */
116
+ animate?: boolean
117
+ }
118
+
119
+ /** One marker. `id` comes back in `onTap`; `icon` names an image of the style's sprite; `color` /
120
+ * `title` feed the default layers; extra keys become feature properties. */
121
+ export interface MarkerItem {
122
+ id: string | number
123
+ lngLat: LngLat
124
+ icon?: string
125
+ color?: string
126
+ title?: string
127
+ [property: string]: any
128
+ }
129
+
130
+ export interface MarkerTap {
131
+ id: string | number
132
+ lngLat: LngLat
133
+ /** Every property of the tapped feature (the item's keys, `id` and `lngLat` excluded). */
134
+ properties: Record<string, any>
135
+ }
136
+
137
+ export interface MarkerLayerOptions {
138
+ /** Group nearby markers into clusters (a cluster tap zooms in). Default false. */
139
+ cluster?: boolean
140
+ /** Cluster radius, px (default 50). */
141
+ clusterRadius?: number
142
+ /** Zoom at which clusters stop forming (default: maxZoom − 1). */
143
+ clusterMaxZoom?: number
144
+ }
145
+
146
+ export interface LineLayerOptions {
147
+ color?: string
148
+ /** Px (default 4). */
149
+ width?: number
150
+ /** 0–1 (default 1). */
151
+ opacity?: number
152
+ }
153
+
154
+ export interface UserLocationOptions {
155
+ /** Horizontal accuracy radius, meters — drawn as the halo around the dot. */
156
+ accuracy?: number | null
157
+ /** Degrees clockwise from north — drawn as the direction wedge; null hides it. */
158
+ heading?: number | null
159
+ }
160
+
161
+ export interface MarkerLayer {
162
+ readonly name: string
163
+ /** Replace the layer's markers. */
164
+ set(items: MarkerItem[]): this
165
+ clear(): this
166
+ /** A marker (or any feature of this layer's source) was tapped. */
167
+ onTap(callback: (marker: MarkerTap) => void): this
168
+ }
169
+
170
+ export interface LineLayer {
171
+ readonly name: string
172
+ /** Replace the line with these vertices. */
173
+ set(coordinates: LngLat[]): this
174
+ clear(): this
175
+ }
176
+
177
+ export interface MapView extends NativeView {
178
+ /** The style loaded and the map is interactive (queued calls have been replayed). */
179
+ onReady(callback: () => void): this
180
+ /** A tap that hit no feature of a managed layer. */
181
+ onTap(callback: (tap: MapTap) => void): this
182
+ /** The camera settled after a gesture or an animation. */
183
+ onMove(callback: (camera: MapCamera) => void): this
184
+ /** The map reported a problem — a style that wouldn't load, a source it couldn't reach. Never
185
+ * fatal; with no handler the message goes to `console.error`, so it is never silent. */
186
+ onError(callback: (error: { message: string }) => void): this
187
+
188
+ /** A named marker layer (one GeoJSON source). If the style already declares a source with this
189
+ * name, its layers are used as-is and only the data is pushed; otherwise the plugin creates the
190
+ * source and default marker layers (colored dot, `icon`, `title` label; clusters on request). */
191
+ markers(name: string, options?: MarkerLayerOptions): MarkerLayer
192
+ /** A named line layer (one GeoJSON source) — same style-first rule as `markers`. */
193
+ line(name: string, options?: LineLayerOptions): LineLayer
194
+ /** Raw escape hatch: replace the data of any GeoJSON source in the style. */
195
+ setData(source: string, geojson: object): this
196
+
197
+ flyTo(center: LngLat, options?: CameraMove): this
198
+ jumpTo(center: LngLat, options?: CameraMove): this
199
+ /** Fit the camera to these points (padding + the view padding respected). */
200
+ fitPoints(points: LngLat[], options?: FitOptions): this
201
+ /** Content inset: the part of the view covered by your UI (`"40%"` = of the view's size).
202
+ * Camera operations center inside the remaining area. */
203
+ setPadding(padding: MapPaddingValues): this
204
+ getCamera(): Promise<MapCamera>
205
+
206
+ /** Move the user puck (the map draws it; the position comes from you — `Geolocation.watch`).
207
+ * `null` hides it. */
208
+ setUserLocation(lngLat: LngLat | null, options?: UserLocationOptions): this
209
+ }
210
+
211
+ type Queued = { method: string, args: any[] }
212
+ type LayerSpec = { kind: "markers" | "line", options: object }
213
+
214
+ /** Methods whose effect lives in the STYLE, not in the view: a style (re)load wipes them, so the
215
+ * wrapper keeps their last value and re-applies it on every `ready` instead of replaying them
216
+ * from the queue. Camera calls are not here — the camera survives a style reload. */
217
+ const STATEFUL = ["ensureLayer", "setData", "setPadding", "setUserLocation"]
218
+
219
+ class MapViewElement extends NativeViewElement {
220
+ private _ready = false
221
+ private _queue: Queued[] = []
222
+ private readonly _layers = new Map<string, MapLayerHandle>()
223
+ /** Style-lifetime state, re-applied on every `ready` (see STATEFUL). */
224
+ private readonly _specs = new Map<string, LayerSpec>()
225
+ private readonly _data = new Map<string, object>()
226
+ private _padding?: MapPaddingValues
227
+ private _user?: any[]
228
+ private readonly _readyCallbacks: (() => void)[] = []
229
+ private readonly _tapCallbacks: ((tap: MapTap) => void)[] = []
230
+ private readonly _moveCallbacks: ((camera: MapCamera) => void)[] = []
231
+ private readonly _errorCallbacks: ((error: { message: string }) => void)[] = []
232
+
233
+ constructor(options: MapOptions = {}) {
234
+ super("map", _styleParams(options))
235
+ _channelOn(this.nvl, "ready", () => this._onReady())
236
+ _channelOn(this.nvl, "tap", (data: any) => this._onTap(data))
237
+ _channelOn(this.nvl, "moveEnd", (data: any) => { for (const cb of [...this._moveCallbacks]) cb(data) })
238
+ _channelOn(this.nvl, "error", (data: any) => this._onError(data))
239
+ }
240
+
241
+ onReady(callback: () => void): this { this._readyCallbacks.push(callback); return this }
242
+ onTap(callback: (tap: MapTap) => void): this { this._tapCallbacks.push(callback); return this }
243
+ onMove(callback: (camera: MapCamera) => void): this { this._moveCallbacks.push(callback); return this }
244
+ onError(callback: (error: { message: string }) => void): this { this._errorCallbacks.push(callback); return this }
245
+
246
+ markers(name: string, options: MarkerLayerOptions = {}): MarkerLayer {
247
+ return this._layer(name, "markers", options) as MarkerLayer
248
+ }
249
+ line(name: string, options: LineLayerOptions = {}): LineLayer {
250
+ return this._layer(name, "line", options) as LineLayer
251
+ }
252
+ setData(source: string, geojson: object): this {
253
+ this._data.set(source, geojson)
254
+ this._send("setData", [source, geojson])
255
+ return this
256
+ }
257
+
258
+ flyTo(center: LngLat, options: CameraMove = {}): this { this._send("flyTo", [center, options]); return this }
259
+ jumpTo(center: LngLat, options: CameraMove = {}): this { this._send("jumpTo", [center, options]); return this }
260
+ fitPoints(points: LngLat[], options: FitOptions = {}): this { this._send("fitPoints", [points, options]); return this }
261
+ setPadding(padding: MapPaddingValues): this { this._padding = padding; this._send("setPadding", [padding]); return this }
262
+ getCamera(): Promise<MapCamera> { return this.call("getCamera") }
263
+
264
+ setUserLocation(lngLat: LngLat | null, options: UserLocationOptions = {}): this {
265
+ this._user = [lngLat, options]
266
+ this._send("setUserLocation", [lngLat, options])
267
+ return this
268
+ }
269
+
270
+ /** @internal queue until `ready`, then straight through. Rejections are logged, never thrown:
271
+ * a fire-and-forget camera/data call has no caller to reject to. */
272
+ _send(method: string, args: any[]): void {
273
+ if (!this._ready) { this._queue.push({ method, args }); return }
274
+ this.call(method, ...args).catch((e: Error) => console.error(`MapView.${method}: ${e.message}`))
275
+ }
276
+
277
+ private _layer(name: string, kind: "markers" | "line", options: object): MapLayerHandle {
278
+ let layer = this._layers.get(name)
279
+ if (!layer) {
280
+ layer = new MapLayerHandle(this, name)
281
+ this._layers.set(name, layer)
282
+ this._specs.set(name, { kind, options })
283
+ this._send("ensureLayer", [name, kind, options])
284
+ }
285
+ return layer
286
+ }
287
+
288
+ /** A style load — the first one or a reload — starts from a style that knows nothing about the
289
+ * app's layers, so re-apply them all, then whatever else waited in the queue. Without this a
290
+ * second `ready` (a host that loads its default style before the app's, a style swap) leaves a
291
+ * correct-looking map with no data on it. */
292
+ private _onReady(): void {
293
+ this._ready = true
294
+ const queue = this._queue
295
+ this._queue = []
296
+ for (const [name, spec] of this._specs) this._send("ensureLayer", [name, spec.kind, spec.options])
297
+ for (const [source, geojson] of this._data) this._send("setData", [source, geojson])
298
+ if (this._padding) this._send("setPadding", [this._padding])
299
+ if (this._user) this._send("setUserLocation", this._user)
300
+ for (const q of queue) if (STATEFUL.indexOf(q.method) < 0) this._send(q.method, q.args)
301
+ for (const cb of [...this._readyCallbacks]) cb()
302
+ }
303
+
304
+ private _onError(data: any): void {
305
+ // Unhandled is not unheard: a style the host couldn't load is exactly the failure that looks
306
+ // like "the map is blank and nothing happened".
307
+ if (this._errorCallbacks.length === 0) { console.error(`MapView: ${data?.message ?? "unknown error"}`); return }
308
+ for (const cb of [...this._errorCallbacks]) cb(data)
309
+ }
310
+
311
+ private _onTap(data: any): void {
312
+ const feature = data?.feature
313
+ const layer = feature ? this._layers.get(feature.source) : undefined
314
+ if (layer) {
315
+ layer._tap({ id: feature.id, lngLat: data.lngLat, properties: feature.properties ?? {} })
316
+ return
317
+ }
318
+ for (const cb of [...this._tapCallbacks]) cb({ lngLat: data.lngLat, point: data.point })
319
+ }
320
+ }
321
+
322
+ /** One named GeoJSON source on the map — the object behind both `MarkerLayer` and `LineLayer`. */
323
+ class MapLayerHandle {
324
+ private readonly _tapCallbacks: ((marker: MarkerTap) => void)[] = []
325
+ private readonly _map: MapViewElement
326
+ readonly name: string
327
+
328
+ constructor(map: MapViewElement, name: string) {
329
+ this._map = map
330
+ this.name = name
331
+ }
332
+
333
+ set(items: MarkerItem[] | LngLat[]): this {
334
+ this._map.setData(this.name, _toGeoJson(items))
335
+ return this
336
+ }
337
+ clear(): this {
338
+ this._map.setData(this.name, { type: "FeatureCollection", features: [] })
339
+ return this
340
+ }
341
+ onTap(callback: (marker: MarkerTap) => void): this {
342
+ this._tapCallbacks.push(callback)
343
+ return this
344
+ }
345
+ /** @internal */
346
+ _tap(marker: MarkerTap): void {
347
+ for (const cb of [...this._tapCallbacks]) cb(marker)
348
+ }
349
+ }
350
+
351
+ /** @internal `MapOptions` → host params. A style URL stays a url the map fetches itself; every
352
+ * other form is read HERE and crosses as `styleJson` text — one string, once, at creation. A
353
+ * bundled `asset()` is `"id:N"` in shell compiles (the host already holds the bytes) and a plain
354
+ * url in server compiles, so the same call works on every host. */
355
+ export const _styleParams = (options: MapOptions = {}): any => {
356
+ const { style = "liberty", ...rest } = options
357
+ if (typeof style === "string") {
358
+ if (!style.startsWith("id:")) return { ...rest, style: NAMED_STYLES[style] ?? style }
359
+ const text = _creatorUtils.fetchToText(+style.slice(3))
360
+ if (!text) throw new Error("MapView: the style asset is empty or missing")
361
+ return { ...rest, styleJson: text }
362
+ }
363
+ const response = style as FetchResponse
364
+ return { ...rest, styleJson: typeof response.text === "function" ? response.text() : JSON.stringify(style) }
365
+ }
366
+
367
+ /** @internal marker items → a FeatureCollection of Points; a coordinate list → one LineString. */
368
+ export const _toGeoJson = (items: MarkerItem[] | LngLat[]): object => {
369
+ if (items.length > 0 && Array.isArray(items[0])) {
370
+ return { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: items } }
371
+ }
372
+ return {
373
+ type: "FeatureCollection",
374
+ features: (items as MarkerItem[]).map(item => {
375
+ const { id, lngLat, ...properties } = item
376
+ return { type: "Feature", id, properties: { ...properties, id }, geometry: { type: "Point", coordinates: lngLat } }
377
+ }),
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Create a map view. `MapView.isSupported` reports whether this host registered a "map" view —
383
+ * check it before offering the feature (web, headless and shells without the plugin have none).
384
+ */
385
+ // PURE IIFE so an app that never uses the map tree-shakes the whole plugin away (see NativeView).
386
+ export const MapView: {
387
+ (options?: MapOptions): MapView
388
+ /** Whether this host registered a "map" view. */
389
+ readonly isSupported: boolean
390
+ } = /*#__PURE__*/ (() => {
391
+ const factory = (options: MapOptions = {}): MapView => new MapViewElement(options) as unknown as MapView
392
+ Object.defineProperty(factory, "isSupported", {
393
+ get: (): boolean => NativeView.isSupported("map"),
394
+ })
395
+ return factory as any
396
+ })()