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 = {}; 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 '#22c55e'; } // 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(); const canvasRef = useRef(null); const paneRef = useRef(null); const animFrameRef = useRef(0); const clickCallbackRef = useRef(onCellClick); const gridsRef = useRef([]); 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'; 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; if (!visibleRef.current) return; 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 = {}; // 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; }); }; // 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 full redraw const onMoveEnd = () => { canvas.style.transform = ''; drawnOriginRef.current = null; redraw(); }; 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); pane.removeChild(canvas); if (pane.parentNode) pane.parentNode.removeChild(pane); canvasRef.current = null; paneRef.current = null; }; }, [map]); // Trigger redraw when data changes useEffect(() => { const canvas = canvasRef.current; if (canvas && (canvas as any).__lodRedraw) { (canvas as any).__lodRedraw(); } }, [grids, forecastDay, riskRange, visible]); return null; }