import { memo, useEffect, useRef, useState, useCallback } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { geocodedApi } from '@/services/api'; import type { CaseGrid, GeocodedCase } from '@/types'; interface CaseMapProps { height?: string; } type ViewMode = 'grid' | 'point'; // Grid is 100m x 100m at Wuhan latitude (~30.5°N) const GRID_HALF_SIZE_LAT = 0.00045; // ~50m in degrees const GRID_HALF_SIZE_LON = 0.00052; // ~50m in degrees function getGridBounds(g: { latitude: number; longitude: number }) { if (typeof g.latitude !== 'number' || typeof g.longitude !== 'number') { return null; } return { lat_min: g.latitude - GRID_HALF_SIZE_LAT, lat_max: g.latitude + GRID_HALF_SIZE_LAT, lon_min: g.longitude - GRID_HALF_SIZE_LON, lon_max: g.longitude + GRID_HALF_SIZE_LON, }; } const RISK_COLORS = { high: '#ff4444', medium: '#ffaa44', low: '#44bb44', }; function getRiskColor(riskIndex: number): string { if (riskIndex >= 0.67) return RISK_COLORS.high; if (riskIndex >= 0.33) return RISK_COLORS.medium; return RISK_COLORS.low; } function getRiskLabel(riskIndex: number): string { if (riskIndex >= 0.67) return '高风险'; if (riskIndex >= 0.33) return '中风险'; return '低风险'; } function debounce void>(fn: T, ms: number) { let timer: ReturnType | null = null; return (...args: Parameters) => { if (timer) clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; } function CaseMapComponent({ height = '480px' }: CaseMapProps) { const mapDivRef = useRef(null); const mapRef = useRef(null); const gridLayerRef = useRef(null); const pointLayerRef = useRef(null); const [viewMode, setViewMode] = useState('grid'); const [grids, setGrids] = useState([]); const [cases, setCases] = useState([]); const [totalCases, setTotalCases] = useState(0); const [gridCount, setGridCount] = useState(0); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; async function fetchData() { setIsLoading(true); setError(null); try { const [gridRes, geoRes] = await Promise.all([ geocodedApi.getGrid(), geocodedApi.getGeocoded({ limit: 5000 }), ]); if (cancelled) return; setGrids(gridRes.grids || []); setGridCount(gridRes.total_count || 0); setTotalCases(gridRes.total_cases || 0); setCases(geoRes.cases || []); } catch (err) { if (cancelled) return; setError(err instanceof Error ? err.message : '加载失败'); } finally { if (!cancelled) setIsLoading(false); } } fetchData(); return () => { cancelled = true; }; }, []); useEffect(() => { if (!mapDivRef.current || mapRef.current) return; const map = L.map(mapDivRef.current, { center: [30.59, 114.31], zoom: 11, zoomControl: true, preferCanvas: false, }); L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19, }).addTo(map); mapRef.current = map; const handleZoom = debounce(() => renderLayers(), 150); const handleMove = debounce(() => renderLayers(), 150); map.on('zoomend', handleZoom); map.on('moveend', handleMove); return () => { if (mapRef.current) { mapRef.current.remove(); mapRef.current = null; gridLayerRef.current = null; pointLayerRef.current = null; } }; }, []); useEffect(() => { if (!mapRef.current) return; renderLayers(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [grids, cases, viewMode]); const renderLayers = useCallback(() => { if (!mapRef.current) return; const map = mapRef.current; if (gridLayerRef.current) { try { map.removeLayer(gridLayerRef.current); } catch { /* silent */ } gridLayerRef.current = null; } if (pointLayerRef.current) { try { map.removeLayer(pointLayerRef.current); } catch { /* silent */ } pointLayerRef.current = null; } const zoom = map.getZoom(); if (viewMode === 'grid') { const gridLayer = L.layerGroup(); const bounds = map.getBounds(); let rendered = 0; const maxRender = 5000; for (const g of grids) { if (rendered >= maxRender) break; const gBounds = getGridBounds(g); if (!gBounds) continue; if ( gBounds.lat_max < bounds.getSouth() || gBounds.lat_min > bounds.getNorth() || gBounds.lon_max < bounds.getWest() || gBounds.lon_min > bounds.getEast() ) { continue; } const color = getRiskColor(g.risk_index); const opacity = 0.5 + g.risk_index * 0.35; const rect = L.rectangle( [[gBounds.lat_min, gBounds.lon_min], [gBounds.lat_max, gBounds.lon_max]], { fillColor: color, fillOpacity: opacity, color: color, weight: zoom >= 14 ? 1 : 0, opacity: 0.3, } ); rect.bindTooltip( `
网格 ${g.grid_id}
病例数: ${g.total_cases.toLocaleString()}
风险指数: ${(g.risk_index * 100).toFixed(1)}%
${getRiskLabel(g.risk_index)}
`, { direction: 'top', offset: [0, -5] } ); rect.addTo(gridLayer); rendered++; } gridLayer.addTo(map); gridLayerRef.current = gridLayer; } else { const pointLayer = L.layerGroup(); const bounds = map.getBounds(); const caseColor = (c: GeocodedCase) => c.case_type === 'inpatient' ? '#DC2626' : '#2563EB'; // Viewport culling + maxRender to avoid Leaflet canvas intersects bug const maxRender = 500; let rendered = 0; for (const c of cases) { if (rendered >= maxRender) break; if (typeof c.latitude !== 'number' || typeof c.longitude !== 'number') continue; // Viewport culling - skip points outside visible area if ( c.latitude < bounds.getSouth() || c.latitude > bounds.getNorth() || c.longitude < bounds.getWest() || c.longitude > bounds.getEast() ) { continue; } // Use tiny rectangles instead of circleMarker to avoid Leaflet 1.9.4 intersects bug const size = zoom >= 14 ? 0.00005 : zoom >= 12 ? 0.00003 : 0.00002; const rect = L.rectangle( [[c.latitude - size, c.longitude - size], [c.latitude + size, c.longitude + size]], { fillColor: caseColor(c), fillOpacity: 0.8, color: '#FFFFFF', weight: 0.5, } ); rect.bindTooltip( `
${c.case_type === 'inpatient' ? '住院' : '门诊'}病例
坐标:${c.latitude.toFixed(5)}, ${c.longitude.toFixed(5)}
`, { direction: 'top', offset: [0, -5] } ); rect.addTo(pointLayer); rendered++; } pointLayer.addTo(map); pointLayerRef.current = pointLayer; } }, [grids, cases, viewMode]); const handleToggle = useCallback((mode: ViewMode) => { setViewMode(mode); }, []); return (
病例空间分布
{viewMode === 'grid' ? '100×100m 网格' : '个体病例定位'}
{viewMode === 'grid' ? ( <>
风险等级
高风险 (>67%)
中风险 (33-67%)
低风险 (<33%)
) : ( <>
病例类型
住院病例
门诊病例
)}
{isLoading ? ( 数据加载中... ) : error ? ( 加载失败: {error} ) : ( <> {totalCases.toLocaleString()} 例病例 | {viewMode === 'grid' ? ( <> {gridCount.toLocaleString()} 个网格 ) : ( <> {cases.length.toLocaleString()} 个定位点 )} )}
{!isLoading && !error && viewMode === 'grid' && (
基于真实病例地理编码数据
)}
); } export const CaseMap = memo(CaseMapComponent);