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.
- package/README.md +383 -0
- package/UniLeafletMap.vue +706 -0
- package/app-render.d.ts +2 -0
- package/app-render.js +550 -0
- package/engine/canvas-tile-engine.ts +1252 -0
- package/engine/factory.ts +101 -0
- package/engine/h5-map-adapter.ts +470 -0
- package/engine/tile-manager.ts +192 -0
- package/index.ts +19 -0
- package/package.json +46 -0
- package/types.ts +159 -0
- package/utils/crs.ts +60 -0
- package/utils/tile.ts +118 -0
|
@@ -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
|
+
}
|