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.
@@ -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 points = line.latLngs.map((pt) =>
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.dashArray && ctx.setLineDash) {
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
- for (const m of this.overlays.markers) {
547
- if (!m || !m.latLng) continue;
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
- 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]];
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
- 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);
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
- // 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 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
- this.drawLabelBadge(
617
- ctx,
618
- labelText,
619
- tipX,
620
- tipY,
621
- labelStyle,
622
- true
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
- // E. Render Zoom Controls if enabled (Drawn directly in Canvas 2D frame buffer)
632
- if (this.showControls) {
633
- this.drawZoomControls(ctx);
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 markers hit
1026
- if (this.overlays.markers) {
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 anchor = m.icon?.anchor || [size[0] / 2, size[1]];
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;