uni-leaflet 1.0.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,101 @@
1
+ import type {
2
+ IMapEngine,
3
+ LatLngTuple,
4
+ Point,
5
+ TileLayerConfig,
6
+ MapOverlays,
7
+ OverlayClickEvent,
8
+ } from '../types';
9
+
10
+ // #ifdef H5
11
+ import { H5MapAdapter } from './h5-map-adapter';
12
+ // #endif
13
+
14
+ // #ifndef H5
15
+ import { CanvasTileEngine } from './canvas-tile-engine';
16
+ // #endif
17
+
18
+ export interface CreateEngineOptions {
19
+ center: LatLngTuple;
20
+ zoom: number;
21
+ minZoom: number;
22
+ maxZoom: number;
23
+ tileUrl: string;
24
+ subdomains: string[];
25
+ layers?: Array<string | TileLayerConfig>;
26
+ overlays?: MapOverlays;
27
+ showControls?: boolean;
28
+ onMove: (center: LatLngTuple, zoom: number) => void;
29
+ onMoveEnd: (center: LatLngTuple, zoom: number) => void;
30
+ onZoom: (zoom: number) => void;
31
+ onZoomEnd: (zoom: number) => void;
32
+ onClick: (latLng: LatLngTuple, point: Point) => void;
33
+ onOverlayClick?: (event: OverlayClickEvent) => void;
34
+
35
+ // H5 container
36
+ container?: HTMLElement | null;
37
+
38
+ // Mini-program / App canvas
39
+ canvas?: any;
40
+ ctx?: any;
41
+ width?: number;
42
+ height?: number;
43
+ dpr?: number;
44
+ }
45
+
46
+ /**
47
+ * Factory to create platform-specific map engine implementing unified IMapEngine
48
+ */
49
+ export async function createMapEngine(
50
+ options: CreateEngineOptions
51
+ ): Promise<IMapEngine> {
52
+ // #ifdef H5
53
+ const adapter = new H5MapAdapter();
54
+ if (!options.container) {
55
+ throw new Error('H5MapAdapter requires container DOM element');
56
+ }
57
+ await adapter.init({
58
+ container: options.container,
59
+ center: options.center,
60
+ zoom: options.zoom,
61
+ minZoom: options.minZoom,
62
+ maxZoom: options.maxZoom,
63
+ tileUrl: options.tileUrl,
64
+ subdomains: options.subdomains,
65
+ layers: options.layers,
66
+ overlays: options.overlays,
67
+ onMove: options.onMove,
68
+ onMoveEnd: options.onMoveEnd,
69
+ onZoom: options.onZoom,
70
+ onZoomEnd: options.onZoomEnd,
71
+ onClick: options.onClick,
72
+ onOverlayClick: options.onOverlayClick,
73
+ });
74
+ return adapter;
75
+ // #endif
76
+
77
+ // #ifndef H5
78
+ return new CanvasTileEngine({
79
+ canvas: options.canvas,
80
+ ctx: options.ctx,
81
+ width: options.width || 300,
82
+ height: options.height || 300,
83
+ dpr: options.dpr || 1,
84
+ center: options.center,
85
+ zoom: options.zoom,
86
+ minZoom: options.minZoom,
87
+ maxZoom: options.maxZoom,
88
+ tileUrl: options.tileUrl,
89
+ subdomains: options.subdomains,
90
+ layers: options.layers,
91
+ overlays: options.overlays,
92
+ showControls: options.showControls,
93
+ onMove: options.onMove,
94
+ onMoveEnd: options.onMoveEnd,
95
+ onZoom: options.onZoom,
96
+ onZoomEnd: options.onZoomEnd,
97
+ onClick: options.onClick,
98
+ onOverlayClick: options.onOverlayClick,
99
+ });
100
+ // #endif
101
+ }
@@ -0,0 +1,470 @@
1
+ import type {
2
+ IMapEngine,
3
+ LatLngTuple,
4
+ Point,
5
+ TileLayerConfig,
6
+ MarkerOptions,
7
+ PolylineOptions,
8
+ PolygonOptions,
9
+ CircleOptions,
10
+ MapOverlays,
11
+ OverlayClickEvent,
12
+ } from '../types';
13
+
14
+ export interface H5AdapterOptions {
15
+ container: HTMLElement;
16
+ center?: LatLngTuple;
17
+ zoom?: number;
18
+ minZoom?: number;
19
+ maxZoom?: number;
20
+ tileUrl?: string;
21
+ subdomains?: string[];
22
+ layers?: Array<string | TileLayerConfig>;
23
+ overlays?: MapOverlays;
24
+ onMove?: (center: LatLngTuple, zoom: number) => void;
25
+ onMoveEnd?: (center: LatLngTuple, zoom: number) => void;
26
+ onZoom?: (zoom: number) => void;
27
+ onZoomEnd?: (zoom: number) => void;
28
+ onClick?: (latLng: LatLngTuple, point: Point) => void;
29
+ onOverlayClick?: (event: OverlayClickEvent) => void;
30
+ }
31
+
32
+ import L from 'leaflet';
33
+ import 'leaflet/dist/leaflet.css';
34
+
35
+ export class H5MapAdapter implements IMapEngine {
36
+ private map: any = null;
37
+ private tileLayers: any[] = [];
38
+ private overlayGroup: any = null;
39
+ private L: any = L;
40
+ private pendingLayers?: Array<string | TileLayerConfig>;
41
+ private pendingOverlays?: MapOverlays;
42
+
43
+ public onMove?: (center: LatLngTuple, zoom: number) => void;
44
+ public onMoveEnd?: (center: LatLngTuple, zoom: number) => void;
45
+ public onZoom?: (zoom: number) => void;
46
+ public onZoomEnd?: (zoom: number) => void;
47
+ public onClick?: (latLng: LatLngTuple, point: Point) => void;
48
+ public onOverlayClick?: (event: OverlayClickEvent) => void;
49
+
50
+ public async init(options: H5AdapterOptions): Promise<void> {
51
+ this.onMove = options.onMove;
52
+ this.onMoveEnd = options.onMoveEnd;
53
+ this.onZoom = options.onZoom;
54
+ this.onZoomEnd = options.onZoomEnd;
55
+ this.onClick = options.onClick;
56
+ this.onOverlayClick = options.onOverlayClick;
57
+
58
+ const center = options.center || [39.9042, 116.4074];
59
+ const zoom = options.zoom || 13;
60
+ const minZoom = options.minZoom ?? 3;
61
+ const maxZoom = options.maxZoom ?? 18;
62
+
63
+ this.map = L.map(options.container, {
64
+ center: [center[0], center[1]],
65
+ zoom,
66
+ minZoom,
67
+ maxZoom,
68
+ zoomControl: false, // We provide unified UI controls
69
+ attributionControl: false,
70
+ });
71
+
72
+ // Create a dedicated overlay pane for annotation / label layers above tilePane
73
+ try {
74
+ this.map.createPane('overlayTilePane');
75
+ const overlayPane = this.map.getPane('overlayTilePane');
76
+ if (overlayPane) {
77
+ overlayPane.style.zIndex = '350';
78
+ overlayPane.style.pointerEvents = 'none';
79
+ }
80
+ } catch (e) {}
81
+
82
+ // Create overlay layer group for markers, lines, polygons
83
+ this.overlayGroup = this.L.layerGroup().addTo(this.map);
84
+
85
+ const initialLayers =
86
+ this.pendingLayers ||
87
+ options.layers ||
88
+ [
89
+ {
90
+ url:
91
+ options.tileUrl ||
92
+ 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
93
+ subdomains: options.subdomains || ['a', 'b', 'c'],
94
+ },
95
+ ];
96
+
97
+ this.setLayers(initialLayers);
98
+
99
+ if (this.pendingOverlays || options.overlays) {
100
+ this.setOverlays(this.pendingOverlays || options.overlays || {});
101
+ }
102
+
103
+ // Bind Leaflet events
104
+ this.map.on('move', () => {
105
+ const c = this.map.getCenter();
106
+ const z = this.map.getZoom();
107
+ this.onMove?.([c.lat, c.lng], z);
108
+ });
109
+
110
+ this.map.on('moveend', () => {
111
+ const c = this.map.getCenter();
112
+ const z = this.map.getZoom();
113
+ this.onMoveEnd?.([c.lat, c.lng], z);
114
+ });
115
+
116
+ this.map.on('zoom', () => {
117
+ const z = this.map.getZoom();
118
+ this.onZoom?.(z);
119
+ });
120
+
121
+ this.map.on('zoomend', () => {
122
+ const z = this.map.getZoom();
123
+ this.onZoomEnd?.(z);
124
+ });
125
+
126
+ this.map.on('click', (e: any) => {
127
+ this.onClick?.([e.latlng.lat, e.latlng.lng], {
128
+ x: e.containerPoint.x,
129
+ y: e.containerPoint.y,
130
+ });
131
+ });
132
+
133
+ // Invalidate size in next tick to avoid tile rendering glitches
134
+ setTimeout(() => {
135
+ this.map?.invalidateSize();
136
+ }, 100);
137
+ }
138
+
139
+ public setCenter(center: LatLngTuple, animate = true) {
140
+ if (!this.map) return;
141
+ if (animate) {
142
+ this.map.panTo([center[0], center[1]]);
143
+ } else {
144
+ this.map.setView([center[0], center[1]], this.map.getZoom());
145
+ }
146
+ }
147
+
148
+ public setZoom(zoom: number) {
149
+ if (!this.map) return;
150
+ this.map.setZoom(zoom);
151
+ }
152
+
153
+ public zoomIn() {
154
+ if (!this.map) return;
155
+ this.map.zoomIn();
156
+ }
157
+
158
+ public zoomOut() {
159
+ if (!this.map) return;
160
+ this.map.zoomOut();
161
+ }
162
+
163
+ public panTo(center: LatLngTuple, duration?: number) {
164
+ if (!this.map) return;
165
+ this.map.panTo([center[0], center[1]], {
166
+ duration: duration ? duration / 1000 : 0.5,
167
+ });
168
+ }
169
+
170
+ public setTileUrl(url: string, subdomains?: string[]) {
171
+ this.setLayers([{ url, subdomains }]);
172
+ }
173
+
174
+ public setLayers(layers: Array<string | TileLayerConfig>) {
175
+ if (!this.map || !this.L) {
176
+ this.pendingLayers = layers;
177
+ return;
178
+ }
179
+
180
+ // Remove existing tile layers
181
+ for (const layer of this.tileLayers) {
182
+ try {
183
+ this.map.removeLayer(layer);
184
+ } catch (e) {}
185
+ }
186
+ this.tileLayers = [];
187
+
188
+ // Add new layers
189
+ let index = 0;
190
+ for (const l of layers) {
191
+ const cfg = typeof l === 'string' ? { url: l } : l;
192
+ if (!cfg.url) continue;
193
+
194
+ // Base layer in tilePane (zIndex 200), overlays in overlayTilePane (zIndex 350)
195
+ const isBase = index === 0;
196
+ const pane = isBase ? 'tilePane' : 'overlayTilePane';
197
+ const zIndex = cfg.zIndex ?? (index + 1) * 10;
198
+
199
+ const tileLayer = this.L.tileLayer(cfg.url, {
200
+ subdomains: cfg.subdomains || ['a', 'b', 'c', 'd'],
201
+ opacity: cfg.opacity ?? 1,
202
+ zIndex,
203
+ pane,
204
+ minZoom: cfg.minZoom ?? this.map.getMinZoom?.() ?? 3,
205
+ maxZoom: cfg.maxZoom ?? this.map.getMaxZoom?.() ?? 18,
206
+ }).addTo(this.map);
207
+
208
+ if (typeof tileLayer.setZIndex === 'function') {
209
+ tileLayer.setZIndex(zIndex);
210
+ }
211
+
212
+ this.tileLayers.push(tileLayer);
213
+ index++;
214
+ }
215
+ }
216
+
217
+ // --- Vector Overlays (Markers, Polylines, Polygons, Circles) ---
218
+
219
+ public setOverlays(overlays: MapOverlays) {
220
+ if (!this.map || !this.L || !this.overlayGroup) {
221
+ this.pendingOverlays = overlays;
222
+ return;
223
+ }
224
+
225
+ this.clearOverlays();
226
+
227
+ if (overlays.polygons) {
228
+ this.setPolygons(overlays.polygons);
229
+ }
230
+ if (overlays.polylines) {
231
+ this.setPolylines(overlays.polylines);
232
+ }
233
+ if (overlays.circles) {
234
+ this.setCircles(overlays.circles);
235
+ }
236
+ if (overlays.markers) {
237
+ this.setMarkers(overlays.markers);
238
+ }
239
+ }
240
+
241
+ public setMarkers(markers: MarkerOptions[]) {
242
+ if (!this.map || !this.L || !this.overlayGroup) return;
243
+
244
+ for (const m of markers) {
245
+ let icon = undefined;
246
+ const size = m.icon?.size || [32, 32];
247
+ const anchor = m.icon?.anchor || [size[0] / 2, size[1]];
248
+ const tooltipAnchor: [number, number] = [
249
+ size[0] / 2 - anchor[0],
250
+ -anchor[1],
251
+ ];
252
+
253
+ if (m.icon?.svg) {
254
+ let svgStr = m.icon.svg.trim();
255
+ if (!svgStr.includes('xmlns=')) {
256
+ svgStr = svgStr.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
257
+ }
258
+ const html = `<div style="width:${size[0]}px;height:${size[1]}px;display:flex;align-items:center;justify-content:center;filter:drop-shadow(0 2px 4px rgba(0,0,0,0.3));">${svgStr}</div>`;
259
+ icon = this.L.divIcon({
260
+ html,
261
+ className: 'uni-custom-svg-icon',
262
+ iconSize: size,
263
+ iconAnchor: anchor,
264
+ tooltipAnchor,
265
+ });
266
+ } else if (m.icon?.html) {
267
+ icon = this.L.divIcon({
268
+ html: m.icon.html,
269
+ className: 'uni-custom-html-icon',
270
+ iconSize: size,
271
+ iconAnchor: anchor,
272
+ tooltipAnchor,
273
+ });
274
+ } else if (m.icon?.url) {
275
+ let finalUrl = m.icon.url;
276
+ if (finalUrl && typeof finalUrl === 'string' && !finalUrl.startsWith('data:') && !finalUrl.startsWith('http://') && !finalUrl.startsWith('https://')) {
277
+ if (finalUrl.startsWith('/')) {
278
+ finalUrl = finalUrl.slice(1);
279
+ }
280
+ }
281
+ const html = `<img src="${finalUrl}" style="width:${size[0]}px;height:${size[1]}px;display:block;filter:drop-shadow(0 2px 4px rgba(0,0,0,0.3));" />`;
282
+ icon = this.L.divIcon({
283
+ className: 'uni-custom-img-icon',
284
+ html,
285
+ iconSize: size,
286
+ iconAnchor: anchor,
287
+ tooltipAnchor,
288
+ });
289
+ } else if (m.icon?.text) {
290
+ const fontSize = Math.round(size[0] * 0.72);
291
+ const html = `<div style="display:flex;align-items:center;justify-content:center;width:${size[0]}px;height:${size[1]}px;font-size:${fontSize}px;filter:drop-shadow(0 2px 4px rgba(0,0,0,0.3));">${m.icon.text}</div>`;
292
+ icon = this.L.divIcon({
293
+ html,
294
+ className: 'uni-custom-icon',
295
+ iconSize: size,
296
+ iconAnchor: anchor,
297
+ tooltipAnchor,
298
+ });
299
+ } else {
300
+ // Sleek default SVG Pin
301
+ const pinColor = m.icon?.color || '#ef4444';
302
+ const html = `
303
+ <svg viewBox="0 0 24 32" width="${size[0]}" height="${size[1]}" style="filter:drop-shadow(0 2px 4px rgba(0,0,0,0.35));">
304
+ <path d="M12 0C5.37 0 0 5.37 0 12c0 9 12 20 12 20s12-11 12-20c0-6.63-5.37-12-12-12z" fill="${pinColor}"/>
305
+ <circle cx="12" cy="11" r="4.5" fill="#ffffff"/>
306
+ </svg>`;
307
+ icon = this.L.divIcon({
308
+ html,
309
+ className: 'uni-default-pin',
310
+ iconSize: size,
311
+ iconAnchor: anchor,
312
+ tooltipAnchor,
313
+ });
314
+ }
315
+
316
+ const leafletMarker = this.L.marker([m.latLng[0], m.latLng[1]], {
317
+ icon,
318
+ title: m.title,
319
+ }).addTo(this.overlayGroup);
320
+
321
+ // Label tooltip
322
+ if (m.label) {
323
+ const labelText = typeof m.label === 'string' ? m.label : m.label.text;
324
+ const customOffset =
325
+ typeof m.label === 'object' && m.label.offset
326
+ ? m.label.offset
327
+ : undefined;
328
+
329
+ let offset: [number, number] = [0, -2];
330
+ if (customOffset) {
331
+ const ox = customOffset[0] || 0;
332
+ const oy = customOffset[1] || 0;
333
+ if (Math.abs(oy) <= 10) {
334
+ offset = [ox, -2 + oy];
335
+ } else {
336
+ offset = [ox, -2];
337
+ }
338
+ }
339
+
340
+ leafletMarker.bindTooltip(labelText, {
341
+ permanent: true,
342
+ direction: 'top',
343
+ offset,
344
+ className: 'uni-overlay-label',
345
+ });
346
+ }
347
+
348
+ leafletMarker.on('click', () => {
349
+ this.onOverlayClick?.({ type: 'marker', data: m });
350
+ });
351
+ }
352
+ }
353
+
354
+ public setPolylines(polylines: PolylineOptions[]) {
355
+ if (!this.map || !this.L || !this.overlayGroup) return;
356
+
357
+ for (const p of polylines) {
358
+ const leafletLine = this.L.polyline(p.latLngs, {
359
+ color: p.color || '#3b82f6',
360
+ weight: p.width || 4,
361
+ opacity: p.opacity ?? 1,
362
+ dashArray: p.dashArray?.join(','),
363
+ }).addTo(this.overlayGroup);
364
+
365
+ if (p.label) {
366
+ const labelText = typeof p.label === 'string' ? p.label : p.label.text;
367
+ leafletLine.bindTooltip(labelText, {
368
+ permanent: true,
369
+ direction: 'center',
370
+ className: 'uni-overlay-label',
371
+ });
372
+ }
373
+
374
+ leafletLine.on('click', () => {
375
+ this.onOverlayClick?.({ type: 'polyline', data: p });
376
+ });
377
+ }
378
+ }
379
+
380
+ public setPolygons(polygons: PolygonOptions[]) {
381
+ if (!this.map || !this.L || !this.overlayGroup) return;
382
+
383
+ for (const poly of polygons) {
384
+ const leafletPolygon = this.L.polygon(poly.latLngs, {
385
+ color: poly.color || '#ef4444',
386
+ weight: poly.width || 2,
387
+ fillColor: poly.fillColor || poly.color || '#ef4444',
388
+ fillOpacity: poly.fillOpacity ?? 0.25,
389
+ }).addTo(this.overlayGroup);
390
+
391
+ if (poly.label) {
392
+ const labelText =
393
+ typeof poly.label === 'string' ? poly.label : poly.label.text;
394
+ leafletPolygon.bindTooltip(labelText, {
395
+ permanent: true,
396
+ direction: 'center',
397
+ className: 'uni-overlay-label',
398
+ });
399
+ }
400
+
401
+ leafletPolygon.on('click', () => {
402
+ this.onOverlayClick?.({ type: 'polygon', data: poly });
403
+ });
404
+ }
405
+ }
406
+
407
+ public setCircles(circles: CircleOptions[]) {
408
+ if (!this.map || !this.L || !this.overlayGroup) return;
409
+
410
+ for (const c of circles) {
411
+ const leafletCircle = this.L.circle([c.latLng[0], c.latLng[1]], {
412
+ radius: c.radius,
413
+ color: c.color || '#10b981',
414
+ weight: c.width || 2,
415
+ fillColor: c.fillColor || c.color || '#10b981',
416
+ fillOpacity: c.fillOpacity ?? 0.2,
417
+ }).addTo(this.overlayGroup);
418
+
419
+ if (c.label) {
420
+ const labelText = typeof c.label === 'string' ? c.label : c.label.text;
421
+ leafletCircle.bindTooltip(labelText, {
422
+ permanent: true,
423
+ direction: 'center',
424
+ className: 'uni-overlay-label',
425
+ });
426
+ }
427
+
428
+ leafletCircle.on('click', () => {
429
+ this.onOverlayClick?.({ type: 'circle', data: c });
430
+ });
431
+ }
432
+ }
433
+
434
+ public clearOverlays() {
435
+ if (this.overlayGroup) {
436
+ this.overlayGroup.clearLayers();
437
+ }
438
+ }
439
+
440
+ public getCenter(): LatLngTuple {
441
+ if (!this.map) return [0, 0];
442
+ const c = this.map.getCenter();
443
+ return [c.lat, c.lng];
444
+ }
445
+
446
+ public getZoom(): number {
447
+ return this.map ? this.map.getZoom() : 0;
448
+ }
449
+
450
+ public getLeafletInstance(): any {
451
+ return this.map;
452
+ }
453
+
454
+ public getNativeInstance(): any {
455
+ return this.map;
456
+ }
457
+
458
+ public resize() {
459
+ this.map?.invalidateSize();
460
+ }
461
+
462
+ public destroy() {
463
+ if (this.map) {
464
+ this.map.remove();
465
+ this.map = null;
466
+ this.tileLayers = [];
467
+ this.overlayGroup = null;
468
+ }
469
+ }
470
+ }