feat: build vehicle data platform and production pipeline

This commit is contained in:
lingniu
2026-07-14 12:35:33 +08:00
parent b452be3b94
commit bb59303a4b
270 changed files with 88016 additions and 1975 deletions

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

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