Files
CA/frontend/src/components/CaseMap.tsx

377 lines
13 KiB
TypeScript
Raw Normal View History

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<T extends (...args: any[]) => void>(fn: T, ms: number) {
let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
function CaseMapComponent({ height = '480px' }: CaseMapProps) {
const mapDivRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<any>(null);
const gridLayerRef = useRef<any>(null);
const pointLayerRef = useRef<any>(null);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [grids, setGrids] = useState<CaseGrid[]>([]);
const [cases, setCases] = useState<GeocodedCase[]>([]);
const [totalCases, setTotalCases] = useState(0);
const [gridCount, setGridCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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(
`<div style="font-size: 12px;">
<strong> ${g.grid_id}</strong><br/>
病例数: ${g.total_cases.toLocaleString()}<br/>
风险指数: ${(g.risk_index * 100).toFixed(1)}%<br/>
<span style="color: ${color}; font-weight: 600;">${getRiskLabel(g.risk_index)}</span>
</div>`,
{ 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(
`<div style="font-size: 12px;">
<strong>${c.case_type === 'inpatient' ? '住院' : '门诊'}</strong><br/>
${c.latitude.toFixed(5)}, ${c.longitude.toFixed(5)}
</div>`,
{ 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 (
<div className="card">
<div className="flex items-center justify-between px-4 py-3 border-b border-border-light">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-primary" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.5 3l-.16.03L15 5.1 9 3 3.36 4.9c-.21.07-.36.25-.36.48V20.5c0 .28.22.5.5.5l.16-.03L9 18.9l6 2.1 5.64-1.9c.21-.07.36-.25.36-.48V3.5c0-.28-.22-.5-.5-.5zM15 19l-6-2.11V5l6 2.11V19z"/>
</svg>
<span className="font-medium text-[14px]"></span>
</div>
<div className="flex items-center gap-3">
<div className="flex gap-0.5 bg-bg-page p-0.5 rounded">
<button
onClick={() => handleToggle('grid')}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
viewMode === 'grid'
? 'bg-bg-card text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
<button
onClick={() => handleToggle('point')}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
viewMode === 'point'
? 'bg-bg-card text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
</div>
<div className="text-[11px] text-text-muted">
{viewMode === 'grid' ? '100×100m 网格' : '个体病例定位'}
</div>
</div>
</div>
<div className="relative" style={{ height }}>
<div ref={mapDivRef} className="w-full h-full overflow-hidden rounded-lg" />
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-4 py-3">
{viewMode === 'grid' ? (
<>
<div className="text-[11px] font-semibold text-text-secondary mb-2"></div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.high }} />
<span className="text-[11px] text-text-secondary"> (&gt;67%)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.medium }} />
<span className="text-[11px] text-text-secondary"> (33-67%)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.low }} />
<span className="text-[11px] text-text-secondary"> (&lt;33%)</span>
</div>
</div>
</>
) : (
<>
<div className="text-[11px] font-semibold text-text-secondary mb-2"></div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: '#DC2626' }} />
<span className="text-[11px] text-text-secondary"></span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: '#2563EB' }} />
<span className="text-[11px] text-text-secondary"></span>
</div>
</div>
</>
)}
</div>
<div className="absolute top-4 left-4 space-y-2 z-[1000]">
<div className="bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm px-3 py-2">
<div className="text-[11px] text-text-secondary">
{isLoading ? (
<span className="text-text-muted">...</span>
) : error ? (
<span className="text-danger">: {error}</span>
) : (
<>
<span className="font-semibold text-text-primary">{totalCases.toLocaleString()}</span>
<span className="mx-2 text-border">|</span>
{viewMode === 'grid' ? (
<>
<span className="font-semibold text-text-primary">{gridCount.toLocaleString()}</span>
</>
) : (
<>
<span className="font-semibold text-text-primary">{cases.length.toLocaleString()}</span>
</>
)}
</>
)}
</div>
</div>
{!isLoading && !error && viewMode === 'grid' && (
<div className="bg-success/10 backdrop-blur rounded-lg border border-success/30 shadow-sm px-3 py-2">
<div className="text-[11px] text-success font-medium">
</div>
</div>
)}
</div>
</div>
</div>
);
}
export const CaseMap = memo(CaseMapComponent);