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/index.ts CHANGED
@@ -4,6 +4,10 @@ import UniLeafletMap from './UniLeafletMap.vue';
4
4
  export * from './types';
5
5
  export * from './utils/crs';
6
6
  export * from './utils/tile';
7
+ export * from './utils/bounds';
8
+ export * from './utils/geojson';
9
+ export * from './utils/curve';
10
+ export { clusterMarkers } from './utils/cluster';
7
11
  export * from './engine/factory';
8
12
  export { UniLeafletMap };
9
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uni-leaflet",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Cross-platform Leaflet and Native Canvas 2D Map Component for Uni-App (Vue 3, H5, WeChat Mini-Program, App)",
5
5
  "main": "index.ts",
6
6
  "scripts": {
package/types.ts CHANGED
@@ -46,6 +46,8 @@ export interface MarkerLabelOptions {
46
46
  }
47
47
 
48
48
  export interface MarkerIconOptions {
49
+ /** Icon rendering type template */
50
+ type?: 'default' | 'image' | 'emoji' | 'svg' | 'html' | 'avatar';
49
51
  /** Image URL for PNG/JPG/WebP/SVG or data URI (e.g. '/static/icons/pin-blue.png', 'data:image/png;base64,...') */
50
52
  url?: string;
51
53
  /** Raw SVG string markup (e.g. '<svg viewBox="0 0 24 24">...</svg>') */
@@ -60,6 +62,20 @@ export interface MarkerIconOptions {
60
62
  size?: [number, number];
61
63
  /** Icon anchor point in px: [anchorX, anchorY], default [width/2, height] */
62
64
  anchor?: [number, number];
65
+
66
+ // --- Avatar Bubble Options ---
67
+ /** 头像气泡右上角角标文本或 Emoji (例如 '❤️', 'VIP', '👑') */
68
+ badgeText?: string;
69
+ /** 头像气泡角标背景色,默认线性渐变红 */
70
+ badgeColor?: string;
71
+ /** 是否带有心跳脉冲光晕微动效,默认 false */
72
+ pulse?: boolean;
73
+ /** 脉冲光晕颜色,默认与 borderColor 一致 */
74
+ pulseColor?: string;
75
+ /** 头像气泡外圆环边框颜色,默认 '#3b82f6' */
76
+ borderColor?: string;
77
+ /** 头像气泡外圆环边框宽度,默认 3 */
78
+ borderWidth?: number;
63
79
  }
64
80
 
65
81
  export interface MarkerOptions {
@@ -77,8 +93,17 @@ export interface PolylineOptions {
77
93
  color?: string;
78
94
  width?: number;
79
95
  dashArray?: number[];
96
+ dashOffset?: number;
80
97
  opacity?: number;
81
98
  label?: string | MarkerLabelOptions;
99
+ /** 是否开启贝塞尔平滑弧线(旅行航线/航迹线),基于两点或多点生成弧线,默认 false */
100
+ curved?: boolean;
101
+ /** 弧线曲率系数,默认 0.25(正数向左/上一侧拱起,负数向右/下一侧拱起,0 为直线) */
102
+ curvature?: number;
103
+ /** 是否开启流光/流动虚线动效,默认 false */
104
+ flowing?: boolean;
105
+ /** 流光流动速度,默认 1(数值越大流动越快,负数反向流动) */
106
+ flowSpeed?: number;
82
107
  data?: any;
83
108
  }
84
109
 
@@ -105,16 +130,31 @@ export interface CircleOptions {
105
130
  data?: any;
106
131
  }
107
132
 
133
+ export interface GeoJsonStyleOptions {
134
+ color?: string;
135
+ weight?: number;
136
+ opacity?: number;
137
+ fillColor?: string;
138
+ fillOpacity?: number;
139
+ dashArray?: number[];
140
+ }
141
+
142
+ export type GeoJsonStyle =
143
+ | GeoJsonStyleOptions
144
+ | ((feature: any) => GeoJsonStyleOptions);
145
+
108
146
  export interface MapOverlays {
109
147
  markers?: MarkerOptions[];
110
148
  polylines?: PolylineOptions[];
111
149
  polygons?: PolygonOptions[];
112
150
  circles?: CircleOptions[];
151
+ geojson?: any;
152
+ geojsonStyle?: GeoJsonStyle;
113
153
  }
114
154
 
115
155
  export interface OverlayClickEvent {
116
- type: 'marker' | 'polyline' | 'polygon' | 'circle';
117
- data: MarkerOptions | PolylineOptions | PolygonOptions | CircleOptions;
156
+ type: 'marker' | 'polyline' | 'polygon' | 'circle' | 'geojson';
157
+ data: MarkerOptions | PolylineOptions | PolygonOptions | CircleOptions | any;
118
158
  }
119
159
 
120
160
  export interface MapOptions {
@@ -126,6 +166,8 @@ export interface MapOptions {
126
166
  subdomains?: string[];
127
167
  layers?: Array<string | TileLayerConfig>;
128
168
  overlays?: MapOverlays;
169
+ geojson?: any;
170
+ geojsonStyle?: GeoJsonStyle;
129
171
  showControls?: boolean;
130
172
  }
131
173
 
@@ -134,6 +176,51 @@ export interface MapChangeEvent {
134
176
  zoom: number;
135
177
  }
136
178
 
179
+ export type LatLngBoundsExpression =
180
+ | LatLngTuple[]
181
+ | [LatLngTuple, LatLngTuple]
182
+ | MapBounds
183
+ | { southWest: LatLngTuple; northEast: LatLngTuple }
184
+ | 'markers'
185
+ | 'polylines'
186
+ | 'polygons'
187
+ | 'circles'
188
+ | 'geojson'
189
+ | 'all'
190
+ | 'points'
191
+ | 'lines'
192
+ | any;
193
+
194
+ export interface FitBoundsOptions {
195
+ padding?: [number, number]; // [paddingX, paddingY] in px
196
+ paddingTopLeft?: [number, number];
197
+ paddingBottomRight?: [number, number];
198
+ maxZoom?: number;
199
+ animate?: boolean;
200
+ duration?: number;
201
+ [key: string]: any;
202
+ }
203
+
204
+ export interface ClusterGroup {
205
+ id: string;
206
+ count: number;
207
+ center: LatLngTuple;
208
+ bounds: [LatLngTuple, LatLngTuple];
209
+ markers: MarkerOptions[];
210
+ representativeMarker: MarkerOptions;
211
+ }
212
+
213
+ export type ClusterResult =
214
+ | { isCluster: false; marker: MarkerOptions }
215
+ | { isCluster: true; cluster: ClusterGroup };
216
+
217
+ export interface ClusterOptions {
218
+ enable?: boolean;
219
+ clusterRadius?: number; // default 60px
220
+ clusterMaxZoom?: number; // default 17
221
+ tileSize?: number;
222
+ }
223
+
137
224
  /**
138
225
  * Unified Map Engine Interface (Strategy / Adapter Pattern)
139
226
  */
@@ -143,6 +230,10 @@ export interface IMapEngine {
143
230
  zoomIn(): void;
144
231
  zoomOut(): void;
145
232
  panTo(center: LatLngTuple, duration?: number): void;
233
+ fitBounds(
234
+ bounds: LatLngBoundsExpression | LatLngTuple[] | MapBounds,
235
+ options?: FitBoundsOptions
236
+ ): void;
146
237
  setTileUrl(url: string, subdomains?: string[]): void;
147
238
  setLayers(layers: Array<string | TileLayerConfig>): void;
148
239
  setOverlays(overlays: MapOverlays): void;
@@ -150,7 +241,9 @@ export interface IMapEngine {
150
241
  setPolylines(polylines: PolylineOptions[]): void;
151
242
  setPolygons(polygons: PolygonOptions[]): void;
152
243
  setCircles(circles: CircleOptions[]): void;
244
+ setGeoJSON?(data: any, style?: GeoJsonStyle): void;
153
245
  clearOverlays(): void;
246
+ setClusterOptions?(options: ClusterOptions): void;
154
247
  getCenter(): LatLngTuple;
155
248
  getZoom(): number;
156
249
  resize(width?: number, height?: number): void;
@@ -0,0 +1,264 @@
1
+ import type { LatLngTuple, MapBounds, Point } from '../types';
2
+ import { clamp, latLngToWorldPixel, worldPixelToLatLng } from './crs';
3
+ import { extractGeoJsonLatLngs } from './geojson';
4
+ import { generateCurvedPolyline } from './curve';
5
+
6
+ /**
7
+ * Universal helper to extract an array of [lat, lng] coordinates
8
+ * from various bounds representations:
9
+ * - LatLngTuple[] (e.g. [[39.9, 116.4], [39.92, 116.45]])
10
+ * - Single LatLngTuple ([39.9, 116.4])
11
+ * - MapBounds object ({ minLat, maxLat, minLng, maxLng })
12
+ * - Object with southWest & northEast ({ southWest: [lat, lng], northEast: [lat, lng] })
13
+ * - Object with _southWest & _northEast (Leaflet bounds internal)
14
+ * - Objects with lat & lng ({ lat: 39.9, lng: 116.4 })
15
+ * - Objects with latLng property ({ latLng: [39.9, 116.4] })
16
+ * - Leaflet LatLngBounds instance (getSouthWest(), getNorthEast())
17
+ * - GeoJSON objects (FeatureCollection, Feature, Geometry)
18
+ */
19
+ export function extractLatLngs(bounds: any): LatLngTuple[] {
20
+ if (!bounds) return [];
21
+
22
+ // GeoJSON object (FeatureCollection, Feature, or Geometry)
23
+ if (
24
+ bounds.type &&
25
+ (bounds.features || bounds.geometry || bounds.coordinates || bounds.geometries)
26
+ ) {
27
+ return extractGeoJsonLatLngs(bounds);
28
+ }
29
+
30
+ // Single LatLngTuple: [lat, lng]
31
+ if (
32
+ Array.isArray(bounds) &&
33
+ bounds.length === 2 &&
34
+ typeof bounds[0] === 'number' &&
35
+ typeof bounds[1] === 'number'
36
+ ) {
37
+ return [[bounds[0], bounds[1]]];
38
+ }
39
+
40
+ // Array of points or coordinates or polylines/polygons
41
+ if (Array.isArray(bounds)) {
42
+ const list: LatLngTuple[] = [];
43
+ for (const item of bounds) {
44
+ if (!item) continue;
45
+ if (
46
+ Array.isArray(item) &&
47
+ item.length >= 2 &&
48
+ typeof item[0] === 'number' &&
49
+ typeof item[1] === 'number'
50
+ ) {
51
+ list.push([item[0], item[1]]);
52
+ } else if (typeof item.lat === 'number' && typeof item.lng === 'number') {
53
+ list.push([item.lat, item.lng]);
54
+ } else if (
55
+ Array.isArray(item.latLng) &&
56
+ item.latLng.length >= 2 &&
57
+ typeof item.latLng[0] === 'number' &&
58
+ typeof item.latLng[1] === 'number'
59
+ ) {
60
+ list.push([item.latLng[0], item.latLng[1]]);
61
+ } else if (Array.isArray(item.latLngs)) {
62
+ // Polyline or Polygon object (if curved polyline, extract interpolated arc points)
63
+ const pts = item.curved
64
+ ? generateCurvedPolyline(item.latLngs, item.curvature ?? 0.25)
65
+ : item.latLngs;
66
+ for (const pt of pts) {
67
+ if (
68
+ Array.isArray(pt) &&
69
+ pt.length >= 2 &&
70
+ typeof pt[0] === 'number' &&
71
+ typeof pt[1] === 'number'
72
+ ) {
73
+ list.push([pt[0], pt[1]]);
74
+ } else if (pt && typeof pt.lat === 'number' && typeof pt.lng === 'number') {
75
+ list.push([pt.lat, pt.lng]);
76
+ }
77
+ }
78
+ } else if (item.type && (item.features || item.geometry || item.coordinates)) {
79
+ list.push(...extractGeoJsonLatLngs(item));
80
+ }
81
+ }
82
+ return list;
83
+ }
84
+
85
+ // Overlays collection object: { markers, polylines, polygons, circles, geojson }
86
+ if (
87
+ bounds.markers ||
88
+ bounds.polylines ||
89
+ bounds.polygons ||
90
+ bounds.circles ||
91
+ bounds.geojson
92
+ ) {
93
+ const list: LatLngTuple[] = [];
94
+ if (Array.isArray(bounds.markers)) {
95
+ list.push(...extractLatLngs(bounds.markers));
96
+ }
97
+ if (Array.isArray(bounds.polylines)) {
98
+ list.push(...extractLatLngs(bounds.polylines));
99
+ }
100
+ if (Array.isArray(bounds.polygons)) {
101
+ list.push(...extractLatLngs(bounds.polygons));
102
+ }
103
+ if (Array.isArray(bounds.circles)) {
104
+ list.push(...extractLatLngs(bounds.circles));
105
+ }
106
+ if (bounds.geojson) {
107
+ list.push(...extractGeoJsonLatLngs(bounds.geojson));
108
+ }
109
+ return list;
110
+ }
111
+
112
+ // Leaflet LatLngBounds object
113
+ if (
114
+ typeof bounds.getSouthWest === 'function' &&
115
+ typeof bounds.getNorthEast === 'function'
116
+ ) {
117
+ const sw = bounds.getSouthWest();
118
+ const ne = bounds.getNorthEast();
119
+ return [
120
+ [sw.lat, sw.lng],
121
+ [ne.lat, ne.lng],
122
+ ];
123
+ }
124
+
125
+ // MapBounds object: { minLat, maxLat, minLng, maxLng }
126
+ if (
127
+ typeof bounds.minLat === 'number' &&
128
+ typeof bounds.maxLat === 'number' &&
129
+ typeof bounds.minLng === 'number' &&
130
+ typeof bounds.maxLng === 'number'
131
+ ) {
132
+ return [
133
+ [bounds.minLat, bounds.minLng],
134
+ [bounds.maxLat, bounds.maxLng],
135
+ ];
136
+ }
137
+
138
+ // Object with southWest and northEast
139
+ if (bounds.southWest && bounds.northEast) {
140
+ const sw = bounds.southWest;
141
+ const ne = bounds.northEast;
142
+ const swLat = Array.isArray(sw) ? sw[0] : sw.lat;
143
+ const swLng = Array.isArray(sw) ? sw[1] : sw.lng;
144
+ const neLat = Array.isArray(ne) ? ne[0] : ne.lat;
145
+ const neLng = Array.isArray(ne) ? ne[1] : ne.lng;
146
+ if (
147
+ typeof swLat === 'number' &&
148
+ typeof swLng === 'number' &&
149
+ typeof neLat === 'number' &&
150
+ typeof neLng === 'number'
151
+ ) {
152
+ return [
153
+ [swLat, swLng],
154
+ [neLat, neLng],
155
+ ];
156
+ }
157
+ }
158
+
159
+ // Object with _southWest and _northEast
160
+ if (bounds._southWest && bounds._northEast) {
161
+ const sw = bounds._southWest;
162
+ const ne = bounds._northEast;
163
+ if (typeof sw.lat === 'number' && typeof ne.lat === 'number') {
164
+ return [
165
+ [sw.lat, sw.lng],
166
+ [ne.lat, ne.lng],
167
+ ];
168
+ }
169
+ }
170
+
171
+ return [];
172
+ }
173
+
174
+ /**
175
+ * Calculate the bounding box { minLat, maxLat, minLng, maxLng } from points
176
+ */
177
+ export function calculateBounds(points: LatLngTuple[]): MapBounds | null {
178
+ if (!points || points.length === 0) return null;
179
+
180
+ let minLat = Infinity;
181
+ let maxLat = -Infinity;
182
+ let minLng = Infinity;
183
+ let maxLng = -Infinity;
184
+
185
+ for (const [lat, lng] of points) {
186
+ if (typeof lat !== 'number' || typeof lng !== 'number') continue;
187
+ if (isNaN(lat) || isNaN(lng)) continue;
188
+ if (lat < minLat) minLat = lat;
189
+ if (lat > maxLat) maxLat = lat;
190
+ if (lng < minLng) minLng = lng;
191
+ if (lng > maxLng) maxLng = lng;
192
+ }
193
+
194
+ if (!isFinite(minLat) || !isFinite(maxLat) || !isFinite(minLng) || !isFinite(maxLng)) {
195
+ return null;
196
+ }
197
+
198
+ return { minLat, maxLat, minLng, maxLng };
199
+ }
200
+
201
+ export interface BoundsCalcOptions {
202
+ padding?: [number, number]; // [paddingX, paddingY] in pixels
203
+ minZoom?: number;
204
+ maxZoom?: number;
205
+ tileSize?: number;
206
+ }
207
+
208
+ /**
209
+ * Calculate optimal center LatLng and integer Zoom to fit the bounds in given view dimensions
210
+ */
211
+ export function getBoundsCenterAndZoom(
212
+ bounds: any,
213
+ viewWidth: number,
214
+ viewHeight: number,
215
+ options?: BoundsCalcOptions
216
+ ): { center: LatLngTuple; zoom: number } | null {
217
+ const points = extractLatLngs(bounds);
218
+ const bbox = calculateBounds(points);
219
+ if (!bbox) return null;
220
+
221
+ const { minLat, maxLat, minLng, maxLng } = bbox;
222
+ const minZoom = options?.minZoom ?? 3;
223
+ const maxZoom = options?.maxZoom ?? 18;
224
+ const tileSize = options?.tileSize || 256;
225
+
226
+ // Single point case
227
+ if (Math.abs(maxLat - minLat) < 1e-7 && Math.abs(maxLng - minLng) < 1e-7) {
228
+ return {
229
+ center: [minLat, minLng],
230
+ zoom: clamp(options?.maxZoom ?? 14, minZoom, maxZoom),
231
+ };
232
+ }
233
+
234
+ // Convert bounding corners to Mercator world pixels at zoom 0
235
+ const pNW = latLngToWorldPixel(maxLat, minLng, 0, tileSize);
236
+ const pSE = latLngToWorldPixel(minLat, maxLng, 0, tileSize);
237
+
238
+ // Geometric center in world pixels at zoom 0
239
+ const centerWorldX = (pNW.x + pSE.x) / 2;
240
+ const centerWorldY = (pNW.y + pSE.y) / 2;
241
+ const centerLatLng = worldPixelToLatLng(centerWorldX, centerWorldY, 0, tileSize);
242
+
243
+ // Available viewport space considering padding
244
+ const padX = options?.padding ? options.padding[0] : 20;
245
+ const padY = options?.padding ? options.padding[1] : 20;
246
+ const availW = Math.max(viewWidth - 2 * padX, 20);
247
+ const availH = Math.max(viewHeight - 2 * padY, 20);
248
+
249
+ // World pixel spans at zoom 0
250
+ const deltaX0 = Math.abs(pSE.x - pNW.x);
251
+ const deltaY0 = Math.abs(pSE.y - pNW.y);
252
+
253
+ const zoomX = deltaX0 > 1e-6 ? Math.log2(availW / deltaX0) : Infinity;
254
+ const zoomY = deltaY0 > 1e-6 ? Math.log2(availH / deltaY0) : Infinity;
255
+
256
+ // Use Math.floor to ensure entire bounding area + padding fits within view
257
+ const rawZoom = Math.min(zoomX, zoomY);
258
+ const targetZoom = clamp(Math.floor(rawZoom), minZoom, maxZoom);
259
+
260
+ return {
261
+ center: [centerLatLng.lat, centerLatLng.lng],
262
+ zoom: targetZoom,
263
+ };
264
+ }
@@ -0,0 +1,158 @@
1
+ import type {
2
+ LatLngTuple,
3
+ MarkerOptions,
4
+ ClusterGroup,
5
+ ClusterResult,
6
+ ClusterOptions,
7
+ } from '../types';
8
+ import { latLngToWorldPixel } from './crs';
9
+
10
+ export type { ClusterGroup, ClusterResult, ClusterOptions };
11
+
12
+ /**
13
+ * Cluster markers based on screen pixel distance at current zoom level (EPSG:3857)
14
+ */
15
+ export function clusterMarkers(
16
+ markers: MarkerOptions[],
17
+ zoom: number,
18
+ options?: ClusterOptions
19
+ ): ClusterResult[] {
20
+ if (!markers || markers.length === 0) return [];
21
+
22
+ const radius = options?.clusterRadius ?? 60;
23
+ const maxZoom = options?.clusterMaxZoom ?? 17;
24
+ const tileSize = options?.tileSize || 256;
25
+
26
+ // If beyond max cluster zoom, return all markers directly
27
+ if (zoom >= maxZoom) {
28
+ return markers.map((marker) => ({ isCluster: false, marker }));
29
+ }
30
+
31
+ // Calculate world pixel coordinate for each marker at zoom
32
+ interface PixelMarker {
33
+ marker: MarkerOptions;
34
+ x: number;
35
+ y: number;
36
+ assigned: boolean;
37
+ }
38
+
39
+ const pixelMarkers: PixelMarker[] = [];
40
+ for (const marker of markers) {
41
+ if (!marker || !marker.latLng) continue;
42
+ const [lat, lng] = marker.latLng;
43
+ if (typeof lat !== 'number' || typeof lng !== 'number' || isNaN(lat) || isNaN(lng)) continue;
44
+ const pt = latLngToWorldPixel(lat, lng, zoom, tileSize);
45
+ pixelMarkers.push({
46
+ marker,
47
+ x: pt.x,
48
+ y: pt.y,
49
+ assigned: false,
50
+ });
51
+ }
52
+
53
+ if (pixelMarkers.length === 0) return [];
54
+
55
+ // Spatial Grid Index for fast neighbor search
56
+ const grid = new Map<string, PixelMarker[]>();
57
+ const cellSize = radius;
58
+
59
+ for (const pm of pixelMarkers) {
60
+ const gx = Math.floor(pm.x / cellSize);
61
+ const gy = Math.floor(pm.y / cellSize);
62
+ const key = `${gx}_${gy}`;
63
+ let cell = grid.get(key);
64
+ if (!cell) {
65
+ cell = [];
66
+ grid.set(key, cell);
67
+ }
68
+ cell.push(pm);
69
+ }
70
+
71
+ const results: ClusterResult[] = [];
72
+ const radiusSq = radius * radius;
73
+
74
+ for (let i = 0; i < pixelMarkers.length; i++) {
75
+ const pm = pixelMarkers[i];
76
+ if (pm.assigned) continue;
77
+
78
+ pm.assigned = true;
79
+ const clusterItems: PixelMarker[] = [pm];
80
+
81
+ const gx = Math.floor(pm.x / cellSize);
82
+ const gy = Math.floor(pm.y / cellSize);
83
+
84
+ // Search 3x3 neighbor grid cells
85
+ for (let dx = -1; dx <= 1; dx++) {
86
+ for (let dy = -1; dy <= 1; dy++) {
87
+ const neighborKey = `${gx + dx}_${gy + dy}`;
88
+ const neighborCell = grid.get(neighborKey);
89
+ if (!neighborCell) continue;
90
+
91
+ for (const neighbor of neighborCell) {
92
+ if (neighbor.assigned) continue;
93
+ const distSq =
94
+ (neighbor.x - pm.x) * (neighbor.x - pm.x) +
95
+ (neighbor.y - pm.y) * (neighbor.y - pm.y);
96
+ if (distSq <= radiusSq) {
97
+ neighbor.assigned = true;
98
+ clusterItems.push(neighbor);
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ if (clusterItems.length === 1) {
105
+ // Single unclustered marker
106
+ results.push({
107
+ isCluster: false,
108
+ marker: clusterItems[0].marker,
109
+ });
110
+ } else {
111
+ // Multi-marker cluster
112
+ let sumLat = 0;
113
+ let sumLng = 0;
114
+ let minLat = Infinity;
115
+ let maxLat = -Infinity;
116
+ let minLng = Infinity;
117
+ let maxLng = -Infinity;
118
+
119
+ const clusterRawMarkers: MarkerOptions[] = [];
120
+ for (const item of clusterItems) {
121
+ const [lat, lng] = item.marker.latLng;
122
+ sumLat += lat;
123
+ sumLng += lng;
124
+ if (lat < minLat) minLat = lat;
125
+ if (lat > maxLat) maxLat = lat;
126
+ if (lng < minLng) minLng = lng;
127
+ if (lng > maxLng) maxLng = lng;
128
+ clusterRawMarkers.push(item.marker);
129
+ }
130
+
131
+ const count = clusterItems.length;
132
+ const centerLat = sumLat / count;
133
+ const centerLng = sumLng / count;
134
+
135
+ // Pick representative marker (one that has an icon or first)
136
+ const rep =
137
+ clusterRawMarkers.find((m) => m.icon?.url || m.icon?.svg || m.icon?.text) ||
138
+ clusterRawMarkers[0];
139
+
140
+ results.push({
141
+ isCluster: true,
142
+ cluster: {
143
+ id: `cluster_${zoom}_${centerLat.toFixed(5)}_${centerLng.toFixed(5)}_${count}`,
144
+ count,
145
+ center: [centerLat, centerLng],
146
+ bounds: [
147
+ [minLat, minLng],
148
+ [maxLat, maxLng],
149
+ ],
150
+ markers: clusterRawMarkers,
151
+ representativeMarker: rep,
152
+ },
153
+ });
154
+ }
155
+ }
156
+
157
+ return results;
158
+ }
package/utils/curve.ts ADDED
@@ -0,0 +1,86 @@
1
+ import type { LatLngTuple } from '../types';
2
+
3
+ /**
4
+ * Generate a smooth arc / flight route between two LatLng points using quadratic Bézier curve.
5
+ * @param p1 Starting [lat, lng]
6
+ * @param p2 Ending [lat, lng]
7
+ * @param curvature Curvature factor, default 0.25 (positive arches left/up, negative arches right/down, 0 is straight)
8
+ * @param segments Number of interpolation segments along the curve, default 36
9
+ */
10
+ export function generateCurvedArc(
11
+ p1: LatLngTuple,
12
+ p2: LatLngTuple,
13
+ curvature = 0.25,
14
+ segments = 36
15
+ ): LatLngTuple[] {
16
+ if (Math.abs(curvature) < 1e-4) {
17
+ return [p1, p2];
18
+ }
19
+
20
+ const [lat1, lng1] = p1;
21
+ const [lat2, lng2] = p2;
22
+
23
+ // Vector between points
24
+ const dLat = lat2 - lat1;
25
+ const dLng = lng2 - lng1;
26
+ const dist = Math.hypot(dLat, dLng);
27
+
28
+ if (dist < 1e-7) {
29
+ return [p1, p2];
30
+ }
31
+
32
+ // Midpoint
33
+ const midLat = (lat1 + lat2) / 2;
34
+ const midLng = (lng1 + lng2) / 2;
35
+
36
+ // Normal perpendicular vector [-dLng / dist, dLat / dist]
37
+ const normLat = -dLng / dist;
38
+ const normLng = dLat / dist;
39
+
40
+ // Control point
41
+ const ctrlLat = midLat + normLat * dist * curvature;
42
+ const ctrlLng = midLng + normLng * dist * curvature;
43
+
44
+ const points: LatLngTuple[] = [];
45
+ const steps = Math.max(12, segments);
46
+
47
+ for (let i = 0; i <= steps; i++) {
48
+ const t = i / steps;
49
+ const invT = 1 - t;
50
+ // Quadratic Bézier formula: B(t) = (1-t)^2 * P1 + 2*(1-t)*t * C + t^2 * P2
51
+ const lat = invT * invT * lat1 + 2 * invT * t * ctrlLat + t * t * lat2;
52
+ const lng = invT * invT * lng1 + 2 * invT * t * ctrlLng + t * t * lng2;
53
+ points.push([lat, lng]);
54
+ }
55
+
56
+ return points;
57
+ }
58
+
59
+ /**
60
+ * Generate a curved polyline from an array of waypoints.
61
+ * If 2 points: generates a single arc.
62
+ * If > 2 points: generates sequential arcs between consecutive pairs.
63
+ */
64
+ export function generateCurvedPolyline(
65
+ latLngs: LatLngTuple[],
66
+ curvature = 0.25,
67
+ segments = 36
68
+ ): LatLngTuple[] {
69
+ if (!latLngs || latLngs.length < 2) return latLngs || [];
70
+ if (Math.abs(curvature) < 1e-4) return latLngs;
71
+
72
+ if (latLngs.length === 2) {
73
+ return generateCurvedArc(latLngs[0], latLngs[1], curvature, segments);
74
+ }
75
+
76
+ const result: LatLngTuple[] = [];
77
+ for (let i = 0; i < latLngs.length - 1; i++) {
78
+ const arc = generateCurvedArc(latLngs[i], latLngs[i + 1], curvature, segments);
79
+ if (i > 0) {
80
+ // Avoid duplicate junction point
81
+ arc.shift();
82
+ }
83
+ result.push(...arc);
84
+ }
85
+ return result;
86
+ }