import { recentDayRange } from '../../shared/date-range.js'; 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; }; /** * 未显式传日期时的兜底窗口。 * 此前写死为 2026-01-01 ~ 2026-07-13,会让默认查询永久指向一段历史数据; * 现改为"近 N 天"滚动窗口。 */ export const HEATMAP_DEFAULT_WINDOW_DAYS = 30; function isDate(value: string | undefined): value is string { return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value)); } export function parseVehicleHeatmapQuery(input: VehicleHeatmapQueryInput, now: Date = new Date()) { const fallback = recentDayRange(HEATMAP_DEFAULT_WINDOW_DAYS, now); return { startDate: isDate(input.startDate) ? input.startDate : fallback.start, endDate: isDate(input.endDate) ? input.endDate : fallback.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 }>(); 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() : 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(); 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 )); }