This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user