feat: add vehicle and hydrogen heatmaps
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
kkfluous
2026-07-13 21:44:03 +08:00
parent a558db5795
commit 392a36a0ec
23 changed files with 2313 additions and 52 deletions
@@ -0,0 +1,249 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, Clock3, MapPinned, Maximize2, Minimize2, SlidersHorizontal } from 'lucide-react';
import AmapHeatmapCanvas from './AmapHeatmapCanvas';
import HeatmapDetailPanel from './HeatmapDetailPanel';
import HeatmapFilters from './HeatmapFilters';
import { fetchAmapConfig, fetchHeatmapMeta, fetchHeatmapPoints, fetchNearbyVehicles } from './api';
import type { AmapConfig, HeatmapMeta, HeatmapMetric, HeatmapResponse, NearbyResponse } from './types';
const DEFAULT_START = '2026-01-01';
const DEFAULT_END = '2026-07-13';
const numberFormat = new Intl.NumberFormat('zh-CN');
function Metric({ value, label }: { value: number; label: string }) {
return (
<div className="min-w-[82px] text-center max-md:min-w-[64px]">
<p className="text-[22px] font-semibold leading-none tracking-tight text-blue-600 tabular-nums max-md:text-[16px]">{numberFormat.format(value)}</p>
<p className="mt-1.5 text-[11px] text-slate-500 max-md:mt-1 max-md:text-[9px]">{label}</p>
</div>
);
}
export default function VehicleHeatmapModule() {
const [meta, setMeta] = useState<HeatmapMeta | null>(null);
const [config, setConfig] = useState<AmapConfig | null>(null);
const [data, setData] = useState<HeatmapResponse | null>(null);
const [nearby, setNearby] = useState<NearbyResponse | null>(null);
const [startDate, setStartDate] = useState(DEFAULT_START);
const [endDate, setEndDate] = useState(DEFAULT_END);
const [query, setQuery] = useState('');
const [batchModel, setBatchModel] = useState('');
const [metric, setMetric] = useState<HeatmapMetric>('locations');
const [isFullscreen, setIsFullscreen] = useState(false);
const [showFilters, setShowFilters] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
const [showDetails, setShowDetails] = useState(() => typeof window === 'undefined' || !window.matchMedia('(max-width: 767px)').matches);
const [loading, setLoading] = useState(true);
const [nearbyLoading, setNearbyLoading] = useState(false);
const [error, setError] = useState('');
const deferredQuery = useDeferredValue(query.trim());
useEffect(() => {
if (!isFullscreen) return undefined;
const previousOverflow = document.body.style.overflow;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsFullscreen(false);
};
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
};
}, [isFullscreen]);
const toggleFullscreen = useCallback(() => {
setIsFullscreen((current) => !current);
}, []);
useEffect(() => {
const controller = new AbortController();
Promise.all([fetchHeatmapMeta(controller.signal), fetchAmapConfig(controller.signal)])
.then(([nextMeta, nextConfig]) => {
setMeta(nextMeta);
setConfig(nextConfig);
setStartDate(nextMeta.startDate);
setEndDate(nextMeta.endDate);
})
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('热力图基础配置加载失败');
});
return () => controller.abort();
}, []);
useEffect(() => {
const controller = new AbortController();
const timer = window.setTimeout(() => {
setLoading(true);
setError('');
fetchHeatmapPoints({ startDate, endDate, query: deferredQuery, batchModel, metric }, controller.signal)
.then((response) => {
setData(response);
setNearby(null);
})
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('热力图数据加载失败,请稍后重试');
})
.finally(() => setLoading(false));
}, 220);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [batchModel, deferredQuery, endDate, metric, startDate]);
const handleMapClick = useCallback((longitude: number, latitude: number) => {
const controller = new AbortController();
setNearbyLoading(true);
fetchNearbyVehicles({ lng: longitude, lat: latitude, startDate, endDate, query: deferredQuery, batchModel }, controller.signal)
.then(setNearby)
.catch((requestError) => {
if (requestError?.name !== 'AbortError') setError('区域车辆明细加载失败');
})
.finally(() => setNearbyLoading(false));
}, [batchModel, deferredQuery, endDate, startDate]);
const reset = () => {
setStartDate(meta?.startDate || DEFAULT_START);
setEndDate(meta?.endDate || DEFAULT_END);
setQuery('');
setBatchModel('');
setMetric('locations');
setNearby(null);
};
const detail = useMemo(() => {
if (nearby) {
return {
title: '选中区域',
subtitle: `${nearby.center.lng.toFixed(3)}, ${nearby.center.lat.toFixed(3)} · 半径 ${nearby.radiusKm} 公里`,
locationCount: nearby.locationCount,
vehicleCount: nearby.vehicleCount,
vehicles: nearby.topVehicles,
selected: true,
};
}
return {
title: deferredQuery ? '车辆定位概览' : '全国 · 当前范围',
subtitle: `${batchModel ? `${batchModel} · ` : ''}${startDate} ${endDate}`,
locationCount: data?.locationCount || 0,
vehicleCount: data?.vehicleCount || 0,
vehicles: data?.topVehicles || [],
selected: false,
};
}, [batchModel, data, deferredQuery, endDate, nearby, startDate]);
return (
<section className={`flex flex-col overflow-hidden bg-white ${isFullscreen ? 'fixed inset-0 z-[100] h-screen w-screen min-h-0' : 'h-[100dvh] min-h-[620px] max-md:h-[calc(100dvh-4rem)] max-md:min-h-[520px]'}`}>
<header className="relative z-30 flex min-h-[98px] shrink-0 items-center justify-between border-b border-slate-200 bg-white px-8 max-md:min-h-[104px] max-md:flex-wrap max-md:gap-y-2 max-md:px-3 max-md:py-3">
<div className="min-w-0">
<h1 className="truncate text-[25px] font-semibold tracking-[-0.025em] text-slate-950 max-md:text-[18px]"></h1>
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-slate-400 max-md:mt-1 max-md:text-[9px]"><MapPinned size={13} /> · </p>
</div>
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-7 max-md:order-3 max-md:static max-md:w-full max-md:translate-x-0 max-md:translate-y-0 max-md:justify-center max-md:gap-2">
<Metric value={data?.locationCount ?? meta?.locationCount ?? 0} label="定位点" />
<span className="h-8 w-px bg-slate-200" />
<Metric value={data?.vehicleCount ?? meta?.vehicleCount ?? 0} label="辆车" />
<span className="h-8 w-px bg-slate-200" />
<Metric value={data?.dayCount ?? meta?.dayCount ?? 0} label="天" />
</div>
<div className="flex items-center gap-3">
<p className="flex items-center gap-2 text-[12px] text-slate-500 max-xl:hidden"><Clock3 size={15} strokeWidth={1.7} /> {meta?.endDate || DEFAULT_END}</p>
<button
type="button"
onClick={toggleFullscreen}
className="grid h-9 w-9 place-items-center rounded-lg border border-slate-200 text-slate-500 transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
title={isFullscreen ? '退出全屏' : '地图全屏'}
aria-label={isFullscreen ? '退出地图全屏' : '地图全屏'}
aria-pressed={isFullscreen}
>
{isFullscreen ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
</div>
</header>
<div className="relative flex min-h-0 flex-1">
<div className="relative z-0 min-w-0 flex-1 overflow-hidden">
{config ? (
<AmapHeatmapCanvas
config={config}
points={data?.points || []}
max={data?.max || 1}
metric={metric}
focusQuery={deferredQuery}
onMapClick={handleMapClick}
/>
) : <div className="absolute inset-0 bg-slate-100" />}
{showFilters ? (
<div className="pointer-events-none absolute left-3 right-3 top-3 z-10 md:left-5 md:right-auto md:top-5">
<HeatmapFilters
startDate={startDate}
endDate={endDate}
minDate={meta?.startDate || DEFAULT_START}
maxDate={meta?.endDate || DEFAULT_END}
query={query}
batchModel={batchModel}
batchModels={meta?.batchModels || []}
metric={metric}
vehicles={meta?.vehicles || []}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onQueryChange={setQuery}
onBatchModelChange={setBatchModel}
onMetricChange={setMetric}
onReset={reset}
onHide={() => setShowFilters(false)}
/>
</div>
) : (
<button
type="button"
onClick={() => setShowFilters(true)}
aria-label="显示筛选"
className="absolute left-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 md:left-5 md:top-5 md:h-10"
>
<SlidersHorizontal size={15} strokeWidth={1.8} />
</button>
)}
{!showDetails ? (
<button
type="button"
onClick={() => setShowDetails(true)}
aria-label="显示详情"
className={`absolute right-3 top-3 z-20 flex h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white/95 px-3 text-xs font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 md:right-5 md:top-5 md:h-10 ${showFilters ? 'max-md:hidden' : ''}`}
>
<ChevronLeft size={16} strokeWidth={1.8} />
</button>
) : null}
<div className="pointer-events-none absolute bottom-5 left-5 z-10 w-[286px] rounded-xl border border-slate-200/80 bg-white/95 p-3.5 shadow-[0_8px_30px_rgba(15,23,42,0.11)] backdrop-blur-sm max-lg:bottom-[calc(43vh+16px)] max-md:hidden">
<div className="flex items-center justify-between text-[11px] font-medium text-slate-700"><span>{metric === 'locations' ? '定位活跃度' : '车辆覆盖度'}</span>{loading ? <i className="h-3 w-3 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" /> : null}</div>
<p className="mt-1 text-[10px] leading-4 text-slate-500">
{metric === 'locations'
? '同一网格累计每日首个定位,同车跨天重复计数;对数平滑强度。'
: '同一网格按 VIN 去重,同车仅计 1 辆;平方根平滑强度。'}
</p>
<div className="mt-2.5 h-2.5 rounded-full bg-[linear-gradient(90deg,#2563eb_0%,#0891b2_25%,#16a34a_45%,#eab308_65%,#f97316_82%,#dc2626_100%)]" />
<div className="mt-1.5 flex justify-between text-[10px] text-slate-400"><span></span><span></span></div>
</div>
{error ? (
<div className="absolute bottom-5 left-1/2 z-30 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-slate-900 px-4 py-2.5 text-xs text-white shadow-xl">
<AlertTriangle size={15} className="text-amber-300" />{error}
</div>
) : null}
</div>
{showDetails ? (
<HeatmapDetailPanel
{...detail}
loading={nearbyLoading}
onClose={() => setNearby(null)}
onHide={() => setShowDetails(false)}
/>
) : null}
</div>
</section>
);
}