uni-leaflet 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +383 -0
- package/UniLeafletMap.vue +706 -0
- package/app-render.d.ts +2 -0
- package/app-render.js +550 -0
- package/engine/canvas-tile-engine.ts +1252 -0
- package/engine/factory.ts +101 -0
- package/engine/h5-map-adapter.ts +470 -0
- package/engine/tile-manager.ts +192 -0
- package/index.ts +19 -0
- package/package.json +46 -0
- package/types.ts +159 -0
- package/utils/crs.ts +60 -0
- package/utils/tile.ts +118 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { formatTileUrl } from '../utils/tile';
|
|
2
|
+
|
|
3
|
+
export interface TileImageRecord {
|
|
4
|
+
image: any;
|
|
5
|
+
status: 'loading' | 'loaded' | 'error';
|
|
6
|
+
lastUsed: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ParentFallbackInfo {
|
|
10
|
+
image: any;
|
|
11
|
+
// Sub-rectangle in parent tile (0..256)
|
|
12
|
+
srcX: number;
|
|
13
|
+
srcY: number;
|
|
14
|
+
srcSize: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class TileManager {
|
|
18
|
+
private cache: Map<string, TileImageRecord> = new Map();
|
|
19
|
+
private maxCacheSize: number;
|
|
20
|
+
private canvasNode: any;
|
|
21
|
+
private onTileLoadedCallback?: (key: string) => void;
|
|
22
|
+
|
|
23
|
+
constructor(maxCacheSize = 250) {
|
|
24
|
+
this.maxCacheSize = maxCacheSize;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public setCanvasNode(canvasNode: any) {
|
|
28
|
+
this.canvasNode = canvasNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public setOnTileLoaded(cb: (key: string) => void) {
|
|
32
|
+
this.onTileLoadedCallback = cb;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create an Image instance (supports mini-program Canvas 2D and Web)
|
|
37
|
+
*/
|
|
38
|
+
private createImageInstance(): any {
|
|
39
|
+
if (this.canvasNode && typeof this.canvasNode.createImage === 'function') {
|
|
40
|
+
return this.canvasNode.createImage();
|
|
41
|
+
}
|
|
42
|
+
if (typeof Image !== 'undefined') {
|
|
43
|
+
try {
|
|
44
|
+
const img = new Image();
|
|
45
|
+
img.crossOrigin = 'Anonymous';
|
|
46
|
+
return img;
|
|
47
|
+
} catch (e) {}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Request a tile. If cached and loaded, returns the image record.
|
|
54
|
+
* If not cached, initiates image loading across Web, Mini-Program, and App-Plus.
|
|
55
|
+
*/
|
|
56
|
+
public requestTile(key: string, url: string): TileImageRecord | null {
|
|
57
|
+
const existing = this.cache.get(key);
|
|
58
|
+
if (existing) {
|
|
59
|
+
existing.lastUsed = Date.now();
|
|
60
|
+
return existing;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const img = this.createImageInstance();
|
|
64
|
+
if (img) {
|
|
65
|
+
const record: TileImageRecord = {
|
|
66
|
+
image: img,
|
|
67
|
+
status: 'loading',
|
|
68
|
+
lastUsed: Date.now(),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
this.evictIfNeeded();
|
|
72
|
+
this.cache.set(key, record);
|
|
73
|
+
|
|
74
|
+
img.onload = () => {
|
|
75
|
+
record.status = 'loaded';
|
|
76
|
+
if (this.onTileLoadedCallback) {
|
|
77
|
+
this.onTileLoadedCallback(key);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
img.onerror = () => {
|
|
82
|
+
record.status = 'error';
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
img.src = url;
|
|
86
|
+
return record;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Fallback for App-Plus / non-DOM environments using uni.getImageInfo
|
|
90
|
+
if (typeof uni !== 'undefined' && typeof uni.getImageInfo === 'function') {
|
|
91
|
+
const record: TileImageRecord = {
|
|
92
|
+
image: url,
|
|
93
|
+
status: 'loading',
|
|
94
|
+
lastUsed: Date.now(),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
this.evictIfNeeded();
|
|
98
|
+
this.cache.set(key, record);
|
|
99
|
+
|
|
100
|
+
uni.getImageInfo({
|
|
101
|
+
src: url,
|
|
102
|
+
success: (res: any) => {
|
|
103
|
+
record.image = res.path || url;
|
|
104
|
+
record.status = 'loaded';
|
|
105
|
+
if (this.onTileLoadedCallback) {
|
|
106
|
+
this.onTileLoadedCallback(key);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
fail: () => {
|
|
110
|
+
record.status = 'error';
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return record;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check if a parent tile (from lower zoom levels) is already loaded to serve as fallback
|
|
122
|
+
*/
|
|
123
|
+
public getParentFallback(
|
|
124
|
+
z: number,
|
|
125
|
+
x: number,
|
|
126
|
+
y: number,
|
|
127
|
+
tileUrlTemplate: string,
|
|
128
|
+
subdomains: string[] = ['a', 'b', 'c'],
|
|
129
|
+
minZ = 0,
|
|
130
|
+
tileSize = 256
|
|
131
|
+
): ParentFallbackInfo | null {
|
|
132
|
+
for (let pz = z - 1; pz >= minZ; pz--) {
|
|
133
|
+
const zoomDiff = z - pz;
|
|
134
|
+
const factor = 1 << zoomDiff;
|
|
135
|
+
const px = Math.floor(x / factor);
|
|
136
|
+
const py = Math.floor(y / factor);
|
|
137
|
+
|
|
138
|
+
const parentUrl = formatTileUrl(tileUrlTemplate, px, py, pz, subdomains);
|
|
139
|
+
const parentKey = `${pz}_${px}_${py}_${parentUrl}`;
|
|
140
|
+
const record = this.cache.get(parentKey);
|
|
141
|
+
|
|
142
|
+
if (record && record.status === 'loaded' && record.image) {
|
|
143
|
+
const subTileSize = tileSize / factor;
|
|
144
|
+
const subX = (x % factor) * subTileSize;
|
|
145
|
+
const subY = (y % factor) * subTileSize;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
image: record.image,
|
|
149
|
+
srcX: subX,
|
|
150
|
+
srcY: subY,
|
|
151
|
+
srcSize: subTileSize,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private evictIfNeeded() {
|
|
159
|
+
if (this.cache.size < this.maxCacheSize) return;
|
|
160
|
+
|
|
161
|
+
let oldestKey: string | null = null;
|
|
162
|
+
let oldestTime = Infinity;
|
|
163
|
+
|
|
164
|
+
for (const [k, v] of this.cache.entries()) {
|
|
165
|
+
if (v.lastUsed < oldestTime) {
|
|
166
|
+
oldestTime = v.lastUsed;
|
|
167
|
+
oldestKey = k;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (oldestKey) {
|
|
172
|
+
const item = this.cache.get(oldestKey);
|
|
173
|
+
if (item && item.image) {
|
|
174
|
+
item.image.onload = null;
|
|
175
|
+
item.image.onerror = null;
|
|
176
|
+
item.image.src = '';
|
|
177
|
+
}
|
|
178
|
+
this.cache.delete(oldestKey);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
public clear() {
|
|
183
|
+
for (const [, v] of this.cache.entries()) {
|
|
184
|
+
if (v.image) {
|
|
185
|
+
v.image.onload = null;
|
|
186
|
+
v.image.onerror = null;
|
|
187
|
+
v.image.src = '';
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
this.cache.clear();
|
|
191
|
+
}
|
|
192
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { App, Plugin } from 'vue';
|
|
2
|
+
import UniLeafletMap from './UniLeafletMap.vue';
|
|
3
|
+
|
|
4
|
+
export * from './types';
|
|
5
|
+
export * from './utils/crs';
|
|
6
|
+
export * from './utils/tile';
|
|
7
|
+
export * from './engine/factory';
|
|
8
|
+
export { UniLeafletMap };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Vue 3 Plugin installation helper (app.use(UniLeaflet))
|
|
12
|
+
*/
|
|
13
|
+
const UniLeaflet: Plugin = {
|
|
14
|
+
install(app: App) {
|
|
15
|
+
app.component('UniLeafletMap', UniLeafletMap);
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default UniLeaflet;
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "uni-leaflet",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Cross-platform Leaflet and Native Canvas 2D Map Component for Uni-App (Vue 3, H5, WeChat Mini-Program, App)",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"module": "index.ts",
|
|
7
|
+
"types": "index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"**/*"
|
|
10
|
+
],
|
|
11
|
+
"keywords": [
|
|
12
|
+
"uniapp",
|
|
13
|
+
"leaflet",
|
|
14
|
+
"map",
|
|
15
|
+
"canvas",
|
|
16
|
+
"canvas-2d",
|
|
17
|
+
"wechat-miniprogram",
|
|
18
|
+
"wechat",
|
|
19
|
+
"h5",
|
|
20
|
+
"gis",
|
|
21
|
+
"tianditu",
|
|
22
|
+
"gaode",
|
|
23
|
+
"vue3",
|
|
24
|
+
"typescript"
|
|
25
|
+
],
|
|
26
|
+
"author": "jikekaifa@qq.com",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/yyylrrr/uniapp-helper"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@dcloudio/uni-app": ">=3.0.0",
|
|
34
|
+
"leaflet": "^1.9.4",
|
|
35
|
+
"vue": "^3.0.0"
|
|
36
|
+
},
|
|
37
|
+
"uni_modules": {
|
|
38
|
+
"scripts": {},
|
|
39
|
+
"dcloudext": {
|
|
40
|
+
"category": [
|
|
41
|
+
"前端组件",
|
|
42
|
+
"地图组件"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
export type LatLngTuple = [number, number]; // [latitude, longitude]
|
|
2
|
+
|
|
3
|
+
export interface LatLng {
|
|
4
|
+
lat: number;
|
|
5
|
+
lng: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Point {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TileCoordinate {
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
z: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MapBounds {
|
|
20
|
+
minLat: number;
|
|
21
|
+
maxLat: number;
|
|
22
|
+
minLng: number;
|
|
23
|
+
maxLng: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TileLayerConfig {
|
|
27
|
+
id?: string;
|
|
28
|
+
url: string;
|
|
29
|
+
subdomains?: string[];
|
|
30
|
+
opacity?: number;
|
|
31
|
+
minZoom?: number;
|
|
32
|
+
maxZoom?: number;
|
|
33
|
+
zIndex?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MarkerLabelOptions {
|
|
37
|
+
text: string;
|
|
38
|
+
offset?: [number, number]; // [offsetX, offsetY] in px
|
|
39
|
+
color?: string;
|
|
40
|
+
fontSize?: number;
|
|
41
|
+
backgroundColor?: string;
|
|
42
|
+
borderColor?: string;
|
|
43
|
+
borderRadius?: number;
|
|
44
|
+
padding?: [number, number]; // [paddingY, paddingX] in px
|
|
45
|
+
show?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface MarkerIconOptions {
|
|
49
|
+
/** Image URL for PNG/JPG/WebP/SVG or data URI (e.g. '/static/icons/pin-blue.png', 'data:image/png;base64,...') */
|
|
50
|
+
url?: string;
|
|
51
|
+
/** Raw SVG string markup (e.g. '<svg viewBox="0 0 24 24">...</svg>') */
|
|
52
|
+
svg?: string;
|
|
53
|
+
/** Custom HTML markup string (H5 and App renderjs) */
|
|
54
|
+
html?: string;
|
|
55
|
+
/** Emoji or single character e.g. '📍', '🏛️', '🚩', '☕' */
|
|
56
|
+
text?: string;
|
|
57
|
+
/** Default pin fill color or tint color (e.g. '#3b82f6', '#ef4444') */
|
|
58
|
+
color?: string;
|
|
59
|
+
/** Icon size in px: [width, height], default [32, 32] */
|
|
60
|
+
size?: [number, number];
|
|
61
|
+
/** Icon anchor point in px: [anchorX, anchorY], default [width/2, height] */
|
|
62
|
+
anchor?: [number, number];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MarkerOptions {
|
|
66
|
+
id?: string | number;
|
|
67
|
+
latLng: LatLngTuple;
|
|
68
|
+
title?: string;
|
|
69
|
+
icon?: MarkerIconOptions;
|
|
70
|
+
label?: string | MarkerLabelOptions;
|
|
71
|
+
data?: any;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PolylineOptions {
|
|
75
|
+
id?: string | number;
|
|
76
|
+
latLngs: LatLngTuple[];
|
|
77
|
+
color?: string;
|
|
78
|
+
width?: number;
|
|
79
|
+
dashArray?: number[];
|
|
80
|
+
opacity?: number;
|
|
81
|
+
label?: string | MarkerLabelOptions;
|
|
82
|
+
data?: any;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface PolygonOptions {
|
|
86
|
+
id?: string | number;
|
|
87
|
+
latLngs: LatLngTuple[];
|
|
88
|
+
color?: string; // stroke border color
|
|
89
|
+
fillColor?: string; // fill color
|
|
90
|
+
fillOpacity?: number;
|
|
91
|
+
width?: number; // stroke width
|
|
92
|
+
label?: string | MarkerLabelOptions;
|
|
93
|
+
data?: any;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface CircleOptions {
|
|
97
|
+
id?: string | number;
|
|
98
|
+
latLng: LatLngTuple;
|
|
99
|
+
radius: number; // in meters
|
|
100
|
+
color?: string;
|
|
101
|
+
fillColor?: string;
|
|
102
|
+
fillOpacity?: number;
|
|
103
|
+
width?: number;
|
|
104
|
+
label?: string | MarkerLabelOptions;
|
|
105
|
+
data?: any;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface MapOverlays {
|
|
109
|
+
markers?: MarkerOptions[];
|
|
110
|
+
polylines?: PolylineOptions[];
|
|
111
|
+
polygons?: PolygonOptions[];
|
|
112
|
+
circles?: CircleOptions[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface OverlayClickEvent {
|
|
116
|
+
type: 'marker' | 'polyline' | 'polygon' | 'circle';
|
|
117
|
+
data: MarkerOptions | PolylineOptions | PolygonOptions | CircleOptions;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface MapOptions {
|
|
121
|
+
center: LatLngTuple;
|
|
122
|
+
zoom: number;
|
|
123
|
+
minZoom?: number;
|
|
124
|
+
maxZoom?: number;
|
|
125
|
+
tileUrl?: string;
|
|
126
|
+
subdomains?: string[];
|
|
127
|
+
layers?: Array<string | TileLayerConfig>;
|
|
128
|
+
overlays?: MapOverlays;
|
|
129
|
+
showControls?: boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface MapChangeEvent {
|
|
133
|
+
center: LatLngTuple;
|
|
134
|
+
zoom: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Unified Map Engine Interface (Strategy / Adapter Pattern)
|
|
139
|
+
*/
|
|
140
|
+
export interface IMapEngine {
|
|
141
|
+
setCenter(center: LatLngTuple, animate?: boolean): void;
|
|
142
|
+
setZoom(zoom: number): void;
|
|
143
|
+
zoomIn(): void;
|
|
144
|
+
zoomOut(): void;
|
|
145
|
+
panTo(center: LatLngTuple, duration?: number): void;
|
|
146
|
+
setTileUrl(url: string, subdomains?: string[]): void;
|
|
147
|
+
setLayers(layers: Array<string | TileLayerConfig>): void;
|
|
148
|
+
setOverlays(overlays: MapOverlays): void;
|
|
149
|
+
setMarkers(markers: MarkerOptions[]): void;
|
|
150
|
+
setPolylines(polylines: PolylineOptions[]): void;
|
|
151
|
+
setPolygons(polygons: PolygonOptions[]): void;
|
|
152
|
+
setCircles(circles: CircleOptions[]): void;
|
|
153
|
+
clearOverlays(): void;
|
|
154
|
+
getCenter(): LatLngTuple;
|
|
155
|
+
getZoom(): number;
|
|
156
|
+
resize(width?: number, height?: number): void;
|
|
157
|
+
destroy(): void;
|
|
158
|
+
getNativeInstance?(): any;
|
|
159
|
+
}
|
package/utils/crs.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { LatLng, Point } from '../types';
|
|
2
|
+
|
|
3
|
+
const MAX_LATITUDE = 85.0511287798;
|
|
4
|
+
const DEG_TO_RAD = Math.PI / 180;
|
|
5
|
+
const RAD_TO_DEG = 180 / Math.PI;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Limit value within [min, max]
|
|
9
|
+
*/
|
|
10
|
+
export function clamp(val: number, min: number, max: number): number {
|
|
11
|
+
return Math.min(Math.max(val, min), max);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Convert Latitude & Longitude to world pixel coordinates at a given zoom level (EPSG:3857)
|
|
16
|
+
*/
|
|
17
|
+
export function latLngToWorldPixel(
|
|
18
|
+
lat: number,
|
|
19
|
+
lng: number,
|
|
20
|
+
zoom: number,
|
|
21
|
+
tileSize = 256
|
|
22
|
+
): Point {
|
|
23
|
+
const clampedLat = clamp(lat, -MAX_LATITUDE, MAX_LATITUDE);
|
|
24
|
+
const scale = tileSize * Math.pow(2, zoom);
|
|
25
|
+
|
|
26
|
+
// X coordinate
|
|
27
|
+
const x = ((lng + 180) / 360) * scale;
|
|
28
|
+
|
|
29
|
+
// Y coordinate
|
|
30
|
+
const sin = Math.sin(clampedLat * DEG_TO_RAD);
|
|
31
|
+
const y = (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * scale;
|
|
32
|
+
|
|
33
|
+
return { x, y };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Convert world pixel coordinates back to Latitude & Longitude at a given zoom level (EPSG:3857)
|
|
38
|
+
*/
|
|
39
|
+
export function worldPixelToLatLng(
|
|
40
|
+
x: number,
|
|
41
|
+
y: number,
|
|
42
|
+
zoom: number,
|
|
43
|
+
tileSize = 256
|
|
44
|
+
): LatLng {
|
|
45
|
+
const scale = tileSize * Math.pow(2, zoom);
|
|
46
|
+
|
|
47
|
+
// Longitude
|
|
48
|
+
let lng = (x / scale) * 360 - 180;
|
|
49
|
+
// Normalize longitude to [-180, 180]
|
|
50
|
+
lng = (((lng + 180) % 360) + 360) % 360 - 180;
|
|
51
|
+
|
|
52
|
+
// Latitude
|
|
53
|
+
const normY = 0.5 - y / scale;
|
|
54
|
+
const lat = RAD_TO_DEG * (2 * Math.atan(Math.exp(normY * 2 * Math.PI)) - Math.PI / 2);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
lat: clamp(lat, -MAX_LATITUDE, MAX_LATITUDE),
|
|
58
|
+
lng,
|
|
59
|
+
};
|
|
60
|
+
}
|
package/utils/tile.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Point } from '../types';
|
|
2
|
+
import { latLngToWorldPixel } from './crs';
|
|
3
|
+
|
|
4
|
+
export interface VisibleTile {
|
|
5
|
+
key: string;
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
z: number;
|
|
9
|
+
url: string;
|
|
10
|
+
screenX: number;
|
|
11
|
+
screenY: number;
|
|
12
|
+
screenSize: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Format tile URL with template string {z}, {x}, {y}, {s}, {-y}
|
|
17
|
+
*/
|
|
18
|
+
export function formatTileUrl(
|
|
19
|
+
template: string,
|
|
20
|
+
x: number,
|
|
21
|
+
y: number,
|
|
22
|
+
z: number,
|
|
23
|
+
subdomains: string[] = ['a', 'b', 'c']
|
|
24
|
+
): string {
|
|
25
|
+
const maxTile = 1 << z;
|
|
26
|
+
// Normalize x (wrap around longitude)
|
|
27
|
+
const normalizedX = ((x % maxTile) + maxTile) % maxTile;
|
|
28
|
+
|
|
29
|
+
// Calculate subdomain
|
|
30
|
+
const subdomain =
|
|
31
|
+
subdomains.length > 0
|
|
32
|
+
? subdomains[Math.abs(normalizedX + y) % subdomains.length]
|
|
33
|
+
: '';
|
|
34
|
+
|
|
35
|
+
// TMS inverted y
|
|
36
|
+
const invertedY = maxTile - 1 - y;
|
|
37
|
+
|
|
38
|
+
return template
|
|
39
|
+
.replace('{z}', String(z))
|
|
40
|
+
.replace('{x}', String(normalizedX))
|
|
41
|
+
.replace('{y}', String(y))
|
|
42
|
+
.replace('{-y}', String(invertedY))
|
|
43
|
+
.replace('{s}', subdomain);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Calculate all visible tiles in current viewport
|
|
48
|
+
*/
|
|
49
|
+
export function getVisibleTiles(
|
|
50
|
+
centerLat: number,
|
|
51
|
+
centerLng: number,
|
|
52
|
+
zoom: number,
|
|
53
|
+
viewWidth: number,
|
|
54
|
+
viewHeight: number,
|
|
55
|
+
tileUrlTemplate: string,
|
|
56
|
+
subdomains: string[] = ['a', 'b', 'c'],
|
|
57
|
+
tileSize = 256
|
|
58
|
+
): { tiles: VisibleTile[]; centerWorldPixel: Point } {
|
|
59
|
+
// Determine base integer zoom for tile fetching
|
|
60
|
+
const baseZoom = Math.floor(zoom);
|
|
61
|
+
const zoomFraction = zoom - baseZoom;
|
|
62
|
+
const zoomScale = Math.pow(2, zoomFraction);
|
|
63
|
+
const currentTileSize = tileSize * zoomScale;
|
|
64
|
+
|
|
65
|
+
// World pixel position of center at baseZoom
|
|
66
|
+
const centerWorldPixel = latLngToWorldPixel(centerLat, centerLng, baseZoom, tileSize);
|
|
67
|
+
|
|
68
|
+
// Viewport half dimensions scaled
|
|
69
|
+
const halfW = viewWidth / 2;
|
|
70
|
+
const halfH = viewHeight / 2;
|
|
71
|
+
|
|
72
|
+
// World pixel bounds visible in viewport (relative to baseZoom)
|
|
73
|
+
const minWorldX = centerWorldPixel.x - halfW / zoomScale;
|
|
74
|
+
const maxWorldX = centerWorldPixel.x + halfW / zoomScale;
|
|
75
|
+
const minWorldY = centerWorldPixel.y - halfH / zoomScale;
|
|
76
|
+
const maxWorldY = centerWorldPixel.y + halfH / zoomScale;
|
|
77
|
+
|
|
78
|
+
// Tile index bounds
|
|
79
|
+
const minTileX = Math.floor(minWorldX / tileSize);
|
|
80
|
+
const maxTileX = Math.floor(maxWorldX / tileSize);
|
|
81
|
+
const minTileY = Math.floor(minWorldY / tileSize);
|
|
82
|
+
const maxTileY = Math.floor(maxWorldY / tileSize);
|
|
83
|
+
|
|
84
|
+
const maxTileCount = 1 << baseZoom;
|
|
85
|
+
const tiles: VisibleTile[] = [];
|
|
86
|
+
|
|
87
|
+
for (let tileY = minTileY; tileY <= maxTileY; tileY++) {
|
|
88
|
+
// Y tile index must be within [0, 2^z - 1]
|
|
89
|
+
if (tileY < 0 || tileY >= maxTileCount) continue;
|
|
90
|
+
|
|
91
|
+
for (let tileX = minTileX; tileX <= maxTileX; tileX++) {
|
|
92
|
+
// Calculate tile top-left in world pixels at baseZoom
|
|
93
|
+
const tileWorldX = tileX * tileSize;
|
|
94
|
+
const tileWorldY = tileY * tileSize;
|
|
95
|
+
|
|
96
|
+
// Project into screen coordinates
|
|
97
|
+
const screenX = halfW + (tileWorldX - centerWorldPixel.x) * zoomScale;
|
|
98
|
+
const screenY = halfH + (tileWorldY - centerWorldPixel.y) * zoomScale;
|
|
99
|
+
|
|
100
|
+
const normX = ((tileX % maxTileCount) + maxTileCount) % maxTileCount;
|
|
101
|
+
const url = formatTileUrl(tileUrlTemplate, normX, tileY, baseZoom, subdomains);
|
|
102
|
+
const key = url;
|
|
103
|
+
|
|
104
|
+
tiles.push({
|
|
105
|
+
key,
|
|
106
|
+
x: normX,
|
|
107
|
+
y: tileY,
|
|
108
|
+
z: baseZoom,
|
|
109
|
+
url,
|
|
110
|
+
screenX,
|
|
111
|
+
screenY,
|
|
112
|
+
screenSize: currentTileSize,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { tiles, centerWorldPixel };
|
|
118
|
+
}
|