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,1252 @@
1
+ import type {
2
+ IMapEngine,
3
+ LatLngTuple,
4
+ Point,
5
+ TileLayerConfig,
6
+ MarkerOptions,
7
+ PolylineOptions,
8
+ PolygonOptions,
9
+ CircleOptions,
10
+ MapOverlays,
11
+ OverlayClickEvent,
12
+ MarkerLabelOptions,
13
+ } from '../types';
14
+ import { clamp, latLngToWorldPixel, worldPixelToLatLng } from '../utils/crs';
15
+ import { getVisibleTiles } from '../utils/tile';
16
+ import { TileManager } from './tile-manager';
17
+
18
+ export interface CanvasEngineOptions {
19
+ canvas: any;
20
+ ctx: any;
21
+ width: number;
22
+ height: number;
23
+ dpr?: number;
24
+ center?: LatLngTuple;
25
+ zoom?: number;
26
+ minZoom?: number;
27
+ maxZoom?: number;
28
+ tileUrl?: string;
29
+ subdomains?: string[];
30
+ layers?: Array<string | TileLayerConfig>;
31
+ overlays?: MapOverlays;
32
+ tileSize?: number;
33
+ showControls?: boolean;
34
+ onMove?: (center: LatLngTuple, zoom: number) => void;
35
+ onMoveEnd?: (center: LatLngTuple, zoom: number) => void;
36
+ onZoom?: (zoom: number) => void;
37
+ onZoomEnd?: (zoom: number) => void;
38
+ onClick?: (latLng: LatLngTuple, point: Point) => void;
39
+ onOverlayClick?: (event: OverlayClickEvent) => void;
40
+ }
41
+
42
+ export class CanvasTileEngine implements IMapEngine {
43
+ private canvas: any;
44
+ private ctx: any;
45
+ private width: number;
46
+ private height: number;
47
+ private dpr: number;
48
+
49
+ private center: LatLngTuple;
50
+ private zoom: number;
51
+ private minZoom: number;
52
+ private maxZoom: number;
53
+ private tileSize: number;
54
+
55
+ private tileUrl: string;
56
+ private subdomains: string[];
57
+ private layers: TileLayerConfig[] = [];
58
+ private overlays: MapOverlays = {};
59
+ private showControls: boolean;
60
+
61
+ private tileManager: TileManager;
62
+ private isRendering = false;
63
+ private animFrameId: any = null;
64
+
65
+ // Touch tracking
66
+ private isTouching = false;
67
+ private touchCount = 0;
68
+ private startTouches: Point[] = [];
69
+ private startCenter: LatLngTuple = [0, 0];
70
+ private startZoom = 0;
71
+ private startPinchDist = 0;
72
+ private pinchMidPoint: Point = { x: 0, y: 0 };
73
+ private touchStartTime = 0;
74
+ private hasMoved = false;
75
+
76
+ private iconCache: Map<string, { image: any; status: 'loading' | 'loaded' | 'error' }> = new Map();
77
+
78
+ // Double tap detection
79
+ private lastTapTime = 0;
80
+ private lastTapPos: Point = { x: 0, y: 0 };
81
+ private wheelEndTimeout: any = null;
82
+
83
+ // Callbacks
84
+ public onMove?: (center: LatLngTuple, zoom: number) => void;
85
+ public onMoveEnd?: (center: LatLngTuple, zoom: number) => void;
86
+ public onZoom?: (zoom: number) => void;
87
+ public onZoomEnd?: (zoom: number) => void;
88
+ public onClick?: (latLng: LatLngTuple, point: Point) => void;
89
+ public onOverlayClick?: (event: OverlayClickEvent) => void;
90
+
91
+ constructor(options: CanvasEngineOptions) {
92
+ this.canvas = options.canvas;
93
+ this.ctx = options.ctx;
94
+ this.width = options.width || 300;
95
+ this.height = options.height || 300;
96
+ this.dpr = options.dpr || 1;
97
+
98
+ this.center = options.center || [39.9042, 116.4074];
99
+ this.zoom = options.zoom || 13;
100
+ this.minZoom = options.minZoom !== undefined ? options.minZoom : 3;
101
+ this.maxZoom = options.maxZoom !== undefined ? options.maxZoom : 18;
102
+ this.tileSize = options.tileSize || 256;
103
+ this.showControls =
104
+ options.showControls !== undefined ? options.showControls : true;
105
+
106
+ this.tileUrl =
107
+ options.tileUrl || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
108
+ this.subdomains = options.subdomains || ['a', 'b', 'c'];
109
+ this.layers = this.normalizeLayers(options.layers);
110
+ this.overlays = options.overlays || {};
111
+
112
+ this.onMove = options.onMove;
113
+ this.onMoveEnd = options.onMoveEnd;
114
+ this.onZoom = options.onZoom;
115
+ this.onZoomEnd = options.onZoomEnd;
116
+ this.onClick = options.onClick;
117
+ this.onOverlayClick = options.onOverlayClick;
118
+
119
+ this.tileManager = new TileManager();
120
+ this.tileManager.setCanvasNode(this.canvas);
121
+ this.tileManager.setOnTileLoaded(() => {
122
+ this.requestRender();
123
+ });
124
+
125
+ this.initCanvasDpr();
126
+ this.requestRender();
127
+ }
128
+
129
+ private normalizeLayers(
130
+ layers?: Array<string | TileLayerConfig>
131
+ ): TileLayerConfig[] {
132
+ if (!layers || layers.length === 0) {
133
+ return [
134
+ {
135
+ url: this.tileUrl,
136
+ subdomains: this.subdomains,
137
+ },
138
+ ];
139
+ }
140
+ return layers.map((l) =>
141
+ typeof l === 'string'
142
+ ? { url: l, subdomains: this.subdomains }
143
+ : {
144
+ ...l,
145
+ subdomains: l.subdomains || this.subdomains,
146
+ }
147
+ );
148
+ }
149
+
150
+ private initCanvasDpr() {
151
+ if (this.canvas && this.dpr > 1) {
152
+ this.canvas.width = Math.round(this.width * this.dpr);
153
+ this.canvas.height = Math.round(this.height * this.dpr);
154
+ if (this.ctx && this.ctx.scale) {
155
+ this.ctx.scale(this.dpr, this.dpr);
156
+ }
157
+ }
158
+ }
159
+
160
+ public resize(width?: number, height?: number) {
161
+ if (width) this.width = width;
162
+ if (height) this.height = height;
163
+ this.initCanvasDpr();
164
+ this.requestRender();
165
+ }
166
+
167
+ public setCenter(center: LatLngTuple, animate = true) {
168
+ if (animate) {
169
+ this.panTo(center);
170
+ } else {
171
+ this.center = [...center];
172
+ this.requestRender();
173
+ this.onMove?.(this.center, this.zoom);
174
+ this.onMoveEnd?.(this.center, this.zoom);
175
+ }
176
+ }
177
+
178
+ public setZoom(zoom: number) {
179
+ const targetZoom = clamp(zoom, this.minZoom, this.maxZoom);
180
+ if (Math.abs(this.zoom - targetZoom) < 1e-4) return;
181
+ this.zoom = targetZoom;
182
+ this.requestRender();
183
+ this.onZoom?.(this.zoom);
184
+ this.onZoomEnd?.(this.zoom);
185
+ this.onMove?.(this.center, this.zoom);
186
+ }
187
+
188
+ public zoomIn() {
189
+ this.setZoom(Math.floor(this.zoom + 1));
190
+ }
191
+
192
+ public zoomOut() {
193
+ this.setZoom(Math.ceil(this.zoom - 1));
194
+ }
195
+
196
+ public panTo(targetCenter: LatLngTuple, duration = 300) {
197
+ const startCenter = [...this.center] as LatLngTuple;
198
+ const startTime = Date.now();
199
+
200
+ if (this.animFrameId) {
201
+ if (typeof cancelAnimationFrame !== 'undefined') {
202
+ cancelAnimationFrame(this.animFrameId);
203
+ } else {
204
+ clearTimeout(this.animFrameId);
205
+ }
206
+ this.animFrameId = null;
207
+ }
208
+
209
+ const animate = () => {
210
+ const elapsed = Date.now() - startTime;
211
+ const progress = Math.min(elapsed / duration, 1);
212
+ const ease = 1 - Math.pow(1 - progress, 3);
213
+
214
+ const lat = startCenter[0] + (targetCenter[0] - startCenter[0]) * ease;
215
+ const lng = startCenter[1] + (targetCenter[1] - startCenter[1]) * ease;
216
+
217
+ this.center = [lat, lng];
218
+ this.requestRender();
219
+ this.onMove?.(this.center, this.zoom);
220
+
221
+ if (progress < 1) {
222
+ if (typeof requestAnimationFrame !== 'undefined') {
223
+ this.animFrameId = requestAnimationFrame(animate);
224
+ } else {
225
+ this.animFrameId = setTimeout(animate, 16);
226
+ }
227
+ } else {
228
+ this.animFrameId = null;
229
+ this.onMoveEnd?.(this.center, this.zoom);
230
+ }
231
+ };
232
+
233
+ animate();
234
+ }
235
+
236
+ public setTileUrl(url: string, subdomains?: string[]) {
237
+ this.tileUrl = url;
238
+ if (subdomains) this.subdomains = subdomains;
239
+ this.setLayers([{ url, subdomains: subdomains || this.subdomains }]);
240
+ }
241
+
242
+ public setLayers(layers: Array<string | TileLayerConfig>) {
243
+ this.layers = this.normalizeLayers(layers);
244
+ this.requestRender();
245
+ }
246
+
247
+ public setShowControls(show: boolean) {
248
+ this.showControls = show;
249
+ this.requestRender();
250
+ }
251
+
252
+ // --- Vector Overlays Management ---
253
+
254
+ public setOverlays(overlays: MapOverlays) {
255
+ this.overlays = { ...overlays };
256
+ this.requestRender();
257
+ }
258
+
259
+ public setMarkers(markers: MarkerOptions[]) {
260
+ this.overlays.markers = markers;
261
+ this.requestRender();
262
+ }
263
+
264
+ public setPolylines(polylines: PolylineOptions[]) {
265
+ this.overlays.polylines = polylines;
266
+ this.requestRender();
267
+ }
268
+
269
+ public setPolygons(polygons: PolygonOptions[]) {
270
+ this.overlays.polygons = polygons;
271
+ this.requestRender();
272
+ }
273
+
274
+ public setCircles(circles: CircleOptions[]) {
275
+ this.overlays.circles = circles;
276
+ this.requestRender();
277
+ }
278
+
279
+ public clearOverlays() {
280
+ this.overlays = {};
281
+ this.requestRender();
282
+ }
283
+
284
+ public getCenter(): LatLngTuple {
285
+ return [...this.center];
286
+ }
287
+
288
+ public getZoom(): number {
289
+ return this.zoom;
290
+ }
291
+
292
+ public requestRender() {
293
+ if (this.isRendering) return;
294
+ this.isRendering = true;
295
+
296
+ const doRender = () => {
297
+ this.isRendering = false;
298
+ this.render();
299
+ };
300
+
301
+ if (typeof requestAnimationFrame !== 'undefined') {
302
+ requestAnimationFrame(doRender);
303
+ } else {
304
+ setTimeout(doRender, 16);
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Main rendering routine: Tiles -> Polygons -> Circles -> Polylines -> Markers -> Labels
310
+ */
311
+ public render() {
312
+ if (!this.ctx) return;
313
+
314
+ const ctx = this.ctx;
315
+ const w = this.width;
316
+ const h = this.height;
317
+
318
+ // 1. Clear background
319
+ ctx.clearRect(0, 0, w, h);
320
+ ctx.fillStyle = '#f2efe9';
321
+ ctx.fillRect(0, 0, w, h);
322
+
323
+ // 2. Render Tile Layers
324
+ for (const layer of this.layers) {
325
+ if (!layer.url) continue;
326
+
327
+ const subdomains = layer.subdomains || this.subdomains || ['a', 'b', 'c'];
328
+ const opacity = layer.opacity !== undefined ? layer.opacity : 1;
329
+
330
+ ctx.save();
331
+ if (opacity < 1) {
332
+ ctx.globalAlpha = opacity;
333
+ }
334
+
335
+ const { tiles } = getVisibleTiles(
336
+ this.center[0],
337
+ this.center[1],
338
+ this.zoom,
339
+ w,
340
+ h,
341
+ layer.url,
342
+ subdomains,
343
+ this.tileSize
344
+ );
345
+
346
+ for (const tile of tiles) {
347
+ const record = this.tileManager.requestTile(tile.key, tile.url);
348
+
349
+ if (record && record.status === 'loaded' && record.image) {
350
+ try {
351
+ ctx.drawImage(
352
+ record.image,
353
+ tile.screenX,
354
+ tile.screenY,
355
+ tile.screenSize,
356
+ tile.screenSize
357
+ );
358
+ } catch (e) {}
359
+ } else {
360
+ const fallback = this.tileManager.getParentFallback(
361
+ tile.z,
362
+ tile.x,
363
+ tile.y,
364
+ layer.url,
365
+ subdomains,
366
+ Math.max(0, tile.z - 3),
367
+ this.tileSize
368
+ );
369
+
370
+ if (fallback) {
371
+ try {
372
+ ctx.drawImage(
373
+ fallback.image,
374
+ fallback.srcX,
375
+ fallback.srcY,
376
+ fallback.srcSize,
377
+ fallback.srcSize,
378
+ tile.screenX,
379
+ tile.screenY,
380
+ tile.screenSize,
381
+ tile.screenSize
382
+ );
383
+ } catch (e) {}
384
+ }
385
+ }
386
+ }
387
+
388
+ ctx.restore();
389
+ }
390
+
391
+ // 3. Render Vector Overlays
392
+ this.renderOverlays(ctx);
393
+
394
+ // 4. Flush drawing buffer for App-Plus / older canvas context (uni.createCanvasContext)
395
+ if (typeof ctx.draw === 'function') {
396
+ ctx.draw(false);
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Render vector overlays (Polygons, Circles, Polylines, Markers, Labels)
402
+ */
403
+ private renderOverlays(ctx: any) {
404
+ // A. Render Polygons
405
+ if (this.overlays.polygons && Array.isArray(this.overlays.polygons)) {
406
+ for (const poly of this.overlays.polygons) {
407
+ if (!poly.latLngs || poly.latLngs.length < 3) continue;
408
+
409
+ try {
410
+ ctx.save();
411
+ ctx.beginPath();
412
+ const points = poly.latLngs.map((pt) =>
413
+ this.latLngToScreenPoint(pt[0], pt[1])
414
+ );
415
+
416
+ ctx.moveTo(points[0].x, points[0].y);
417
+ for (let i = 1; i < points.length; i++) {
418
+ ctx.lineTo(points[i].x, points[i].y);
419
+ }
420
+ ctx.closePath();
421
+
422
+ ctx.fillStyle = poly.fillColor || 'rgba(239, 68, 68, 0.25)';
423
+ ctx.globalAlpha = poly.fillOpacity ?? 0.25;
424
+ ctx.fill();
425
+
426
+ ctx.globalAlpha = 1;
427
+ ctx.strokeStyle = poly.color || '#ef4444';
428
+ ctx.lineWidth = poly.width || 2;
429
+ ctx.stroke();
430
+ ctx.restore();
431
+
432
+ // Polygon Label at centroid
433
+ if (poly.label) {
434
+ const avgX =
435
+ points.reduce((sum, p) => sum + p.x, 0) / points.length;
436
+ const avgY =
437
+ points.reduce((sum, p) => sum + p.y, 0) / points.length;
438
+ const labelText =
439
+ typeof poly.label === 'string' ? poly.label : poly.label.text;
440
+ const labelStyle =
441
+ typeof poly.label === 'object' ? poly.label : undefined;
442
+ this.drawLabelBadge(ctx, labelText, avgX, avgY, labelStyle);
443
+ }
444
+ } catch (err) {
445
+ console.error('Error drawing polygon:', err);
446
+ }
447
+ }
448
+ }
449
+
450
+ // B. Render Circles
451
+ if (this.overlays.circles && Array.isArray(this.overlays.circles)) {
452
+ for (const c of this.overlays.circles) {
453
+ try {
454
+ const centerPt = this.latLngToScreenPoint(c.latLng[0], c.latLng[1]);
455
+ const radiusPx = this.metersToPixels(c.radius, c.latLng[0]);
456
+
457
+ ctx.save();
458
+ ctx.beginPath();
459
+ ctx.arc(centerPt.x, centerPt.y, radiusPx, 0, Math.PI * 2);
460
+
461
+ ctx.fillStyle = c.fillColor || c.color || '#10b981';
462
+ ctx.globalAlpha = c.fillOpacity ?? 0.2;
463
+ ctx.fill();
464
+
465
+ ctx.globalAlpha = 1;
466
+ ctx.strokeStyle = c.color || '#10b981';
467
+ ctx.lineWidth = c.width || 2;
468
+ ctx.stroke();
469
+ ctx.restore();
470
+
471
+ if (c.label) {
472
+ const labelText =
473
+ typeof c.label === 'string' ? c.label : c.label.text;
474
+ const labelStyle =
475
+ typeof c.label === 'object' ? c.label : undefined;
476
+ this.drawLabelBadge(
477
+ ctx,
478
+ labelText,
479
+ centerPt.x,
480
+ centerPt.y,
481
+ labelStyle
482
+ );
483
+ }
484
+ } catch (err) {
485
+ console.error('Error drawing circle:', err);
486
+ }
487
+ }
488
+ }
489
+
490
+ // C. Render Polylines
491
+ if (this.overlays.polylines && Array.isArray(this.overlays.polylines)) {
492
+ for (const line of this.overlays.polylines) {
493
+ if (!line.latLngs || line.latLngs.length < 2) continue;
494
+
495
+ try {
496
+ ctx.save();
497
+ ctx.beginPath();
498
+ const points = line.latLngs.map((pt) =>
499
+ this.latLngToScreenPoint(pt[0], pt[1])
500
+ );
501
+
502
+ ctx.moveTo(points[0].x, points[0].y);
503
+ for (let i = 1; i < points.length; i++) {
504
+ ctx.lineTo(points[i].x, points[i].y);
505
+ }
506
+
507
+ ctx.strokeStyle = line.color || '#3b82f6';
508
+ ctx.lineWidth = line.width || 4;
509
+ ctx.globalAlpha = line.opacity ?? 1;
510
+ ctx.lineCap = 'round';
511
+ ctx.lineJoin = 'round';
512
+
513
+ if (line.dashArray && ctx.setLineDash) {
514
+ try {
515
+ ctx.setLineDash(line.dashArray);
516
+ } catch (e) {}
517
+ }
518
+
519
+ ctx.stroke();
520
+
521
+ if (ctx.setLineDash) {
522
+ try {
523
+ ctx.setLineDash([]);
524
+ } catch (e) {}
525
+ }
526
+ ctx.restore();
527
+
528
+ // Line Label at mid point
529
+ if (line.label) {
530
+ const midIdx = Math.floor(points.length / 2);
531
+ const midPt = points[midIdx];
532
+ const labelText =
533
+ typeof line.label === 'string' ? line.label : line.label.text;
534
+ const labelStyle =
535
+ typeof line.label === 'object' ? line.label : undefined;
536
+ this.drawLabelBadge(ctx, labelText, midPt.x, midPt.y, labelStyle);
537
+ }
538
+ } catch (err) {
539
+ console.error('Error drawing polyline:', err);
540
+ }
541
+ }
542
+ }
543
+
544
+ // D. Render Markers & Pins
545
+ if (this.overlays.markers && Array.isArray(this.overlays.markers)) {
546
+ for (const m of this.overlays.markers) {
547
+ if (!m || !m.latLng) continue;
548
+
549
+ try {
550
+ const pt = this.latLngToScreenPoint(m.latLng[0], m.latLng[1]);
551
+ const size = m.icon?.size || [32, 32];
552
+ const anchor = m.icon?.anchor || [size[0] / 2, size[1]];
553
+
554
+ ctx.save();
555
+
556
+ if (m.icon?.svg) {
557
+ // Raw SVG string markup
558
+ let svgStr = m.icon.svg.trim();
559
+ if (!svgStr.includes('xmlns=')) {
560
+ svgStr = svgStr.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
561
+ }
562
+ const svgDataUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgStr)}`;
563
+ const img = this.getOrLoadIcon(svgDataUri);
564
+ if (img) {
565
+ ctx.drawImage(img, pt.x - anchor[0], pt.y - anchor[1], size[0], size[1]);
566
+ } else {
567
+ this.drawPinFallback(ctx, pt, m.icon?.color || '#ef4444', size, anchor);
568
+ }
569
+ } else if (m.icon?.url) {
570
+ // PNG / JPG / WebP / SVG Image URL or Data URI
571
+ const img = this.getOrLoadIcon(m.icon.url);
572
+ if (img) {
573
+ ctx.drawImage(img, pt.x - anchor[0], pt.y - anchor[1], size[0], size[1]);
574
+ } else {
575
+ this.drawPinFallback(ctx, pt, m.icon?.color || '#3b82f6', size, anchor);
576
+ }
577
+ } else if (m.icon?.text) {
578
+ // Emoji / Character Icon
579
+ const fontSize = Math.round(size[0] * 0.72);
580
+ ctx.font = `${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`;
581
+ ctx.textAlign = 'center';
582
+ ctx.textBaseline = 'middle';
583
+ ctx.fillText(m.icon.text, pt.x - anchor[0] + size[0] / 2, pt.y - anchor[1] + size[1] / 2);
584
+ } else {
585
+ // Sleek Vector Pin
586
+ this.drawPinFallback(ctx, pt, m.icon?.color || '#ef4444', size, anchor);
587
+ }
588
+
589
+ ctx.restore();
590
+
591
+ // Marker Label badge on top of pin / icon (Speech bubble with bottom arrow pointing tightly at icon top)
592
+ if (m.label) {
593
+ const labelText =
594
+ typeof m.label === 'string' ? m.label : m.label.text;
595
+ const labelStyle =
596
+ typeof m.label === 'object' ? m.label : undefined;
597
+
598
+ // Accurate visual top edge and horizontal center of the icon
599
+ const iconTopY = pt.y - anchor[1];
600
+ const iconCenterX = pt.x - anchor[0] + size[0] / 2;
601
+
602
+ // Tip sits directly 1px above the top-center of the icon
603
+ let tipX = iconCenterX;
604
+ let tipY = iconTopY - 1;
605
+
606
+ if (labelStyle?.offset) {
607
+ const offX = labelStyle.offset[0] || 0;
608
+ const offY = labelStyle.offset[1] || 0;
609
+ tipX += offX;
610
+ // Allow fine-tuning offset within ±10px, ignoring excessive legacy displacement numbers
611
+ if (Math.abs(offY) <= 10) {
612
+ tipY += offY;
613
+ }
614
+ }
615
+
616
+ this.drawLabelBadge(
617
+ ctx,
618
+ labelText,
619
+ tipX,
620
+ tipY,
621
+ labelStyle,
622
+ true
623
+ );
624
+ }
625
+ } catch (err) {
626
+ console.error('Error drawing marker:', err);
627
+ }
628
+ }
629
+ }
630
+
631
+ // E. Render Zoom Controls if enabled (Drawn directly in Canvas 2D frame buffer)
632
+ if (this.showControls) {
633
+ this.drawZoomControls(ctx);
634
+ }
635
+ }
636
+
637
+ /**
638
+ * Draw floating zoom controls (+ / -) directly onto Canvas 2D frame buffer
639
+ */
640
+ private drawZoomControls(ctx: any) {
641
+ const btnW = 38;
642
+ const btnH = 38;
643
+ const pad = 12;
644
+ const x = this.width - btnW - pad;
645
+ const y = pad;
646
+ const radius = 8;
647
+
648
+ try {
649
+ ctx.save();
650
+ // Drop shadow
651
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.15)';
652
+ ctx.shadowBlur = 10;
653
+ ctx.shadowOffsetY = 2;
654
+
655
+ // Card Background (Universally safe arcTo path)
656
+ ctx.fillStyle = '#ffffff';
657
+ ctx.beginPath();
658
+ ctx.moveTo(x + radius, y);
659
+ ctx.lineTo(x + btnW - radius, y);
660
+ ctx.arcTo(x + btnW, y, x + btnW, y + radius, radius);
661
+ ctx.lineTo(x + btnW, y + btnH * 2 - radius);
662
+ ctx.arcTo(x + btnW, y + btnH * 2, x + btnW - radius, y + btnH * 2, radius);
663
+ ctx.lineTo(x + radius, y + btnH * 2);
664
+ ctx.arcTo(x, y + btnH * 2, x, y + btnH * 2 - radius, radius);
665
+ ctx.lineTo(x, y + radius);
666
+ ctx.arcTo(x, y, x + radius, y, radius);
667
+ ctx.closePath();
668
+ ctx.fill();
669
+
670
+ // Reset shadow for border & divider
671
+ ctx.shadowColor = 'transparent';
672
+ ctx.shadowBlur = 0;
673
+ ctx.shadowOffsetY = 0;
674
+ ctx.strokeStyle = '#e2e8f0';
675
+ ctx.lineWidth = 1;
676
+ ctx.stroke();
677
+
678
+ // Divider line
679
+ ctx.beginPath();
680
+ ctx.moveTo(x, y + btnH);
681
+ ctx.lineTo(x + btnW, y + btnH);
682
+ ctx.stroke();
683
+
684
+ // Text '+'
685
+ ctx.font = 'bold 22px sans-serif';
686
+ ctx.fillStyle = '#334155';
687
+ ctx.textAlign = 'center';
688
+ ctx.textBaseline = 'middle';
689
+ ctx.fillText('+', x + btnW / 2, y + btnH / 2);
690
+
691
+ // Text '−'
692
+ ctx.font = 'bold 22px sans-serif';
693
+ ctx.fillStyle = '#334155';
694
+ ctx.textAlign = 'center';
695
+ ctx.textBaseline = 'middle';
696
+ ctx.fillText('−', x + btnW / 2, y + btnH + btnH / 2);
697
+
698
+ ctx.restore();
699
+ } catch (err) {
700
+ console.error('Error drawing zoom controls on canvas:', err);
701
+ }
702
+ }
703
+
704
+ /**
705
+ * Draw rounded pill badge or speech bubble with bottom arrow for label text safely on WeChat Canvas 2D
706
+ */
707
+ private drawLabelBadge(
708
+ ctx: any,
709
+ text: string,
710
+ x: number,
711
+ y: number,
712
+ style?: MarkerLabelOptions,
713
+ withArrow: boolean = false
714
+ ) {
715
+ if (!text || typeof text !== 'string') return;
716
+ try {
717
+ const fontSize = style?.fontSize || 12;
718
+ const color = style?.color || '#1e293b';
719
+ const bg = style?.backgroundColor || 'rgba(255, 255, 255, 0.95)';
720
+ const border = style?.borderColor || '#cbd5e1';
721
+ const radius = style?.borderRadius ?? 6;
722
+ const paddingX = style?.padding ? style.padding[1] : 8;
723
+ const paddingY = style?.padding ? style.padding[0] : 4;
724
+
725
+ ctx.save();
726
+ // Drop shadow for floating card feel
727
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.15)';
728
+ ctx.shadowBlur = 6;
729
+ ctx.shadowOffsetY = 2;
730
+
731
+ ctx.font = `bold ${Math.round(fontSize)}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`;
732
+
733
+ let textWidth = text.length * fontSize * 0.65;
734
+ try {
735
+ if (ctx.measureText) {
736
+ const m = ctx.measureText(text);
737
+ if (m && m.width > 0) {
738
+ textWidth = m.width;
739
+ }
740
+ }
741
+ } catch (e) {}
742
+
743
+ const boxWidth = textWidth + paddingX * 2;
744
+ const boxHeight = fontSize + paddingY * 2 + 2;
745
+
746
+ ctx.beginPath();
747
+
748
+ if (withArrow) {
749
+ // Speech bubble with compact bottom arrow pointing at (x, y)
750
+ const arrowH = 4;
751
+ const arrowHalfW = 4;
752
+ const boxBottom = y - arrowH;
753
+ const boxTop = boxBottom - boxHeight;
754
+ const boxLeft = x - boxWidth / 2;
755
+ const boxRight = x + boxWidth / 2;
756
+ const r = Math.min(radius, boxWidth / 4, boxHeight / 2);
757
+
758
+ ctx.moveTo(boxLeft + r, boxTop);
759
+ ctx.lineTo(boxRight - r, boxTop);
760
+ ctx.arcTo(boxRight, boxTop, boxRight, boxTop + r, r);
761
+ ctx.lineTo(boxRight, boxBottom - r);
762
+ ctx.arcTo(boxRight, boxBottom, boxRight - r, boxBottom, r);
763
+ // Bottom arrow pointer
764
+ ctx.lineTo(x + arrowHalfW, boxBottom);
765
+ ctx.lineTo(x, y); // Arrow tip pointing at top of icon
766
+ ctx.lineTo(x - arrowHalfW, boxBottom);
767
+ ctx.lineTo(boxLeft + r, boxBottom);
768
+ ctx.arcTo(boxLeft, boxBottom, boxLeft, boxBottom - r, r);
769
+ ctx.lineTo(boxLeft, boxTop + r);
770
+ ctx.arcTo(boxLeft, boxTop, boxLeft + r, boxTop, r);
771
+ ctx.closePath();
772
+
773
+ // Background fill
774
+ ctx.fillStyle = bg;
775
+ ctx.fill();
776
+
777
+ // Stroke border
778
+ ctx.shadowColor = 'transparent';
779
+ ctx.shadowBlur = 0;
780
+ ctx.shadowOffsetY = 0;
781
+ ctx.strokeStyle = border;
782
+ ctx.lineWidth = 1;
783
+ ctx.stroke();
784
+
785
+ // Text Content
786
+ ctx.fillStyle = color;
787
+ ctx.textAlign = 'center';
788
+ ctx.textBaseline = 'middle';
789
+ ctx.fillText(text, x, boxTop + boxHeight / 2 + 1);
790
+ } else {
791
+ // Simple centered pill badge
792
+ const boxX = x - boxWidth / 2;
793
+ const boxY = y - boxHeight / 2;
794
+ const r = Math.min(radius, boxWidth / 2, boxHeight / 2);
795
+
796
+ ctx.moveTo(boxX + r, boxY);
797
+ ctx.lineTo(boxX + boxWidth - r, boxY);
798
+ ctx.arcTo(boxX + boxWidth, boxY, boxX + boxWidth, boxY + r, r);
799
+ ctx.lineTo(boxX + boxWidth, boxY + boxHeight - r);
800
+ ctx.arcTo(
801
+ boxX + boxWidth,
802
+ boxY + boxHeight,
803
+ boxX + boxWidth - r,
804
+ boxY + boxHeight,
805
+ r
806
+ );
807
+ ctx.lineTo(boxX + r, boxY + boxHeight);
808
+ ctx.arcTo(boxX, boxY + boxHeight, boxX, boxY + boxHeight - r, r);
809
+ ctx.lineTo(boxX, boxY + r);
810
+ ctx.arcTo(boxX, boxY, boxX + r, boxY, r);
811
+ ctx.closePath();
812
+
813
+ ctx.fillStyle = bg;
814
+ ctx.fill();
815
+
816
+ ctx.shadowColor = 'transparent';
817
+ ctx.shadowBlur = 0;
818
+ ctx.shadowOffsetY = 0;
819
+ ctx.strokeStyle = border;
820
+ ctx.lineWidth = 1;
821
+ ctx.stroke();
822
+
823
+ ctx.fillStyle = color;
824
+ ctx.textAlign = 'center';
825
+ ctx.textBaseline = 'middle';
826
+ ctx.fillText(text, x, y + 1);
827
+ }
828
+
829
+ ctx.restore();
830
+ } catch (err) {
831
+ try {
832
+ ctx.restore();
833
+ } catch (e) {}
834
+ }
835
+ }
836
+
837
+ /**
838
+ * Convert LatLng coordinate to screen point (X, Y)
839
+ */
840
+ public latLngToScreenPoint(lat: number, lng: number): Point {
841
+ const centerWorld = latLngToWorldPixel(
842
+ this.center[0],
843
+ this.center[1],
844
+ this.zoom,
845
+ this.tileSize
846
+ );
847
+ const pointWorld = latLngToWorldPixel(lat, lng, this.zoom, this.tileSize);
848
+
849
+ return {
850
+ x: this.width / 2 + (pointWorld.x - centerWorld.x),
851
+ y: this.height / 2 + (pointWorld.y - centerWorld.y),
852
+ };
853
+ }
854
+
855
+ /**
856
+ * Convert geographic distance in meters to screen pixels at given latitude
857
+ */
858
+ public metersToPixels(meters: number, lat: number): number {
859
+ const metersPerPixel =
860
+ (40075016.686 * Math.cos((lat * Math.PI) / 180)) /
861
+ (Math.pow(2, this.zoom) * this.tileSize);
862
+ return meters / metersPerPixel;
863
+ }
864
+
865
+ /**
866
+ * Screen point (X, Y) to LatLng coordinate
867
+ */
868
+ public screenPointToLatLng(screenX: number, screenY: number): LatLngTuple {
869
+ const centerWorld = latLngToWorldPixel(
870
+ this.center[0],
871
+ this.center[1],
872
+ this.zoom,
873
+ this.tileSize
874
+ );
875
+ const worldX = centerWorld.x + (screenX - this.width / 2);
876
+ const worldY = centerWorld.y + (screenY - this.height / 2);
877
+
878
+ const latLng = worldPixelToLatLng(worldX, worldY, this.zoom, this.tileSize);
879
+ return [latLng.lat, latLng.lng];
880
+ }
881
+
882
+ // --- Touch & Mouse Event Handlers ---
883
+
884
+ public handleTouchStart(e: any) {
885
+ const touches = e.touches || e.changedTouches || [];
886
+ this.touchCount = touches.length;
887
+ this.isTouching = true;
888
+ this.hasMoved = false;
889
+ this.touchStartTime = Date.now();
890
+
891
+ this.startTouches = [];
892
+ for (let i = 0; i < touches.length; i++) {
893
+ this.startTouches.push({
894
+ x: touches[i].x ?? touches[i].clientX,
895
+ y: touches[i].y ?? touches[i].clientY,
896
+ });
897
+ }
898
+
899
+ this.startCenter = [...this.center];
900
+ this.startZoom = this.zoom;
901
+
902
+ if (touches.length >= 2) {
903
+ const t1 = this.startTouches[0];
904
+ const t2 = this.startTouches[1];
905
+ this.startPinchDist = Math.hypot(t1.x - t2.x, t1.y - t2.y);
906
+ this.pinchMidPoint = {
907
+ x: (t1.x + t2.x) / 2,
908
+ y: (t1.y + t2.y) / 2,
909
+ };
910
+ }
911
+ }
912
+
913
+ public handleTouchMove(e: any) {
914
+ if (!this.isTouching) return;
915
+ const touches = e.touches || e.changedTouches || [];
916
+
917
+ if (touches.length === 1 && this.touchCount === 1) {
918
+ const curX = touches[0].x ?? touches[0].clientX;
919
+ const curY = touches[0].y ?? touches[0].clientY;
920
+ const dx = curX - this.startTouches[0].x;
921
+ const dy = curY - this.startTouches[0].y;
922
+
923
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
924
+ this.hasMoved = true;
925
+ }
926
+
927
+ const startWorld = latLngToWorldPixel(
928
+ this.startCenter[0],
929
+ this.startCenter[1],
930
+ this.startZoom,
931
+ this.tileSize
932
+ );
933
+
934
+ const curWorldX = startWorld.x - dx;
935
+ const curWorldY = startWorld.y - dy;
936
+
937
+ const newLatLng = worldPixelToLatLng(
938
+ curWorldX,
939
+ curWorldY,
940
+ this.startZoom,
941
+ this.tileSize
942
+ );
943
+
944
+ this.center = [newLatLng.lat, newLatLng.lng];
945
+ this.requestRender();
946
+ this.onMove?.(this.center, this.zoom);
947
+ } else if (touches.length >= 2) {
948
+ this.hasMoved = true;
949
+ const t1 = {
950
+ x: touches[0].x ?? touches[0].clientX,
951
+ y: touches[0].y ?? touches[0].clientY,
952
+ };
953
+ const t2 = {
954
+ x: touches[1].x ?? touches[1].clientX,
955
+ y: touches[1].y ?? touches[1].clientY,
956
+ };
957
+
958
+ const curDist = Math.hypot(t1.x - t2.x, t1.y - t2.y);
959
+ if (this.startPinchDist > 0 && curDist > 0) {
960
+ const scale = curDist / this.startPinchDist;
961
+ const newZoom = clamp(
962
+ this.startZoom + Math.log2(scale),
963
+ this.minZoom,
964
+ this.maxZoom
965
+ );
966
+
967
+ this.zoom = newZoom;
968
+ this.requestRender();
969
+ this.onZoom?.(this.zoom);
970
+ this.onMove?.(this.center, this.zoom);
971
+ }
972
+ }
973
+ }
974
+
975
+ public handleTouchEnd(e: any) {
976
+ if (!this.isTouching) return;
977
+ this.isTouching = false;
978
+
979
+ const touchDuration = Date.now() - this.touchStartTime;
980
+
981
+ // Check tap / click
982
+ if (!this.hasMoved && touchDuration < 400 && this.startTouches.length === 1) {
983
+ const tapPos = this.startTouches[0];
984
+
985
+ // Check hit on Canvas-drawn Zoom Controls
986
+ if (this.showControls) {
987
+ const btnW = 38;
988
+ const btnH = 38;
989
+ const pad = 12;
990
+ const x = this.width - btnW - pad;
991
+ const y = pad;
992
+
993
+ if (tapPos.x >= x - 6 && tapPos.x <= x + btnW + 6) {
994
+ if (tapPos.y >= y - 6 && tapPos.y <= y + btnH) {
995
+ this.zoomIn();
996
+ return;
997
+ } else if (tapPos.y > y + btnH && tapPos.y <= y + btnH * 2 + 6) {
998
+ this.zoomOut();
999
+ return;
1000
+ }
1001
+ }
1002
+ }
1003
+
1004
+ const now = Date.now();
1005
+
1006
+ // Check double tap
1007
+ if (
1008
+ now - this.lastTapTime < 300 &&
1009
+ Math.hypot(tapPos.x - this.lastTapPos.x, tapPos.y - this.lastTapPos.y) <
1010
+ 30
1011
+ ) {
1012
+ const tapLatLng = this.screenPointToLatLng(tapPos.x, tapPos.y);
1013
+ this.center = tapLatLng;
1014
+ this.setZoom(Math.min(Math.floor(this.zoom + 1), this.maxZoom));
1015
+ this.lastTapTime = 0;
1016
+ return;
1017
+ }
1018
+
1019
+ this.lastTapTime = now;
1020
+ this.lastTapPos = tapPos;
1021
+
1022
+ // Hit testing for overlays
1023
+ let hitOverlay = false;
1024
+
1025
+ // Check markers hit
1026
+ if (this.overlays.markers) {
1027
+ for (const m of this.overlays.markers) {
1028
+ const pt = this.latLngToScreenPoint(m.latLng[0], m.latLng[1]);
1029
+ const size = m.icon?.size || [32, 32];
1030
+ const anchor = m.icon?.anchor || [size[0] / 2, size[1]];
1031
+ const centerX = pt.x - anchor[0] + size[0] / 2;
1032
+ const centerY = pt.y - anchor[1] + size[1] / 2;
1033
+ const hitRadius = Math.max(20, Math.max(size[0], size[1]) / 2 + 4);
1034
+ if (Math.hypot(centerX - tapPos.x, centerY - tapPos.y) <= hitRadius) {
1035
+ this.onOverlayClick?.({ type: 'marker', data: m });
1036
+ hitOverlay = true;
1037
+ break;
1038
+ }
1039
+ }
1040
+ }
1041
+
1042
+ if (!hitOverlay) {
1043
+ const clickLatLng = this.screenPointToLatLng(tapPos.x, tapPos.y);
1044
+ this.onClick?.(clickLatLng, tapPos);
1045
+ }
1046
+ } else {
1047
+ this.onMoveEnd?.(this.center, this.zoom);
1048
+ this.onZoomEnd?.(this.zoom);
1049
+ }
1050
+ }
1051
+
1052
+ /**
1053
+ * Helper to create an Image instance (compatible with WeChat Mini-Program Canvas 2D & Web)
1054
+ */
1055
+ private createIconImageInstance(): any {
1056
+ if (this.canvas && typeof this.canvas.createImage === 'function') {
1057
+ return this.canvas.createImage();
1058
+ }
1059
+ if (typeof Image !== 'undefined') {
1060
+ try {
1061
+ const img = new Image();
1062
+ img.crossOrigin = 'Anonymous';
1063
+ return img;
1064
+ } catch (e) {}
1065
+ }
1066
+ return null;
1067
+ }
1068
+
1069
+ /**
1070
+ * Request / cache an icon image (PNG, JPG, SVG, Data URI)
1071
+ */
1072
+ private getOrLoadIcon(src: string): any {
1073
+ if (!src) return null;
1074
+ const existing = this.iconCache.get(src);
1075
+ if (existing) {
1076
+ return existing.status === 'loaded' ? existing.image : null;
1077
+ }
1078
+
1079
+ const img = this.createIconImageInstance();
1080
+ if (img) {
1081
+ const record: { image: any; status: 'loading' | 'loaded' | 'error' } = {
1082
+ image: img,
1083
+ status: 'loading',
1084
+ };
1085
+ this.iconCache.set(src, record);
1086
+
1087
+ img.onload = () => {
1088
+ record.status = 'loaded';
1089
+ this.requestRender();
1090
+ };
1091
+ img.onerror = () => {
1092
+ record.status = 'error';
1093
+ };
1094
+ img.src = src;
1095
+ return null;
1096
+ }
1097
+
1098
+ // Fallback for non-DOM WeChat / App environments
1099
+ if (typeof uni !== 'undefined' && typeof uni.getImageInfo === 'function') {
1100
+ const record: { image: any; status: 'loading' | 'loaded' | 'error' } = {
1101
+ image: src,
1102
+ status: 'loading',
1103
+ };
1104
+ this.iconCache.set(src, record);
1105
+
1106
+ uni.getImageInfo({
1107
+ src,
1108
+ success: (res: any) => {
1109
+ record.image = res.path || src;
1110
+ record.status = 'loaded';
1111
+ this.requestRender();
1112
+ },
1113
+ fail: () => {
1114
+ record.status = 'error';
1115
+ },
1116
+ });
1117
+ return null;
1118
+ }
1119
+
1120
+ return null;
1121
+ }
1122
+
1123
+ /**
1124
+ * Draw fallback sleek vector pin
1125
+ */
1126
+ private drawPinFallback(
1127
+ ctx: any,
1128
+ pt: Point,
1129
+ pinColor: string,
1130
+ size: [number, number] = [28, 36],
1131
+ anchor: [number, number] = [14, 36]
1132
+ ) {
1133
+ const scale = (size[1] || 36) / 36;
1134
+ const pinTopX = pt.x - anchor[0] + size[0] / 2;
1135
+ const pinTipY = pt.y - anchor[1] + size[1];
1136
+ const circleCenterY = pinTipY - 18 * scale;
1137
+ const headRadius = 8 * scale;
1138
+
1139
+ ctx.fillStyle = pinColor;
1140
+ ctx.beginPath();
1141
+ ctx.arc(pinTopX, circleCenterY, headRadius, 0, Math.PI * 2);
1142
+ ctx.moveTo(pinTopX - 5 * scale, circleCenterY + 5 * scale);
1143
+ ctx.lineTo(pinTopX, pinTipY);
1144
+ ctx.lineTo(pinTopX + 5 * scale, circleCenterY + 5 * scale);
1145
+ ctx.fill();
1146
+
1147
+ // White center dot
1148
+ ctx.fillStyle = '#ffffff';
1149
+ ctx.beginPath();
1150
+ ctx.arc(pinTopX, circleCenterY, 3 * scale, 0, Math.PI * 2);
1151
+ ctx.fill();
1152
+ }
1153
+
1154
+ /**
1155
+ * Handle mouse wheel zoom (for PC Mini-Program, WeChat DevTools, etc.)
1156
+ */
1157
+ public handleWheel(e: any) {
1158
+ let delta = 0;
1159
+ if (e.deltaY !== undefined) {
1160
+ delta = e.deltaY;
1161
+ } else if (e.wheelDelta !== undefined) {
1162
+ delta = -e.wheelDelta;
1163
+ } else if (e.detail && e.detail.deltaY !== undefined) {
1164
+ delta = e.detail.deltaY;
1165
+ } else if (e.detail && typeof e.detail === 'number') {
1166
+ delta = e.detail;
1167
+ }
1168
+
1169
+ if (!delta) return;
1170
+
1171
+ let mouseX = this.width / 2;
1172
+ let mouseY = this.height / 2;
1173
+
1174
+ if (e.x !== undefined && e.y !== undefined) {
1175
+ mouseX = e.x;
1176
+ mouseY = e.y;
1177
+ } else if (e.offsetX !== undefined && e.offsetY !== undefined) {
1178
+ mouseX = e.offsetX;
1179
+ mouseY = e.offsetY;
1180
+ } else if (e.detail && e.detail.x !== undefined && e.detail.y !== undefined) {
1181
+ mouseX = e.detail.x;
1182
+ mouseY = e.detail.y;
1183
+ }
1184
+
1185
+ const pivotLatLng = this.screenPointToLatLng(mouseX, mouseY);
1186
+ const zoomDelta = delta > 0 ? -0.5 : 0.5;
1187
+ const targetZoom = clamp(
1188
+ Math.round((this.zoom + zoomDelta) * 2) / 2,
1189
+ this.minZoom,
1190
+ this.maxZoom
1191
+ );
1192
+
1193
+ if (Math.abs(targetZoom - this.zoom) < 1e-4) return;
1194
+
1195
+ const pivotWorld = latLngToWorldPixel(
1196
+ pivotLatLng[0],
1197
+ pivotLatLng[1],
1198
+ targetZoom,
1199
+ this.tileSize
1200
+ );
1201
+
1202
+ const newCenterWorldX = pivotWorld.x - (mouseX - this.width / 2);
1203
+ const newCenterWorldY = pivotWorld.y - (mouseY - this.height / 2);
1204
+
1205
+ const newCenterLatLng = worldPixelToLatLng(
1206
+ newCenterWorldX,
1207
+ newCenterWorldY,
1208
+ targetZoom,
1209
+ this.tileSize
1210
+ );
1211
+
1212
+ this.zoom = targetZoom;
1213
+ this.center = [newCenterLatLng.lat, newCenterLatLng.lng];
1214
+
1215
+ this.requestRender();
1216
+ this.onZoom?.(this.zoom);
1217
+ this.onMove?.(this.center, this.zoom);
1218
+
1219
+ if (this.wheelEndTimeout) {
1220
+ clearTimeout(this.wheelEndTimeout);
1221
+ }
1222
+ this.wheelEndTimeout = setTimeout(() => {
1223
+ this.onZoomEnd?.(this.zoom);
1224
+ this.onMoveEnd?.(this.center, this.zoom);
1225
+ }, 200);
1226
+ }
1227
+
1228
+ public getNativeInstance(): any {
1229
+ return this.canvas;
1230
+ }
1231
+
1232
+ public destroy() {
1233
+ if (this.wheelEndTimeout) {
1234
+ clearTimeout(this.wheelEndTimeout);
1235
+ this.wheelEndTimeout = null;
1236
+ }
1237
+ if (this.animFrameId) {
1238
+ if (typeof cancelAnimationFrame !== 'undefined') {
1239
+ cancelAnimationFrame(this.animFrameId);
1240
+ }
1241
+ this.animFrameId = null;
1242
+ }
1243
+ for (const [, v] of this.iconCache.entries()) {
1244
+ if (v.image && typeof v.image === 'object') {
1245
+ v.image.onload = null;
1246
+ v.image.onerror = null;
1247
+ }
1248
+ }
1249
+ this.iconCache.clear();
1250
+ this.tileManager.clear();
1251
+ }
1252
+ }