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
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
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;
|
package/utils/bounds.ts
ADDED
|
@@ -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
|
+
}
|
package/utils/cluster.ts
ADDED
|
@@ -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
|
+
}
|