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
376 lines
12 KiB
TypeScript
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;
|
|
}
|