This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { AmapConfig, HydrogenHeatmapMetric, HydrogenHeatmapPoint } from './types';
|
||||
|
||||
type Props = {
|
||||
config: AmapConfig;
|
||||
points: HydrogenHeatmapPoint[];
|
||||
max: number;
|
||||
metric: HydrogenHeatmapMetric;
|
||||
focusQuery: string;
|
||||
onMapClick: (longitude: number, latitude: number) => void;
|
||||
};
|
||||
|
||||
type AmapInstance = {
|
||||
Map: new (container: HTMLElement, options: Record<string, unknown>) => any;
|
||||
HeatMap: new (map: any, options: Record<string, unknown>) => any;
|
||||
ToolBar: new (options?: Record<string, unknown>) => any;
|
||||
Scale: new (options?: Record<string, unknown>) => any;
|
||||
Bounds: new (southWest: [number, number], northEast: [number, number]) => any;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
_AMapSecurityConfig?: { securityJsCode: string };
|
||||
}
|
||||
}
|
||||
|
||||
function getBounds(points: HydrogenHeatmapPoint[]) {
|
||||
if (!points.length) return null;
|
||||
return points.reduce((bounds, point) => ({
|
||||
minLng: Math.min(bounds.minLng, point.lng),
|
||||
maxLng: Math.max(bounds.maxLng, point.lng),
|
||||
minLat: Math.min(bounds.minLat, point.lat),
|
||||
maxLat: Math.max(bounds.maxLat, point.lat),
|
||||
}), { minLng: points[0].lng, maxLng: points[0].lng, minLat: points[0].lat, maxLat: points[0].lat });
|
||||
}
|
||||
|
||||
export default function HydrogenAmapCanvas({ config, points, max, metric, focusQuery, onMapClick }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<any>(null);
|
||||
const heatmapRef = useRef<any>(null);
|
||||
const amapRef = useRef<AmapInstance | null>(null);
|
||||
const clickHandlerRef = useRef(onMapClick);
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const intensity = useMemo(() => {
|
||||
const transform = metric === 'kg'
|
||||
? (value: number) => Math.log1p(value)
|
||||
: (value: number) => Math.sqrt(value);
|
||||
return {
|
||||
points: points.map((point) => ({ ...point, count: transform(point.count) })),
|
||||
max: transform(Math.max(1, max)),
|
||||
};
|
||||
}, [max, metric, points]);
|
||||
|
||||
useEffect(() => {
|
||||
clickHandlerRef.current = onMapClick;
|
||||
}, [onMapClick]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const container = containerRef.current;
|
||||
if (!container) return undefined;
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
window._AMapSecurityConfig = { securityJsCode: config.securityCode };
|
||||
const loaderModule = await import('@amap/amap-jsapi-loader');
|
||||
const AMap = await loaderModule.default.load({
|
||||
key: config.key,
|
||||
version: '2.0',
|
||||
plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'],
|
||||
}) as unknown as AmapInstance;
|
||||
if (cancelled || !container) return;
|
||||
const map = new AMap.Map(container, {
|
||||
viewMode: '2D',
|
||||
zoom: 5,
|
||||
center: [105.4, 34.4],
|
||||
mapStyle: 'amap://styles/whitesmoke',
|
||||
resizeEnable: true,
|
||||
showLabel: true,
|
||||
});
|
||||
map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } }));
|
||||
map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } }));
|
||||
const heatmap = new AMap.HeatMap(map, {
|
||||
radius: 34,
|
||||
opacity: [0.14, 0.84],
|
||||
gradient: {
|
||||
0.1: '#2563eb',
|
||||
0.3: '#0891b2',
|
||||
0.5: '#16a34a',
|
||||
0.68: '#eab308',
|
||||
0.84: '#f97316',
|
||||
1: '#dc2626',
|
||||
},
|
||||
});
|
||||
map.on('click', (event: any) => clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat()));
|
||||
mapRef.current = map;
|
||||
heatmapRef.current = heatmap;
|
||||
amapRef.current = AMap;
|
||||
setStatus('ready');
|
||||
} catch (error) {
|
||||
console.error('Hydrogen heatmap AMap initialization failed', error);
|
||||
if (!cancelled) setStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
initialize();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
heatmapRef.current?.setMap?.(null);
|
||||
mapRef.current?.destroy?.();
|
||||
heatmapRef.current = null;
|
||||
mapRef.current = null;
|
||||
amapRef.current = null;
|
||||
};
|
||||
}, [config.key, config.securityCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'ready' || !heatmapRef.current) return;
|
||||
heatmapRef.current.setDataSet({ data: intensity.points, max: intensity.max });
|
||||
if (!focusQuery || !points.length || !mapRef.current || !amapRef.current) return;
|
||||
const bounds = getBounds(points);
|
||||
if (!bounds) return;
|
||||
if (points.length === 1) {
|
||||
mapRef.current.setZoomAndCenter(12, [points[0].lng, points[0].lat]);
|
||||
return;
|
||||
}
|
||||
mapRef.current.setBounds(new amapRef.current.Bounds(
|
||||
[bounds.minLng, bounds.minLat],
|
||||
[bounds.maxLng, bounds.maxLat],
|
||||
), false, [80, 80, 80, 80]);
|
||||
}, [focusQuery, intensity, points, status]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 bg-[#eef3f7]">
|
||||
<div ref={containerRef} className="h-full w-full" aria-label="加氢站加氢热力图地图" />
|
||||
{status === 'loading' ? (
|
||||
<div className="absolute inset-0 grid place-items-center bg-white/76 text-sm font-medium text-slate-500 backdrop-blur-[2px]">
|
||||
<span className="flex items-center gap-2.5"><i className="h-4 w-4 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" />正在加载高德地图</span>
|
||||
</div>
|
||||
) : null}
|
||||
{status === 'error' ? (
|
||||
<div className="absolute inset-0 grid place-items-center bg-slate-50 text-center">
|
||||
<div><p className="text-sm font-semibold text-slate-700">地图加载失败</p><p className="mt-1 text-xs text-slate-400">请检查高德 Key、域名白名单与网络连接</p></div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ChevronRight, Fuel, X } from 'lucide-react';
|
||||
import type { HydrogenHeatmapMetric, HydrogenStationRank } from './types';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
kg: number;
|
||||
refuelCount: number;
|
||||
vehicleCount: number;
|
||||
stationCount: number;
|
||||
stations: HydrogenStationRank[];
|
||||
metric: HydrogenHeatmapMetric;
|
||||
selected: boolean;
|
||||
loading: boolean;
|
||||
coverageText: string;
|
||||
onClose: () => void;
|
||||
onHide: () => void;
|
||||
};
|
||||
|
||||
const integerFormat = new Intl.NumberFormat('zh-CN');
|
||||
const kgFormat = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 });
|
||||
|
||||
function metricLabel(metric: HydrogenHeatmapMetric) {
|
||||
if (metric === 'refuels') return '按加氢次数排序';
|
||||
if (metric === 'vehicles') return '按车辆数排序';
|
||||
return '按加氢量排序';
|
||||
}
|
||||
|
||||
function metricValue(station: HydrogenStationRank, metric: HydrogenHeatmapMetric) {
|
||||
if (metric === 'kg') return `${kgFormat.format(station.kg)} kg`;
|
||||
if (metric === 'refuels') return `${integerFormat.format(station.refuelCount)} 次`;
|
||||
return `${integerFormat.format(station.vehicleCount)} 辆`;
|
||||
}
|
||||
|
||||
export default function HydrogenHeatmapDetailPanel(props: Props) {
|
||||
return (
|
||||
<aside className="relative z-20 flex h-full w-[310px] shrink-0 flex-col border-l border-slate-200 bg-white max-lg:absolute max-lg:bottom-0 max-lg:left-0 max-lg:right-0 max-lg:h-[43vh] max-lg:w-full max-lg:rounded-t-2xl max-lg:border-l-0 max-lg:border-t max-lg:shadow-[0_-10px_30px_rgba(15,23,42,0.14)] max-md:h-[50dvh]">
|
||||
<div className="border-b border-slate-100 px-5 pb-4 pt-5 max-md:px-4 max-md:pb-3 max-md:pt-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="truncate text-[17px] font-semibold tracking-tight text-slate-900">{props.title}</h2>
|
||||
{props.loading ? <i className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" /> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-slate-400">{props.subtitle}</p>
|
||||
<p className="mt-1 text-[10px] font-medium text-emerald-600">{props.coverageText}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{props.selected ? (
|
||||
<button type="button" onClick={props.onClose} aria-label="关闭加氢区域详情" className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 hover:bg-slate-50 hover:text-slate-700"><X size={18} strokeWidth={1.8} /></button>
|
||||
) : null}
|
||||
<button type="button" onClick={props.onHide} aria-label="隐藏加氢详情" title="隐藏详情" className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 transition hover:bg-cyan-50 hover:text-cyan-700 max-md:h-10 max-md:w-10"><ChevronRight size={18} strokeWidth={1.8} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-3 border-t border-slate-100 pt-4 max-md:mt-3 max-md:gap-y-2 max-md:pt-3">
|
||||
<div><p className="text-[10px] text-slate-400">加氢量</p><p className="mt-0.5 text-[19px] font-semibold tracking-tight text-cyan-700">{kgFormat.format(props.kg)} <small className="text-[10px] font-medium">kg</small></p></div>
|
||||
<div><p className="text-[10px] text-slate-400">加氢次数</p><p className="mt-0.5 text-[19px] font-semibold tracking-tight text-cyan-700">{integerFormat.format(props.refuelCount)}</p></div>
|
||||
<div><p className="text-[10px] text-slate-400">车辆数</p><p className="mt-0.5 text-[16px] font-semibold text-slate-700">{integerFormat.format(props.vehicleCount)}</p></div>
|
||||
<div><p className="text-[10px] text-slate-400">站点数</p><p className="mt-0.5 text-[16px] font-semibold text-slate-700">{integerFormat.format(props.stationCount)}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto px-5 py-4 max-md:px-4 max-md:py-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[13px] font-semibold text-slate-800">加氢站 TOP10</h3>
|
||||
<span className="text-[10px] text-slate-400">{metricLabel(props.metric)}</span>
|
||||
</div>
|
||||
{props.stations.length ? (
|
||||
<ol className="divide-y divide-slate-100">
|
||||
{props.stations.map((station, index) => (
|
||||
<li key={station.stationId} className="grid grid-cols-[24px_minmax(0,1fr)_68px] items-center gap-2 py-2.5">
|
||||
<span className={`text-[11px] font-medium ${index < 3 ? 'text-cyan-700' : 'text-slate-400'}`}>{index + 1}</span>
|
||||
<span className="min-w-0"><span className="block truncate text-[12px] font-medium text-slate-700" title={station.stationName}>{station.stationName}</span><span className="mt-0.5 block truncate text-[9px] text-slate-400" title={station.address}>{station.address || '地址未维护'}</span></span>
|
||||
<span className="text-right text-[10px] tabular-nums text-slate-600">{metricValue(station, props.metric)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<div className="grid h-44 place-items-center text-center text-xs text-slate-400"><span><Fuel className="mx-auto mb-2 text-slate-300" size={22} />该范围暂无加氢记录</span></div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { CalendarDays, ChevronUp, RotateCcw, Search } from 'lucide-react';
|
||||
import type { HydrogenHeatmapMetric, HydrogenPayer, HydrogenStationOption } from './types';
|
||||
|
||||
type Props = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
minDate: string;
|
||||
maxDate: string;
|
||||
query: string;
|
||||
payer: HydrogenPayer;
|
||||
metric: HydrogenHeatmapMetric;
|
||||
stations: HydrogenStationOption[];
|
||||
onStartDateChange: (value: string) => void;
|
||||
onEndDateChange: (value: string) => void;
|
||||
onQueryChange: (value: string) => void;
|
||||
onPayerChange: (value: HydrogenPayer) => void;
|
||||
onMetricChange: (value: HydrogenHeatmapMetric) => void;
|
||||
onReset: () => void;
|
||||
onHide: () => void;
|
||||
};
|
||||
|
||||
const FIELD = 'relative h-11 min-w-0 rounded-lg border border-slate-200 bg-white transition focus-within:border-cyan-400 focus-within:ring-2 focus-within:ring-cyan-100';
|
||||
const LABEL = 'pointer-events-none absolute left-3 top-1 z-10 text-[9px] font-medium leading-none text-slate-400';
|
||||
const CONTROL = 'h-full w-full min-w-0 rounded-lg border-0 bg-transparent px-3 pt-3 text-[12px] text-slate-700 outline-none';
|
||||
|
||||
export default function HydrogenHeatmapFilters(props: Props) {
|
||||
return (
|
||||
<div className="pointer-events-auto grid max-h-[calc(100dvh-180px)] w-full grid-cols-2 gap-2 overflow-y-auto rounded-xl border border-slate-200/80 bg-white/96 p-2 shadow-[0_8px_28px_rgba(15,23,42,0.12)] backdrop-blur-md md:flex md:w-auto md:min-w-max md:items-center md:overflow-visible">
|
||||
<label className={`${FIELD} col-span-2 md:w-[320px]`}>
|
||||
<span className={`${LABEL} left-9`}>日期范围</span>
|
||||
<span className="flex h-full min-w-0 items-center pl-8 pt-2">
|
||||
<CalendarDays className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={15} strokeWidth={1.8} />
|
||||
<input aria-label="加氢开始日期" type="date" min={props.minDate} max={props.endDate} value={props.startDate} onChange={(event) => props.onStartDateChange(event.target.value)} className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none" />
|
||||
<span className="shrink-0 px-0.5 text-slate-300">—</span>
|
||||
<input aria-label="加氢结束日期" type="date" min={props.startDate} max={props.maxDate} value={props.endDate} onChange={(event) => props.onEndDateChange(event.target.value)} className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className={`${FIELD} col-span-2 md:w-[145px]`}>
|
||||
<span className={LABEL}>加氢站</span>
|
||||
<span className="relative block h-full">
|
||||
<input
|
||||
aria-label="搜索加氢站"
|
||||
value={props.query}
|
||||
list="hydrogen-heatmap-stations"
|
||||
onChange={(event) => props.onQueryChange(event.target.value)}
|
||||
placeholder="输入站点名称"
|
||||
className={`${CONTROL} pr-9`}
|
||||
/>
|
||||
<Search className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} strokeWidth={1.8} />
|
||||
<datalist id="hydrogen-heatmap-stations">
|
||||
{props.stations.map((station) => <option key={station.stationId} value={station.stationName} />)}
|
||||
</datalist>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className={`${FIELD} md:w-[100px]`}>
|
||||
<span className={LABEL}>费用承担</span>
|
||||
<select aria-label="费用承担" value={props.payer} onChange={(event) => props.onPayerChange(event.target.value as HydrogenPayer)} className={CONTROL}>
|
||||
<option value="all">全部</option>
|
||||
<option value="lingniu">羚牛承担</option>
|
||||
<option value="customer">客户承担</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={`${FIELD} md:w-[100px]`}>
|
||||
<span className={LABEL}>统计维度</span>
|
||||
<select aria-label="加氢统计维度" value={props.metric} onChange={(event) => props.onMetricChange(event.target.value as HydrogenHeatmapMetric)} className={CONTROL}>
|
||||
<option value="kg">加氢量</option>
|
||||
<option value="refuels">加氢次数</option>
|
||||
<option value="vehicles">车辆覆盖</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="col-span-2 flex min-w-0 justify-end gap-2 md:w-auto">
|
||||
<button type="button" onClick={props.onReset} className="flex h-11 min-w-[72px] items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 text-[12px] font-medium text-slate-600 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700">
|
||||
<RotateCcw size={14} strokeWidth={1.8} />重置
|
||||
</button>
|
||||
<button type="button" onClick={props.onHide} aria-label="隐藏加氢筛选" title="隐藏筛选" className="grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-slate-200 bg-white text-slate-400 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 md:w-10">
|
||||
<ChevronUp size={16} strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, ChevronLeft, Clock3, Fuel, Maximize2, Minimize2, SlidersHorizontal } from 'lucide-react';
|
||||
import HydrogenAmapCanvas from './HydrogenAmapCanvas';
|
||||
import HydrogenHeatmapDetailPanel from './HydrogenHeatmapDetailPanel';
|
||||
import HydrogenHeatmapFilters from './HydrogenHeatmapFilters';
|
||||
import { fetchAmapConfig, fetchHydrogenHeatmapMeta, fetchHydrogenHeatmapPoints, fetchNearbyHydrogenStations } from './api';
|
||||
import type { AmapConfig, HydrogenHeatmapMeta, HydrogenHeatmapMetric, HydrogenHeatmapResponse, HydrogenNearbyResponse, HydrogenPayer } from './types';
|
||||
|
||||
const DEFAULT_START = '2026-01-01';
|
||||
const DEFAULT_END = '2026-07-13';
|
||||
const integerFormat = new Intl.NumberFormat('zh-CN');
|
||||
const kgFormat = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 });
|
||||
|
||||
function Metric({ value, label }: { value: string; label: string }) {
|
||||
return (
|
||||
<div className="min-w-[88px] text-center max-md:min-w-[68px]">
|
||||
<p className="text-[21px] font-semibold leading-none tracking-tight text-cyan-700 tabular-nums max-md:text-[16px]">{value}</p>
|
||||
<p className="mt-1.5 text-[11px] text-slate-500 max-md:mt-1 max-md:text-[9px]">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function metricName(metric: HydrogenHeatmapMetric) {
|
||||
if (metric === 'refuels') return '加氢频次';
|
||||
if (metric === 'vehicles') return '车辆覆盖';
|
||||
return '加氢量强度';
|
||||
}
|
||||
|
||||
function metricDescription(metric: HydrogenHeatmapMetric) {
|
||||
if (metric === 'refuels') return '每个站点累计有效加氢订单数;平方根平滑强度。';
|
||||
if (metric === 'vehicles') return '每个站点按车牌去重统计车辆数;平方根平滑强度。';
|
||||
return '每个站点累计有效加氢量(kg);对数平滑强度。';
|
||||
}
|
||||
|
||||
export default function HydrogenHeatmapModule() {
|
||||
const [meta, setMeta] = useState<HydrogenHeatmapMeta | null>(null);
|
||||
const [config, setConfig] = useState<AmapConfig | null>(null);
|
||||
const [data, setData] = useState<HydrogenHeatmapResponse | null>(null);
|
||||
const [nearby, setNearby] = useState<HydrogenNearbyResponse | null>(null);
|
||||
const [startDate, setStartDate] = useState(DEFAULT_START);
|
||||
const [endDate, setEndDate] = useState(DEFAULT_END);
|
||||
const [query, setQuery] = useState('');
|
||||
const [payer, setPayer] = useState<HydrogenPayer>('all');
|
||||
const [metric, setMetric] = useState<HydrogenHeatmapMetric>('kg');
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
Promise.all([fetchHydrogenHeatmapMeta(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('');
|
||||
fetchHydrogenHeatmapPoints({ startDate, endDate, query: deferredQuery, payer, 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();
|
||||
};
|
||||
}, [deferredQuery, endDate, metric, payer, startDate]);
|
||||
|
||||
const handleMapClick = useCallback((longitude: number, latitude: number) => {
|
||||
const controller = new AbortController();
|
||||
setNearbyLoading(true);
|
||||
fetchNearbyHydrogenStations({ lng: longitude, lat: latitude, startDate, endDate, query: deferredQuery, payer, metric }, controller.signal)
|
||||
.then(setNearby)
|
||||
.catch((requestError) => {
|
||||
if (requestError?.name !== 'AbortError') setError('区域加氢站明细加载失败');
|
||||
})
|
||||
.finally(() => setNearbyLoading(false));
|
||||
}, [deferredQuery, endDate, metric, payer, startDate]);
|
||||
|
||||
const reset = () => {
|
||||
setStartDate(meta?.startDate || DEFAULT_START);
|
||||
setEndDate(meta?.endDate || DEFAULT_END);
|
||||
setQuery('');
|
||||
setPayer('all');
|
||||
setMetric('kg');
|
||||
setNearby(null);
|
||||
};
|
||||
|
||||
const coverageText = meta
|
||||
? `GPS覆盖 ${(meta.gpsCoverageRate * 100).toFixed(3)}% · 已排除 ${integerFormat.format(meta.excludedRefuelCount)} 笔缺坐标数据`
|
||||
: '正在核对站点 GPS 覆盖率';
|
||||
|
||||
const detail = useMemo(() => {
|
||||
if (nearby) {
|
||||
return {
|
||||
title: '选中区域',
|
||||
subtitle: `${nearby.center.lng.toFixed(3)}, ${nearby.center.lat.toFixed(3)} · 半径 ${nearby.radiusKm} 公里`,
|
||||
kg: nearby.kg,
|
||||
refuelCount: nearby.refuelCount,
|
||||
vehicleCount: nearby.vehicleCount,
|
||||
stationCount: nearby.stationCount,
|
||||
stations: nearby.topStations,
|
||||
selected: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: deferredQuery ? '站点筛选结果' : '全国 · 当前范围',
|
||||
subtitle: `${payer === 'all' ? '' : `${payer === 'lingniu' ? '羚牛承担' : '客户承担'} · `}${startDate} ~ ${endDate}`,
|
||||
kg: data?.kg || 0,
|
||||
refuelCount: data?.refuelCount || 0,
|
||||
vehicleCount: data?.vehicleCount || 0,
|
||||
stationCount: data?.stationCount || 0,
|
||||
stations: data?.topStations || [],
|
||||
selected: false,
|
||||
};
|
||||
}, [data, deferredQuery, endDate, nearby, payer, 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]"><Fuel size={13} />加氢账本 × 加氢站 GPS · 上海时区</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={kgFormat.format(data?.kg ?? meta?.eligibleKg ?? 0)} label="加氢量 kg" />
|
||||
<span className="h-8 w-px bg-slate-200" />
|
||||
<Metric value={integerFormat.format(data?.refuelCount ?? meta?.eligibleRefuelCount ?? 0)} label="加氢次数" />
|
||||
<span className="h-8 w-px bg-slate-200" />
|
||||
<Metric value={integerFormat.format(data?.stationCount ?? meta?.eligibleStationCount ?? 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={() => setIsFullscreen((current) => !current)} className="grid h-9 w-9 place-items-center rounded-lg border border-slate-200 text-slate-500 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700" 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 ? <HydrogenAmapCanvas 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">
|
||||
<HydrogenHeatmapFilters
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
minDate={meta?.startDate || DEFAULT_START}
|
||||
maxDate={meta?.endDate || DEFAULT_END}
|
||||
query={query}
|
||||
payer={payer}
|
||||
metric={metric}
|
||||
stations={meta?.stations || []}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onQueryChange={setQuery}
|
||||
onPayerChange={setPayer}
|
||||
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-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 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-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 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-[300px] 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>{metricName(metric)}</span>{loading ? <i className="h-3 w-3 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" /> : null}</div>
|
||||
<p className="mt-1 text-[10px] leading-4 text-slate-500">{metricDescription(metric)}</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 ? (
|
||||
<HydrogenHeatmapDetailPanel {...detail} metric={metric} loading={nearbyLoading} coverageText={coverageText} onClose={() => setNearby(null)} onHide={() => setShowDetails(false)} />
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import type {
|
||||
AmapConfig,
|
||||
HydrogenHeatmapMeta,
|
||||
HydrogenHeatmapMetric,
|
||||
HydrogenHeatmapResponse,
|
||||
HydrogenNearbyResponse,
|
||||
HydrogenPayer,
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api/hydrogen-heatmap';
|
||||
|
||||
export function fetchAmapConfig(signal?: AbortSignal) {
|
||||
return fetchJson<AmapConfig>(`${BASE}/config`, { signal });
|
||||
}
|
||||
|
||||
export function fetchHydrogenHeatmapMeta(signal?: AbortSignal) {
|
||||
return fetchJson<HydrogenHeatmapMeta>(`${BASE}/meta`, { signal });
|
||||
}
|
||||
|
||||
export function fetchHydrogenHeatmapPoints(
|
||||
params: { startDate: string; endDate: string; query: string; payer: HydrogenPayer; metric: HydrogenHeatmapMetric },
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return fetchJson<HydrogenHeatmapResponse>(`${BASE}/points?${new URLSearchParams(params)}`, { signal });
|
||||
}
|
||||
|
||||
export function fetchNearbyHydrogenStations(
|
||||
params: {
|
||||
lng: number;
|
||||
lat: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
query: string;
|
||||
payer: HydrogenPayer;
|
||||
metric: HydrogenHeatmapMetric;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const search = new URLSearchParams({
|
||||
lng: String(params.lng),
|
||||
lat: String(params.lat),
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate,
|
||||
query: params.query,
|
||||
payer: params.payer,
|
||||
metric: params.metric,
|
||||
radiusKm: '50',
|
||||
});
|
||||
return fetchJson<HydrogenNearbyResponse>(`${BASE}/nearby?${search}`, { signal });
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles';
|
||||
export type HydrogenPayer = 'all' | 'lingniu' | 'customer';
|
||||
|
||||
export type HydrogenHeatmapPoint = {
|
||||
lng: number;
|
||||
lat: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type HydrogenStationRank = {
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
address: string;
|
||||
lng: number;
|
||||
lat: number;
|
||||
kg: number;
|
||||
refuelCount: number;
|
||||
vehicleCount: number;
|
||||
firstRefuel: string;
|
||||
lastRefuel: string;
|
||||
metricValue: number;
|
||||
};
|
||||
|
||||
export type HydrogenStationOption = {
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
};
|
||||
|
||||
export type HydrogenHeatmapMeta = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
totalRefuelCount: number;
|
||||
eligibleRefuelCount: number;
|
||||
excludedRefuelCount: number;
|
||||
gpsCoverageRate: number;
|
||||
totalKg: number;
|
||||
eligibleKg: number;
|
||||
vehicleCount: number;
|
||||
totalStationCount: number;
|
||||
eligibleStationCount: number;
|
||||
stations: HydrogenStationOption[];
|
||||
};
|
||||
|
||||
export type HydrogenHeatmapResponse = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
metric: HydrogenHeatmapMetric;
|
||||
payer: HydrogenPayer;
|
||||
kg: number;
|
||||
refuelCount: number;
|
||||
vehicleCount: number;
|
||||
stationCount: number;
|
||||
dayCount: number;
|
||||
points: HydrogenHeatmapPoint[];
|
||||
max: number;
|
||||
topStations: HydrogenStationRank[];
|
||||
};
|
||||
|
||||
export type HydrogenNearbyResponse = {
|
||||
center: { lng: number; lat: number };
|
||||
radiusKm: number;
|
||||
kg: number;
|
||||
refuelCount: number;
|
||||
vehicleCount: number;
|
||||
stationCount: number;
|
||||
topStations: HydrogenStationRank[];
|
||||
};
|
||||
|
||||
export type AmapConfig = {
|
||||
key: string;
|
||||
securityCode: string;
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { AmapConfig, HeatmapMetric, HeatmapPoint } from './types';
|
||||
|
||||
type Props = {
|
||||
config: AmapConfig;
|
||||
points: HeatmapPoint[];
|
||||
max: number;
|
||||
metric: HeatmapMetric;
|
||||
focusQuery: string;
|
||||
onMapClick: (longitude: number, latitude: number) => void;
|
||||
};
|
||||
|
||||
type AmapInstance = {
|
||||
Map: new (container: HTMLElement, options: Record<string, unknown>) => any;
|
||||
HeatMap: new (map: any, options: Record<string, unknown>) => any;
|
||||
ToolBar: new (options?: Record<string, unknown>) => any;
|
||||
Scale: new (options?: Record<string, unknown>) => any;
|
||||
Bounds: new (southWest: [number, number], northEast: [number, number]) => any;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
_AMapSecurityConfig?: { securityJsCode: string };
|
||||
}
|
||||
}
|
||||
|
||||
function getBounds(points: HeatmapPoint[]) {
|
||||
if (points.length === 0) return null;
|
||||
let minLng = points[0].lng;
|
||||
let maxLng = points[0].lng;
|
||||
let minLat = points[0].lat;
|
||||
let maxLat = points[0].lat;
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const point = points[index];
|
||||
minLng = Math.min(minLng, point.lng);
|
||||
maxLng = Math.max(maxLng, point.lng);
|
||||
minLat = Math.min(minLat, point.lat);
|
||||
maxLat = Math.max(maxLat, point.lat);
|
||||
}
|
||||
return { minLng, maxLng, minLat, maxLat };
|
||||
}
|
||||
|
||||
export default function AmapHeatmapCanvas({ config, points, max, metric, focusQuery, onMapClick }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<any>(null);
|
||||
const heatmapRef = useRef<any>(null);
|
||||
const amapRef = useRef<AmapInstance | null>(null);
|
||||
const clickHandlerRef = useRef(onMapClick);
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const intensity = useMemo(() => {
|
||||
const transform = metric === 'locations'
|
||||
? (value: number) => Math.log1p(value)
|
||||
: (value: number) => Math.sqrt(value);
|
||||
return {
|
||||
points: points.map((point) => ({ ...point, count: transform(point.count) })),
|
||||
max: transform(Math.max(1, max)),
|
||||
};
|
||||
}, [max, metric, points]);
|
||||
|
||||
useEffect(() => {
|
||||
clickHandlerRef.current = onMapClick;
|
||||
}, [onMapClick]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const container = containerRef.current;
|
||||
if (!container) return undefined;
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
window._AMapSecurityConfig = { securityJsCode: config.securityCode };
|
||||
const loaderModule = await import('@amap/amap-jsapi-loader');
|
||||
const AMap = await loaderModule.default.load({
|
||||
key: config.key,
|
||||
version: '2.0',
|
||||
plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'],
|
||||
}) as unknown as AmapInstance;
|
||||
if (cancelled || !container) return;
|
||||
|
||||
const map = new AMap.Map(container, {
|
||||
viewMode: '2D',
|
||||
zoom: 5,
|
||||
center: [105.4, 34.4],
|
||||
mapStyle: 'amap://styles/whitesmoke',
|
||||
resizeEnable: true,
|
||||
showLabel: true,
|
||||
});
|
||||
map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } }));
|
||||
map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } }));
|
||||
const heatmap = new AMap.HeatMap(map, {
|
||||
radius: 25,
|
||||
opacity: [0.12, 0.82],
|
||||
gradient: {
|
||||
0.1: '#2563eb',
|
||||
0.3: '#0891b2',
|
||||
0.5: '#16a34a',
|
||||
0.68: '#eab308',
|
||||
0.84: '#f97316',
|
||||
1: '#dc2626',
|
||||
},
|
||||
});
|
||||
map.on('click', (event: any) => {
|
||||
clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat());
|
||||
});
|
||||
mapRef.current = map;
|
||||
heatmapRef.current = heatmap;
|
||||
amapRef.current = AMap;
|
||||
setStatus('ready');
|
||||
} catch (error) {
|
||||
console.error('AMap initialization failed', error);
|
||||
if (!cancelled) setStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
initialize();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
heatmapRef.current?.setMap?.(null);
|
||||
mapRef.current?.destroy?.();
|
||||
heatmapRef.current = null;
|
||||
mapRef.current = null;
|
||||
amapRef.current = null;
|
||||
};
|
||||
}, [config.key, config.securityCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'ready' || !heatmapRef.current) return;
|
||||
heatmapRef.current.setDataSet({ data: intensity.points, max: intensity.max });
|
||||
|
||||
if (!focusQuery || points.length === 0 || !mapRef.current || !amapRef.current) return;
|
||||
const bounds = getBounds(points);
|
||||
if (!bounds) return;
|
||||
if (points.length === 1) {
|
||||
mapRef.current.setZoomAndCenter(12, [points[0].lng, points[0].lat]);
|
||||
return;
|
||||
}
|
||||
mapRef.current.setBounds(new amapRef.current.Bounds(
|
||||
[bounds.minLng, bounds.minLat],
|
||||
[bounds.maxLng, bounds.maxLat],
|
||||
), false, [80, 80, 80, 80]);
|
||||
}, [focusQuery, intensity, points, status]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 bg-[#eef3f7]">
|
||||
<div ref={containerRef} className="h-full w-full" aria-label="车辆运营热力图地图" />
|
||||
{status === 'loading' ? (
|
||||
<div className="absolute inset-0 grid place-items-center bg-white/76 text-sm font-medium text-slate-500 backdrop-blur-[2px]">
|
||||
<span className="flex items-center gap-2.5"><i className="h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />正在加载高德地图</span>
|
||||
</div>
|
||||
) : null}
|
||||
{status === 'error' ? (
|
||||
<div className="absolute inset-0 grid place-items-center bg-slate-50 text-center">
|
||||
<div><p className="text-sm font-semibold text-slate-700">地图加载失败</p><p className="mt-1 text-xs text-slate-400">请检查高德 Key、域名白名单与网络连接</p></div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ChevronRight, Crosshair, X } from 'lucide-react';
|
||||
import type { VehicleRank } from './types';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
locationCount: number;
|
||||
vehicleCount: number;
|
||||
vehicles: VehicleRank[];
|
||||
selected: boolean;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
onHide: () => void;
|
||||
};
|
||||
|
||||
const numberFormat = new Intl.NumberFormat('zh-CN');
|
||||
|
||||
export default function HeatmapDetailPanel(props: Props) {
|
||||
return (
|
||||
<aside className="relative z-20 flex h-full w-[300px] shrink-0 flex-col border-l border-slate-200 bg-white max-lg:absolute max-lg:bottom-0 max-lg:left-0 max-lg:right-0 max-lg:h-[43vh] max-lg:w-full max-lg:rounded-t-2xl max-lg:border-l-0 max-lg:border-t max-lg:shadow-[0_-10px_30px_rgba(15,23,42,0.14)] max-md:h-[50dvh]">
|
||||
<div className="border-b border-slate-100 px-5 pb-4 pt-5 max-md:px-4 max-md:pb-3 max-md:pt-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-[17px] font-semibold tracking-tight text-slate-900">{props.title}</h2>
|
||||
{props.loading ? <i className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" /> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-slate-400">{props.subtitle}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{props.selected ? (
|
||||
<button type="button" onClick={props.onClose} aria-label="关闭区域详情" className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 hover:bg-slate-50 hover:text-slate-700">
|
||||
<X size={18} strokeWidth={1.8} />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onHide}
|
||||
aria-label="隐藏详情"
|
||||
title="隐藏详情"
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 transition hover:bg-blue-50 hover:text-blue-600 max-md:h-10 max-md:w-10"
|
||||
>
|
||||
<ChevronRight size={18} strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 divide-x divide-slate-200 border-t border-slate-100 pt-4 max-md:mt-3 max-md:pt-3">
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-400">定位点数</p>
|
||||
<p className="mt-1 text-[22px] font-semibold tracking-tight text-blue-600">{numberFormat.format(props.locationCount)}</p>
|
||||
</div>
|
||||
<div className="pl-5">
|
||||
<p className="text-[11px] text-slate-400">车辆数</p>
|
||||
<p className="mt-1 text-[22px] font-semibold tracking-tight text-blue-600">{numberFormat.format(props.vehicleCount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto px-5 py-4 max-md:px-4 max-md:py-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[13px] font-semibold text-slate-800">车辆 TOP10</h3>
|
||||
<span className="text-[10px] text-slate-400">按出现天数排序</span>
|
||||
</div>
|
||||
{props.vehicles.length ? (
|
||||
<ol className="divide-y divide-slate-100">
|
||||
{props.vehicles.map((vehicle, index) => (
|
||||
<li key={vehicle.vin} className="grid grid-cols-[24px_minmax(0,1fr)_42px] items-center gap-2 py-2.5">
|
||||
<span className={`text-[11px] font-medium ${index < 3 ? 'text-blue-600' : 'text-slate-400'}`}>{index + 1}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[12px] font-medium text-slate-700">{vehicle.plateNumber || '未绑定车牌'}</span>
|
||||
<span className="mt-0.5 block truncate font-mono text-[9px] text-slate-400">{vehicle.vin}</span>
|
||||
</span>
|
||||
<span className="text-right text-[12px] tabular-nums text-slate-600">{vehicle.locationCount}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<div className="grid h-44 place-items-center text-center text-xs text-slate-400">
|
||||
<span><Crosshair className="mx-auto mb-2 text-slate-300" size={22} />该区域暂无车辆定位</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { CalendarDays, ChevronUp, RotateCcw, Search } from 'lucide-react';
|
||||
import type { HeatmapMetric, VehicleOption } from './types';
|
||||
|
||||
type Props = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
minDate: string;
|
||||
maxDate: string;
|
||||
query: string;
|
||||
batchModel: string;
|
||||
batchModels: string[];
|
||||
metric: HeatmapMetric;
|
||||
vehicles: VehicleOption[];
|
||||
onStartDateChange: (value: string) => void;
|
||||
onEndDateChange: (value: string) => void;
|
||||
onQueryChange: (value: string) => void;
|
||||
onBatchModelChange: (value: string) => void;
|
||||
onMetricChange: (value: HeatmapMetric) => void;
|
||||
onReset: () => void;
|
||||
onHide: () => void;
|
||||
};
|
||||
|
||||
const FIELD = 'relative h-11 min-w-0 rounded-lg border border-slate-200 bg-white transition focus-within:border-blue-400 focus-within:ring-2 focus-within:ring-blue-100';
|
||||
const LABEL = 'pointer-events-none absolute left-3 top-1 z-10 text-[9px] font-medium leading-none text-slate-400';
|
||||
const CONTROL = 'h-full w-full min-w-0 rounded-lg border-0 bg-transparent px-3 pt-3 text-[12px] text-slate-700 outline-none';
|
||||
|
||||
export default function HeatmapFilters(props: Props) {
|
||||
return (
|
||||
<div className="pointer-events-auto grid max-h-[calc(100dvh-180px)] w-full grid-cols-2 gap-2 overflow-y-auto rounded-xl border border-slate-200/80 bg-white/96 p-2 shadow-[0_8px_28px_rgba(15,23,42,0.12)] backdrop-blur-md md:flex md:w-auto md:min-w-max md:items-center md:overflow-visible">
|
||||
<label className={`${FIELD} col-span-2 md:w-[320px]`}>
|
||||
<span className={`${LABEL} left-9`}>日期范围</span>
|
||||
<span className="flex h-full min-w-0 items-center pl-8 pt-2">
|
||||
<CalendarDays className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={15} strokeWidth={1.8} />
|
||||
<input
|
||||
aria-label="开始日期"
|
||||
type="date"
|
||||
min={props.minDate}
|
||||
max={props.endDate}
|
||||
value={props.startDate}
|
||||
onChange={(event) => props.onStartDateChange(event.target.value)}
|
||||
className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none"
|
||||
/>
|
||||
<span className="shrink-0 px-0.5 text-slate-300">—</span>
|
||||
<input
|
||||
aria-label="结束日期"
|
||||
type="date"
|
||||
min={props.startDate}
|
||||
max={props.maxDate}
|
||||
value={props.endDate}
|
||||
onChange={(event) => props.onEndDateChange(event.target.value)}
|
||||
className="h-full w-[124px] min-w-0 flex-1 border-0 bg-transparent px-1 text-[12px] text-slate-700 outline-none"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className={`${FIELD} col-span-2 md:w-[145px]`}>
|
||||
<span className={LABEL}>VIN / 车牌</span>
|
||||
<span className="relative block h-full">
|
||||
<input
|
||||
aria-label="搜索 VIN 或车牌"
|
||||
value={props.query}
|
||||
list="vehicle-heatmap-options"
|
||||
onChange={(event) => props.onQueryChange(event.target.value)}
|
||||
placeholder="输入 VIN 或车牌"
|
||||
className={`${CONTROL} pr-9`}
|
||||
/>
|
||||
<Search className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} strokeWidth={1.8} />
|
||||
<datalist id="vehicle-heatmap-options">
|
||||
{props.vehicles.map((vehicle) => (
|
||||
<option key={vehicle.vin} value={vehicle.plateNumber || vehicle.vin}>{vehicle.vin}</option>
|
||||
))}
|
||||
</datalist>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className={`${FIELD} col-span-2 md:w-[150px]`}>
|
||||
<span className={LABEL}>车型 / 批次型号</span>
|
||||
<select
|
||||
aria-label="车型 / 批次型号"
|
||||
value={props.batchModel}
|
||||
onChange={(event) => props.onBatchModelChange(event.target.value)}
|
||||
className={CONTROL}
|
||||
title={props.batchModel || '全部车型'}
|
||||
>
|
||||
<option value="">全部车型</option>
|
||||
{props.batchModels.map((batchModel) => (
|
||||
<option key={batchModel} value={batchModel}>{batchModel}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={`${FIELD} md:w-[110px]`}>
|
||||
<span className={LABEL}>统计维度</span>
|
||||
<select
|
||||
aria-label="统计维度"
|
||||
value={props.metric}
|
||||
onChange={(event) => props.onMetricChange(event.target.value as HeatmapMetric)}
|
||||
className={CONTROL}
|
||||
>
|
||||
<option value="locations">定位活跃度</option>
|
||||
<option value="vehicles">车辆覆盖度</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex min-w-0 gap-2 md:w-auto">
|
||||
<button type="button" onClick={props.onReset} className="flex h-11 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 text-[12px] font-medium text-slate-600 transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 md:flex-none">
|
||||
<RotateCcw size={14} strokeWidth={1.8} />重置
|
||||
</button>
|
||||
<button type="button" onClick={props.onHide} aria-label="隐藏筛选" title="隐藏筛选" className="grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-slate-200 bg-white text-slate-400 transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 md:w-10">
|
||||
<ChevronUp size={16} strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import type { AmapConfig, HeatmapMeta, HeatmapMetric, HeatmapResponse, NearbyResponse } from './types';
|
||||
|
||||
export function fetchAmapConfig(signal?: AbortSignal) {
|
||||
return fetchJson<AmapConfig>('/api/vehicle-heatmap/config', { signal });
|
||||
}
|
||||
|
||||
export function fetchHeatmapMeta(signal?: AbortSignal) {
|
||||
return fetchJson<HeatmapMeta>('/api/vehicle-heatmap/meta', { signal });
|
||||
}
|
||||
|
||||
export function fetchHeatmapPoints(
|
||||
params: { startDate: string; endDate: string; query: string; batchModel: string; metric: HeatmapMetric },
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const search = new URLSearchParams(params);
|
||||
return fetchJson<HeatmapResponse>(`/api/vehicle-heatmap/points?${search}`, { signal });
|
||||
}
|
||||
|
||||
export function fetchNearbyVehicles(
|
||||
params: { lng: number; lat: number; startDate: string; endDate: string; query: string; batchModel: string },
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const search = new URLSearchParams({
|
||||
lng: String(params.lng),
|
||||
lat: String(params.lat),
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate,
|
||||
query: params.query,
|
||||
batchModel: params.batchModel,
|
||||
radiusKm: '50',
|
||||
});
|
||||
return fetchJson<NearbyResponse>(`/api/vehicle-heatmap/nearby?${search}`, { signal });
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export type HeatmapMetric = 'locations' | 'vehicles';
|
||||
|
||||
export type HeatmapPoint = {
|
||||
lng: number;
|
||||
lat: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type VehicleRank = {
|
||||
vin: string;
|
||||
plateNumber: string;
|
||||
locationCount: number;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
};
|
||||
|
||||
export type VehicleOption = {
|
||||
vin: string;
|
||||
plateNumber: string;
|
||||
};
|
||||
|
||||
export type HeatmapMeta = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
locationCount: number;
|
||||
vehicleCount: number;
|
||||
dayCount: number;
|
||||
totalLocationCount: number;
|
||||
excludedLocationCount: number;
|
||||
outsideMainlandCount: number;
|
||||
tibetCount: number;
|
||||
batchModels: string[];
|
||||
vehicles: VehicleOption[];
|
||||
};
|
||||
|
||||
export type HeatmapResponse = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
metric: HeatmapMetric;
|
||||
locationCount: number;
|
||||
vehicleCount: number;
|
||||
dayCount: number;
|
||||
points: HeatmapPoint[];
|
||||
max: number;
|
||||
topVehicles: VehicleRank[];
|
||||
};
|
||||
|
||||
export type NearbyResponse = {
|
||||
center: { lng: number; lat: number };
|
||||
radiusKm: number;
|
||||
locationCount: number;
|
||||
vehicleCount: number;
|
||||
topVehicles: VehicleRank[];
|
||||
};
|
||||
|
||||
export type AmapConfig = {
|
||||
key: string;
|
||||
securityCode: string;
|
||||
};
|
||||
Reference in New Issue
Block a user