166 lines
4.9 KiB
TypeScript
166 lines
4.9 KiB
TypeScript
export type VehicleHeatmapMetric = 'locations' | 'vehicles';
|
|
|
|
export type VehicleHeatmapRecord = {
|
|
date: string;
|
|
vin: string;
|
|
plate: string;
|
|
time: string;
|
|
longitude: number;
|
|
latitude: number;
|
|
source: string;
|
|
sourceRecordId: string;
|
|
};
|
|
|
|
export type VehicleRank = {
|
|
vin: string;
|
|
plateNumber: string;
|
|
locationCount: number;
|
|
firstSeen: string;
|
|
lastSeen: string;
|
|
};
|
|
|
|
type VehicleHeatmapQueryInput = {
|
|
startDate?: string;
|
|
endDate?: string;
|
|
query?: string;
|
|
batchModel?: string;
|
|
metric?: string;
|
|
};
|
|
|
|
type NearbyQueryInput = {
|
|
lng?: string;
|
|
lat?: string;
|
|
radiusKm?: string;
|
|
};
|
|
|
|
export const VEHICLE_HEATMAP_DEFAULT_START = '2026-01-01';
|
|
export const VEHICLE_HEATMAP_DEFAULT_END = '2026-07-13';
|
|
|
|
function isDate(value: string | undefined): value is string {
|
|
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
|
|
}
|
|
|
|
export function parseVehicleHeatmapQuery(input: VehicleHeatmapQueryInput) {
|
|
return {
|
|
startDate: isDate(input.startDate) ? input.startDate : VEHICLE_HEATMAP_DEFAULT_START,
|
|
endDate: isDate(input.endDate) ? input.endDate : VEHICLE_HEATMAP_DEFAULT_END,
|
|
query: input.query || '',
|
|
batchModel: (input.batchModel || '').trim(),
|
|
metric: input.metric === 'vehicles' ? 'vehicles' as const : 'locations' as const,
|
|
};
|
|
}
|
|
|
|
export function parseNearbyQuery(input: NearbyQueryInput) {
|
|
const longitude = Number(input.lng);
|
|
const latitude = Number(input.lat);
|
|
return {
|
|
center: Number.isFinite(longitude) && Number.isFinite(latitude)
|
|
? { lng: longitude, lat: latitude }
|
|
: null,
|
|
radiusKm: Math.min(200, Math.max(5, Number(input.radiusKm) || 50)),
|
|
};
|
|
}
|
|
|
|
export function daysInclusive(startDate: string, endDate: string): number {
|
|
const start = Date.parse(`${startDate}T00:00:00+08:00`);
|
|
const end = Date.parse(`${endDate}T00:00:00+08:00`);
|
|
return Math.max(0, Math.floor((end - start) / 86_400_000) + 1);
|
|
}
|
|
|
|
export function vehicleGridPrecision(
|
|
startDate: string,
|
|
endDate: string,
|
|
query: string,
|
|
batchModel: string,
|
|
): number {
|
|
return daysInclusive(startDate, endDate) <= 14 || Boolean(query || batchModel) ? 3 : 2;
|
|
}
|
|
|
|
export function buildVehicleGrid(
|
|
records: VehicleHeatmapRecord[],
|
|
metric: VehicleHeatmapMetric,
|
|
precision: number,
|
|
) {
|
|
const grid = new Map<string, { lng: number; lat: number; count: number; vins?: Set<string> }>();
|
|
|
|
for (const record of records) {
|
|
const lng = Number(record.longitude.toFixed(precision));
|
|
const lat = Number(record.latitude.toFixed(precision));
|
|
const key = `${lng},${lat}`;
|
|
let point = grid.get(key);
|
|
if (!point) {
|
|
point = { lng, lat, count: 0, vins: metric === 'vehicles' ? new Set<string>() : undefined };
|
|
grid.set(key, point);
|
|
}
|
|
if (point.vins) {
|
|
point.vins.add(record.vin);
|
|
point.count = point.vins.size;
|
|
} else {
|
|
point.count += 1;
|
|
}
|
|
}
|
|
|
|
const points = [...grid.values()].map(({ lng, lat, count }) => ({ lng, lat, count }));
|
|
let max = 1;
|
|
for (const point of points) max = Math.max(max, point.count);
|
|
return { points, max };
|
|
}
|
|
|
|
export function buildVehicleRanking(records: VehicleHeatmapRecord[], limit = 10): VehicleRank[] {
|
|
const vehicles = new Map<string, VehicleRank>();
|
|
for (const record of records) {
|
|
const current = vehicles.get(record.vin);
|
|
if (!current) {
|
|
vehicles.set(record.vin, {
|
|
vin: record.vin,
|
|
plateNumber: record.plate,
|
|
locationCount: 1,
|
|
firstSeen: record.time,
|
|
lastSeen: record.time,
|
|
});
|
|
continue;
|
|
}
|
|
current.locationCount += 1;
|
|
if (record.time < current.firstSeen) current.firstSeen = record.time;
|
|
if (record.time > current.lastSeen) current.lastSeen = record.time;
|
|
if (!current.plateNumber && record.plate) current.plateNumber = record.plate;
|
|
}
|
|
return [...vehicles.values()]
|
|
.sort((left, right) => right.locationCount - left.locationCount
|
|
|| left.plateNumber.localeCompare(right.plateNumber, 'zh-CN'))
|
|
.slice(0, limit);
|
|
}
|
|
|
|
export function summarizeVehicleRecords(records: VehicleHeatmapRecord[]) {
|
|
return {
|
|
locationCount: records.length,
|
|
vehicleCount: new Set(records.map((record) => record.vin)).size,
|
|
dayCount: new Set(records.map((record) => record.date)).size,
|
|
};
|
|
}
|
|
|
|
export function haversineKm(
|
|
leftLng: number,
|
|
leftLat: number,
|
|
rightLng: number,
|
|
rightLat: number,
|
|
): number {
|
|
const radians = (degrees: number) => degrees * Math.PI / 180;
|
|
const earthRadius = 6371;
|
|
const dLat = radians(rightLat - leftLat);
|
|
const dLng = radians(rightLng - leftLng);
|
|
const a = Math.sin(dLat / 2) ** 2
|
|
+ Math.cos(radians(leftLat)) * Math.cos(radians(rightLat)) * Math.sin(dLng / 2) ** 2;
|
|
return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
|
|
export function filterVehicleRecordsByRadius(
|
|
records: VehicleHeatmapRecord[],
|
|
center: { lng: number; lat: number },
|
|
radiusKm: number,
|
|
): VehicleHeatmapRecord[] {
|
|
return records.filter((record) => (
|
|
haversineKm(center.lng, center.lat, record.longitude, record.latitude) <= radiusKm
|
|
));
|
|
}
|