Files
CA/frontend/src/components/CaseLocationMap.tsx
Akiba So 8ddd8e87bb feat: add reports center, admin drill-down, disease filter + bug fixes + perf optimization
Frontend features:
- 报表中心 (ReportsCenter): list/detail views, diagnosis breakdown chart, CSV export
- 多级行政下钻 (AdminBreadcrumb): 湖北省→武汉市→区→街道 hierarchical drill-down
- 按病种筛选 (DiseaseFilter): multi-select diagnosis filter on monitoring + reports pages

Backend:
- Add /forecast/{days} endpoint, diagnosis filter params on cases endpoints
- Add /streets aggregation endpoint, enrich reports with real case data
- Extract shared case_loader module

Bug fixes (14):
- Fix missing /risk/forecast route (404), historyApi pointing to non-existent router
- Fix min_risk filter silently ignored in insights/hotspots
- Fix type mismatches: CaseTrendResponse, CaseStatsResponse shapes
- Fix silent .catch(() => {}) swallowing errors, fetchAlerts not clearing stale state
- Fix lru_cache caching exceptions, generateReport used cachedGet for write op
- Fix missing useEffect deps in Insights, DistrictComparison, ReportsCenter

Performance (9):
- Zustand selectors across 9 components (eliminate re-render cascades)
- Fix districtCases.sort() mutating store state, inline IIFE → memo'd component
- CaseLocationMap: React.memo, race protection, correct deps
- AlertCard: stable callbacks, TimelinePlayer: useMemo, TopNav: clock isolation
- SideNav: modules array to module scope, DiseaseFilter: memoized filter
2026-06-08 18:40:08 +08:00

132 lines
4.0 KiB
TypeScript

import { useEffect, useRef, useState, memo } from 'react';
import L from 'leaflet';
import { geocodedApi } from '@/services/api';
import type { GeocodedCase } from '@/types';
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
interface CaseLocationMapProps {
height?: string;
district?: string | null;
street?: string | null;
}
function CaseLocationMapComponent({ height = '400px', district = null, street = null }: CaseLocationMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(null);
const cancelledRef = useRef(false);
const [isLoading, setIsLoading] = useState(true);
const [caseCount, setCaseCount] = useState(0);
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
setIsLoading(true);
cancelledRef.current = false;
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: '&copy; OpenStreetMap',
maxZoom: 18,
}).addTo(map);
mapInstanceRef.current = map;
layerRef.current = L.layerGroup().addTo(map);
// Fetch case locations
geocodedApi.getGeocoded({ limit: 5000, district: district || undefined })
.then((data) => {
if (cancelledRef.current) return;
const cases: GeocodedCase[] = data.cases || [];
const layer = layerRef.current;
if (!layer) return;
layer.clearLayers();
// Deduplicate by case_id to avoid overlapping markers
const seen = new Set<string>();
let unique: GeocodedCase[] = [];
for (const c of cases) {
if (!seen.has(c.case_id)) {
seen.add(c.case_id);
unique.push(c);
}
}
// Client-side street filtering
if (street) {
unique = unique.filter((c) => c.street === street);
}
if (cancelledRef.current) return;
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(() => {
if (!cancelledRef.current) setIsLoading(false);
});
return () => {
cancelledRef.current = true;
map.remove();
mapInstanceRef.current = null;
};
}, [district, street]);
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>
);
}
export const CaseLocationMap = memo(CaseLocationMapComponent);