uni-leaflet 1.1.0 → 1.2.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 +304 -5
- package/UniLeafletMap.vue +433 -65
- package/app-render.js +897 -95
- package/engine/canvas-tile-engine.ts +694 -79
- package/engine/factory.ts +15 -0
- package/engine/h5-map-adapter.ts +894 -133
- package/index.ts +4 -0
- package/package.json +1 -1
- package/types.ts +95 -2
- package/utils/bounds.ts +264 -0
- package/utils/cluster.ts +158 -0
- package/utils/curve.ts +86 -0
- package/utils/geojson.ts +175 -0
- package/uni-leaflet-1.0.0.tgz +0 -0
|
@@ -10,10 +10,25 @@ import type {
|
|
|
10
10
|
MapOverlays,
|
|
11
11
|
OverlayClickEvent,
|
|
12
12
|
MarkerLabelOptions,
|
|
13
|
+
FitBoundsOptions,
|
|
14
|
+
LatLngBoundsExpression,
|
|
15
|
+
MapBounds,
|
|
16
|
+
ClusterOptions,
|
|
17
|
+
ClusterGroup,
|
|
18
|
+
GeoJsonStyle,
|
|
19
|
+
GeoJsonStyleOptions,
|
|
13
20
|
} from '../types';
|
|
14
21
|
import { clamp, latLngToWorldPixel, worldPixelToLatLng } from '../utils/crs';
|
|
15
22
|
import { getVisibleTiles } from '../utils/tile';
|
|
23
|
+
import { getBoundsCenterAndZoom } from '../utils/bounds';
|
|
24
|
+
import { clusterMarkers } from '../utils/cluster';
|
|
25
|
+
import {
|
|
26
|
+
normalizeGeoJsonFeatures,
|
|
27
|
+
resolveGeoJsonStyle,
|
|
28
|
+
isPointInPolygon2D,
|
|
29
|
+
} from '../utils/geojson';
|
|
16
30
|
import { TileManager } from './tile-manager';
|
|
31
|
+
import { generateCurvedPolyline } from '../utils/curve';
|
|
17
32
|
|
|
18
33
|
export interface CanvasEngineOptions {
|
|
19
34
|
canvas: any;
|
|
@@ -29,6 +44,8 @@ export interface CanvasEngineOptions {
|
|
|
29
44
|
subdomains?: string[];
|
|
30
45
|
layers?: Array<string | TileLayerConfig>;
|
|
31
46
|
overlays?: MapOverlays;
|
|
47
|
+
geojson?: any;
|
|
48
|
+
geojsonStyle?: GeoJsonStyle;
|
|
32
49
|
tileSize?: number;
|
|
33
50
|
showControls?: boolean;
|
|
34
51
|
onMove?: (center: LatLngTuple, zoom: number) => void;
|
|
@@ -37,6 +54,8 @@ export interface CanvasEngineOptions {
|
|
|
37
54
|
onZoomEnd?: (zoom: number) => void;
|
|
38
55
|
onClick?: (latLng: LatLngTuple, point: Point) => void;
|
|
39
56
|
onOverlayClick?: (event: OverlayClickEvent) => void;
|
|
57
|
+
onClusterClick?: (cluster: ClusterGroup) => void;
|
|
58
|
+
clusterOptions?: ClusterOptions;
|
|
40
59
|
}
|
|
41
60
|
|
|
42
61
|
export class CanvasTileEngine implements IMapEngine {
|
|
@@ -56,12 +75,26 @@ export class CanvasTileEngine implements IMapEngine {
|
|
|
56
75
|
private subdomains: string[];
|
|
57
76
|
private layers: TileLayerConfig[] = [];
|
|
58
77
|
private overlays: MapOverlays = {};
|
|
78
|
+
private geojson: any = null;
|
|
79
|
+
private geojsonStyle?: GeoJsonStyle;
|
|
59
80
|
private showControls: boolean;
|
|
60
81
|
|
|
82
|
+
private clusterOptions: ClusterOptions = {
|
|
83
|
+
enable: false,
|
|
84
|
+
clusterRadius: 60,
|
|
85
|
+
clusterMaxZoom: 17,
|
|
86
|
+
};
|
|
87
|
+
private currentClusters: ClusterGroup[] = [];
|
|
88
|
+
|
|
61
89
|
private tileManager: TileManager;
|
|
62
90
|
private isRendering = false;
|
|
63
91
|
private animFrameId: any = null;
|
|
64
92
|
|
|
93
|
+
// Flow animation state
|
|
94
|
+
private flowAnimationOffset = 0;
|
|
95
|
+
private flowAnimFrameId: any = null;
|
|
96
|
+
private isFlowAnimating = false;
|
|
97
|
+
|
|
65
98
|
// Touch tracking
|
|
66
99
|
private isTouching = false;
|
|
67
100
|
private touchCount = 0;
|
|
@@ -87,6 +120,7 @@ export class CanvasTileEngine implements IMapEngine {
|
|
|
87
120
|
public onZoomEnd?: (zoom: number) => void;
|
|
88
121
|
public onClick?: (latLng: LatLngTuple, point: Point) => void;
|
|
89
122
|
public onOverlayClick?: (event: OverlayClickEvent) => void;
|
|
123
|
+
public onClusterClick?: (cluster: ClusterGroup) => void;
|
|
90
124
|
|
|
91
125
|
constructor(options: CanvasEngineOptions) {
|
|
92
126
|
this.canvas = options.canvas;
|
|
@@ -103,11 +137,17 @@ export class CanvasTileEngine implements IMapEngine {
|
|
|
103
137
|
this.showControls =
|
|
104
138
|
options.showControls !== undefined ? options.showControls : true;
|
|
105
139
|
|
|
140
|
+
if (options.clusterOptions) {
|
|
141
|
+
this.clusterOptions = { ...this.clusterOptions, ...options.clusterOptions };
|
|
142
|
+
}
|
|
143
|
+
|
|
106
144
|
this.tileUrl =
|
|
107
145
|
options.tileUrl || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
|
108
146
|
this.subdomains = options.subdomains || ['a', 'b', 'c'];
|
|
109
147
|
this.layers = this.normalizeLayers(options.layers);
|
|
110
148
|
this.overlays = options.overlays || {};
|
|
149
|
+
this.geojson = options.geojson || options.overlays?.geojson || null;
|
|
150
|
+
this.geojsonStyle = options.geojsonStyle || options.overlays?.geojsonStyle;
|
|
111
151
|
|
|
112
152
|
this.onMove = options.onMove;
|
|
113
153
|
this.onMoveEnd = options.onMoveEnd;
|
|
@@ -115,6 +155,7 @@ export class CanvasTileEngine implements IMapEngine {
|
|
|
115
155
|
this.onZoomEnd = options.onZoomEnd;
|
|
116
156
|
this.onClick = options.onClick;
|
|
117
157
|
this.onOverlayClick = options.onOverlayClick;
|
|
158
|
+
this.onClusterClick = options.onClusterClick;
|
|
118
159
|
|
|
119
160
|
this.tileManager = new TileManager();
|
|
120
161
|
this.tileManager.setCanvasNode(this.canvas);
|
|
@@ -124,6 +165,7 @@ export class CanvasTileEngine implements IMapEngine {
|
|
|
124
165
|
|
|
125
166
|
this.initCanvasDpr();
|
|
126
167
|
this.requestRender();
|
|
168
|
+
this.checkFlowAnimationState();
|
|
127
169
|
}
|
|
128
170
|
|
|
129
171
|
private normalizeLayers(
|
|
@@ -233,6 +275,81 @@ export class CanvasTileEngine implements IMapEngine {
|
|
|
233
275
|
animate();
|
|
234
276
|
}
|
|
235
277
|
|
|
278
|
+
public fitBounds(
|
|
279
|
+
bounds: LatLngBoundsExpression | LatLngTuple[] | MapBounds,
|
|
280
|
+
options?: FitBoundsOptions
|
|
281
|
+
) {
|
|
282
|
+
const result = getBoundsCenterAndZoom(bounds, this.width, this.height, {
|
|
283
|
+
padding: options?.padding,
|
|
284
|
+
minZoom: this.minZoom,
|
|
285
|
+
maxZoom: options?.maxZoom ?? this.maxZoom,
|
|
286
|
+
tileSize: this.tileSize,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (!result) return;
|
|
290
|
+
|
|
291
|
+
const { center: targetCenter, zoom: targetZoom } = result;
|
|
292
|
+
const animate = options?.animate !== false;
|
|
293
|
+
const duration = options?.duration ?? 300;
|
|
294
|
+
|
|
295
|
+
if (this.animFrameId) {
|
|
296
|
+
if (typeof cancelAnimationFrame !== 'undefined') {
|
|
297
|
+
cancelAnimationFrame(this.animFrameId);
|
|
298
|
+
} else {
|
|
299
|
+
clearTimeout(this.animFrameId);
|
|
300
|
+
}
|
|
301
|
+
this.animFrameId = null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!animate || duration <= 0) {
|
|
305
|
+
this.center = [...targetCenter];
|
|
306
|
+
this.zoom = targetZoom;
|
|
307
|
+
this.requestRender();
|
|
308
|
+
this.onMove?.(this.center, this.zoom);
|
|
309
|
+
this.onZoom?.(this.zoom);
|
|
310
|
+
this.onMoveEnd?.(this.center, this.zoom);
|
|
311
|
+
this.onZoomEnd?.(this.zoom);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const startCenter = [...this.center] as LatLngTuple;
|
|
316
|
+
const startZoom = this.zoom;
|
|
317
|
+
const startTime = Date.now();
|
|
318
|
+
|
|
319
|
+
const doAnimate = () => {
|
|
320
|
+
const elapsed = Date.now() - startTime;
|
|
321
|
+
const progress = Math.min(elapsed / duration, 1);
|
|
322
|
+
const ease = 1 - Math.pow(1 - progress, 3);
|
|
323
|
+
|
|
324
|
+
const lat = startCenter[0] + (targetCenter[0] - startCenter[0]) * ease;
|
|
325
|
+
const lng = startCenter[1] + (targetCenter[1] - startCenter[1]) * ease;
|
|
326
|
+
const currentZoom = startZoom + (targetZoom - startZoom) * ease;
|
|
327
|
+
|
|
328
|
+
this.center = [lat, lng];
|
|
329
|
+
this.zoom = currentZoom;
|
|
330
|
+
this.requestRender();
|
|
331
|
+
this.onMove?.(this.center, this.zoom);
|
|
332
|
+
this.onZoom?.(this.zoom);
|
|
333
|
+
|
|
334
|
+
if (progress < 1) {
|
|
335
|
+
if (typeof requestAnimationFrame !== 'undefined') {
|
|
336
|
+
this.animFrameId = requestAnimationFrame(doAnimate);
|
|
337
|
+
} else {
|
|
338
|
+
this.animFrameId = setTimeout(doAnimate, 16);
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
this.animFrameId = null;
|
|
342
|
+
this.center = [...targetCenter];
|
|
343
|
+
this.zoom = targetZoom;
|
|
344
|
+
this.requestRender();
|
|
345
|
+
this.onMoveEnd?.(this.center, this.zoom);
|
|
346
|
+
this.onZoomEnd?.(this.zoom);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
doAnimate();
|
|
351
|
+
}
|
|
352
|
+
|
|
236
353
|
public setTileUrl(url: string, subdomains?: string[]) {
|
|
237
354
|
this.tileUrl = url;
|
|
238
355
|
if (subdomains) this.subdomains = subdomains;
|
|
@@ -253,17 +370,34 @@ export class CanvasTileEngine implements IMapEngine {
|
|
|
253
370
|
|
|
254
371
|
public setOverlays(overlays: MapOverlays) {
|
|
255
372
|
this.overlays = { ...overlays };
|
|
373
|
+
if (overlays.geojson !== undefined) {
|
|
374
|
+
this.geojson = overlays.geojson;
|
|
375
|
+
}
|
|
376
|
+
if (overlays.geojsonStyle !== undefined) {
|
|
377
|
+
this.geojsonStyle = overlays.geojsonStyle;
|
|
378
|
+
}
|
|
379
|
+
this.requestRender();
|
|
380
|
+
this.checkFlowAnimationState();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
public setGeoJSON(data: any, style?: GeoJsonStyle) {
|
|
384
|
+
this.geojson = data;
|
|
385
|
+
if (style !== undefined) {
|
|
386
|
+
this.geojsonStyle = style;
|
|
387
|
+
}
|
|
256
388
|
this.requestRender();
|
|
257
389
|
}
|
|
258
390
|
|
|
259
391
|
public setMarkers(markers: MarkerOptions[]) {
|
|
260
392
|
this.overlays.markers = markers;
|
|
261
393
|
this.requestRender();
|
|
394
|
+
this.checkFlowAnimationState();
|
|
262
395
|
}
|
|
263
396
|
|
|
264
397
|
public setPolylines(polylines: PolylineOptions[]) {
|
|
265
398
|
this.overlays.polylines = polylines;
|
|
266
399
|
this.requestRender();
|
|
400
|
+
this.checkFlowAnimationState();
|
|
267
401
|
}
|
|
268
402
|
|
|
269
403
|
public setPolygons(polygons: PolygonOptions[]) {
|
|
@@ -278,6 +412,14 @@ export class CanvasTileEngine implements IMapEngine {
|
|
|
278
412
|
|
|
279
413
|
public clearOverlays() {
|
|
280
414
|
this.overlays = {};
|
|
415
|
+
this.currentClusters = [];
|
|
416
|
+
this.geojson = null;
|
|
417
|
+
this.stopFlowAnimation();
|
|
418
|
+
this.requestRender();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
public setClusterOptions(options: ClusterOptions) {
|
|
422
|
+
this.clusterOptions = { ...this.clusterOptions, ...options };
|
|
281
423
|
this.requestRender();
|
|
282
424
|
}
|
|
283
425
|
|
|
@@ -398,9 +540,14 @@ export class CanvasTileEngine implements IMapEngine {
|
|
|
398
540
|
}
|
|
399
541
|
|
|
400
542
|
/**
|
|
401
|
-
* Render vector overlays (Polygons, Circles, Polylines, Markers, Labels)
|
|
543
|
+
* Render vector overlays (Polygons, Circles, Polylines, Markers, Labels, GeoJSON)
|
|
402
544
|
*/
|
|
403
545
|
private renderOverlays(ctx: any) {
|
|
546
|
+
// 0. Render GeoJSON Vector Layer
|
|
547
|
+
if (this.geojson) {
|
|
548
|
+
this.drawGeoJSON(ctx, this.geojson, this.geojsonStyle);
|
|
549
|
+
}
|
|
550
|
+
|
|
404
551
|
// A. Render Polygons
|
|
405
552
|
if (this.overlays.polygons && Array.isArray(this.overlays.polygons)) {
|
|
406
553
|
for (const poly of this.overlays.polygons) {
|
|
@@ -495,7 +642,11 @@ export class CanvasTileEngine implements IMapEngine {
|
|
|
495
642
|
try {
|
|
496
643
|
ctx.save();
|
|
497
644
|
ctx.beginPath();
|
|
498
|
-
const
|
|
645
|
+
const effectiveLatLngs = line.curved
|
|
646
|
+
? generateCurvedPolyline(line.latLngs, line.curvature ?? 0.2)
|
|
647
|
+
: line.latLngs;
|
|
648
|
+
|
|
649
|
+
const points = effectiveLatLngs.map((pt) =>
|
|
499
650
|
this.latLngToScreenPoint(pt[0], pt[1])
|
|
500
651
|
);
|
|
501
652
|
|
|
@@ -510,9 +661,27 @@ export class CanvasTileEngine implements IMapEngine {
|
|
|
510
661
|
ctx.lineCap = 'round';
|
|
511
662
|
ctx.lineJoin = 'round';
|
|
512
663
|
|
|
513
|
-
if (line.
|
|
664
|
+
if (line.flowing) {
|
|
665
|
+
const dash =
|
|
666
|
+
line.dashArray && line.dashArray.length > 0
|
|
667
|
+
? line.dashArray
|
|
668
|
+
: [10, 10];
|
|
669
|
+
if (ctx.setLineDash) {
|
|
670
|
+
try {
|
|
671
|
+
ctx.setLineDash(dash);
|
|
672
|
+
} catch (e) {}
|
|
673
|
+
}
|
|
674
|
+
const speed = line.flowSpeed ?? 1;
|
|
675
|
+
const offset = (this.flowAnimationOffset * speed) % 40;
|
|
676
|
+
if ('lineDashOffset' in ctx) {
|
|
677
|
+
ctx.lineDashOffset = -offset;
|
|
678
|
+
}
|
|
679
|
+
} else if (line.dashArray && ctx.setLineDash) {
|
|
514
680
|
try {
|
|
515
681
|
ctx.setLineDash(line.dashArray);
|
|
682
|
+
if (line.dashOffset !== undefined && 'lineDashOffset' in ctx) {
|
|
683
|
+
ctx.lineDashOffset = line.dashOffset;
|
|
684
|
+
}
|
|
516
685
|
} catch (e) {}
|
|
517
686
|
}
|
|
518
687
|
|
|
@@ -521,6 +690,9 @@ export class CanvasTileEngine implements IMapEngine {
|
|
|
521
690
|
if (ctx.setLineDash) {
|
|
522
691
|
try {
|
|
523
692
|
ctx.setLineDash([]);
|
|
693
|
+
if ('lineDashOffset' in ctx) {
|
|
694
|
+
ctx.lineDashOffset = 0;
|
|
695
|
+
}
|
|
524
696
|
} catch (e) {}
|
|
525
697
|
}
|
|
526
698
|
ctx.restore();
|
|
@@ -543,95 +715,424 @@ export class CanvasTileEngine implements IMapEngine {
|
|
|
543
715
|
|
|
544
716
|
// D. Render Markers & Pins
|
|
545
717
|
if (this.overlays.markers && Array.isArray(this.overlays.markers)) {
|
|
546
|
-
|
|
547
|
-
|
|
718
|
+
this.currentClusters = [];
|
|
719
|
+
if (!this.clusterOptions.enable) {
|
|
720
|
+
for (const m of this.overlays.markers) {
|
|
721
|
+
this.drawSingleMarker(ctx, m);
|
|
722
|
+
}
|
|
723
|
+
} else {
|
|
724
|
+
const results = clusterMarkers(
|
|
725
|
+
this.overlays.markers,
|
|
726
|
+
this.zoom,
|
|
727
|
+
this.clusterOptions
|
|
728
|
+
);
|
|
729
|
+
for (const item of results) {
|
|
730
|
+
if (!item.isCluster) {
|
|
731
|
+
this.drawSingleMarker(ctx, item.marker);
|
|
732
|
+
} else {
|
|
733
|
+
this.currentClusters.push(item.cluster);
|
|
734
|
+
this.drawClusterMarker(ctx, item.cluster);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
548
739
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
740
|
+
// E. Render Zoom Controls if enabled (Drawn directly in Canvas 2D frame buffer)
|
|
741
|
+
if (this.showControls) {
|
|
742
|
+
this.drawZoomControls(ctx);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private drawGeoJSON(ctx: any, geojson: any, style?: GeoJsonStyle) {
|
|
747
|
+
if (!geojson) return;
|
|
748
|
+
const features = normalizeGeoJsonFeatures(geojson);
|
|
553
749
|
|
|
750
|
+
for (const feat of features) {
|
|
751
|
+
if (!feat.geometry) continue;
|
|
752
|
+
const s = resolveGeoJsonStyle(style, feat, '#3b82f6');
|
|
753
|
+
const gType = feat.geometry.type;
|
|
754
|
+
const coords = feat.geometry.coordinates;
|
|
755
|
+
|
|
756
|
+
if (gType === 'Polygon' || gType === 'MultiPolygon') {
|
|
757
|
+
const polys = gType === 'Polygon' ? [coords] : coords;
|
|
758
|
+
for (const poly of polys) {
|
|
759
|
+
if (!poly || poly.length === 0) continue;
|
|
554
760
|
ctx.save();
|
|
761
|
+
ctx.beginPath();
|
|
762
|
+
const extRing = poly[0];
|
|
763
|
+
if (!extRing || extRing.length === 0) continue;
|
|
764
|
+
const firstPt = this.latLngToScreenPoint(extRing[0][1], extRing[0][0]);
|
|
765
|
+
ctx.moveTo(firstPt.x, firstPt.y);
|
|
766
|
+
for (let i = 1; i < extRing.length; i++) {
|
|
767
|
+
const pt = this.latLngToScreenPoint(extRing[i][1], extRing[i][0]);
|
|
768
|
+
ctx.lineTo(pt.x, pt.y);
|
|
769
|
+
}
|
|
770
|
+
ctx.closePath();
|
|
555
771
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
this.drawPinFallback(ctx, pt, m.icon?.color || '#ef4444', size, anchor);
|
|
772
|
+
ctx.fillStyle = s.fillColor;
|
|
773
|
+
ctx.globalAlpha = s.fillOpacity ?? 0.25;
|
|
774
|
+
ctx.fill();
|
|
775
|
+
|
|
776
|
+
ctx.globalAlpha = s.opacity ?? 1;
|
|
777
|
+
ctx.strokeStyle = s.color;
|
|
778
|
+
ctx.lineWidth = s.weight ?? 2;
|
|
779
|
+
if (s.dashArray && typeof ctx.setLineDash === 'function') {
|
|
780
|
+
ctx.setLineDash(s.dashArray);
|
|
781
|
+
}
|
|
782
|
+
ctx.stroke();
|
|
783
|
+
ctx.restore();
|
|
784
|
+
}
|
|
785
|
+
} else if (gType === 'LineString' || gType === 'MultiLineString') {
|
|
786
|
+
const lines = gType === 'LineString' ? [coords] : coords;
|
|
787
|
+
for (const line of lines) {
|
|
788
|
+
if (!line || line.length < 2) continue;
|
|
789
|
+
ctx.save();
|
|
790
|
+
ctx.beginPath();
|
|
791
|
+
const firstPt = this.latLngToScreenPoint(line[0][1], line[0][0]);
|
|
792
|
+
ctx.moveTo(firstPt.x, firstPt.y);
|
|
793
|
+
for (let i = 1; i < line.length; i++) {
|
|
794
|
+
const pt = this.latLngToScreenPoint(line[i][1], line[i][0]);
|
|
795
|
+
ctx.lineTo(pt.x, pt.y);
|
|
796
|
+
}
|
|
797
|
+
ctx.strokeStyle = s.color;
|
|
798
|
+
ctx.lineWidth = s.weight ?? 2;
|
|
799
|
+
ctx.globalAlpha = s.opacity ?? 1;
|
|
800
|
+
if (s.dashArray && typeof ctx.setLineDash === 'function') {
|
|
801
|
+
ctx.setLineDash(s.dashArray);
|
|
587
802
|
}
|
|
803
|
+
ctx.stroke();
|
|
804
|
+
ctx.restore();
|
|
805
|
+
}
|
|
806
|
+
} else if (gType === 'Point') {
|
|
807
|
+
const pt = this.latLngToScreenPoint(coords[1], coords[0]);
|
|
808
|
+
ctx.save();
|
|
809
|
+
ctx.beginPath();
|
|
810
|
+
ctx.arc(pt.x, pt.y, 6, 0, Math.PI * 2);
|
|
811
|
+
ctx.fillStyle = s.fillColor;
|
|
812
|
+
ctx.fill();
|
|
813
|
+
ctx.strokeStyle = s.color;
|
|
814
|
+
ctx.lineWidth = s.weight ?? 2;
|
|
815
|
+
ctx.stroke();
|
|
816
|
+
ctx.restore();
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
private drawSingleMarker(ctx: any, m: MarkerOptions) {
|
|
822
|
+
if (!m || !m.latLng) return;
|
|
823
|
+
try {
|
|
824
|
+
const pt = this.latLngToScreenPoint(m.latLng[0], m.latLng[1]);
|
|
825
|
+
const size = m.icon?.size || [32, 32];
|
|
826
|
+
const anchor = m.icon?.anchor || [size[0] / 2, size[1]];
|
|
827
|
+
|
|
828
|
+
ctx.save();
|
|
829
|
+
|
|
830
|
+
if (m.icon?.type === 'avatar') {
|
|
831
|
+
const avatarSize = m.icon.size || size || [32, 32];
|
|
832
|
+
const avatarW = avatarSize[0];
|
|
833
|
+
const avatarH = avatarSize[1];
|
|
834
|
+
const arrowW = avatarW <= 36 ? 4 : 5;
|
|
835
|
+
const arrowH = avatarW <= 36 ? 5 : 6;
|
|
836
|
+
const anchor = m.icon.anchor || [avatarW / 2, avatarH + arrowH];
|
|
837
|
+
const borderColor = m.icon.borderColor || '#3b82f6';
|
|
838
|
+
const borderWidth = m.icon.borderWidth ?? (avatarW <= 36 ? 2.5 : 3);
|
|
839
|
+
const pulseColor = m.icon.pulseColor || borderColor;
|
|
840
|
+
const radius = Math.min(avatarW, avatarH) / 2;
|
|
841
|
+
const centerX = pt.x - anchor[0] + avatarW / 2;
|
|
842
|
+
const centerY = pt.y - anchor[1] + avatarH / 2;
|
|
843
|
+
|
|
844
|
+
// 1. Draw pulse ripple halo if pulse enabled
|
|
845
|
+
if (m.icon.pulse) {
|
|
846
|
+
const pulsePhase = (Date.now() % 1800) / 1800;
|
|
847
|
+
const pulseRadius = radius + pulsePhase * (radius * 0.6);
|
|
848
|
+
const pulseAlpha = (1 - pulsePhase) * 0.8;
|
|
588
849
|
|
|
850
|
+
ctx.save();
|
|
851
|
+
ctx.beginPath();
|
|
852
|
+
ctx.arc(centerX, centerY, pulseRadius, 0, Math.PI * 2);
|
|
853
|
+
ctx.strokeStyle = pulseColor;
|
|
854
|
+
ctx.lineWidth = borderWidth;
|
|
855
|
+
ctx.globalAlpha = pulseAlpha;
|
|
856
|
+
ctx.stroke();
|
|
589
857
|
ctx.restore();
|
|
858
|
+
}
|
|
590
859
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
// Tip sits 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
|
-
}
|
|
860
|
+
// 2. Draw white background circle & drop shadow
|
|
861
|
+
ctx.save();
|
|
862
|
+
ctx.shadowColor = 'rgba(0, 0, 0, 0.25)';
|
|
863
|
+
ctx.shadowBlur = 8;
|
|
864
|
+
ctx.shadowOffsetY = 3;
|
|
865
|
+
ctx.beginPath();
|
|
866
|
+
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
|
867
|
+
ctx.fillStyle = '#ffffff';
|
|
868
|
+
ctx.fill();
|
|
869
|
+
ctx.restore();
|
|
615
870
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
871
|
+
// 3. Draw bottom pointer arrow
|
|
872
|
+
ctx.save();
|
|
873
|
+
ctx.beginPath();
|
|
874
|
+
const arrowTopY = centerY + radius - 1;
|
|
875
|
+
const arrowBottomY = centerY + radius + arrowH;
|
|
876
|
+
ctx.moveTo(centerX - arrowW, arrowTopY);
|
|
877
|
+
ctx.lineTo(centerX + arrowW, arrowTopY);
|
|
878
|
+
ctx.lineTo(centerX, arrowBottomY);
|
|
879
|
+
ctx.closePath();
|
|
880
|
+
ctx.fillStyle = borderColor;
|
|
881
|
+
ctx.fill();
|
|
882
|
+
ctx.restore();
|
|
883
|
+
|
|
884
|
+
// 4. Draw avatar image with circular clipping
|
|
885
|
+
if (m.icon.url) {
|
|
886
|
+
const img = this.getOrLoadIcon(m.icon.url);
|
|
887
|
+
if (img) {
|
|
888
|
+
ctx.save();
|
|
889
|
+
ctx.beginPath();
|
|
890
|
+
ctx.arc(
|
|
891
|
+
centerX,
|
|
892
|
+
centerY,
|
|
893
|
+
Math.max(1, radius - borderWidth / 2),
|
|
894
|
+
0,
|
|
895
|
+
Math.PI * 2
|
|
896
|
+
);
|
|
897
|
+
ctx.clip();
|
|
898
|
+
ctx.drawImage(
|
|
899
|
+
img,
|
|
900
|
+
centerX - radius,
|
|
901
|
+
centerY - radius,
|
|
902
|
+
radius * 2,
|
|
903
|
+
radius * 2
|
|
623
904
|
);
|
|
905
|
+
ctx.restore();
|
|
624
906
|
}
|
|
625
|
-
} catch (err) {
|
|
626
|
-
console.error('Error drawing marker:', err);
|
|
627
907
|
}
|
|
908
|
+
|
|
909
|
+
// 5. Draw border circle
|
|
910
|
+
ctx.save();
|
|
911
|
+
ctx.beginPath();
|
|
912
|
+
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
|
913
|
+
ctx.strokeStyle = borderColor;
|
|
914
|
+
ctx.lineWidth = borderWidth;
|
|
915
|
+
ctx.stroke();
|
|
916
|
+
ctx.restore();
|
|
917
|
+
|
|
918
|
+
// 6. Draw badge on top right
|
|
919
|
+
if (m.icon.badgeText) {
|
|
920
|
+
const isSmall = avatarW <= 36;
|
|
921
|
+
const badgeRadius = isSmall ? 6 : Math.max(7, Math.round(radius * 0.35));
|
|
922
|
+
const badgeFontSize = isSmall ? 8 : Math.max(9, Math.round(badgeRadius * 1.1));
|
|
923
|
+
const badgeX = centerX + radius * 0.72;
|
|
924
|
+
const badgeY = centerY - radius * 0.72;
|
|
925
|
+
|
|
926
|
+
ctx.save();
|
|
927
|
+
ctx.shadowColor = 'rgba(0, 0, 0, 0.25)';
|
|
928
|
+
ctx.shadowBlur = 3;
|
|
929
|
+
ctx.shadowOffsetY = 1;
|
|
930
|
+
|
|
931
|
+
ctx.beginPath();
|
|
932
|
+
ctx.arc(badgeX, badgeY, badgeRadius, 0, Math.PI * 2);
|
|
933
|
+
ctx.fillStyle = m.icon.badgeColor || '#ffffff';
|
|
934
|
+
ctx.fill();
|
|
935
|
+
|
|
936
|
+
ctx.strokeStyle = m.icon.badgeColor ? '#ffffff' : 'rgba(0, 0, 0, 0.1)';
|
|
937
|
+
ctx.lineWidth = 1;
|
|
938
|
+
ctx.stroke();
|
|
939
|
+
ctx.restore();
|
|
940
|
+
|
|
941
|
+
ctx.save();
|
|
942
|
+
ctx.fillStyle = m.icon.badgeColor ? '#ffffff' : '#1e293b';
|
|
943
|
+
ctx.font =
|
|
944
|
+
`bold ${badgeFontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
|
|
945
|
+
ctx.textAlign = 'center';
|
|
946
|
+
ctx.textBaseline = 'middle';
|
|
947
|
+
ctx.fillText(m.icon.badgeText, badgeX, badgeY);
|
|
948
|
+
ctx.restore();
|
|
949
|
+
}
|
|
950
|
+
} else if (m.icon?.svg) {
|
|
951
|
+
let svgStr = m.icon.svg.trim();
|
|
952
|
+
if (!svgStr.includes('xmlns=')) {
|
|
953
|
+
svgStr = svgStr.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
|
|
954
|
+
}
|
|
955
|
+
const svgDataUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgStr)}`;
|
|
956
|
+
const img = this.getOrLoadIcon(svgDataUri);
|
|
957
|
+
if (img) {
|
|
958
|
+
ctx.drawImage(img, pt.x - anchor[0], pt.y - anchor[1], size[0], size[1]);
|
|
959
|
+
} else {
|
|
960
|
+
this.drawPinFallback(ctx, pt, m.icon?.color || '#ef4444', size, anchor);
|
|
961
|
+
}
|
|
962
|
+
} else if (m.icon?.url) {
|
|
963
|
+
const img = this.getOrLoadIcon(m.icon.url);
|
|
964
|
+
if (img) {
|
|
965
|
+
ctx.drawImage(img, pt.x - anchor[0], pt.y - anchor[1], size[0], size[1]);
|
|
966
|
+
} else {
|
|
967
|
+
this.drawPinFallback(ctx, pt, m.icon?.color || '#3b82f6', size, anchor);
|
|
968
|
+
}
|
|
969
|
+
} else if (m.icon?.text) {
|
|
970
|
+
const fontSize = Math.round(size[0] * 0.72);
|
|
971
|
+
ctx.font = `${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`;
|
|
972
|
+
ctx.textAlign = 'center';
|
|
973
|
+
ctx.textBaseline = 'middle';
|
|
974
|
+
ctx.fillText(m.icon.text, pt.x - anchor[0] + size[0] / 2, pt.y - anchor[1] + size[1] / 2);
|
|
975
|
+
} else {
|
|
976
|
+
this.drawPinFallback(ctx, pt, m.icon?.color || '#ef4444', size, anchor);
|
|
628
977
|
}
|
|
978
|
+
|
|
979
|
+
ctx.restore();
|
|
980
|
+
|
|
981
|
+
if (m.label) {
|
|
982
|
+
const labelText =
|
|
983
|
+
typeof m.label === 'string' ? m.label : m.label.text;
|
|
984
|
+
const labelStyle =
|
|
985
|
+
typeof m.label === 'object' ? m.label : undefined;
|
|
986
|
+
|
|
987
|
+
const iconTopY = pt.y - anchor[1];
|
|
988
|
+
const iconCenterX = pt.x - anchor[0] + size[0] / 2;
|
|
989
|
+
|
|
990
|
+
let tipX = iconCenterX;
|
|
991
|
+
let tipY = iconTopY - 1;
|
|
992
|
+
|
|
993
|
+
if (labelStyle?.offset) {
|
|
994
|
+
const offX = labelStyle.offset[0] || 0;
|
|
995
|
+
const offY = labelStyle.offset[1] || 0;
|
|
996
|
+
tipX += offX;
|
|
997
|
+
if (Math.abs(offY) <= 10) {
|
|
998
|
+
tipY += offY;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
this.drawLabelBadge(
|
|
1003
|
+
ctx,
|
|
1004
|
+
labelText,
|
|
1005
|
+
tipX,
|
|
1006
|
+
tipY,
|
|
1007
|
+
labelStyle,
|
|
1008
|
+
true
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
} catch (err) {
|
|
1012
|
+
console.error('Error drawing marker:', err);
|
|
629
1013
|
}
|
|
1014
|
+
}
|
|
630
1015
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1016
|
+
private drawClusterMarker(ctx: any, cluster: ClusterGroup) {
|
|
1017
|
+
const pt = this.latLngToScreenPoint(cluster.center[0], cluster.center[1]);
|
|
1018
|
+
const radius = 22;
|
|
1019
|
+
ctx.save();
|
|
1020
|
+
|
|
1021
|
+
// 1. Drop shadow
|
|
1022
|
+
ctx.shadowColor = 'rgba(0, 0, 0, 0.25)';
|
|
1023
|
+
ctx.shadowBlur = 10;
|
|
1024
|
+
ctx.shadowOffsetY = 3;
|
|
1025
|
+
|
|
1026
|
+
// 2. White Circle Background
|
|
1027
|
+
ctx.beginPath();
|
|
1028
|
+
ctx.arc(pt.x, pt.y, radius, 0, Math.PI * 2);
|
|
1029
|
+
ctx.fillStyle = '#ffffff';
|
|
1030
|
+
ctx.fill();
|
|
1031
|
+
|
|
1032
|
+
// 3. Blue Ring Border (3px)
|
|
1033
|
+
ctx.shadowColor = 'transparent';
|
|
1034
|
+
ctx.shadowBlur = 0;
|
|
1035
|
+
ctx.shadowOffsetY = 0;
|
|
1036
|
+
ctx.lineWidth = 3;
|
|
1037
|
+
ctx.strokeStyle = '#3b82f6';
|
|
1038
|
+
ctx.stroke();
|
|
1039
|
+
|
|
1040
|
+
// 4. Center Icon or Rep Marker
|
|
1041
|
+
const rep = cluster.representativeMarker;
|
|
1042
|
+
if (rep?.icon?.text) {
|
|
1043
|
+
ctx.font = 'bold 18px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
1044
|
+
ctx.textAlign = 'center';
|
|
1045
|
+
ctx.textBaseline = 'middle';
|
|
1046
|
+
ctx.fillText(rep.icon.text, pt.x, pt.y + 1);
|
|
1047
|
+
} else if (rep?.icon?.url) {
|
|
1048
|
+
const img = this.getOrLoadIcon(rep.icon.url);
|
|
1049
|
+
if (img) {
|
|
1050
|
+
ctx.save();
|
|
1051
|
+
ctx.beginPath();
|
|
1052
|
+
ctx.arc(pt.x, pt.y, 14, 0, Math.PI * 2);
|
|
1053
|
+
ctx.clip();
|
|
1054
|
+
ctx.drawImage(img, pt.x - 14, pt.y - 14, 28, 28);
|
|
1055
|
+
ctx.restore();
|
|
1056
|
+
} else {
|
|
1057
|
+
this.drawDefaultClusterIcon(ctx, pt.x, pt.y);
|
|
1058
|
+
}
|
|
1059
|
+
} else {
|
|
1060
|
+
this.drawDefaultClusterIcon(ctx, pt.x, pt.y);
|
|
634
1061
|
}
|
|
1062
|
+
|
|
1063
|
+
// 5. Top-Right Red Badge with +count
|
|
1064
|
+
const badgeText = `+${cluster.count}`;
|
|
1065
|
+
const badgeHeight = 18;
|
|
1066
|
+
ctx.font = 'bold 11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
1067
|
+
let textWidth = 14;
|
|
1068
|
+
try {
|
|
1069
|
+
if (ctx.measureText) {
|
|
1070
|
+
const m = ctx.measureText(badgeText);
|
|
1071
|
+
if (m && m.width > 0) textWidth = m.width;
|
|
1072
|
+
}
|
|
1073
|
+
} catch (e) {}
|
|
1074
|
+
const badgeWidth = Math.max(18, textWidth + 10);
|
|
1075
|
+
const badgeCenterX = pt.x + 14;
|
|
1076
|
+
const badgeCenterY = pt.y - 14;
|
|
1077
|
+
const badgeX = badgeCenterX - badgeWidth / 2;
|
|
1078
|
+
const badgeY = badgeCenterY - badgeHeight / 2;
|
|
1079
|
+
const r = badgeHeight / 2;
|
|
1080
|
+
|
|
1081
|
+
// Badge shadow
|
|
1082
|
+
ctx.shadowColor = 'rgba(220, 38, 38, 0.45)';
|
|
1083
|
+
ctx.shadowBlur = 6;
|
|
1084
|
+
ctx.shadowOffsetY = 2;
|
|
1085
|
+
|
|
1086
|
+
// Badge pill
|
|
1087
|
+
ctx.beginPath();
|
|
1088
|
+
ctx.moveTo(badgeX + r, badgeY);
|
|
1089
|
+
ctx.lineTo(badgeX + badgeWidth - r, badgeY);
|
|
1090
|
+
ctx.arcTo(badgeX + badgeWidth, badgeY, badgeX + badgeWidth, badgeY + r, r);
|
|
1091
|
+
ctx.lineTo(badgeX + badgeWidth, badgeY + badgeHeight - r);
|
|
1092
|
+
ctx.arcTo(badgeX + badgeWidth, badgeY + badgeHeight, badgeX + badgeWidth - r, badgeY + badgeHeight, r);
|
|
1093
|
+
ctx.lineTo(badgeX + r, badgeY + badgeHeight);
|
|
1094
|
+
ctx.arcTo(badgeX, badgeY + badgeHeight, badgeX, badgeY + badgeHeight - r, r);
|
|
1095
|
+
ctx.lineTo(badgeX, badgeY + r);
|
|
1096
|
+
ctx.arcTo(badgeX, badgeY, badgeX + r, badgeY, r);
|
|
1097
|
+
ctx.closePath();
|
|
1098
|
+
ctx.fillStyle = '#ef4444';
|
|
1099
|
+
ctx.fill();
|
|
1100
|
+
|
|
1101
|
+
// Badge white border
|
|
1102
|
+
ctx.shadowColor = 'transparent';
|
|
1103
|
+
ctx.shadowBlur = 0;
|
|
1104
|
+
ctx.shadowOffsetY = 0;
|
|
1105
|
+
ctx.strokeStyle = '#ffffff';
|
|
1106
|
+
ctx.lineWidth = 2;
|
|
1107
|
+
ctx.stroke();
|
|
1108
|
+
|
|
1109
|
+
// Badge text
|
|
1110
|
+
ctx.fillStyle = '#ffffff';
|
|
1111
|
+
ctx.textAlign = 'center';
|
|
1112
|
+
ctx.textBaseline = 'middle';
|
|
1113
|
+
ctx.fillText(badgeText, badgeCenterX, badgeCenterY + 1);
|
|
1114
|
+
|
|
1115
|
+
ctx.restore();
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
private drawDefaultClusterIcon(ctx: any, cx: number, cy: number) {
|
|
1119
|
+
ctx.beginPath();
|
|
1120
|
+
ctx.arc(cx, cy - 2, 7, 0, Math.PI * 2);
|
|
1121
|
+
ctx.fillStyle = '#3b82f6';
|
|
1122
|
+
ctx.fill();
|
|
1123
|
+
|
|
1124
|
+
ctx.beginPath();
|
|
1125
|
+
ctx.arc(cx, cy - 2, 3, 0, Math.PI * 2);
|
|
1126
|
+
ctx.fillStyle = '#ffffff';
|
|
1127
|
+
ctx.fill();
|
|
1128
|
+
|
|
1129
|
+
ctx.beginPath();
|
|
1130
|
+
ctx.moveTo(cx - 5, cy);
|
|
1131
|
+
ctx.lineTo(cx + 5, cy);
|
|
1132
|
+
ctx.lineTo(cx, cy + 8);
|
|
1133
|
+
ctx.closePath();
|
|
1134
|
+
ctx.fillStyle = '#3b82f6';
|
|
1135
|
+
ctx.fill();
|
|
635
1136
|
}
|
|
636
1137
|
|
|
637
1138
|
/**
|
|
@@ -1022,12 +1523,38 @@ export class CanvasTileEngine implements IMapEngine {
|
|
|
1022
1523
|
// Hit testing for overlays
|
|
1023
1524
|
let hitOverlay = false;
|
|
1024
1525
|
|
|
1025
|
-
// Check
|
|
1026
|
-
if (this.
|
|
1526
|
+
// 1. Check cluster bubble hit
|
|
1527
|
+
if (this.clusterOptions.enable && this.currentClusters.length > 0) {
|
|
1528
|
+
for (const cluster of this.currentClusters) {
|
|
1529
|
+
const pt = this.latLngToScreenPoint(cluster.center[0], cluster.center[1]);
|
|
1530
|
+
const dist = Math.hypot(pt.x - tapPos.x, pt.y - tapPos.y);
|
|
1531
|
+
if (dist <= 26) {
|
|
1532
|
+
hitOverlay = true;
|
|
1533
|
+
const bounds = cluster.bounds;
|
|
1534
|
+
const isSameCoord =
|
|
1535
|
+
Math.abs(bounds[0][0] - bounds[1][0]) < 1e-6 &&
|
|
1536
|
+
Math.abs(bounds[0][1] - bounds[1][1]) < 1e-6;
|
|
1537
|
+
if (isSameCoord) {
|
|
1538
|
+
this.center = cluster.center;
|
|
1539
|
+
this.setZoom(Math.min(this.zoom + 2, this.maxZoom));
|
|
1540
|
+
} else {
|
|
1541
|
+
this.fitBounds(bounds, { padding: [60, 60] });
|
|
1542
|
+
}
|
|
1543
|
+
this.onClusterClick?.(cluster);
|
|
1544
|
+
break;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// 2. Check markers hit
|
|
1550
|
+
if (!hitOverlay && this.overlays.markers) {
|
|
1027
1551
|
for (const m of this.overlays.markers) {
|
|
1028
1552
|
const pt = this.latLngToScreenPoint(m.latLng[0], m.latLng[1]);
|
|
1553
|
+
const isAvatar = m.icon?.type === 'avatar';
|
|
1029
1554
|
const size = m.icon?.size || [32, 32];
|
|
1030
|
-
const
|
|
1555
|
+
const arrowH = isAvatar ? (size[0] <= 36 ? 5 : 6) : 0;
|
|
1556
|
+
const anchor =
|
|
1557
|
+
m.icon?.anchor || (isAvatar ? [size[0] / 2, size[1] + arrowH] : [size[0] / 2, size[1]]);
|
|
1031
1558
|
const centerX = pt.x - anchor[0] + size[0] / 2;
|
|
1032
1559
|
const centerY = pt.y - anchor[1] + size[1] / 2;
|
|
1033
1560
|
const hitRadius = Math.max(20, Math.max(size[0], size[1]) / 2 + 4);
|
|
@@ -1039,6 +1566,39 @@ export class CanvasTileEngine implements IMapEngine {
|
|
|
1039
1566
|
}
|
|
1040
1567
|
}
|
|
1041
1568
|
|
|
1569
|
+
// 3. Check GeoJSON polygon features hit
|
|
1570
|
+
if (!hitOverlay && this.geojson) {
|
|
1571
|
+
const features = normalizeGeoJsonFeatures(this.geojson);
|
|
1572
|
+
for (const feat of features) {
|
|
1573
|
+
if (
|
|
1574
|
+
feat.geometry &&
|
|
1575
|
+
(feat.geometry.type === 'Polygon' || feat.geometry.type === 'MultiPolygon')
|
|
1576
|
+
) {
|
|
1577
|
+
const polys =
|
|
1578
|
+
feat.geometry.type === 'Polygon'
|
|
1579
|
+
? [feat.geometry.coordinates]
|
|
1580
|
+
: feat.geometry.coordinates;
|
|
1581
|
+
let matched = false;
|
|
1582
|
+
for (const poly of polys) {
|
|
1583
|
+
if (!poly || !poly[0]) continue;
|
|
1584
|
+
const ring: [number, number][] = poly[0].map((coord: any) => {
|
|
1585
|
+
const pt = this.latLngToScreenPoint(coord[1], coord[0]);
|
|
1586
|
+
return [pt.x, pt.y] as [number, number];
|
|
1587
|
+
});
|
|
1588
|
+
if (isPointInPolygon2D([tapPos.x, tapPos.y], ring)) {
|
|
1589
|
+
matched = true;
|
|
1590
|
+
break;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
if (matched) {
|
|
1594
|
+
hitOverlay = true;
|
|
1595
|
+
this.onOverlayClick?.({ type: 'geojson', data: feat });
|
|
1596
|
+
break;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1042
1602
|
if (!hitOverlay) {
|
|
1043
1603
|
const clickLatLng = this.screenPointToLatLng(tapPos.x, tapPos.y);
|
|
1044
1604
|
this.onClick?.(clickLatLng, tapPos);
|
|
@@ -1229,7 +1789,62 @@ export class CanvasTileEngine implements IMapEngine {
|
|
|
1229
1789
|
return this.canvas;
|
|
1230
1790
|
}
|
|
1231
1791
|
|
|
1792
|
+
private checkFlowAnimationState() {
|
|
1793
|
+
const hasFlowing = this.overlays.polylines?.some((line) => line.flowing);
|
|
1794
|
+
const hasPulse = this.overlays.markers?.some(
|
|
1795
|
+
(m) => m.icon?.type === 'avatar' && m.icon?.pulse
|
|
1796
|
+
);
|
|
1797
|
+
if (hasFlowing || hasPulse) {
|
|
1798
|
+
this.startFlowAnimation();
|
|
1799
|
+
} else {
|
|
1800
|
+
this.stopFlowAnimation();
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
private startFlowAnimation() {
|
|
1805
|
+
if (this.isFlowAnimating) return;
|
|
1806
|
+
this.isFlowAnimating = true;
|
|
1807
|
+
|
|
1808
|
+
let lastTime = 0;
|
|
1809
|
+
const step = (time: number) => {
|
|
1810
|
+
if (!this.isFlowAnimating) return;
|
|
1811
|
+
if (!lastTime) lastTime = time;
|
|
1812
|
+
const delta = (time - lastTime) / 1000;
|
|
1813
|
+
lastTime = time;
|
|
1814
|
+
|
|
1815
|
+
// 20 pixels per second as base flow speed
|
|
1816
|
+
this.flowAnimationOffset =
|
|
1817
|
+
(this.flowAnimationOffset + delta * 20) % 10000;
|
|
1818
|
+
this.requestRender();
|
|
1819
|
+
|
|
1820
|
+
if (typeof requestAnimationFrame !== 'undefined') {
|
|
1821
|
+
this.flowAnimFrameId = requestAnimationFrame(step);
|
|
1822
|
+
} else {
|
|
1823
|
+
this.flowAnimFrameId = setTimeout(() => step(Date.now()), 16);
|
|
1824
|
+
}
|
|
1825
|
+
};
|
|
1826
|
+
|
|
1827
|
+
if (typeof requestAnimationFrame !== 'undefined') {
|
|
1828
|
+
this.flowAnimFrameId = requestAnimationFrame(step);
|
|
1829
|
+
} else {
|
|
1830
|
+
this.flowAnimFrameId = setTimeout(() => step(Date.now()), 16);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
private stopFlowAnimation() {
|
|
1835
|
+
this.isFlowAnimating = false;
|
|
1836
|
+
if (this.flowAnimFrameId) {
|
|
1837
|
+
if (typeof cancelAnimationFrame !== 'undefined') {
|
|
1838
|
+
cancelAnimationFrame(this.flowAnimFrameId);
|
|
1839
|
+
} else {
|
|
1840
|
+
clearTimeout(this.flowAnimFrameId);
|
|
1841
|
+
}
|
|
1842
|
+
this.flowAnimFrameId = null;
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1232
1846
|
public destroy() {
|
|
1847
|
+
this.stopFlowAnimation();
|
|
1233
1848
|
if (this.wheelEndTimeout) {
|
|
1234
1849
|
clearTimeout(this.wheelEndTimeout);
|
|
1235
1850
|
this.wheelEndTimeout = null;
|