diff --git a/client/index.html b/client/index.html index ae65c03..7777281 100644 --- a/client/index.html +++ b/client/index.html @@ -5,25 +5,9 @@ SignalMap | Collaborative Hotspot Tracker -
- - diff --git a/client/package-lock.json b/client/package-lock.json index 53c1d0e..7424f4b 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11,12 +11,15 @@ "@radix-ui/react-slot": "^1.2.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", "react": "^19.1.1", "react-dom": "^19.1.1", "tailwind-merge": "^3.3.1" }, "devDependencies": { "@eslint/js": "^9.36.0", + "@types/leaflet": "^1.9.12", "@types/node": "^24.6.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", @@ -1064,6 +1067,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1071,6 +1081,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.20", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.20.tgz", + "integrity": "sha512-rooalPMlk61LCaLOvBF2VIf9M47HgMQqi5xQ9QRi7c8PkdIe0WrIi5IxXUXQjAdL0c+vcQ01mYWbthzmp9GHWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "24.7.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz", @@ -2623,6 +2643,17 @@ "json-buffer": "3.0.1" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/leaflet.heat": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz", + "integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ==" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/client/package.json b/client/package.json index d1118e4..c614c10 100644 --- a/client/package.json +++ b/client/package.json @@ -13,12 +13,15 @@ "@radix-ui/react-slot": "^1.2.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", "react": "^19.1.1", "react-dom": "^19.1.1", "tailwind-merge": "^3.3.1" }, "devDependencies": { "@eslint/js": "^9.36.0", + "@types/leaflet": "^1.9.12", "@types/node": "^24.6.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", diff --git a/client/src/App.tsx b/client/src/App.tsx index 104f21f..524d09b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,9 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Badge } from '@ui/badge' -import { Button } from '@ui/button' -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@ui/card' -import { Separator } from '@ui/separator' -import { cn, distanceInKm, formatCoordinate, formatRelativeTime } from '@lib/utils' +import L, { type LayerGroup, type LeafletMouseEvent, type Map as LeafletMap } from 'leaflet' +import 'leaflet.heat' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { Separator } from '@/components/ui/separator' +import { cn, distanceInKm, formatCoordinate, formatRelativeTime } from '@/lib/utils' const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/signals' const VISIBLE_RADIUS_KM = 5 @@ -36,6 +39,25 @@ type ApiResponse = { updatedAt?: string } +type HeatPoint = [number, number, number?] + +type LeafletHeatLayer = L.Layer & { + setLatLngs(points: HeatPoint[]): LeafletHeatLayer +} + +interface HeatLayerOptions { + radius?: number + blur?: number + maxZoom?: number + max?: number + minOpacity?: number + gradient?: Record +} + +type LeafletWithHeat = typeof L & { + heatLayer?: (points: HeatPoint[], options?: HeatLayerOptions) => LeafletHeatLayer +} + interface LatLng { lat: number lng: number @@ -70,12 +92,12 @@ function geolocationErrorMessage(error: GeolocationPositionError): string { } export default function App() { - const mapRef = useRef(null) + const mapRef = useRef(null) const mapContainerRef = useRef(null) - const heatLayerRef = useRef(null) - const markersLayerRef = useRef(null) - const zonesLayerRef = useRef(null) - const userLayerRef = useRef(null) + const heatLayerRef = useRef(null) + const markersLayerRef = useRef(null) + const zonesLayerRef = useRef(null) + const userLayerRef = useRef(null) const locationWatchIdRef = useRef(null) const statusRef = useRef('loading') const initialLoadRef = useRef(true) @@ -229,15 +251,11 @@ export default function App() { return } - const leaflet = (window as any).L - if (!leaflet) { - setErrorMessage('Leaflet failed to load. Refresh the page to try again.') - setStatusSafe('error') - return - } + const leaflet = L as LeafletWithHeat + const container = mapContainerRef.current const map = leaflet - .map(mapContainerRef.current, { + .map(container, { worldCopyJump: true, minZoom: 2, zoomControl: true, @@ -252,20 +270,21 @@ export default function App() { }) .addTo(map) - const heatLayer = typeof leaflet.heatLayer === 'function' - ? leaflet.heatLayer([], { - radius: 32, - blur: 24, - maxZoom: 12, - gradient: { - 0.2: '#38bdf8', - 0.4: '#0ea5e9', - 0.6: '#fbbf24', - 0.8: '#f97316', - 1.0: '#ef4444', - }, - }) - : null + const heatLayer = + typeof leaflet.heatLayer === 'function' + ? leaflet.heatLayer([], { + radius: 32, + blur: 24, + maxZoom: 12, + gradient: { + 0.2: '#38bdf8', + 0.4: '#0ea5e9', + 0.6: '#fbbf24', + 0.8: '#f97316', + 1.0: '#ef4444', + }, + }) + : null const markersLayer = leaflet.layerGroup().addTo(map) const zonesLayer = leaflet.layerGroup().addTo(map) @@ -275,8 +294,8 @@ export default function App() { heatLayer.addTo(map) } - const onClick = (event: any) => { - const { lat, lng } = event.latlng ?? {} + const onClick = (event: LeafletMouseEvent) => { + const { lat, lng } = event.latlng if (typeof lat === 'number' && typeof lng === 'number') { handleMapClick({ lat, lng }) } @@ -314,7 +333,7 @@ export default function App() { zonesLayerRef.current = null userLayerRef.current = null } - }, [handleMapClick, setStatusSafe]) + }, [handleMapClick]) useEffect(() => { const cleanup = initialiseMap() @@ -382,8 +401,7 @@ export default function App() { useEffect(() => { const heatLayer = heatLayerRef.current - const leaflet = (window as any).L - if (!heatLayer || !leaflet) { + if (!heatLayer) { return } @@ -393,14 +411,17 @@ export default function App() { } const maxIntensity = Math.max(...visibleDensity.map((entry) => entry.intensity)) || 1 - const heatPoints = visibleDensity.map((entry) => [entry.lat, entry.lng, Math.max(0.25, entry.intensity / maxIntensity)]) + const heatPoints: HeatPoint[] = visibleDensity.map((entry) => [ + entry.lat, + entry.lng, + Math.max(0.25, entry.intensity / maxIntensity), + ]) heatLayer.setLatLngs(heatPoints) }, [visibleDensity]) useEffect(() => { const layer = zonesLayerRef.current - const leaflet = (window as any).L - if (!layer || !leaflet) { + if (!layer) { return } @@ -415,7 +436,7 @@ export default function App() { visibleDangerZones.forEach((zone, index) => { const intensityRatio = maxIntensity ? zone.intensity / maxIntensity : 0.5 const radius = 400 + intensityRatio * 1600 - const circle = leaflet.circle([zone.lat, zone.lng], { + const circle = L.circle([zone.lat, zone.lng], { radius, color: index === 0 ? '#ef4444' : '#f97316', weight: 2, @@ -432,8 +453,7 @@ export default function App() { useEffect(() => { const layer = markersLayerRef.current - const leaflet = (window as any).L - if (!layer || !leaflet) { + if (!layer) { return } @@ -449,7 +469,7 @@ export default function App() { latestMap.forEach((point) => { const isSelf = clientKey && point.userKey === clientKey - const marker = leaflet.circleMarker([point.lat, point.lng], { + const marker = L.circleMarker([point.lat, point.lng], { radius: isSelf ? 10 : 6, color: isSelf ? '#38bdf8' : '#94a3b8', weight: isSelf ? 3 : 1.5, @@ -470,8 +490,7 @@ export default function App() { useEffect(() => { const layer = userLayerRef.current - const leaflet = (window as any).L - if (!layer || !leaflet) { + if (!layer) { return } @@ -481,28 +500,24 @@ export default function App() { return } - leaflet - .circle([userLocation.lat, userLocation.lng], { - radius: VISIBLE_RADIUS_KM * 1000, - color: '#38bdf8', - weight: 1.5, - opacity: 0.6, - dashArray: '6 6', - fillColor: '#38bdf8', - fillOpacity: 0.05, - }) - .addTo(layer) + L.circle([userLocation.lat, userLocation.lng], { + radius: VISIBLE_RADIUS_KM * 1000, + color: '#38bdf8', + weight: 1.5, + opacity: 0.6, + dashArray: '6 6', + fillColor: '#38bdf8', + fillOpacity: 0.05, + }).addTo(layer) - leaflet - .circleMarker([userLocation.lat, userLocation.lng], { - radius: 7, - color: '#38bdf8', - weight: 2, - opacity: 0.9, - fillColor: '#38bdf8', - fillOpacity: 0.5, - }) - .addTo(layer) + L.circleMarker([userLocation.lat, userLocation.lng], { + radius: 7, + color: '#38bdf8', + weight: 2, + opacity: 0.9, + fillColor: '#38bdf8', + fillOpacity: 0.5, + }).addTo(layer) }, [userLocation]) useEffect(() => { diff --git a/client/src/components/ui/badge.tsx b/client/src/components/ui/badge.tsx index 0eb89da..04bb22e 100644 --- a/client/src/components/ui/badge.tsx +++ b/client/src/components/ui/badge.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { cva, type VariantProps } from 'class-variance-authority' -import { cn } from '@lib/utils' +import { cn } from '@/lib/utils' const badgeVariants = cva( 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx index 042bfa2..3d4e426 100644 --- a/client/src/components/ui/button.tsx +++ b/client/src/components/ui/button.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { Slot } from '@radix-ui/react-slot' import { cva, type VariantProps } from 'class-variance-authority' -import { cn } from '@lib/utils' +import { cn } from '@/lib/utils' const buttonVariants = cva( 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 focus-visible:ring-offset-background', diff --git a/client/src/components/ui/card.tsx b/client/src/components/ui/card.tsx index 8d50572..ccac6a0 100644 --- a/client/src/components/ui/card.tsx +++ b/client/src/components/ui/card.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -import { cn } from '@lib/utils' +import { cn } from '@/lib/utils' const Card = React.forwardRef>( ({ className, ...props }, ref) => ( diff --git a/client/src/components/ui/separator.tsx b/client/src/components/ui/separator.tsx index 70f53ed..fbd4354 100644 --- a/client/src/components/ui/separator.tsx +++ b/client/src/components/ui/separator.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -import { cn } from '@lib/utils' +import { cn } from '@/lib/utils' const Separator = React.forwardRef< HTMLDivElement, diff --git a/client/src/main.tsx b/client/src/main.tsx index 0e79b71..f9078a5 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,5 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import 'leaflet/dist/leaflet.css' + import '@/index.css' import App from '@/App.tsx' diff --git a/client/src/types/leaflet-heat.d.ts b/client/src/types/leaflet-heat.d.ts new file mode 100644 index 0000000..66f2ae4 --- /dev/null +++ b/client/src/types/leaflet-heat.d.ts @@ -0,0 +1,22 @@ +declare module 'leaflet.heat' { + import type { Layer } from 'leaflet' + + export interface HeatLayer extends Layer { + setLatLngs(latlngs: Array<[number, number, number?]>): HeatLayer + addLatLng(latlng: [number, number, number?]): HeatLayer + } + + export interface HeatLayerOptions { + radius?: number + blur?: number + maxZoom?: number + max?: number + minOpacity?: number + gradient?: Record + } + + export default function heatLayer( + latlngs: Array<[number, number, number?]>, + options?: HeatLayerOptions, + ): HeatLayer +} diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json index 635dfd0..6a3a5b2 100644 --- a/client/tsconfig.app.json +++ b/client/tsconfig.app.json @@ -9,9 +9,7 @@ "skipLibCheck": true, "baseUrl": "./", "paths": { - "@/*": ["src/*"], - "@ui/*": ["src/components/ui/*"], - "@lib/*": ["src/lib/*"] + "@/*": ["src/*"] }, /* Bundler mode */ diff --git a/client/vite.config.ts b/client/vite.config.ts index 932083e..603ea9e 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -14,8 +14,6 @@ export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), - '@ui': fileURLToPath(new URL('./src/components/ui', import.meta.url)), - '@lib': fileURLToPath(new URL('./src/lib', import.meta.url)), }, }, })