diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3c5eebb..5fe40ba 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "Bash(npm create:*)", "Bash(npm install:*)", "Bash(npx tsc:*)", - "Bash(npm run build:*)" + "Bash(npm run build:*)", + "Bash(npx eslint:*)" ] } } diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 5e6b472..45b74b2 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -19,5 +19,9 @@ export default defineConfig([ ecmaVersion: 2020, globals: globals.browser, }, + rules: { + // Allow unused vars prefixed with _ (standard TS convention for interface impls) + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, }, ]) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 96bba7f..7489013 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { useSettingsStore } from '@/store/settings.ts'; import { RFCalculator } from '@/rf/calculator.ts'; import { useToastStore } from '@/components/ui/Toast.tsx'; import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts.ts'; +import { logger } from '@/utils/logger.ts'; import MapView from '@/components/map/Map.tsx'; import GeographicHeatmap from '@/components/map/GeographicHeatmap.tsx'; import HeatmapLegend from '@/components/map/HeatmapLegend.tsx'; @@ -111,6 +112,20 @@ export default function App() { return; } + // Warn if grid will be auto-coarsened (very large area + fine resolution) + const latitudes = currentSites.map((s) => s.lat); + const longitudes = currentSites.map((s) => s.lon); + const latRange = (Math.max(...latitudes) - Math.min(...latitudes)) + (2 * currentSettings.radius / 111); + const lonRange = (Math.max(...longitudes) - Math.min(...longitudes)) + (2 * currentSettings.radius / 111); + const estPoints = Math.ceil(latRange * 111000 / currentSettings.resolution) * + Math.ceil(lonRange * 111000 / currentSettings.resolution); + if (estPoints > 500_000) { + addToast( + `Large area detected (~${(estPoints / 1_000_000).toFixed(1)}M points). Resolution will be auto-adjusted for performance.`, + 'warning' + ); + } + setIsCalculating(true); try { const latitudes = currentSites.map((s) => s.lat); @@ -148,7 +163,7 @@ export default function App() { ); } } catch (err) { - console.error('Coverage calculation error:', err); + logger.error('Coverage calculation error:', err); const msg = err instanceof Error ? err.message : 'Unknown error'; let userMessage = 'Calculation failed'; @@ -375,6 +390,7 @@ export default function App() { max={100} step={5} unit="km" + hint="Calculation area around each site" />
+ Pixel radius per point — larger = smoother, smaller = sharper +
Wide radius works best with fine resolution (200m or less). Current: {settings.resolution}m diff --git a/frontend/src/components/map/GeographicHeatmap.tsx b/frontend/src/components/map/GeographicHeatmap.tsx index efed89d..0419785 100644 --- a/frontend/src/components/map/GeographicHeatmap.tsx +++ b/frontend/src/components/map/GeographicHeatmap.tsx @@ -17,6 +17,7 @@ import { useMap } from 'react-leaflet'; import L from 'leaflet'; import { HeatmapTileRenderer } from './HeatmapTileRenderer.ts'; import type { HeatmapPoint } from './HeatmapTileRenderer.ts'; +import { logger } from '@/utils/logger.ts'; interface GeographicHeatmapProps { points: HeatmapPoint[]; @@ -80,7 +81,7 @@ export default function GeographicHeatmap({ const renderer = rendererRef.current; const currentPointsRef = pointsRef; - // Custom GridLayer with canvas tiles + // Custom GridLayer with canvas tiles (Leaflet's class system requires `this`) const HeatmapGridLayer = L.GridLayer.extend({ createTile( this: L.GridLayer, @@ -100,7 +101,7 @@ export default function GeographicHeatmap({ coords.z ); } catch (error) { - console.error('Tile render error:', error); + logger.error('Tile render error:', error); // Draw error indicator const ctx = canvas.getContext('2d'); if (ctx) { diff --git a/frontend/src/components/map/Heatmap.tsx b/frontend/src/components/map/Heatmap.tsx deleted file mode 100644 index 96e5951..0000000 --- a/frontend/src/components/map/Heatmap.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useMap } from 'react-leaflet'; -import L from 'leaflet'; -import 'leaflet.heat'; -import type { CoveragePoint } from '@/types/index.ts'; - -// Extend L with heat layer type -declare module 'leaflet' { - function heatLayer( - latlngs: Array<[number, number, number]>, - options?: Record - ): L.Layer; -} - -interface HeatmapProps { - points: CoveragePoint[]; - visible: boolean; - opacity?: number; -} - -/** - * Normalize RSRP to 0-1 intensity for heatmap. - * - * Range: -130 to -50 dBm - * - * -130 dBm → 0.0 (deep blue, no service) - * -90 dBm → 0.5 (green, fair) - * -50 dBm → 1.0 (red, very strong) - */ -function rsrpToIntensity(rsrp: number): number { - const minRSRP = -130; - const maxRSRP = -50; - return Math.max(0, Math.min(1, (rsrp - minRSRP) / (maxRSRP - minRSRP))); -} - -/** - * Geographic scale-aware heatmap parameters. - * - * The key insight: we want each heatmap point to cover a constant - * GEOGRAPHIC area (400m radius) regardless of zoom level. Since - * leaflet.heat works in pixels, we convert: - * - * pixelsPerKm = 2^zoom * 6.4 (at equator, simplified) - * radiusPixels = targetRadiusKm * pixelsPerKm - * - * With progressive clamps for visual quality: - * - Low zoom (≤9): min 15px, max 45px (avoid giant blobs) - * - High zoom (≥10): min 30px, max 80px (fill gaps between grid points) - * - * maxIntensity is CONSTANT at 0.75 — no compensation needed because - * the geographic overlap between adjacent points stays consistent - * when radius tracks geographic scale. - */ -function getHeatmapParams(zoom: number) { - const pixelsPerKm = Math.pow(2, zoom) * 6.4; - const targetRadiusKm = 0.4; // 400 meters - const radiusPixels = targetRadiusKm * pixelsPerKm; - - // Progressive clamps: wider range at high zoom for smooth fill - const minRadius = zoom < 10 ? 15 : 30; - const maxRadius = zoom < 10 ? 45 : 80; - const radius = Math.max(minRadius, Math.min(maxRadius, radiusPixels)); - - const blur = Math.round(radius * 0.6); - - return { - radius: Math.round(radius), - blur, - maxIntensity: 0.75, // CONSTANT — geographic consistency means no compensation needed - pixelsPerKm, - radiusPixels, - }; -} - -const HEATMAP_GRADIENT = { - 0.0: '#1a237e', // Deep blue (-130 dBm, no service) - 0.15: '#0d47a1', // Dark blue - 0.25: '#2196f3', // Blue (-110 dBm, weak) - 0.35: '#00bcd4', // Cyan - 0.45: '#00897b', // Teal - 0.55: '#4caf50', // Green ( -90 dBm, fair) - 0.65: '#8bc34a', // Light green - 0.75: '#ffeb3b', // Yellow ( -70 dBm, good) - 0.85: '#ff9800', // Orange - 1.0: '#f44336', // Red ( -50 dBm, excellent) -}; - -export default function Heatmap({ points, visible, opacity = 0.7 }: HeatmapProps) { - const map = useMap(); - const [mapZoom, setMapZoom] = useState(map.getZoom()); - - // Track zoom changes - useEffect(() => { - const handleZoomEnd = () => setMapZoom(map.getZoom()); - map.on('zoomend', handleZoomEnd); - return () => { - map.off('zoomend', handleZoomEnd); - }; - }, [map]); - - // Recreate heatmap layer when points, visibility, or zoom changes - useEffect(() => { - if (!visible || points.length === 0) return; - - const heatData: Array<[number, number, number]> = points.map((p) => [ - p.lat, - p.lon, - rsrpToIntensity(p.rsrp), - ]); - - const { radius, blur, maxIntensity, pixelsPerKm, radiusPixels } = - getHeatmapParams(mapZoom); - - // Debug: log geographic scale params (dev only) - if (import.meta.env.DEV && heatData.length > 0) { - const rsrpValues = points.map((p) => p.rsrp); - console.log('🔍 Heatmap Geographic:', { - zoom: mapZoom, - pixelsPerKm: pixelsPerKm.toFixed(1), - targetRadiusKm: 0.4, - radiusPixelsRaw: radiusPixels.toFixed(1), - radiusClamped: radius, - blur, - maxIntensity, - points: points.length, - rsrpRange: `${Math.min(...rsrpValues).toFixed(1)} to ${Math.max(...rsrpValues).toFixed(1)} dBm`, - }); - } - - const heatLayer = L.heatLayer(heatData, { - radius, - blur, - max: maxIntensity, - maxZoom: 17, - minOpacity: 0.3, - gradient: HEATMAP_GRADIENT, - }); - - heatLayer.addTo(map); - - // Apply opacity to the canvas element - const container = (heatLayer as unknown as { _canvas?: HTMLCanvasElement })._canvas; - if (container) { - container.style.opacity = String(opacity); - } - - return () => { - map.removeLayer(heatLayer); - }; - }, [map, points, visible, mapZoom, opacity]); - - return null; -} diff --git a/frontend/src/components/map/HeatmapTileRenderer.ts b/frontend/src/components/map/HeatmapTileRenderer.ts index caa7d6a..f66d1d6 100644 --- a/frontend/src/components/map/HeatmapTileRenderer.ts +++ b/frontend/src/components/map/HeatmapTileRenderer.ts @@ -22,6 +22,7 @@ import { valueToColorRGB, gaussianWeight, } from '@/utils/colorGradient.ts'; +import { logger } from '@/utils/logger.ts'; export interface HeatmapPoint { lat: number; @@ -186,14 +187,12 @@ export class HeatmapTileRenderer { // Cache the rendered tile this.cacheStore(cacheKey, imageData); - // Dev perf log - if (import.meta.env.DEV) { - const ms = performance.now() - t0; - if (ms > 50) { - console.log( - `🖌️ Tile ${tileX},${tileY} z${zoom}: ${ms.toFixed(1)}ms (${relevant.length} pts)` - ); - } + // Dev perf log (slow tiles only) + const ms = performance.now() - t0; + if (ms > 50) { + logger.log( + `Tile ${tileX},${tileY} z${zoom}: ${ms.toFixed(1)}ms (${relevant.length} pts)` + ); } return true; diff --git a/frontend/src/components/map/Legend.tsx b/frontend/src/components/map/Legend.tsx deleted file mode 100644 index d8768f2..0000000 --- a/frontend/src/components/map/Legend.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { RSRP_LEGEND } from '@/constants/rsrp-thresholds.ts'; -import { useCoverageStore } from '@/store/coverage.ts'; -import { useSitesStore } from '@/store/sites.ts'; - -export default function Legend() { - const result = useCoverageStore((s) => s.result); - const heatmapVisible = useCoverageStore((s) => s.heatmapVisible); - const toggleHeatmap = useCoverageStore((s) => s.toggleHeatmap); - const settings = useCoverageStore((s) => s.settings); - const sites = useSitesStore((s) => s.sites); - - if (!result) return null; - - // Estimate coverage area: total points * resolution^2 - const areaKm2 = (result.totalPoints * settings.resolution * settings.resolution) / 1e6; - - return ( - - {/* Header with toggle */} - - Signal (RSRP) - - - - - - {/* Legend items */} - - {RSRP_LEGEND.map((item) => ( - - - - {item.label}: {item.range} - - - ))} - - - {/* Stats */} - - Points: {result.totalPoints.toLocaleString()} - Time: {(result.calculationTime / 1000).toFixed(2)}s - Area: ~{areaKm2.toFixed(1)} km² - Sites: {sites.length} - - - ); -} diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index b10edd1..aaf9415 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -1,4 +1,4 @@ -import { useRef, useCallback } from 'react'; +import { useRef, useCallback, useEffect } from 'react'; import { MapContainer, TileLayer, useMapEvents, useMap } from 'react-leaflet'; import 'leaflet/dist/leaflet.css'; import type { Map as LeafletMap } from 'leaflet'; @@ -41,7 +41,9 @@ function MapClickHandler({ */ function MapRefSetter({ mapRef }: { mapRef: React.MutableRefObject }) { const map = useMap(); - mapRef.current = map; + useEffect(() => { + mapRef.current = map; + }, [map, mapRef]); return null; } diff --git a/frontend/src/components/map/MeasurementTool.tsx b/frontend/src/components/map/MeasurementTool.tsx index a55a5c1..a3c6a52 100644 --- a/frontend/src/components/map/MeasurementTool.tsx +++ b/frontend/src/components/map/MeasurementTool.tsx @@ -43,14 +43,18 @@ export default function MeasurementTool({ enabled, onComplete }: MeasurementTool const map = useMap(); const [points, setPoints] = useState<[number, number][]>([]); const pointsRef = useRef(points); - pointsRef.current = points; + useEffect(() => { + pointsRef.current = points; + }, [points]); // Clear on disable + /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { if (!enabled) { setPoints([]); } }, [enabled]); + /* eslint-enable react-hooks/set-state-in-effect */ // Click handler: add measurement point useEffect(() => { diff --git a/frontend/src/components/map/SiteMarker.tsx b/frontend/src/components/map/SiteMarker.tsx index 8bb69a1..7064a97 100644 --- a/frontend/src/components/map/SiteMarker.tsx +++ b/frontend/src/components/map/SiteMarker.tsx @@ -1,8 +1,8 @@ +import { memo, useEffect } from 'react'; import { Marker, Popup, Polygon, useMap } from 'react-leaflet'; import L from 'leaflet'; import type { Site } from '@/types/index.ts'; import { useSitesStore } from '@/store/sites.ts'; -import { useEffect } from 'react'; interface SiteMarkerProps { site: Site; @@ -65,7 +65,7 @@ function FlyToSelected({ site, isSelected }: { site: Site; isSelected: boolean } return null; } -export default function SiteMarker({ site, onEdit }: SiteMarkerProps) { +export default memo(function SiteMarker({ site, onEdit }: SiteMarkerProps) { const selectedSiteId = useSitesStore((s) => s.selectedSiteId); const selectSite = useSitesStore((s) => s.selectSite); const updateSite = useSitesStore((s) => s.updateSite); @@ -126,4 +126,4 @@ export default function SiteMarker({ site, onEdit }: SiteMarkerProps) { )} > ); -} +}); diff --git a/frontend/src/components/panels/CoverageStats.tsx b/frontend/src/components/panels/CoverageStats.tsx index 9d31a1c..c97d283 100644 --- a/frontend/src/components/panels/CoverageStats.tsx +++ b/frontend/src/components/panels/CoverageStats.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import type { CoveragePoint } from '@/types/index.ts'; interface CoverageStatsProps { @@ -32,16 +33,22 @@ function classifyPoints(points: CoveragePoint[]) { return counts; } -export default function CoverageStats({ points, resolution }: CoverageStatsProps) { +export default memo(function CoverageStats({ points, resolution }: CoverageStatsProps) { if (points.length === 0) { return ( Coverage Analysis - - No coverage data. Calculate coverage first. - + + 📊 + + No coverage data yet. + + + Press Ctrl+Enter to calculate. + + ); } @@ -135,4 +142,4 @@ export default function CoverageStats({ points, resolution }: CoverageStatsProps
- No coverage data. Calculate coverage first. -
+ No coverage data yet. +
+ Press Ctrl+Enter to calculate. +
- No coverage data. Calculate coverage first to enable export. -
+ No coverage data to export. +
+ Calculate coverage first to enable CSV and GeoJSON export. +
{hint}