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" />
); -} +}); diff --git a/frontend/src/components/panels/ExportPanel.tsx b/frontend/src/components/panels/ExportPanel.tsx index a3bc3fc..47f468e 100644 --- a/frontend/src/components/panels/ExportPanel.tsx +++ b/frontend/src/components/panels/ExportPanel.tsx @@ -83,9 +83,15 @@ export default function ExportPanel() { ) : ( -

- No coverage data. Calculate coverage first to enable export. -

+
+
📁
+

+ No coverage data to export. +

+

+ Calculate coverage first to enable CSV and GeoJSON export. +

+
)} ); diff --git a/frontend/src/components/panels/ProjectPanel.tsx b/frontend/src/components/panels/ProjectPanel.tsx index b5bd61b..8513e63 100644 --- a/frontend/src/components/panels/ProjectPanel.tsx +++ b/frontend/src/components/panels/ProjectPanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useProjectsStore } from '@/store/projects.ts'; import { useToastStore } from '@/components/ui/Toast.tsx'; import Button from '@/components/ui/Button.tsx'; +import { logger } from '@/utils/logger.ts'; export default function ProjectPanel() { const projects = useProjectsStore((s) => s.projects); @@ -33,7 +34,7 @@ export default function ProjectPanel() { setProjectName(''); setShowSaveForm(false); } catch (err) { - console.error('Save project error:', err); + logger.error('Save project error:', err); addToast('Failed to save project', 'error'); } }; @@ -48,7 +49,7 @@ export default function ProjectPanel() { addToast('Project not found', 'error'); } } catch (err) { - console.error('Load project error:', err); + logger.error('Load project error:', err); addToast('Failed to load project', 'error'); } finally { setIsLoading(false); @@ -60,7 +61,7 @@ export default function ProjectPanel() { await deleteProject(id); addToast(`Project "${name}" deleted`, 'info'); } catch (err) { - console.error('Delete project error:', err); + logger.error('Delete project error:', err); addToast('Failed to delete project', 'error'); } }; diff --git a/frontend/src/components/panels/SiteForm.tsx b/frontend/src/components/panels/SiteForm.tsx index c658255..70fce4e 100644 --- a/frontend/src/components/panels/SiteForm.tsx +++ b/frontend/src/components/panels/SiteForm.tsx @@ -130,6 +130,8 @@ export default function SiteForm({ const [beamwidth, setBeamwidth] = useState(editSite?.beamwidth ?? 65); const [notes, setNotes] = useState(editSite?.notes ?? ''); + // Sync pending map-click location into form fields + /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { if (pendingLocation) { setLat(pendingLocation.lat); @@ -154,6 +156,7 @@ export default function SiteForm({ setNotes(editSite.notes ?? ''); } }, [editSite]); + /* eslint-enable react-hooks/set-state-in-effect */ const applyTemplate = (key: keyof typeof TEMPLATES) => { const t = TEMPLATES[key]; diff --git a/frontend/src/components/panels/SiteImportExport.tsx b/frontend/src/components/panels/SiteImportExport.tsx index b61864d..06dfab0 100644 --- a/frontend/src/components/panels/SiteImportExport.tsx +++ b/frontend/src/components/panels/SiteImportExport.tsx @@ -2,6 +2,7 @@ import { useRef } from 'react'; import { useSitesStore } from '@/store/sites.ts'; import { useToastStore } from '@/components/ui/Toast.tsx'; import Button from '@/components/ui/Button.tsx'; +import { logger } from '@/utils/logger.ts'; /** * Import/Export site configurations as JSON. @@ -95,7 +96,7 @@ export default function SiteImportExport() { const count = await importSites(sitesData); addToast(`Imported ${count} site(s)`, 'success'); } catch (error) { - console.error('Import failed:', error); + logger.error('Import failed:', error); addToast('Invalid JSON file', 'error'); } diff --git a/frontend/src/components/ui/Slider.tsx b/frontend/src/components/ui/Slider.tsx deleted file mode 100644 index 63d34dd..0000000 --- a/frontend/src/components/ui/Slider.tsx +++ /dev/null @@ -1,47 +0,0 @@ -interface SliderProps { - label: string; - value: number; - min: number; - max: number; - step?: number; - unit: string; - hint?: string; - onChange: (value: number) => void; -} - -export default function Slider({ - label, - value, - min, - max, - step = 1, - unit, - hint, - onChange, -}: SliderProps) { - return ( -
-
- - - {value} {unit} - -
- onChange(Number(e.target.value))} - className="w-full h-2 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer - accent-blue-600" - /> -
- {min} - {max} -
- {hint &&

{hint}

} -
- ); -} diff --git a/frontend/src/components/ui/Toast.tsx b/frontend/src/components/ui/Toast.tsx index 9270426..bb312a2 100644 --- a/frontend/src/components/ui/Toast.tsx +++ b/frontend/src/components/ui/Toast.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components -- store co-located with its UI */ import { useState, useEffect, useCallback } from 'react'; import { create } from 'zustand'; diff --git a/frontend/src/hooks/useElevation.ts b/frontend/src/hooks/useElevation.ts index 894ba55..1bb38c5 100644 --- a/frontend/src/hooks/useElevation.ts +++ b/frontend/src/hooks/useElevation.ts @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { useMap } from 'react-leaflet'; import L from 'leaflet'; +import { logger } from '@/utils/logger.ts'; interface ElevationState { elevation: number | null; @@ -47,7 +48,7 @@ export function useElevation() { // Intentional abort, ignore return; } - console.error('Elevation fetch failed:', error); + logger.error('Elevation fetch failed:', error); setState((prev) => ({ ...prev, elevation: null, loading: false })); } }, 300); diff --git a/frontend/src/rf/calculator.ts b/frontend/src/rf/calculator.ts index 898ba8d..d48432e 100644 --- a/frontend/src/rf/calculator.ts +++ b/frontend/src/rf/calculator.ts @@ -1,4 +1,5 @@ import type { Site, CoveragePoint, CoverageResult, CoverageSettings, GridPoint } from '@/types/index.ts'; +import { logger } from '@/utils/logger.ts'; export class RFCalculator { /** @@ -49,8 +50,13 @@ export class RFCalculator { // Cleanup workers workers.forEach((w) => w.terminate()); - // Merge results - const allPoints = results.flat(); + // Merge results (concat avoids stack overflow that .flat() can cause on huge arrays) + const allPoints: CoveragePoint[] = []; + for (const chunk of results) { + for (const point of chunk) { + allPoints.push(point); + } + } const calculationTime = performance.now() - startTime; @@ -62,18 +68,56 @@ export class RFCalculator { }; } + /** + * Maximum grid points to prevent stack overflow / memory exhaustion. + * 500k points at 4 workers = 125k per worker — safe for postMessage serialization. + */ + private static readonly MAX_GRID_POINTS = 500_000; + private generateGrid( bounds: { north: number; south: number; east: number; west: number }, resolution: number ): GridPoint[] { - const points: GridPoint[] = []; - // Convert resolution to degrees const latStep = resolution / 111000; // ~111km per degree latitude const centerLat = (bounds.north + bounds.south) / 2; const lonStep = resolution / (111000 * Math.cos((centerLat * Math.PI) / 180)); + // Pre-check total count to avoid building an array we'd throw away + const latRange = bounds.north - bounds.south; + const lonRange = bounds.east - bounds.west; + const estimatedRows = Math.ceil(latRange / latStep) + 1; + const estimatedCols = Math.ceil(lonRange / lonStep) + 1; + const estimated = estimatedRows * estimatedCols; + + if (estimated > RFCalculator.MAX_GRID_POINTS) { + // Auto-coarsen: pick a resolution that fits within the cap + const autoResolution = Math.ceil( + Math.sqrt((latRange * 111000 * lonRange * 111000 * Math.cos((centerLat * Math.PI) / 180)) / + RFCalculator.MAX_GRID_POINTS) + ); + const coarsenedLatStep = autoResolution / 111000; + const coarsenedLonStep = + autoResolution / (111000 * Math.cos((centerLat * Math.PI) / 180)); + + logger.warn( + `Grid too large (${estimated.toLocaleString()} points at ${resolution}m). ` + + `Auto-coarsening to ~${autoResolution}m for safety (max ${RFCalculator.MAX_GRID_POINTS.toLocaleString()}).` + ); + + return this.buildGrid(bounds, coarsenedLatStep, coarsenedLonStep); + } + + return this.buildGrid(bounds, latStep, lonStep); + } + + private buildGrid( + bounds: { north: number; south: number; east: number; west: number }, + latStep: number, + lonStep: number + ): GridPoint[] { + const points: GridPoint[] = []; let lat = bounds.south; while (lat <= bounds.north) { let lon = bounds.west; @@ -83,7 +127,6 @@ export class RFCalculator { } lat += latStep; } - return points; } diff --git a/frontend/src/utils/logger.ts b/frontend/src/utils/logger.ts new file mode 100644 index 0000000..d67c9e7 --- /dev/null +++ b/frontend/src/utils/logger.ts @@ -0,0 +1,18 @@ +/** + * Production-safe logger. + * - In development: all levels log normally + * - In production: only errors log (warnings suppressed, logs suppressed) + */ +const isDev = import.meta.env.DEV; + +export const logger = { + log: (...args: unknown[]): void => { + if (isDev) console.log(...args); + }, + warn: (...args: unknown[]): void => { + if (isDev) console.warn(...args); + }, + error: (...args: unknown[]): void => { + console.error(...args); + }, +};