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) => 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 = { subFeatureIndex: number; subFeature?: AMapDistrictFeature | null; pointsIndexes: number[]; points: T[]; }; export type AMapAreaNode = { getSubFeatures: () => AMapDistrictFeature[]; groupByPosition: (points: T[], getPosition: (point: T, index: number) => [number, number]) => AMapDistrictGroup[]; }; export type AMapDistrictExplorer = { loadAreaNode: (adcode: number, callback: (error: Error | null, areaNode?: AMapAreaNode) => void) => void; }; export type AMapDistrictExplorerConstructor = new (options?: Record) => AMapDistrictExplorer; export type AMapLike = { Map: new (container: HTMLDivElement, options: Record) => AMapMap; Marker: new (options: Record) => AMapOverlay; Polyline: new (options: Record) => AMapOverlay; Circle?: new (options: Record) => AMapCircleOverlay; MouseTool?: new (map: AMapMap) => AMapMouseTool; Scale: new () => unknown; ToolBar?: new (options?: Record) => unknown; Size: new (width: number, height: number) => unknown; Pixel: new (x: number, y: number) => unknown; MassMarks: new (data: AMapMassPoint[], options: Record) => AMapMassMarks; LabelsLayer?: new (options?: Record) => AMapLabelsLayer; LabelMarker?: new (options: Record) => AMapOverlay; Geocoder?: new (options?: Record) => { getAddress: ( lnglat: [number, number], callback: (status: string, result: AMapGeocoderResult | string) => void ) => void; }; }; const amapLoaderPromises = new Map>(); function loadScript(src: string) { return new Promise((resolve, reject) => { if (window.AMapLoader) { resolve(); return; } const existing = document.querySelector(`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((resolve, reject) => { if (window.AMapUI) { resolve(); return; } const existing = document.querySelector(`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; }) .catch((error: unknown) => { amapLoaderPromises.delete(key); throw error; }); amapLoaderPromises.set(key, promise); return promise; } export async function loadAMapDistrictExplorer(): Promise { 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 { 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 }); }); }); }