Files
CA/frontend/src/components/LodGridLayer.tsx
Akiba So e95e2f1338 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
2026-06-21 17:35:03 +08:00

376 lines
12 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import L from 'leaflet';
import { useLodGrid, type MapBounds } from '@/hooks/useLodGrid';
const RISK_COLORS: [number, number, string][] = [
[0.0, 0.2, '#22c55e'],
[0.2, 0.4, '#3b82f6'],
[0.4, 0.6, '#eab308'],
[0.6, 0.8, '#f97316'],
[0.8, 1.0, '#ef4444'],
];
// Pre-computed color buckets for fillStyle caching
const COLOR_BUCKETS: Record<string, { full: string; dim: string }> = {};
for (const [, , color] of RISK_COLORS) {
COLOR_BUCKETS[color] = { full: color, dim: color + '14' };
}
function getRiskColor(value: number): string {
for (const [min, max, color] of RISK_COLORS) {
if (value >= min && value < max) return color;
}
return '#ef4444';
}
// 100m grid step in degrees
const LAT_STEP = 0.0009;
const LON_STEP = 0.001046;
// Mercator helpers (avoid per-cell latLngToContainerPoint)
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;
}
interface LodGridLayerProps {
map: L.Map | null;
forecastDay: 1 | 3 | 7;
visible?: boolean;
riskRange?: [number, number];
onCellClick?: (lat: number, lon: number, risk: number) => void;
}
export function LodGridLayer({
map,
forecastDay,
visible = true,
riskRange,
onCellClick,
}: LodGridLayerProps) {
const [zoom, setZoom] = useState(map?.getZoom() ?? 10);
const [mapBounds, setMapBounds] = useState<MapBounds | undefined>();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const paneRef = useRef<HTMLElement | null>(null);
const animFrameRef = useRef<number>(0);
const clickCallbackRef = useRef(onCellClick);
const gridsRef = useRef<number[][]>([]);
const forecastDayRef = useRef(forecastDay);
const riskRangeRef = useRef(riskRange);
const visibleRef = useRef(visible);
const drawnOriginRef = useRef<{ x: number; y: number } | null>(null);
// Keep refs in sync
useEffect(() => { clickCallbackRef.current = onCellClick; }, [onCellClick]);
useEffect(() => { forecastDayRef.current = forecastDay; }, [forecastDay]);
useEffect(() => { riskRangeRef.current = riskRange; }, [riskRange]);
useEffect(() => { visibleRef.current = visible; }, [visible]);
// Track map bounds and zoom
useEffect(() => {
if (!map) return;
const update = () => {
const b = map.getBounds();
setMapBounds({
min_lat: b.getSouth(),
max_lat: b.getNorth(),
min_lon: b.getWest(),
max_lon: b.getEast(),
});
setZoom(map.getZoom());
};
update();
map.on('moveend', update);
map.on('zoomend', update);
return () => {
map.off('moveend', update);
map.off('zoomend', update);
};
}, [map]);
const { grids } = useLodGrid(zoom, forecastDay, mapBounds);
// Update gridsRef only when we have actual data (preserve stale data during loading)
useEffect(() => {
if (grids.length > 0) {
gridsRef.current = grids;
}
}, [grids]);
// Create canvas overlay pane and attach to map
useEffect(() => {
if (!map) return;
const pane = map.createPane('lod-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';
canvas.style.display = visibleRef.current ? '' : 'none';
pane.appendChild(canvas);
canvasRef.current = canvas;
// Handle map clicks for grid cell selection
const handleMapClick = (e: L.LeafletMouseEvent) => {
if (!clickCallbackRef.current) return;
const currentGrids = gridsRef.current;
if (!currentGrids || currentGrids.length === 0) return;
const { lat, lng } = e.latlng;
const riskIdx = forecastDayRef.current === 1 ? 2 : forecastDayRef.current === 3 ? 3 : 4;
let nearestDist = Infinity;
let nearestRisk = 0;
let nearestLat = 0;
let nearestLon = 0;
for (const g of currentGrids) {
const d = Math.sqrt((g[0] - lat) ** 2 + (g[1] - lng) ** 2);
if (d < nearestDist) {
nearestDist = d;
nearestRisk = g[riskIdx] ?? 0;
nearestLat = g[0];
nearestLon = g[1];
}
}
if (nearestDist < 0.01) {
clickCallbackRef.current(nearestLat, nearestLon, nearestRisk);
}
};
map.on('click', handleMapClick);
// Full redraw function
const redraw = () => {
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);
// Reset drift transform after redraw
canvas.style.transform = '';
drawnOriginRef.current = null;
// 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;
const z = map.getZoom();
const riskIdx = forecastDayRef.current === 1 ? 2 : forecastDayRef.current === 3 ? 3 : 4;
const range = riskRangeRef.current;
const mapBounds = map.getBounds();
const south = mapBounds.getSouth();
const north = mapBounds.getNorth();
const west = mapBounds.getWest();
const east = mapBounds.getEast();
// Use Mercator math for pixel conversion (avoids per-cell latLngToContainerPoint)
const scale = 2 ** z;
const origin = map.getPixelOrigin();
drawnOriginRef.current = { x: origin.x, y: origin.y };
// Pre-compute Mercator Y steps for cell size at this zoom
const halfLat = LAT_STEP / 2;
const halfLon = LON_STEP / 2;
// Group cells by color to minimize fillStyle changes
const colorGroups: Record<string, { x: number; y: number; w: number; h: number }[]> = {};
// Viewport culling margin in degrees
const margin = 0.02;
const isHighZoom = z >= 12;
const isMedZoom = z >= 10;
for (const g of currentGrids) {
const lat = g[0];
const lon = g[1];
const risk = g[riskIdx] ?? 0;
// Pre-filter: skip zero-risk cells (majority of cells at most zooms)
if (risk === 0) continue;
// Viewport culling
if (lat < south - margin || lat > north + margin ||
lon < west - margin || lon > east + margin) {
continue;
}
// Risk range filter
let alpha = 0.85;
if (range) {
if (risk < range[0]) {
alpha = 0.08;
} else if (risk > range[1]) {
alpha = 0.3;
}
}
const color = getRiskColor(risk);
if (isHighZoom) {
// Compute cell rectangle using Mercator math
const lx = lonToMercX(lon - halfLon) * scale - origin.x;
const rx = lonToMercX(lon + halfLon) * scale - origin.x;
const ty = latToMercY(lat + halfLat) * scale - origin.y;
const by = latToMercY(lat - halfLat) * scale - origin.y;
const cellW = rx - lx;
const cellH = by - ty;
if (cellW < 0.5 || cellH < 0.5) continue;
// Group by color+alpha for batch rendering
const key = alpha < 1 ? `${color}_${alpha}` : color;
if (!colorGroups[key]) colorGroups[key] = [];
colorGroups[key].push({ x: lx, y: ty, w: cellW, h: cellH });
} else {
// Medium/low zoom: compute center pixel
const cx = lonToMercX(lon) * scale - origin.x;
const cy = latToMercY(lat) * scale - origin.y;
const key = alpha < 1 ? `${color}_${alpha}` : color;
if (!colorGroups[key]) colorGroups[key] = [];
colorGroups[key].push({ x: cx, y: cy, w: 0, h: 0 });
}
}
// Render grouped cells
for (const [key, cells] of Object.entries(colorGroups)) {
const parts = key.split('_');
const color = parts[0];
const alpha = parts.length > 1 ? parseFloat(parts[1]) : 1;
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
if (isHighZoom) {
for (const c of cells) {
ctx.fillRect(c.x, c.y, c.w, c.h);
}
// Stroke only at high enough cell sizes
ctx.globalAlpha = 0.4;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 0.5;
for (const c of cells) {
if (c.w > 2 && c.h > 2) {
ctx.strokeRect(c.x, c.y, c.w, c.h);
}
}
} else if (isMedZoom) {
const size = Math.max(2, Math.min(6, z - 7));
const halfSize = size / 2;
for (const c of cells) {
ctx.fillRect(c.x - halfSize, c.y - halfSize, size, size);
}
} else {
const radius = Math.max(1, Math.min(3, z - 5));
for (const c of cells) {
ctx.beginPath();
ctx.arc(c.x, c.y, radius, 0, Math.PI * 2);
ctx.fill();
}
}
}
ctx.globalAlpha = 1;
});
};
// Debounced full redraw (avoid thrashing during rapid pan/zoom)
let redrawTimer: ReturnType<typeof setTimeout> | null = null;
const debouncedRedraw = () => {
if (redrawTimer) clearTimeout(redrawTimer);
redrawTimer = setTimeout(redraw, 300);
};
// During pan: apply CSS transform to track tile movement (fixes drift)
const onMove = () => {
const drawn = drawnOriginRef.current;
if (!drawn) {
// No previous draw yet, just request a redraw
redraw();
return;
}
const current = map.getPixelOrigin();
const dx = drawn.x - current.x;
const dy = drawn.y - current.y;
canvas.style.transform = `translate(${dx}px, ${dy}px)`;
};
// On moveend/zoomend: reset transform and do debounced full redraw
const onMoveEnd = () => {
canvas.style.transform = '';
drawnOriginRef.current = null;
debouncedRedraw();
};
const onResize = () => redraw();
map.on('move', onMove);
map.on('moveend', onMoveEnd);
map.on('zoomend', onMoveEnd);
map.on('resize', onResize);
// Store redraw reference for external triggers
(canvas as any).__lodRedraw = redraw;
// Initial draw
redraw();
return () => {
map.off('move', onMove);
map.off('moveend', onMoveEnd);
map.off('zoomend', onMoveEnd);
map.off('resize', onResize);
map.off('click', handleMapClick);
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
if (redrawTimer) clearTimeout(redrawTimer);
pane.removeChild(canvas);
if (pane.parentNode) pane.parentNode.removeChild(pane);
canvasRef.current = null;
paneRef.current = null;
};
}, [map]);
// 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]);
// 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;
}