refactor: modularize application domains
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
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
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user