feat: add analysis pages and raster risk map
Ship a new app version with broader analytics, restructured dashboards, and a server-rendered risk map. Frontend: - Add Overview, Demographic, Disease, and Environmental Health analysis pages - Add AnomalyMarkers, CalendarHeatmap, and MetricHeatmapTable components - Rebuild Alerts map onto server-rendered raster risk tiles; expand Monitoring, Trend, and District Comparison views - Extend API client, stores, and TypeScript types Backend: - Add environment router (pollutants, lag correlations) - Add risk_raster util serving XYZ 100m risk tiles - Expand cases endpoints (demographics, seasonality, diagnoses) and insights; harden auth and file-based loaders Data & tooling: - Add processed outpatient/inpatient/combined case parquet (LFS) - Add nested CLAUDE.md guides, pyrightconfig, and test updates
This commit is contained in:
@@ -1,15 +1,18 @@
|
||||
import { useEffect, useRef, useState, useCallback, memo } from 'react';
|
||||
import L from 'leaflet';
|
||||
import { useRiskStore } from '@/stores';
|
||||
import { LodGridLayer } from '@/components/LodGridLayer';
|
||||
import { GridStatsOverlay } from '@/components/GridStatsOverlay';
|
||||
import { useLodGrid } from '@/hooks/useLodGrid';
|
||||
import type { Alert, GridRisk } from '@/types';
|
||||
import { riskApi } from '@/services/api';
|
||||
import type { RiskGridStats } from '@/services/api';
|
||||
import type { Alert } from '@/types';
|
||||
|
||||
export interface CellInfo {
|
||||
lat: number;
|
||||
lon: number;
|
||||
risk: number;
|
||||
grid_id: string;
|
||||
risk_1d: number;
|
||||
risk_3d: number;
|
||||
risk_7d: number;
|
||||
nearestAlertId: string | null;
|
||||
nearestAlertDist: number;
|
||||
}
|
||||
@@ -22,16 +25,16 @@ interface AlertMapProps {
|
||||
showAlertMarkers?: boolean;
|
||||
showGrid?: boolean;
|
||||
filteredAlerts?: Alert[];
|
||||
riskRange?: [number, number];
|
||||
isFullscreen?: boolean;
|
||||
}
|
||||
|
||||
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
|
||||
const GRID_OPACITY = 0.72;
|
||||
|
||||
const RISK_COLORS: [number, number, string][] = [
|
||||
[0.0, 0.2, '#22c55e'],
|
||||
[0.2, 0.4, '#3b82f6'],
|
||||
[0.4, 0.6, '#eab308'],
|
||||
// Mirrors the server-side colormap in backend/utils/risk_raster.py.
|
||||
const RISK_LEGEND: [number, number, string][] = [
|
||||
[0.25, 0.4, '#38b000'],
|
||||
[0.4, 0.6, '#facc15'],
|
||||
[0.6, 0.8, '#f97316'],
|
||||
[0.8, 1.0, '#ef4444'],
|
||||
];
|
||||
@@ -40,12 +43,9 @@ function getRiskLabel(value: number): string {
|
||||
if (value >= 0.8) return '高风险';
|
||||
if (value >= 0.6) return '中高';
|
||||
if (value >= 0.4) return '中风险';
|
||||
if (value >= 0.2) return '中低';
|
||||
return '低风险';
|
||||
}
|
||||
|
||||
const EMPTY_GRIDS: GridRisk[] = [];
|
||||
|
||||
function AlertMapComponent({
|
||||
selectedGridId,
|
||||
onGridClick,
|
||||
@@ -54,26 +54,31 @@ function AlertMapComponent({
|
||||
showAlertMarkers = true,
|
||||
showGrid = true,
|
||||
filteredAlerts = [],
|
||||
riskRange,
|
||||
isFullscreen = false,
|
||||
}: AlertMapProps) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||
const riskTileRef = useRef<L.TileLayer | null>(null);
|
||||
const alertLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const markerMapRef = useRef<Map<string, L.CircleMarker>>(new Map());
|
||||
const selectedMarkerRef = useRef<L.Rectangle | null>(null);
|
||||
const clickHandlerRef = useRef(onGridClick);
|
||||
const [currentZoom, setCurrentZoom] = useState(10);
|
||||
const cellInfoRef = useRef(onCellInfo);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
// Latest inputs the once-subscribed map handlers read, so we never have to
|
||||
// re-subscribe (and tear down listeners) when prop/callback identities change.
|
||||
const inputsRef = useRef({ filteredAlerts, showAlertMarkers, forecastDay });
|
||||
|
||||
const grids = useRiskStore((s) => s.grids ?? EMPTY_GRIDS);
|
||||
|
||||
// LOD grid data for stats overlay
|
||||
const { count, avgRisk, maxRisk, loading } = useLodGrid(currentZoom, forecastDay);
|
||||
const [gridStats, setGridStats] = useState<RiskGridStats | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
|
||||
useEffect(() => { clickHandlerRef.current = onGridClick; }, [onGridClick]);
|
||||
useEffect(() => { cellInfoRef.current = onCellInfo; }, [onCellInfo]);
|
||||
useEffect(() => {
|
||||
clickHandlerRef.current = onGridClick;
|
||||
}, [onGridClick]);
|
||||
inputsRef.current = { filteredAlerts, showAlertMarkers, forecastDay };
|
||||
}, [filteredAlerts, showAlertMarkers, forecastDay]);
|
||||
|
||||
// Initialize map
|
||||
// --- Initialize map once ---
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
@@ -88,63 +93,137 @@ function AlertMapComponent({
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
map.on('zoomend', () => {
|
||||
setCurrentZoom(map.getZoom());
|
||||
// Full-Wuhan 100m risk grid as raster tiles. The browser only fetches PNGs
|
||||
// (Leaflet caches them per z/x/y); LOD is inherent in the tile pyramid.
|
||||
const riskTiles = L.tileLayer(riskApi.tileUrlTemplate(forecastDay), {
|
||||
opacity: showGrid ? GRID_OPACITY : 0,
|
||||
maxNativeZoom: 16,
|
||||
maxZoom: 19,
|
||||
updateWhenZooming: false,
|
||||
keepBuffer: 2,
|
||||
zIndex: 200,
|
||||
}).addTo(map);
|
||||
riskTileRef.current = riskTiles;
|
||||
|
||||
const alertLayer = L.layerGroup().addTo(map);
|
||||
alertLayerRef.current = alertLayer;
|
||||
|
||||
// Click-to-inspect: query the 100m cell under the cursor. Clicks on alert
|
||||
// markers are consumed by the marker handler and never reach this.
|
||||
map.on('click', async (e: L.LeafletMouseEvent) => {
|
||||
const { lat, lng } = e.latlng;
|
||||
const { filteredAlerts: alerts, forecastDay: day } = inputsRef.current;
|
||||
try {
|
||||
const cell = await riskApi.getCell(lat, lng, day);
|
||||
// Nearest alert (squared degree distance — cheap, no sqrt).
|
||||
let nearestId: string | null = null;
|
||||
let minSq = Infinity;
|
||||
for (const a of alerts) {
|
||||
const dx = a.latitude - lat;
|
||||
const dy = a.longitude - lng;
|
||||
const d = dx * dx + dy * dy;
|
||||
if (d < minSq) { minSq = d; nearestId = a.grid_id; }
|
||||
}
|
||||
const nearestDist = Math.sqrt(minSq);
|
||||
if (nearestId && nearestDist < 0.01) {
|
||||
clickHandlerRef.current(nearestId);
|
||||
} else if (cellInfoRef.current) {
|
||||
cellInfoRef.current({
|
||||
lat, lon: lng,
|
||||
risk: cell.risk_value,
|
||||
grid_id: cell.grid_id,
|
||||
risk_1d: cell.risk_1d,
|
||||
risk_3d: cell.risk_3d,
|
||||
risk_7d: cell.risk_7d,
|
||||
nearestAlertId: nearestId,
|
||||
nearestAlertDist: nearestDist,
|
||||
});
|
||||
}
|
||||
} catch { /* transient fetch error — ignore the click */ }
|
||||
});
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
mapInstanceRef.current?.invalidateSize({ animate: false });
|
||||
});
|
||||
resizeObserver.observe(mapRef.current);
|
||||
resizeObserverRef.current = resizeObserver;
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
markerMapRef.current.clear();
|
||||
alertLayerRef.current = null;
|
||||
riskTileRef.current = null;
|
||||
map.remove();
|
||||
mapInstanceRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Render alert markers overlay
|
||||
// --- Update risk tiles + stats when the forecast horizon changes ---
|
||||
useEffect(() => {
|
||||
const layer = riskTileRef.current;
|
||||
if (layer) layer.setUrl(riskApi.tileUrlTemplate(forecastDay));
|
||||
|
||||
let cancelled = false;
|
||||
setStatsLoading(true);
|
||||
riskApi.getGridStats(forecastDay)
|
||||
.then((s) => { if (!cancelled) setGridStats(s); })
|
||||
.catch(() => { if (!cancelled) setGridStats(null); })
|
||||
.finally(() => { if (!cancelled) setStatsLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [forecastDay]);
|
||||
|
||||
// --- Toggle grid visibility without rebuilding tiles ---
|
||||
useEffect(() => {
|
||||
riskTileRef.current?.setOpacity(showGrid ? GRID_OPACITY : 0);
|
||||
}, [showGrid]);
|
||||
|
||||
// --- Render alert markers via diffing against a persistent layer group ---
|
||||
const renderAlertMarkers = useCallback(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map) return;
|
||||
const layer = alertLayerRef.current;
|
||||
if (!map || !layer) return;
|
||||
|
||||
if (alertLayerRef.current) {
|
||||
try { map.removeLayer(alertLayerRef.current); } catch { /* ok */ }
|
||||
alertLayerRef.current = null;
|
||||
const markerMap = markerMapRef.current;
|
||||
const { filteredAlerts: alerts, showAlertMarkers: showMarkers } = inputsRef.current;
|
||||
|
||||
if (!showMarkers || !alerts || alerts.length === 0) {
|
||||
if (markerMap.size > 0) { layer.clearLayers(); markerMap.clear(); }
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showAlertMarkers || !filteredAlerts || filteredAlerts.length === 0) return;
|
||||
|
||||
const layer = L.layerGroup();
|
||||
const mapBounds = map.getBounds();
|
||||
const b = map.getBounds();
|
||||
const south = b.getSouth(), north = b.getNorth(), west = b.getWest(), east = b.getEast();
|
||||
const maxMarkers = 500;
|
||||
const step = Math.max(1, Math.floor(filteredAlerts.length / maxMarkers));
|
||||
const step = Math.max(1, Math.floor(alerts.length / maxMarkers));
|
||||
|
||||
for (let i = 0; i < filteredAlerts.length; i += step) {
|
||||
const alert = filteredAlerts[i];
|
||||
const desired = new Map<string, Alert>();
|
||||
for (let i = 0; i < alerts.length; i += step) {
|
||||
const alert = alerts[i];
|
||||
if (!alert.latitude || !alert.longitude) continue;
|
||||
if (alert.latitude < south || alert.latitude > north ||
|
||||
alert.longitude < west || alert.longitude > east) continue;
|
||||
const key = alert.grid_id || `${alert.latitude},${alert.longitude},${i}`;
|
||||
desired.set(key, alert);
|
||||
}
|
||||
|
||||
// Skip if outside viewport
|
||||
if (
|
||||
alert.latitude < mapBounds.getSouth() ||
|
||||
alert.latitude > mapBounds.getNorth() ||
|
||||
alert.longitude < mapBounds.getWest() ||
|
||||
alert.longitude > mapBounds.getEast()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const [key, marker] of markerMap) {
|
||||
if (!desired.has(key)) { layer.removeLayer(marker); markerMap.delete(key); }
|
||||
}
|
||||
|
||||
for (const [key, alert] of desired) {
|
||||
if (markerMap.has(key)) continue;
|
||||
const isP1 = alert.priority === 'P1';
|
||||
|
||||
const marker = L.circleMarker(
|
||||
[alert.latitude, alert.longitude],
|
||||
{
|
||||
radius: isP1 ? 6 : 4,
|
||||
fillColor: isP1 ? '#ef4444' : '#f97316',
|
||||
fillOpacity: 0.7,
|
||||
color: isP1 ? '#ef4444' : '#f97316',
|
||||
weight: 2,
|
||||
dashArray: isP1 ? undefined : '4 2',
|
||||
}
|
||||
);
|
||||
|
||||
const marker = L.circleMarker([alert.latitude, alert.longitude], {
|
||||
radius: isP1 ? 6 : 4,
|
||||
fillColor: isP1 ? '#ef4444' : '#f97316',
|
||||
fillOpacity: 0.7,
|
||||
color: isP1 ? '#ef4444' : '#f97316',
|
||||
weight: 2,
|
||||
dashArray: isP1 ? undefined : '4 2',
|
||||
});
|
||||
marker.bindTooltip(
|
||||
`<div style="font-size:12px;">
|
||||
<strong>${alert.priority}</strong> · ${(alert.risk_value * 100).toFixed(0)}%<br/>
|
||||
@@ -152,106 +231,60 @@ function AlertMapComponent({
|
||||
</div>`,
|
||||
{ direction: 'top', offset: [0, -5] }
|
||||
);
|
||||
|
||||
marker.on('click', () => {
|
||||
if (alert.grid_id) clickHandlerRef.current(alert.grid_id);
|
||||
});
|
||||
|
||||
const gridId = alert.grid_id;
|
||||
marker.on('click', () => { if (gridId) clickHandlerRef.current(gridId); });
|
||||
marker.addTo(layer);
|
||||
markerMap.set(key, marker);
|
||||
}
|
||||
}, []);
|
||||
|
||||
layer.addTo(map);
|
||||
alertLayerRef.current = layer;
|
||||
}, [filteredAlerts, showAlertMarkers]);
|
||||
|
||||
// Re-render alert markers when data changes
|
||||
useEffect(() => {
|
||||
renderAlertMarkers();
|
||||
}, [renderAlertMarkers]);
|
||||
}, [filteredAlerts, showAlertMarkers, renderAlertMarkers]);
|
||||
|
||||
// Also re-render on map zoom/pan
|
||||
// Re-render markers on pan/zoom, throttled, subscribed once per map instance.
|
||||
useEffect(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const handleMove = () => renderAlertMarkers();
|
||||
let throttle: ReturnType<typeof setTimeout> | null = null;
|
||||
const handleMove = () => {
|
||||
if (throttle) return;
|
||||
throttle = setTimeout(() => { throttle = null; renderAlertMarkers(); }, 150);
|
||||
};
|
||||
map.on('moveend', handleMove);
|
||||
return () => { map.off('moveend', handleMove); };
|
||||
return () => {
|
||||
map.off('moveend', handleMove);
|
||||
if (throttle) clearTimeout(throttle);
|
||||
};
|
||||
}, [renderAlertMarkers]);
|
||||
|
||||
// Selected grid highlight
|
||||
// --- Selected alert highlight (located from the alert list, no grid scan) ---
|
||||
useEffect(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map) return;
|
||||
|
||||
if (selectedMarkerRef.current) {
|
||||
try { map.removeLayer(selectedMarkerRef.current); } catch { /* ok */ }
|
||||
selectedMarkerRef.current = null;
|
||||
}
|
||||
if (!selectedGridId) return;
|
||||
const alert = filteredAlerts.find((a) => a.grid_id === selectedGridId);
|
||||
if (!alert) return;
|
||||
const latHalf = 0.00045, lonHalf = 0.00052;
|
||||
const rect = L.rectangle(
|
||||
[[alert.latitude - latHalf, alert.longitude - lonHalf],
|
||||
[alert.latitude + latHalf, alert.longitude + lonHalf]],
|
||||
{ fillColor: '#3b82f6', fillOpacity: 0.3, color: '#3b82f6', weight: 3 }
|
||||
).addTo(map);
|
||||
selectedMarkerRef.current = rect;
|
||||
map.flyTo([alert.latitude, alert.longitude], Math.max(map.getZoom(), 13), { duration: 0.5 });
|
||||
}, [selectedGridId, filteredAlerts]);
|
||||
|
||||
if (selectedGridId) {
|
||||
let grid = grids.find((g) => g.grid_id === selectedGridId);
|
||||
if (!grid) {
|
||||
const selectedAlertObj = filteredAlerts.find((a) => a.grid_id === selectedGridId);
|
||||
if (selectedAlertObj) {
|
||||
grid = grids.find((g) =>
|
||||
Math.abs(g.latitude - selectedAlertObj.latitude) < 0.001 &&
|
||||
Math.abs(g.longitude - selectedAlertObj.longitude) < 0.001
|
||||
);
|
||||
}
|
||||
}
|
||||
if (grid) {
|
||||
const latHalf = 0.00045;
|
||||
const lonHalf = 0.00052;
|
||||
const marker = L.rectangle(
|
||||
[
|
||||
[grid.latitude - latHalf, grid.longitude - lonHalf],
|
||||
[grid.latitude + latHalf, grid.longitude + lonHalf],
|
||||
],
|
||||
{
|
||||
fillColor: '#3b82f6',
|
||||
fillOpacity: 0.3,
|
||||
color: '#3b82f6',
|
||||
weight: 3,
|
||||
}
|
||||
).addTo(map);
|
||||
selectedMarkerRef.current = marker;
|
||||
|
||||
map.flyTo([grid.latitude, grid.longitude], Math.max(map.getZoom(), 12), { duration: 0.5 });
|
||||
}
|
||||
}
|
||||
}, [selectedGridId, grids]);
|
||||
|
||||
// Handle LOD grid cell click → find nearest alert
|
||||
const handleCellClick = useCallback(
|
||||
(lat: number, lon: number, risk: number) => {
|
||||
let nearestId: string | null = null;
|
||||
let minDist = Infinity;
|
||||
|
||||
if (filteredAlerts) {
|
||||
for (const a of filteredAlerts) {
|
||||
const d = Math.sqrt((a.latitude - lat) ** 2 + (a.longitude - lon) ** 2);
|
||||
if (d < minDist) {
|
||||
minDist = d;
|
||||
nearestId = a.grid_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nearestId && minDist < 0.01) {
|
||||
clickHandlerRef.current(nearestId);
|
||||
} else if (onCellInfo) {
|
||||
onCellInfo({ lat, lon, risk, nearestAlertId: nearestId, nearestAlertDist: minDist });
|
||||
}
|
||||
},
|
||||
[filteredAlerts, onCellInfo]
|
||||
);
|
||||
|
||||
// Invalidate Leaflet size after fullscreen toggle
|
||||
// --- Invalidate size after fullscreen toggle (CSS transition ~200ms) ---
|
||||
useEffect(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map) return;
|
||||
const timer = setTimeout(() => map.invalidateSize({ animate: true }), 100);
|
||||
map.invalidateSize({ animate: false });
|
||||
const timer = setTimeout(() => map.invalidateSize({ animate: true }), 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isFullscreen]);
|
||||
|
||||
@@ -261,29 +294,18 @@ function AlertMapComponent({
|
||||
<div className="relative">
|
||||
<div ref={mapRef} className="w-full rounded-lg overflow-hidden" style={{ height: containerHeight }} />
|
||||
|
||||
{/* LOD Grid Layer */}
|
||||
<LodGridLayer
|
||||
map={mapInstanceRef.current}
|
||||
forecastDay={forecastDay}
|
||||
visible={showGrid}
|
||||
riskRange={riskRange}
|
||||
onCellClick={handleCellClick}
|
||||
/>
|
||||
|
||||
{/* Stats overlay */}
|
||||
<GridStatsOverlay
|
||||
count={count}
|
||||
avgRisk={avgRisk}
|
||||
maxRisk={maxRisk}
|
||||
loading={loading}
|
||||
count={gridStats?.cell_count ?? 0}
|
||||
avgRisk={gridStats?.avg_risk ?? 0}
|
||||
maxRisk={gridStats?.max_risk ?? 0}
|
||||
loading={statsLoading}
|
||||
forecastDay={forecastDay}
|
||||
/>
|
||||
|
||||
{/* Legend */}
|
||||
<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">
|
||||
<div className="text-[11px] font-semibold text-text-secondary mb-2">风险等级</div>
|
||||
<div className="text-[11px] font-semibold text-text-secondary mb-2">风险等级 (100m 网格)</div>
|
||||
<div className="space-y-1.5">
|
||||
{RISK_COLORS.slice().reverse().map(([min, max, color]) => (
|
||||
{RISK_LEGEND.slice().reverse().map(([min, max, color]) => (
|
||||
<div key={color} className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: color }} />
|
||||
<span className="text-[11px] text-text-secondary">
|
||||
@@ -291,6 +313,10 @@ function AlertMapComponent({
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border border-border-light bg-transparent" />
|
||||
<span className="text-[11px] text-text-muted"><25% 不显示</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
76
frontend/src/components/AnomalyMarkers.tsx
Normal file
76
frontend/src/components/AnomalyMarkers.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
|
||||
interface AnomalyMarkersProps {
|
||||
anomalies: Array<{ date: string; value: number; description: string }>;
|
||||
}
|
||||
|
||||
export const AnomalyMarkers = React.memo(function AnomalyMarkers({
|
||||
anomalies,
|
||||
}: AnomalyMarkersProps) {
|
||||
if (anomalies.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none" aria-hidden>
|
||||
{anomalies.map((a, i) => {
|
||||
// Parse date to position marker horizontally
|
||||
// This requires the parent to position relative; markers are
|
||||
// positioned via CSS custom properties set by the consumer.
|
||||
// For a data-driven overlay, we expose the anomalies as a
|
||||
// data list that the chart-library integration uses.
|
||||
return (
|
||||
<div
|
||||
key={`${a.date}-${i}`}
|
||||
data-anomaly-date={a.date}
|
||||
data-anomaly-value={a.value}
|
||||
title={`${a.date}: ${a.value} — ${a.description}`}
|
||||
className="absolute w-2.5 h-2.5 rounded-full bg-red-500 border border-red-300 pointer-events-auto cursor-help"
|
||||
style={{
|
||||
// Positioned via CSS custom properties set by parent
|
||||
left: `var(--anomaly-x-${i})`,
|
||||
top: `var(--anomaly-y-${i})`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to compute anomaly positions as percentages within a chart area.
|
||||
*
|
||||
* Usage inside a Recharts chart component:
|
||||
* - Import { computeAnomalyPositions } from './AnomalyMarkers'
|
||||
* - Call with bounds: computeAnomalyPositions(chartData, anomalies, dateKey, valueKey, xDomain, yDomain)
|
||||
* - Apply returned style vars on parent container
|
||||
*/
|
||||
export function computeAnomalyPositions(
|
||||
allDates: string[],
|
||||
anomalies: Array<{ date: string; value: number }>,
|
||||
xMin: number,
|
||||
xMax: number,
|
||||
yMin: number,
|
||||
yMax: number,
|
||||
): Record<string, string> {
|
||||
const vars: Record<string, string> = {};
|
||||
const xRange = xMax - xMin || 1;
|
||||
const yRange = yMax - yMin || 1;
|
||||
|
||||
// Build a date→index map once for O(1) lookups instead of indexOf per anomaly.
|
||||
const dateIndex = new Map<string, number>();
|
||||
for (let i = 0; i < allDates.length; i++) {
|
||||
if (!dateIndex.has(allDates[i])) dateIndex.set(allDates[i], i);
|
||||
}
|
||||
|
||||
for (let i = 0; i < anomalies.length; i++) {
|
||||
const a = anomalies[i];
|
||||
const dateIdx = dateIndex.get(a.date) ?? -1;
|
||||
if (dateIdx === -1) continue;
|
||||
const xPct = ((dateIdx - xMin) / xRange) * 100;
|
||||
const yPct = 100 - ((a.value - yMin) / yRange) * 100;
|
||||
vars[`--anomaly-x-${i}`] = `${xPct}%`;
|
||||
vars[`--anomaly-y-${i}`] = `${yPct}%`;
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
38
frontend/src/components/CLAUDE.md
Normal file
38
frontend/src/components/CLAUDE.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Components — Reusable UI
|
||||
|
||||
## Conventions
|
||||
|
||||
- One component per file, PascalCase, default export
|
||||
- Props interface: `{ComponentName}Props`, typed strictly (no `any`)
|
||||
- Wrap pure display components in `memo()` for render optimization
|
||||
- All styling via Tailwind utility classes — no CSS modules, no inline styles
|
||||
|
||||
## Component Types
|
||||
|
||||
**Map components** (`*Map.tsx`) — Leaflet-based maps:
|
||||
- Use `react-leaflet` / direct Leaflet manipulation via `useRef`
|
||||
- Risk coloring: centralized `RISK_COLORS` and `RISK_LABELS` constants
|
||||
- Coordinate system: `[lat, lng]` (Leaflet convention, NOT `[lng, lat]`)
|
||||
|
||||
**Chart components** (`*Chart*.tsx`) — Recharts:
|
||||
- Responsive containers with `width="100%" height={...}`
|
||||
|
||||
**Navigation** (`TopNav.tsx`, `SideNav.tsx`):
|
||||
- No data fetching — pure navigation/presentation
|
||||
|
||||
**Overlay/Utility** (`GridStatsOverlay`, `ErrorBanner`, `StatCard`, `TimelinePlayer`):
|
||||
- Small, focused, reusable across pages
|
||||
|
||||
## Data Flow
|
||||
|
||||
- Components receive data via props, never fetch directly
|
||||
- Callbacks passed as props: `onGridSelect`, `onClosePanel`, `onForecastChange`
|
||||
- Complex stateful behavior extracted to custom hooks (e.g., `useTimelineStore`)
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Don't fetch data in components — receive via props or store hooks
|
||||
- Don't create god components (>200 lines) — extract sub-components
|
||||
- Don't use `any` in prop types — use `unknown` and narrow
|
||||
- Don't pass Leaflet map instances between components — each map manages its own instance
|
||||
- Don't use CSS modules or inline styles — Tailwind only
|
||||
198
frontend/src/components/CalendarHeatmap.tsx
Normal file
198
frontend/src/components/CalendarHeatmap.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
interface CalendarHeatmapProps {
|
||||
data: Array<{ date: string; value: number }>;
|
||||
year: number;
|
||||
onDayClick?: (date: string) => void;
|
||||
}
|
||||
|
||||
function getColor(value: number): string {
|
||||
if (value < 50) return '#10B981';
|
||||
if (value < 100) return '#F59E0B';
|
||||
if (value < 150) return '#F97316';
|
||||
if (value < 200) return '#EF4444';
|
||||
return '#7C3AED';
|
||||
}
|
||||
|
||||
function getLabel(value: number): string {
|
||||
if (value < 50) return '优';
|
||||
if (value < 100) return '良';
|
||||
if (value < 150) return '轻度';
|
||||
if (value < 200) return '中度';
|
||||
return '重度';
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'1月', '2月', '3月', '4月', '5月', '6月',
|
||||
'7月', '8月', '9月', '10月', '11月', '12月',
|
||||
];
|
||||
|
||||
const DAY_NAMES = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
|
||||
export const CalendarHeatmap = React.memo(function CalendarHeatmap({
|
||||
data,
|
||||
year,
|
||||
onDayClick,
|
||||
}: CalendarHeatmapProps) {
|
||||
const dataMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const d of data) {
|
||||
map.set(d.date, d.value);
|
||||
}
|
||||
return map;
|
||||
}, [data]);
|
||||
|
||||
const months = useMemo(() => {
|
||||
const result: Array<{
|
||||
month: number;
|
||||
name: string;
|
||||
weeks: Array<Array<{ date: string; day: number; value: number | null }>>;
|
||||
}> = [];
|
||||
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const daysInMonth = new Date(year, m + 1, 0).getDate();
|
||||
const cells: Array<{ date: string; day: number; value: number | null }> = [];
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateObj = new Date(year, m, d);
|
||||
const dateStr = dateObj.toISOString().slice(0, 10);
|
||||
cells.push({
|
||||
date: dateStr,
|
||||
day: d,
|
||||
value: dataMap.get(dateStr) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate start day of week (1=Monday, 0=Sunday → JS getDay: 0=Sun)
|
||||
const firstDay = new Date(year, m, 1).getDay();
|
||||
// Convert JS Sunday=0 to Monday=0
|
||||
const startOffset = firstDay === 0 ? 6 : firstDay - 1;
|
||||
|
||||
// Pad beginning with empty cells
|
||||
const padded: Array<{ date: string; day: number; value: number | null } | null> = [];
|
||||
for (let i = 0; i < startOffset; i++) {
|
||||
padded.push(null);
|
||||
}
|
||||
for (const cell of cells) {
|
||||
padded.push(cell);
|
||||
}
|
||||
|
||||
// Split into weeks of 7
|
||||
const weeks: Array<Array<{ date: string; day: number; value: number | null }>> = [];
|
||||
for (let i = 0; i < padded.length; i += 7) {
|
||||
const week = padded.slice(i, i + 7).filter(Boolean) as Array<{
|
||||
date: string;
|
||||
day: number;
|
||||
value: number | null;
|
||||
}>;
|
||||
if (week.length > 0) {
|
||||
weeks.push(week);
|
||||
}
|
||||
}
|
||||
|
||||
result.push({ month: m, name: MONTH_NAMES[m], weeks });
|
||||
}
|
||||
return result;
|
||||
}, [year, dataMap]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-gray-500">
|
||||
{[
|
||||
{ color: '#10B981', label: '优 <50' },
|
||||
{ color: '#F59E0B', label: '良 50-100' },
|
||||
{ color: '#F97316', label: '轻度 100-150' },
|
||||
{ color: '#EF4444', label: '中度 150-200' },
|
||||
{ color: '#7C3AED', label: '重度 ≥200' },
|
||||
{ color: '#E5E7EB', label: '无数据' },
|
||||
].map((item) => (
|
||||
<span key={item.label} className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-sm"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-12 gap-4">
|
||||
{months.map((month) => (
|
||||
<div key={month.month} className="flex flex-col items-center">
|
||||
<div className="text-[11px] font-medium text-gray-500 mb-1">
|
||||
{month.name}
|
||||
</div>
|
||||
{/* Day header row */}
|
||||
<div className="grid grid-cols-7 gap-px mb-0.5" style={{ width: 98 }}>
|
||||
{DAY_NAMES.map((d) => (
|
||||
<div
|
||||
key={d}
|
||||
className="text-[8px] text-gray-400 text-center leading-3 w-[14px] h-3"
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Weeks */}
|
||||
{month.weeks.map((week, wi) => (
|
||||
<div key={wi} className="flex gap-px">
|
||||
{Array.from({ length: 7 }).map((_, di) => {
|
||||
// Match by day-of-week index
|
||||
const dayOfWeekMap = [1, 2, 3, 4, 5, 6, 0]; // Mon=1..Sun=0
|
||||
const matchedCell = week.find(
|
||||
(c) => new Date(c.date).getDay() === dayOfWeekMap[di],
|
||||
);
|
||||
|
||||
if (!matchedCell) {
|
||||
return (
|
||||
<div
|
||||
key={di}
|
||||
className="w-[14px] h-[14px]"
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const bg =
|
||||
matchedCell.value === null
|
||||
? '#E5E7EB'
|
||||
: getColor(matchedCell.value);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={di}
|
||||
title={
|
||||
matchedCell.value !== null
|
||||
? `${matchedCell.date}: AQI ${matchedCell.value} (${getLabel(matchedCell.value)})`
|
||||
: `${matchedCell.date}: 无数据`
|
||||
}
|
||||
onClick={() => onDayClick?.(matchedCell.date)}
|
||||
className={`w-[14px] h-[14px] rounded-sm transition-transform hover:scale-125 ${
|
||||
onDayClick ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: bg }}
|
||||
role={onDayClick ? 'button' : undefined}
|
||||
tabIndex={onDayClick ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onDayClick
|
||||
? (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onDayClick(matchedCell.date);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -9,13 +9,15 @@ interface CaseLocationMapProps {
|
||||
height?: string;
|
||||
district?: string | null;
|
||||
street?: string | null;
|
||||
date?: string | null;
|
||||
}
|
||||
|
||||
function CaseLocationMapComponent({ height = '400px', district = null, street = null }: CaseLocationMapProps) {
|
||||
function CaseLocationMapComponent({ height = '400px', district = null, street = null, date = 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 resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [caseCount, setCaseCount] = useState(0);
|
||||
|
||||
@@ -39,8 +41,19 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
|
||||
mapInstanceRef.current = map;
|
||||
layerRef.current = L.layerGroup().addTo(map);
|
||||
|
||||
// ResizeObserver: auto-invalidate when container size changes (window resize, layout shifts)
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.invalidateSize({ animate: false });
|
||||
}
|
||||
});
|
||||
if (mapRef.current) {
|
||||
resizeObserver.observe(mapRef.current);
|
||||
}
|
||||
resizeObserverRef.current = resizeObserver;
|
||||
|
||||
// Fetch case locations
|
||||
geocodedApi.getGeocoded({ limit: 5000, district: district || undefined })
|
||||
geocodedApi.getGeocoded({ limit: 5000, district: district || undefined, date: date || undefined })
|
||||
.then((data) => {
|
||||
if (cancelledRef.current) return;
|
||||
const cases: GeocodedCase[] = data.cases || [];
|
||||
@@ -104,10 +117,14 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
map.remove();
|
||||
mapInstanceRef.current = null;
|
||||
};
|
||||
}, [district, street]);
|
||||
}, [district, street, date]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
|
||||
@@ -18,9 +18,9 @@ for (const [, , color] of RISK_COLORS) {
|
||||
|
||||
function getRiskColor(value: number): string {
|
||||
for (const [min, max, color] of RISK_COLORS) {
|
||||
if (value >= min && value <= max) return color;
|
||||
if (value >= min && value < max) return color;
|
||||
}
|
||||
return '#22c55e';
|
||||
return '#ef4444';
|
||||
}
|
||||
|
||||
// 100m grid step in degrees
|
||||
@@ -116,6 +116,7 @@ export function LodGridLayer({
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.pointerEvents = 'none';
|
||||
canvas.style.display = visibleRef.current ? '' : 'none';
|
||||
pane.appendChild(canvas);
|
||||
canvasRef.current = canvas;
|
||||
|
||||
@@ -173,8 +174,8 @@ export function LodGridLayer({
|
||||
canvas.style.transform = '';
|
||||
drawnOriginRef.current = null;
|
||||
|
||||
if (!visibleRef.current) return;
|
||||
|
||||
// Visibility is controlled via canvas CSS display (see visible effect),
|
||||
// so we still draw pixels even when hidden to keep them ready on re-show.
|
||||
const currentGrids = gridsRef.current;
|
||||
if (!currentGrids || currentGrids.length === 0) return;
|
||||
|
||||
@@ -354,13 +355,21 @@ export function LodGridLayer({
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
// Trigger redraw when data changes
|
||||
// Trigger redraw when data/geometry-affecting inputs change.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas && (canvas as any).__lodRedraw) {
|
||||
(canvas as any).__lodRedraw();
|
||||
}
|
||||
}, [grids, forecastDay, riskRange, visible]);
|
||||
}, [grids, forecastDay, riskRange]);
|
||||
|
||||
// Visibility toggle: hide/show via CSS instead of a full geometry redraw.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.style.display = visible ? '' : 'none';
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
123
frontend/src/components/MetricHeatmapTable.tsx
Normal file
123
frontend/src/components/MetricHeatmapTable.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
interface MetricHeatmapTableProps {
|
||||
rows: string[];
|
||||
columns: Array<{ key: string; label: string }>;
|
||||
data: Record<string, Record<string, number>>;
|
||||
onSort?: (column: string) => void;
|
||||
onCellClick?: (row: string, column: string) => void;
|
||||
}
|
||||
|
||||
function colorForValue(value: number, min: number, max: number): string {
|
||||
if (max - min === 0) return 'rgb(255, 255, 255)';
|
||||
const ratio = (value - min) / (max - min);
|
||||
// Green (low) → Yellow (mid) → Red (high)
|
||||
if (ratio <= 0.5) {
|
||||
const r = Math.round(ratio * 2 * 245);
|
||||
const g = 220;
|
||||
return `rgb(${r}, ${g}, 230)`;
|
||||
}
|
||||
const r = 245;
|
||||
const g = Math.round(220 - (ratio - 0.5) * 2 * 190);
|
||||
return `rgb(${r}, ${g}, 230)`;
|
||||
}
|
||||
|
||||
function textColorForValue(value: number, min: number, max: number): string {
|
||||
if (max - min === 0) return '#374151';
|
||||
const ratio = (value - min) / (max - min);
|
||||
return ratio > 0.6 ? '#7F1D1D' : '#374151';
|
||||
}
|
||||
|
||||
export const MetricHeatmapTable = React.memo(function MetricHeatmapTable({
|
||||
rows,
|
||||
columns,
|
||||
data,
|
||||
onSort,
|
||||
onCellClick,
|
||||
}: MetricHeatmapTableProps) {
|
||||
const columnStats = useMemo(() => {
|
||||
return columns.map((col) => {
|
||||
const values = rows
|
||||
.map((row) => data[row]?.[col.key])
|
||||
.filter((v): v is number => v !== undefined && v !== null);
|
||||
const min = values.length > 0 ? Math.min(...values) : 0;
|
||||
const max = values.length > 0 ? Math.max(...values) : 0;
|
||||
return { key: col.key, min, max };
|
||||
});
|
||||
}, [columns, rows, data]);
|
||||
|
||||
const colStatMap = useMemo(() => {
|
||||
const map = new Map<string, { min: number; max: number }>();
|
||||
for (const stat of columnStats) {
|
||||
map.set(stat.key, { min: stat.min, max: stat.max });
|
||||
}
|
||||
return map;
|
||||
}, [columnStats]);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto max-h-96">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="bg-gray-100 border border-gray-200 px-2 py-1.5 text-left font-medium text-gray-600 sticky left-0 z-20">
|
||||
|
||||
</th>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
onClick={() => onSort?.(col.key)}
|
||||
className={`bg-gray-100 border border-gray-200 px-2 py-1.5 text-center font-medium text-gray-600 ${
|
||||
onSort ? 'cursor-pointer hover:bg-gray-200 select-none' : ''
|
||||
}`}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row}>
|
||||
<td className="bg-white border border-gray-200 px-2 py-1 text-left font-medium text-gray-700 sticky left-0 z-10">
|
||||
{row}
|
||||
</td>
|
||||
{columns.map((col) => {
|
||||
const value = data[row]?.[col.key];
|
||||
const stats = colStatMap.get(col.key);
|
||||
const hasValue = value !== undefined && value !== null;
|
||||
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
onClick={() =>
|
||||
onCellClick && hasValue
|
||||
? onCellClick(row, col.key)
|
||||
: undefined
|
||||
}
|
||||
className={`border border-gray-200 px-2 py-1 text-center ${
|
||||
onCellClick && hasValue
|
||||
? 'cursor-pointer hover:ring-1 hover:ring-blue-400'
|
||||
: ''
|
||||
}`}
|
||||
style={
|
||||
hasValue && stats && stats.max > stats.min
|
||||
? {
|
||||
backgroundColor: colorForValue(value, stats.min, stats.max),
|
||||
color: textColorForValue(value, stats.min, stats.max),
|
||||
}
|
||||
: hasValue && stats && stats.max === stats.min
|
||||
? { backgroundColor: 'rgb(255, 255, 255)' }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{hasValue ? value.toLocaleString() : '-'}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -46,6 +46,22 @@ function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
|
||||
};
|
||||
}
|
||||
|
||||
// Mercator helpers (avoid per-cell latLngToContainerPoint) — mirrors LodGridLayer.
|
||||
function latToMercY(lat: number): number {
|
||||
return 128 - (256 * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360))) / (2 * Math.PI);
|
||||
}
|
||||
|
||||
function lonToMercX(lon: number): number {
|
||||
return ((lon + 180) / 360) * 256;
|
||||
}
|
||||
|
||||
function riskColorForValue(riskValue: number): string {
|
||||
if (riskValue >= 0.7) return RISK_COLORS.high;
|
||||
if (riskValue >= 0.5) return RISK_COLORS.medium_high;
|
||||
if (riskValue >= 0.3) return RISK_COLORS.medium_low;
|
||||
return RISK_COLORS.low;
|
||||
}
|
||||
|
||||
function RiskMapComponent(props: RiskMapProps) {
|
||||
const {
|
||||
grids,
|
||||
@@ -60,9 +76,14 @@ function RiskMapComponent(props: RiskMapProps) {
|
||||
|
||||
const mapDivRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<any>(null);
|
||||
const gridLayerRef = useRef<any>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const paneRef = useRef<HTMLElement | null>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
const redrawRef = useRef<() => void>(() => {});
|
||||
const zoomRef = useRef(9);
|
||||
const callbacksRef = useRef({ onGridSelect, onClosePanel, onFullscreen, onForecastChange });
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const gridMapRef = useRef<Map<string, GridRisk>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
callbacksRef.current = { onGridSelect, onClosePanel, onFullscreen, onForecastChange };
|
||||
@@ -70,6 +91,19 @@ function RiskMapComponent(props: RiskMapProps) {
|
||||
|
||||
const containerHeight = isFullscreen ? 'calc(100vh - 52px)' : '420px';
|
||||
|
||||
// Invalidate map size when container size changes (window resize, fullscreen, layout shifts)
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
// Fullscreen transition: wait for CSS transition to complete
|
||||
const timer = setTimeout(() => {
|
||||
map.invalidateSize({ animate: true });
|
||||
}, 150);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isFullscreen, containerHeight]);
|
||||
|
||||
const gridMap = useMemo(() => {
|
||||
const map = new Map<string, GridRisk>();
|
||||
grids.forEach((g) => {
|
||||
@@ -79,6 +113,13 @@ function RiskMapComponent(props: RiskMapProps) {
|
||||
return map;
|
||||
}, [grids]);
|
||||
|
||||
// Keep gridMap accessible to the canvas render fn (read via ref, no re-init).
|
||||
useEffect(() => {
|
||||
gridMapRef.current = gridMap;
|
||||
redrawRef.current();
|
||||
}, [gridMap]);
|
||||
|
||||
// Create the map + tile layer + canvas overlay + handlers ONCE.
|
||||
useEffect(() => {
|
||||
if (!mapDivRef.current || mapRef.current) return;
|
||||
|
||||
@@ -95,6 +136,139 @@ function RiskMapComponent(props: RiskMapProps) {
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
// Canvas overlay pane for batched grid rendering (replaces per-cell rectangles).
|
||||
const pane = map.createPane('risk-grid-pane');
|
||||
pane.style.zIndex = '450';
|
||||
pane.style.pointerEvents = 'none';
|
||||
paneRef.current = pane;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.top = '0';
|
||||
canvas.style.left = '0';
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.pointerEvents = 'none';
|
||||
pane.appendChild(canvas);
|
||||
canvasRef.current = canvas;
|
||||
|
||||
// ResizeObserver: auto-invalidate map size when container changes
|
||||
const resizeObserver = new ResizeObserver(
|
||||
debounce(() => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.invalidateSize({ animate: false });
|
||||
}
|
||||
}, 100)
|
||||
);
|
||||
if (mapDivRef.current) {
|
||||
resizeObserver.observe(mapDivRef.current);
|
||||
}
|
||||
resizeObserverRef.current = resizeObserver;
|
||||
|
||||
// Batched canvas render: group cells by color and fillRect on one canvas.
|
||||
function renderGridLayer() {
|
||||
if (!mapRef.current || !canvasRef.current) return;
|
||||
const map = mapRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
animFrameRef.current = requestAnimationFrame(() => {
|
||||
const container = map.getContainer();
|
||||
const w = container.clientWidth;
|
||||
const h = container.clientHeight;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = w + 'px';
|
||||
canvas.style.height = h + 'px';
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const zoom = map.getZoom();
|
||||
let cellSize: number;
|
||||
let step: number;
|
||||
if (zoom <= 8) { cellSize = 0.1; step = 10; }
|
||||
else if (zoom <= 10) { cellSize = 0.025; step = 4; }
|
||||
else if (zoom <= 12) { cellSize = 0.01; step = 2; }
|
||||
else { cellSize = 0.005; step = 2; }
|
||||
|
||||
const bounds = map.getBounds();
|
||||
const minLat = Math.max(bounds.getSouth(), WUHAN_BOUNDS.minLat);
|
||||
const maxLat = Math.min(bounds.getNorth(), WUHAN_BOUNDS.maxLat);
|
||||
const minLon = Math.max(bounds.getWest(), WUHAN_BOUNDS.minLon);
|
||||
const maxLon = Math.min(bounds.getEast(), WUHAN_BOUNDS.maxLon);
|
||||
|
||||
const latStart = Math.floor((minLat - WUHAN_BOUNDS.minLat) / cellSize) * cellSize + WUHAN_BOUNDS.minLat;
|
||||
const lonStart = Math.floor((minLon - WUHAN_BOUNDS.minLon) / cellSize) * cellSize + WUHAN_BOUNDS.minLon;
|
||||
|
||||
const currentGridMap = gridMapRef.current;
|
||||
if (currentGridMap.size > 5000) {
|
||||
console.warn(`[RiskMap] Data too dense: ${currentGridMap.size} grid cells, rendering may be slow`);
|
||||
}
|
||||
|
||||
const scale = 2 ** zoom;
|
||||
const origin = map.getPixelOrigin();
|
||||
const cellDeg = cellSize * step;
|
||||
|
||||
// Group cells by color to minimize fillStyle changes.
|
||||
const colorGroups: Record<string, { x: number; y: number; w: number; h: number }[]> = {};
|
||||
|
||||
let count = 0;
|
||||
const maxCount = 1500;
|
||||
for (let lat = latStart; lat < maxLat && count < maxCount; lat += cellDeg) {
|
||||
for (let lon = lonStart; lon < maxLon && count < maxCount; lon += cellDeg) {
|
||||
const key = `${lat.toFixed(4)}-${lon.toFixed(4)}`;
|
||||
const grid = currentGridMap.get(key);
|
||||
const riskValue = grid?.risk_value ?? 0.5;
|
||||
const color = riskColorForValue(riskValue);
|
||||
|
||||
const lx = lonToMercX(lon) * scale - origin.x;
|
||||
const rx = lonToMercX(lon + cellDeg) * scale - origin.x;
|
||||
const ty = latToMercY(lat + cellDeg) * scale - origin.y;
|
||||
const by = latToMercY(lat) * scale - origin.y;
|
||||
|
||||
if (!colorGroups[color]) colorGroups[color] = [];
|
||||
colorGroups[color].push({ x: lx, y: ty, w: rx - lx, h: by - ty });
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 0.6;
|
||||
for (const [color, cells] of Object.entries(colorGroups)) {
|
||||
ctx.fillStyle = color;
|
||||
for (const c of cells) {
|
||||
ctx.fillRect(c.x, c.y, c.w, c.h);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
});
|
||||
}
|
||||
|
||||
redrawRef.current = renderGridLayer;
|
||||
|
||||
// Single map-level click handler: nearest-cell lookup (replaces 1500 handlers).
|
||||
const handleMapClick = (e: L.LeafletMouseEvent) => {
|
||||
const currentGridMap = gridMapRef.current;
|
||||
if (currentGridMap.size === 0) return;
|
||||
const { lat, lng } = e.latlng;
|
||||
let nearestDist = Infinity;
|
||||
let nearestGrid: GridRisk | null = null;
|
||||
for (const grid of currentGridMap.values()) {
|
||||
const d = (grid.latitude - lat) ** 2 + (grid.longitude - lng) ** 2;
|
||||
if (d < nearestDist) {
|
||||
nearestDist = d;
|
||||
nearestGrid = grid;
|
||||
}
|
||||
}
|
||||
if (nearestGrid && nearestDist < 0.01 * 0.01) {
|
||||
callbacksRef.current.onGridSelect(nearestGrid.grid_id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoom = debounce(() => {
|
||||
zoomRef.current = map.getZoom();
|
||||
renderGridLayer();
|
||||
@@ -106,112 +280,27 @@ function RiskMapComponent(props: RiskMapProps) {
|
||||
|
||||
map.on('zoomend', handleZoom);
|
||||
map.on('moveend', handleMove);
|
||||
|
||||
function renderGridLayer() {
|
||||
if (!mapRef.current) return;
|
||||
const map = mapRef.current;
|
||||
|
||||
if (gridLayerRef.current) {
|
||||
try {
|
||||
map.removeLayer(gridLayerRef.current);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
gridLayerRef.current = null;
|
||||
}
|
||||
|
||||
const zoom = map.getZoom();
|
||||
let cellSize: number;
|
||||
let step: number;
|
||||
|
||||
if (zoom <= 8) {
|
||||
cellSize = 0.1;
|
||||
step = 10;
|
||||
} else if (zoom <= 10) {
|
||||
cellSize = 0.025;
|
||||
step = 4;
|
||||
} else if (zoom <= 12) {
|
||||
cellSize = 0.01;
|
||||
step = 2;
|
||||
} else {
|
||||
cellSize = 0.005;
|
||||
step = 2;
|
||||
}
|
||||
|
||||
const bounds = map.getBounds();
|
||||
const minLat = Math.max(bounds.getSouth(), WUHAN_BOUNDS.minLat);
|
||||
const maxLat = Math.min(bounds.getNorth(), WUHAN_BOUNDS.maxLat);
|
||||
const minLon = Math.max(bounds.getWest(), WUHAN_BOUNDS.minLon);
|
||||
const maxLon = Math.min(bounds.getEast(), WUHAN_BOUNDS.maxLon);
|
||||
|
||||
const latStart = Math.floor((minLat - WUHAN_BOUNDS.minLat) / cellSize) * cellSize + WUHAN_BOUNDS.minLat;
|
||||
const lonStart = Math.floor((minLon - WUHAN_BOUNDS.minLon) / cellSize) * cellSize + WUHAN_BOUNDS.minLon;
|
||||
|
||||
const gridLayer = L.layerGroup();
|
||||
const currentGridMap = gridMap;
|
||||
|
||||
let count = 0;
|
||||
const maxCount = 1500;
|
||||
|
||||
if (currentGridMap.size > 5000) {
|
||||
console.warn(`[RiskMap] Data too dense: ${currentGridMap.size} grid cells, rendering may be slow`);
|
||||
}
|
||||
|
||||
for (let lat = latStart; lat < maxLat && count < maxCount; lat += cellSize * step) {
|
||||
for (let lon = lonStart; lon < maxLon && count < maxCount; lon += cellSize * step) {
|
||||
const key = `${lat.toFixed(4)}-${lon.toFixed(4)}`;
|
||||
const grid = currentGridMap.get(key);
|
||||
|
||||
const riskValue = grid?.risk_value ?? 0.5;
|
||||
let riskLevel = 'medium';
|
||||
if (riskValue >= 0.7) riskLevel = 'high';
|
||||
else if (riskValue >= 0.5) riskLevel = 'medium_high';
|
||||
else if (riskValue >= 0.3) riskLevel = 'medium_low';
|
||||
else riskLevel = 'low';
|
||||
|
||||
const color = RISK_COLORS[riskLevel];
|
||||
|
||||
const rect = L.rectangle(
|
||||
[[lat, lon], [lat + cellSize * step, lon + cellSize * step]],
|
||||
{
|
||||
fillColor: color,
|
||||
fillOpacity: 0.6,
|
||||
color: 'transparent',
|
||||
weight: 0,
|
||||
}
|
||||
);
|
||||
|
||||
if (grid) {
|
||||
const gridId = grid.grid_id;
|
||||
rect.bindTooltip(
|
||||
`<b>${gridId}</b><br/>风险:${Math.round(riskValue * 100)}%`,
|
||||
{ direction: 'center', permanent: false }
|
||||
);
|
||||
rect.on('click', () => {
|
||||
callbacksRef.current.onGridSelect(gridId);
|
||||
});
|
||||
}
|
||||
|
||||
rect.addTo(gridLayer);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
gridLayer.addTo(map);
|
||||
gridLayerRef.current = gridLayer;
|
||||
}
|
||||
map.on('resize', renderGridLayer);
|
||||
map.on('click', handleMapClick);
|
||||
|
||||
// Initial render
|
||||
renderGridLayer();
|
||||
|
||||
return () => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
redrawRef.current = () => {};
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
gridLayerRef.current = null;
|
||||
}
|
||||
canvasRef.current = null;
|
||||
paneRef.current = null;
|
||||
};
|
||||
}, [gridMap]);
|
||||
}, []);
|
||||
|
||||
const handleForecastChange = useCallback((d: ForecastDay) => {
|
||||
callbacksRef.current.onForecastChange(d);
|
||||
|
||||
@@ -44,6 +44,9 @@ const modules: { id: string; label: string; icon: React.ReactNode; items: { id:
|
||||
{ id: 'district-comparison', label: '区域对比' },
|
||||
{ id: 'insights', label: '智能洞察' },
|
||||
{ id: 'reports', label: '报表中心' },
|
||||
{ id: 'demographics', label: '人群分析' },
|
||||
{ id: 'disease', label: '疾病分析' },
|
||||
{ id: 'environment', label: '环境健康' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,44 +1,107 @@
|
||||
import React from 'react';
|
||||
|
||||
interface StatCardProps {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
value: string | number;
|
||||
change?: string;
|
||||
changeType?: 'up' | 'down' | 'neutral';
|
||||
progress?: number;
|
||||
progressColor?: string;
|
||||
trend?: {
|
||||
direction: 'up' | 'down' | 'stable';
|
||||
value: string;
|
||||
};
|
||||
sparkline?: {
|
||||
data: number[];
|
||||
color: string;
|
||||
};
|
||||
color?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
export const StatCard = React.memo(function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
change,
|
||||
changeType = 'neutral',
|
||||
progress,
|
||||
progressColor = 'bg-warning',
|
||||
trend,
|
||||
sparkline,
|
||||
color,
|
||||
onClick,
|
||||
}: StatCardProps) {
|
||||
const trendIndicator = trend ? (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-xs font-medium ${
|
||||
trend.direction === 'up'
|
||||
? 'text-green-600'
|
||||
: trend.direction === 'down'
|
||||
? 'text-red-600'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{trend.direction === 'up' && <span aria-hidden>▲</span>}
|
||||
{trend.direction === 'down' && <span aria-hidden>▼</span>}
|
||||
{trend.direction === 'stable' && <span aria-hidden>▬</span>}
|
||||
{trend.value}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const sparklineSvg = sparkline && sparkline.data.length >= 2 ? (
|
||||
<svg
|
||||
width="60"
|
||||
height="24"
|
||||
className="shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke={sparkline.color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
points={sparkline.data
|
||||
.map((val, i) => {
|
||||
const x = (i / (sparkline.data.length - 1)) * 58 + 1;
|
||||
const max = Math.max(...sparkline.data);
|
||||
const min = Math.min(...sparkline.data);
|
||||
const range = max - min || 1;
|
||||
const y = 22 - ((val - min) / range) * 20 - 1;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ')}
|
||||
/>
|
||||
</svg>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-1.5">
|
||||
{label}
|
||||
<div
|
||||
onClick={onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onClick
|
||||
? (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`bg-white rounded-lg border border-gray-200 p-4 ${
|
||||
onClick ? 'cursor-pointer hover:shadow-md transition-shadow' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-1">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="font-display text-[26px] font-bold text-text-primary mb-1">
|
||||
{value}
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<div
|
||||
className="text-2xl font-bold text-gray-900"
|
||||
style={color ? { color } : undefined}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
{sparklineSvg}
|
||||
</div>
|
||||
{change && (
|
||||
<div className={`text-[11px] ${
|
||||
changeType === 'up' ? 'text-danger' :
|
||||
changeType === 'down' ? 'text-success' : 'text-text-muted'
|
||||
}`}>
|
||||
{change}
|
||||
</div>
|
||||
)}
|
||||
{progress !== undefined && (
|
||||
<div className="h-[3px] bg-bg-page rounded mt-2.5 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded ${progressColor}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{trendIndicator && <div className="mt-1">{trendIndicator}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -38,8 +38,9 @@ export function StatisticalCharts({
|
||||
const secondHalf = values.slice(Math.floor(values.length / 2));
|
||||
|
||||
const firstAvg = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length;
|
||||
const secondAvg = secondHalf.reduce((a, b) => a + b) / secondHalf.length;
|
||||
const secondAvg = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length;
|
||||
|
||||
if (firstAvg === 0) return secondAvg > 0 ? 'up' : 'stable';
|
||||
const change = ((secondAvg - firstAvg) / firstAvg) * 100;
|
||||
|
||||
if (change > 10) return 'up';
|
||||
@@ -48,7 +49,11 @@ export function StatisticalCharts({
|
||||
};
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (data.length === 0) return null;
|
||||
const noData = data.length === 0;
|
||||
|
||||
if (noData) {
|
||||
return { totalCases: 0, avgCases: 0, maxCases: 0, trend: 'stable' as const, noData: true };
|
||||
}
|
||||
|
||||
const totalCases = data.reduce((sum, item) => sum + item.cases, 0);
|
||||
const avgCases = totalCases / data.length;
|
||||
@@ -60,6 +65,7 @@ export function StatisticalCharts({
|
||||
avgCases: Math.round(avgCases),
|
||||
maxCases,
|
||||
trend,
|
||||
noData: false,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
@@ -146,7 +152,7 @@ export function StatisticalCharts({
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
{stats && activeChart === 'cases' && (
|
||||
{activeChart === 'cases' && (
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="bg-blue-50 rounded-lg p-3">
|
||||
<div className="text-sm text-gray-600">总病例数</div>
|
||||
@@ -163,6 +169,11 @@ export function StatisticalCharts({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No data banner */}
|
||||
{stats.noData && (
|
||||
<div className="text-center text-sm text-gray-400 py-4">该时段暂无统计数据</div>
|
||||
)}
|
||||
|
||||
{/* Chart */}
|
||||
<div style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
||||
@@ -26,6 +26,10 @@ export function TimelinePlayer({
|
||||
}: TimelinePlayerProps) {
|
||||
const [playing, setPlaying] = useState(isPlaying);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const advanceRef = useRef<() => void>(() => {});
|
||||
|
||||
// Keep internal play state in sync when the parent/store changes isPlaying.
|
||||
useEffect(() => { setPlaying(isPlaying); }, [isPlaying]);
|
||||
|
||||
const generateDateRange = useCallback((start: string, end: string) => {
|
||||
const dates: string[] = [];
|
||||
@@ -41,7 +45,13 @@ export function TimelinePlayer({
|
||||
}, []);
|
||||
|
||||
const dateRange = useMemo(() => generateDateRange(startDate, endDate), [startDate, endDate, generateDateRange]);
|
||||
const currentIndex = useMemo(() => dateRange.indexOf(currentDate), [dateRange, currentDate]);
|
||||
// Compute index arithmetically from the day difference instead of indexOf.
|
||||
const currentIndex = useMemo(() => {
|
||||
if (dateRange.length === 0) return -1;
|
||||
const ms = new Date(currentDate).getTime() - new Date(startDate).getTime();
|
||||
const idx = Math.round(ms / 86400000);
|
||||
return idx >= 0 && idx < dateRange.length ? idx : dateRange.indexOf(currentDate);
|
||||
}, [startDate, currentDate, dateRange]);
|
||||
const progress = useMemo(() => ((currentIndex + 1) / dateRange.length) * 100, [currentIndex, dateRange.length]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
@@ -71,12 +81,19 @@ export function TimelinePlayer({
|
||||
onDateChange(dateRange[0]);
|
||||
};
|
||||
|
||||
// Keep the advance logic in a ref so the interval doesn't get recreated each
|
||||
// tick when goToNext's identity changes.
|
||||
useEffect(() => {
|
||||
advanceRef.current = goToNext;
|
||||
}, [goToNext]);
|
||||
|
||||
// Interval is created once per play/speed change (not per tick).
|
||||
useEffect(() => {
|
||||
if (playing) {
|
||||
const interval = 1000 / speed;
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
goToNext();
|
||||
advanceRef.current();
|
||||
}, interval);
|
||||
|
||||
return () => {
|
||||
@@ -85,7 +102,7 @@ export function TimelinePlayer({
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [playing, speed, goToNext]);
|
||||
}, [playing, speed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentIndex >= dateRange.length - 1) {
|
||||
|
||||
Reference in New Issue
Block a user