Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/integrations/amap.ts
T

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
});
});
});
}