394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
import { getAMapConfig, isAMapConfigured } from '../config/appConfig';
|
|
|
|
export type AMapPlugin = 'AMap.Scale' | 'AMap.Geocoder' | 'AMap.ToolBar' | 'AMap.MouseTool';
|
|
|
|
export type AMapLngLat = {
|
|
getLng: () => number;
|
|
getLat: () => number;
|
|
};
|
|
|
|
export type AMapMap = {
|
|
add: (overlay: AMapOverlay | AMapOverlay[]) => void;
|
|
addControl: (control: unknown) => void;
|
|
setFitView: (overlays?: AMapOverlay[] | null, immediate?: boolean, padding?: number[]) => void;
|
|
destroy: () => void;
|
|
on?: (eventName: string, handler: (event: unknown) => void) => void;
|
|
off?: (eventName: string, handler: (event: unknown) => void) => void;
|
|
getZoom?: () => number;
|
|
getBounds?: () => {
|
|
getSouthWest?: () => { getLng?: () => number; getLat?: () => number };
|
|
getNorthEast?: () => { getLng?: () => number; getLat?: () => number };
|
|
};
|
|
setCenter?: (center: [number, number]) => void;
|
|
setZoomAndCenter?: (zoom: number, center: [number, number]) => void;
|
|
panTo?: (center: [number, number], duration?: number) => void;
|
|
resize?: () => void;
|
|
};
|
|
|
|
export type AMapOverlay = {
|
|
setMap?: (map: AMapMap | null) => void;
|
|
on?: (eventName: string, handler: (event?: unknown) => void) => void;
|
|
off?: (eventName: string, handler: (event?: unknown) => void) => void;
|
|
setPosition?: (position: [number, number]) => void;
|
|
setPath?: (path: [number, number][]) => void;
|
|
};
|
|
|
|
export type AMapCircleOverlay = AMapOverlay & {
|
|
getCenter: () => AMapLngLat;
|
|
getRadius: () => number;
|
|
};
|
|
|
|
export type AMapMouseTool = {
|
|
circle: (options?: Record<string, unknown>) => void;
|
|
close: (clear?: boolean) => void;
|
|
on: (eventName: 'draw', handler: (event: { obj?: AMapCircleOverlay }) => void) => void;
|
|
off?: (eventName: 'draw', handler: (event: { obj?: AMapCircleOverlay }) => void) => void;
|
|
};
|
|
|
|
export type AMapMassMarks = {
|
|
setMap: (map: AMapMap | null) => void;
|
|
setData: (data: AMapMassPoint[]) => void;
|
|
setStyle?: (style: Array<{ url: string; anchor: unknown; size: unknown }>) => void;
|
|
on: (eventName: string, handler: (event: { data: AMapMassPoint }) => void) => void;
|
|
off?: (eventName: string, handler: (event: { data: AMapMassPoint }) => void) => void;
|
|
};
|
|
|
|
export type AMapLabelsLayer = {
|
|
setMap: (map: AMapMap | null) => void;
|
|
add: (markers: AMapOverlay | AMapOverlay[]) => void;
|
|
remove: (markers: AMapOverlay | AMapOverlay[]) => void;
|
|
clear: () => void;
|
|
};
|
|
|
|
export type AMapMassPoint = {
|
|
lnglat: [number, number];
|
|
style: number;
|
|
id: string;
|
|
label: string;
|
|
sourceToken?: string;
|
|
};
|
|
|
|
type AMapAddressComponent = {
|
|
province?: string;
|
|
city?: string | string[];
|
|
district?: string;
|
|
township?: string;
|
|
adcode?: string;
|
|
};
|
|
|
|
type AMapGeocoderResult = {
|
|
info?: string;
|
|
regeocode?: {
|
|
formattedAddress?: string;
|
|
addressComponent?: AMapAddressComponent;
|
|
};
|
|
};
|
|
|
|
export type AMapAddress = {
|
|
provider: 'AMap Web JS';
|
|
formattedAddress: string;
|
|
province?: string;
|
|
city?: string;
|
|
district?: string;
|
|
township?: string;
|
|
adcode?: string;
|
|
};
|
|
|
|
export type AMapDistrictFeature = {
|
|
properties: {
|
|
adcode: number;
|
|
name: string;
|
|
center?: [number, number];
|
|
};
|
|
};
|
|
|
|
export type AMapDistrictGroup<T> = {
|
|
subFeatureIndex: number;
|
|
subFeature?: AMapDistrictFeature | null;
|
|
pointsIndexes: number[];
|
|
points: T[];
|
|
};
|
|
|
|
export type AMapAreaNode = {
|
|
getSubFeatures: () => AMapDistrictFeature[];
|
|
groupByPosition: <T>(points: T[], getPosition: (point: T, index: number) => [number, number]) => AMapDistrictGroup<T>[];
|
|
};
|
|
|
|
export type AMapDistrictExplorer = {
|
|
loadAreaNode: (adcode: number, callback: (error: Error | null, areaNode?: AMapAreaNode) => void) => void;
|
|
};
|
|
|
|
export type AMapDistrictExplorerConstructor = new (options?: Record<string, unknown>) => AMapDistrictExplorer;
|
|
|
|
export type AMapLike = {
|
|
Map: new (container: HTMLDivElement, options: Record<string, unknown>) => AMapMap;
|
|
Marker: new (options: Record<string, unknown>) => AMapOverlay;
|
|
Polyline: new (options: Record<string, unknown>) => AMapOverlay;
|
|
Circle?: new (options: Record<string, unknown>) => AMapCircleOverlay;
|
|
MouseTool?: new (map: AMapMap) => AMapMouseTool;
|
|
Scale: new () => unknown;
|
|
ToolBar?: new (options?: Record<string, unknown>) => unknown;
|
|
Size: new (width: number, height: number) => unknown;
|
|
Pixel: new (x: number, y: number) => unknown;
|
|
MassMarks: new (data: AMapMassPoint[], options: Record<string, unknown>) => AMapMassMarks;
|
|
LabelsLayer?: new (options?: Record<string, unknown>) => AMapLabelsLayer;
|
|
LabelMarker?: new (options: Record<string, unknown>) => AMapOverlay;
|
|
Geocoder?: new (options?: Record<string, unknown>) => {
|
|
getAddress: (
|
|
lnglat: [number, number],
|
|
callback: (status: string, result: AMapGeocoderResult | string) => void
|
|
) => void;
|
|
};
|
|
};
|
|
|
|
const amapLoaderPromises = new Map<string, Promise<AMapLike>>();
|
|
|
|
function loadScript(src: string) {
|
|
return new Promise<void>((resolve, reject) => {
|
|
if (window.AMapLoader) {
|
|
resolve();
|
|
return;
|
|
}
|
|
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
|
|
if (existing) {
|
|
if (existing.dataset.loadState === 'loaded') {
|
|
existing.remove();
|
|
loadScript(src).then(resolve, reject);
|
|
return;
|
|
}
|
|
existing.addEventListener('load', () => resolve(), { once: true });
|
|
existing.addEventListener('error', () => {
|
|
existing.remove();
|
|
reject(new Error('高德地图 Loader 加载失败'));
|
|
}, { once: true });
|
|
if (window.AMapLoader) resolve();
|
|
return;
|
|
}
|
|
const script = document.createElement('script');
|
|
script.src = src;
|
|
script.async = true;
|
|
script.dataset.loadState = 'loading';
|
|
script.onload = () => {
|
|
script.dataset.loadState = 'loaded';
|
|
resolve();
|
|
};
|
|
script.onerror = () => {
|
|
script.remove();
|
|
reject(new Error('高德地图 Loader 加载失败'));
|
|
};
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
function loadAMapUIScript() {
|
|
const src = 'https://webapi.amap.com/ui/1.1/main.js?v=1.1.1';
|
|
return new Promise<void>((resolve, reject) => {
|
|
if (window.AMapUI) {
|
|
resolve();
|
|
return;
|
|
}
|
|
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
|
|
if (existing) {
|
|
if (existing.dataset.loadState === 'loaded') {
|
|
existing.remove();
|
|
loadAMapUIScript().then(resolve, reject);
|
|
return;
|
|
}
|
|
existing.addEventListener('load', () => resolve(), { once: true });
|
|
existing.addEventListener('error', () => {
|
|
existing.remove();
|
|
reject(new Error('高德行政区组件加载失败'));
|
|
}, { once: true });
|
|
return;
|
|
}
|
|
const script = document.createElement('script');
|
|
script.src = src;
|
|
script.async = true;
|
|
script.dataset.loadState = 'loading';
|
|
script.onload = () => {
|
|
script.dataset.loadState = 'loaded';
|
|
resolve();
|
|
};
|
|
script.onerror = () => {
|
|
script.remove();
|
|
reject(new Error('高德行政区组件加载失败'));
|
|
};
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
function pluginKey(plugins: AMapPlugin[]) {
|
|
return plugins.slice().sort().join('|') || 'base';
|
|
}
|
|
|
|
function normalizeCity(value: AMapAddressComponent['city']) {
|
|
if (Array.isArray(value)) {
|
|
return value.filter(Boolean).join('/');
|
|
}
|
|
return String(value ?? '').trim() || undefined;
|
|
}
|
|
|
|
const GCJ_A = 6378245;
|
|
const GCJ_EE = 0.006693421622965943;
|
|
|
|
function outsideGcjChina(longitude: number, latitude: number) {
|
|
return longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271;
|
|
}
|
|
|
|
function transformLatitude(longitude: number, latitude: number) {
|
|
let value = -100 + 2 * longitude + 3 * latitude + 0.2 * latitude * latitude
|
|
+ 0.1 * longitude * latitude + 0.2 * Math.sqrt(Math.abs(longitude));
|
|
value += (20 * Math.sin(6 * longitude * Math.PI) + 20 * Math.sin(2 * longitude * Math.PI)) * 2 / 3;
|
|
value += (20 * Math.sin(latitude * Math.PI) + 40 * Math.sin(latitude / 3 * Math.PI)) * 2 / 3;
|
|
value += (160 * Math.sin(latitude / 12 * Math.PI) + 320 * Math.sin(latitude * Math.PI / 30)) * 2 / 3;
|
|
return value;
|
|
}
|
|
|
|
function transformLongitude(longitude: number, latitude: number) {
|
|
let value = 300 + longitude + 2 * latitude + 0.1 * longitude * longitude
|
|
+ 0.1 * longitude * latitude + 0.1 * Math.sqrt(Math.abs(longitude));
|
|
value += (20 * Math.sin(6 * longitude * Math.PI) + 20 * Math.sin(2 * longitude * Math.PI)) * 2 / 3;
|
|
value += (20 * Math.sin(longitude * Math.PI) + 40 * Math.sin(longitude / 3 * Math.PI)) * 2 / 3;
|
|
value += (150 * Math.sin(longitude / 12 * Math.PI) + 300 * Math.sin(longitude / 30 * Math.PI)) * 2 / 3;
|
|
return value;
|
|
}
|
|
|
|
export function wgs84ToGcj02(longitude: number, latitude: number): [number, number] {
|
|
if (!Number.isFinite(longitude) || !Number.isFinite(latitude) || outsideGcjChina(longitude, latitude)) {
|
|
return [longitude, latitude];
|
|
}
|
|
const latitudeOffset = transformLatitude(longitude - 105, latitude - 35);
|
|
const longitudeOffset = transformLongitude(longitude - 105, latitude - 35);
|
|
const radianLatitude = latitude / 180 * Math.PI;
|
|
const magic = 1 - GCJ_EE * Math.sin(radianLatitude) ** 2;
|
|
const sqrtMagic = Math.sqrt(magic);
|
|
const convertedLatitude = latitude + latitudeOffset * 180 / ((GCJ_A * (1 - GCJ_EE)) / (magic * sqrtMagic) * Math.PI);
|
|
const convertedLongitude = longitude + longitudeOffset * 180 / (GCJ_A / sqrtMagic * Math.cos(radianLatitude) * Math.PI);
|
|
return [convertedLongitude, convertedLatitude];
|
|
}
|
|
|
|
export function gcj02ToWgs84(longitude: number, latitude: number): [number, number] {
|
|
if (!Number.isFinite(longitude) || !Number.isFinite(latitude) || outsideGcjChina(longitude, latitude)) {
|
|
return [longitude, latitude];
|
|
}
|
|
let wgsLongitude = longitude;
|
|
let wgsLatitude = latitude;
|
|
for (let iteration = 0; iteration < 3; iteration += 1) {
|
|
const [convertedLongitude, convertedLatitude] = wgs84ToGcj02(wgsLongitude, wgsLatitude);
|
|
wgsLongitude -= convertedLongitude - longitude;
|
|
wgsLatitude -= convertedLatitude - latitude;
|
|
}
|
|
return [wgsLongitude, wgsLatitude];
|
|
}
|
|
|
|
export function isValidAMapCoordinate(longitude: number, latitude: number) {
|
|
return (
|
|
Number.isFinite(longitude) &&
|
|
Number.isFinite(latitude) &&
|
|
longitude >= 73 &&
|
|
longitude <= 135 &&
|
|
latitude >= 18 &&
|
|
latitude <= 54
|
|
);
|
|
}
|
|
|
|
export function buildAMapMarkerURL({
|
|
longitude,
|
|
latitude,
|
|
name
|
|
}: {
|
|
longitude: number;
|
|
latitude: number;
|
|
name: string;
|
|
}) {
|
|
const [mapLongitude, mapLatitude] = wgs84ToGcj02(longitude, latitude);
|
|
return `https://uri.amap.com/marker?position=${mapLongitude},${mapLatitude}&name=${encodeURIComponent(name)}&src=lingniu-vehicle-platform`;
|
|
}
|
|
|
|
export function loadAMap(plugins: AMapPlugin[] = ['AMap.Scale']) {
|
|
const config = getAMapConfig();
|
|
if (!isAMapConfigured(config)) {
|
|
return Promise.reject(new Error('高德地图未配置'));
|
|
}
|
|
const key = pluginKey(plugins);
|
|
const cached = amapLoaderPromises.get(key);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
if (config.securityServiceHost) {
|
|
window._AMapSecurityConfig = { serviceHost: config.securityServiceHost };
|
|
} else if (config.securityJsCode) {
|
|
window._AMapSecurityConfig = { securityJsCode: config.securityJsCode };
|
|
}
|
|
const promise = loadScript('https://webapi.amap.com/loader.js')
|
|
.then(() => {
|
|
if (!window.AMapLoader) {
|
|
throw new Error('高德地图 Loader 不可用');
|
|
}
|
|
return window.AMapLoader.load({
|
|
key: config.webJsKey,
|
|
version: '2.0',
|
|
plugins
|
|
}) as Promise<AMapLike>;
|
|
})
|
|
.catch((error: unknown) => {
|
|
amapLoaderPromises.delete(key);
|
|
throw error;
|
|
});
|
|
amapLoaderPromises.set(key, promise);
|
|
return promise;
|
|
}
|
|
|
|
export async function loadAMapDistrictExplorer(): Promise<AMapDistrictExplorerConstructor> {
|
|
await loadAMapUIScript();
|
|
if (!window.AMapUI) {
|
|
throw new Error('高德行政区组件不可用');
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = window.setTimeout(() => reject(new Error('高德行政区组件加载超时')), 10_000);
|
|
window.AMapUI!.loadUI(['geo/DistrictExplorer'], (constructor) => {
|
|
window.clearTimeout(timeout);
|
|
if (typeof constructor !== 'function') {
|
|
reject(new Error('高德行政区组件返回异常'));
|
|
return;
|
|
}
|
|
resolve(constructor as AMapDistrictExplorerConstructor);
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function reverseGeocodeWithAMap(longitude: number, latitude: number): Promise<AMapAddress> {
|
|
if (!isValidAMapCoordinate(longitude, latitude)) {
|
|
throw new Error('坐标不可用');
|
|
}
|
|
const [mapLongitude, mapLatitude] = wgs84ToGcj02(longitude, latitude);
|
|
const AMap = await loadAMap(['AMap.Geocoder']);
|
|
if (!AMap.Geocoder) {
|
|
throw new Error('高德 Geocoder 插件不可用');
|
|
}
|
|
const geocoder = new AMap.Geocoder({ city: '全国' });
|
|
return new Promise((resolve, reject) => {
|
|
geocoder.getAddress([mapLongitude, mapLatitude], (status, result) => {
|
|
if (status !== 'complete' || typeof result === 'string') {
|
|
reject(new Error(typeof result === 'string' ? result : result?.info || '高德地址解析失败'));
|
|
return;
|
|
}
|
|
const formattedAddress = String(result.regeocode?.formattedAddress ?? '').trim();
|
|
if (!formattedAddress) {
|
|
reject(new Error('高德地址解析返回空地址'));
|
|
return;
|
|
}
|
|
const component = result.regeocode?.addressComponent ?? {};
|
|
resolve({
|
|
provider: 'AMap Web JS',
|
|
formattedAddress,
|
|
province: String(component.province ?? '').trim() || undefined,
|
|
city: normalizeCity(component.city),
|
|
district: String(component.district ?? '').trim() || undefined,
|
|
township: String(component.township ?? '').trim() || undefined,
|
|
adcode: String(component.adcode ?? '').trim() || undefined
|
|
});
|
|
});
|
|
});
|
|
}
|