feat: build vehicle data platform and production pipeline
This commit is contained in:
32
vehicle-data-platform/apps/web/src/integrations/amap.test.ts
Normal file
32
vehicle-data-platform/apps/web/src/integrations/amap.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { buildAMapMarkerURL, gcj02ToWgs84, isValidAMapCoordinate, wgs84ToGcj02 } from './amap';
|
||||
|
||||
test('accepts coordinates inside the China vehicle service range', () => {
|
||||
expect(isValidAMapCoordinate(116.397128, 39.916527)).toBe(true);
|
||||
expect(isValidAMapCoordinate(87.6379, 43.98146)).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects placeholder and out-of-range coordinates', () => {
|
||||
expect(isValidAMapCoordinate(0, 0)).toBe(false);
|
||||
expect(isValidAMapCoordinate(-0.999999, -0.999999)).toBe(false);
|
||||
expect(isValidAMapCoordinate(139.6917, 35.6895)).toBe(false);
|
||||
expect(isValidAMapCoordinate(121.4737, 10)).toBe(false);
|
||||
});
|
||||
|
||||
test('converts WGS-84 coordinates to GCJ-02 and supports a precise viewport round trip', () => {
|
||||
const wgs: [number, number] = [116.397128, 39.916527];
|
||||
const gcj = wgs84ToGcj02(...wgs);
|
||||
expect(gcj[0]).toBeCloseTo(116.40337, 5);
|
||||
expect(gcj[1]).toBeCloseTo(39.91793, 5);
|
||||
const roundTrip = gcj02ToWgs84(...gcj);
|
||||
expect(roundTrip[0]).toBeCloseTo(wgs[0], 6);
|
||||
expect(roundTrip[1]).toBeCloseTo(wgs[1], 6);
|
||||
});
|
||||
|
||||
test('keeps coordinates outside China unchanged and converts AMap marker links at the rendering boundary', () => {
|
||||
expect(wgs84ToGcj02(2.3522, 48.8566)).toEqual([2.3522, 48.8566]);
|
||||
const url = new URL(buildAMapMarkerURL({ longitude: 116.397128, latitude: 39.916527, name: '测试车辆' }));
|
||||
const position = url.searchParams.get('position')?.split(',').map(Number) ?? [];
|
||||
expect(position[0]).toBeCloseTo(116.40337, 5);
|
||||
expect(position[1]).toBeCloseTo(39.91793, 5);
|
||||
});
|
||||
267
vehicle-data-platform/apps/web/src/integrations/amap.ts
Normal file
267
vehicle-data-platform/apps/web/src/integrations/amap.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { getAMapConfig, isAMapConfigured } from '../config/appConfig';
|
||||
|
||||
export type AMapPlugin = 'AMap.Scale' | 'AMap.Geocoder' | 'AMap.ToolBar';
|
||||
|
||||
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;
|
||||
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: () => void) => void;
|
||||
setPosition?: (position: [number, number]) => 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;
|
||||
};
|
||||
|
||||
export type AMapLabelsLayer = {
|
||||
setMap: (map: AMapMap | null) => void;
|
||||
add: (markers: AMapOverlay | AMapOverlay[]) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
export type AMapMassPoint = {
|
||||
lnglat: [number, number];
|
||||
style: number;
|
||||
id: string;
|
||||
label: 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 AMapLike = {
|
||||
Map: new (container: HTMLDivElement, options: Record<string, unknown>) => AMapMap;
|
||||
Marker: new (options: Record<string, unknown>) => AMapOverlay;
|
||||
Polyline: new (options: Record<string, unknown>) => AMapOverlay;
|
||||
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) {
|
||||
existing.addEventListener('load', () => resolve(), { once: true });
|
||||
existing.addEventListener('error', () => reject(new Error('高德地图 Loader 加载失败')), { once: true });
|
||||
if (window.AMapLoader) resolve();
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('高德地图 Loader 加载失败'));
|
||||
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>;
|
||||
});
|
||||
amapLoaderPromises.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user