117 lines
3.5 KiB
TypeScript
117 lines
3.5 KiB
TypeScript
|
|
import { useEffect, useRef, useState } from 'react';
|
||
|
|
import L from 'leaflet';
|
||
|
|
|
||
|
|
interface CaseLocation {
|
||
|
|
case_id: string;
|
||
|
|
case_type: string;
|
||
|
|
latitude: number;
|
||
|
|
longitude: number;
|
||
|
|
district: string;
|
||
|
|
street: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
|
||
|
|
|
||
|
|
export function CaseLocationMap({ height = '400px' }: { height?: string }) {
|
||
|
|
const mapRef = useRef<HTMLDivElement>(null);
|
||
|
|
const mapInstanceRef = useRef<L.Map | null>(null);
|
||
|
|
const layerRef = useRef<L.LayerGroup | null>(null);
|
||
|
|
const [isLoading, setIsLoading] = useState(true);
|
||
|
|
const [caseCount, setCaseCount] = useState(0);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!mapRef.current || mapInstanceRef.current) return;
|
||
|
|
|
||
|
|
const map = L.map(mapRef.current, {
|
||
|
|
center: WUHAN_CENTER,
|
||
|
|
zoom: 11,
|
||
|
|
zoomControl: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
|
|
attribution: '© OpenStreetMap',
|
||
|
|
maxZoom: 18,
|
||
|
|
}).addTo(map);
|
||
|
|
|
||
|
|
mapInstanceRef.current = map;
|
||
|
|
layerRef.current = L.layerGroup().addTo(map);
|
||
|
|
|
||
|
|
// Fetch case locations
|
||
|
|
fetch('/api/geocoded/geocoded?limit=5000')
|
||
|
|
.then((res) => res.json())
|
||
|
|
.then((data) => {
|
||
|
|
const cases: CaseLocation[] = data.cases || [];
|
||
|
|
const layer = layerRef.current;
|
||
|
|
if (!layer) return;
|
||
|
|
|
||
|
|
layer.clearLayers();
|
||
|
|
|
||
|
|
// Deduplicate by case_id to avoid overlapping markers
|
||
|
|
const seen = new Set<string>();
|
||
|
|
const unique: CaseLocation[] = [];
|
||
|
|
for (const c of cases) {
|
||
|
|
if (!seen.has(c.case_id)) {
|
||
|
|
seen.add(c.case_id);
|
||
|
|
unique.push(c);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const c of unique) {
|
||
|
|
if (!c.latitude || !c.longitude) continue;
|
||
|
|
|
||
|
|
const color = c.case_type === 'inpatient' ? '#ef4444' : '#3b82f6';
|
||
|
|
const marker = L.circleMarker([c.latitude, c.longitude], {
|
||
|
|
radius: 3,
|
||
|
|
fillColor: color,
|
||
|
|
fillOpacity: 0.6,
|
||
|
|
color: color,
|
||
|
|
weight: 1,
|
||
|
|
});
|
||
|
|
|
||
|
|
marker.bindTooltip(
|
||
|
|
`<div style="font-size:12px">
|
||
|
|
<strong>${c.district}</strong> ${c.street}<br/>
|
||
|
|
类型: ${c.case_type === 'inpatient' ? '住院' : '门诊'}
|
||
|
|
</div>`,
|
||
|
|
{ direction: 'top', offset: [0, -4] }
|
||
|
|
);
|
||
|
|
|
||
|
|
marker.addTo(layer);
|
||
|
|
}
|
||
|
|
|
||
|
|
setCaseCount(unique.length);
|
||
|
|
setIsLoading(false);
|
||
|
|
|
||
|
|
// Fit bounds to case locations
|
||
|
|
if (unique.length > 0) {
|
||
|
|
const bounds = L.latLngBounds(unique.map((c) => [c.latitude, c.longitude]));
|
||
|
|
map.fitBounds(bounds, { padding: [30, 30] });
|
||
|
|
}
|
||
|
|
})
|
||
|
|
.catch(() => setIsLoading(false));
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
map.remove();
|
||
|
|
mapInstanceRef.current = null;
|
||
|
|
};
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="relative">
|
||
|
|
<div ref={mapRef} style={{ height, width: '100%', borderRadius: '8px' }} />
|
||
|
|
{isLoading && (
|
||
|
|
<div className="absolute inset-0 flex items-center justify-center bg-white/80 rounded-lg">
|
||
|
|
<div className="text-sm text-gray-500">加载病例位置...</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{!isLoading && (
|
||
|
|
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
|
||
|
|
<span className="text-blue-600 font-semibold">{caseCount.toLocaleString()}</span> 个病例位置
|
||
|
|
<span className="ml-2 text-red-500">● 住院</span>
|
||
|
|
<span className="ml-1 text-blue-500">● 门诊</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|