Atomic foundation for the consensus-approved UX modernization (makes the platform URL-addressable, refresh-safe, and mobile-usable for hospital demos). - Migrate hand-rolled useState page switching → react-router v6 (routes.tsx, thin App.tsx auth gate, NavLink SideNav, lazy+Suspense per route) - Add responsive AppShell: persistent rail (lg:) ⇄ off-canvas drawer + hamburger (<lg); kills hardcoded ml-[200px]; usable at 375px - Add ui primitive kit (Skeleton/LoadingState/EmptyState/Card/Panel/Segmented) - Sweep all raw "加载中..." text loaders → skeleton primitives (G4) - Centralize test ids (utils/testids.ts); rewrite e2e for URL nav (17/17 pass: deep-link, refresh-preserves-page, back, 375px drawer/no-scroll) - Harden DemographicAnalysis against API shape mismatch (defensive normalize) - gitignore playwright-report/ and test-results/ Gates: tsc --noEmit 0 · pnpm build ok · e2e 17/17 · grep 加载中 zero outside ui/ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
378 lines
13 KiB
TypeScript
378 lines
13 KiB
TypeScript
import { memo, useEffect, useRef, useState, useCallback } from 'react';
|
||
import { Skeleton } from '@/components/ui';
|
||
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">高风险 (>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">低风险 (<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 ? (
|
||
<Skeleton className="h-3 w-20 inline-block align-middle" />
|
||
) : 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);
|