Refactor client Leaflet integration to avoid conflicts

This commit is contained in:
Bernard Ngandu
2025-10-10 08:59:14 +02:00
parent 49d93ffc63
commit 5e094e9258
12 changed files with 144 additions and 91 deletions
-16
View File
@@ -5,25 +5,9 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SignalMap | Collaborative Hotspot Tracker</title> <title>SignalMap | Collaborative Hotspot Tracker</title>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha512-sA+e2u1j7mG2mZHg1F9n3u1kVpWwfvX2gYdEx+kt1/3uzMdGII4XESyqSeX5p1+t0NenE2no0LYh3R1n8z+Gxw=="
crossorigin=""
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha512-vPO0bXiC7hlHLLANkRb114F8CbnMD4HzyBbs6k8ZZr68Su2Ce279b9EcRWSavwgJeayQMZT6BdS8wP3r3MJ5iw=="
crossorigin=""
></script>
<script
src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"
integrity="sha384-24E8AI6UK4SlHe/BUOMwfc9yqD0nV9vsxjMSb7ElJ+PmTWk1FLaeL7XggVzfl8z9"
crossorigin=""
></script>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+31
View File
@@ -11,12 +11,15 @@
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
"@types/leaflet": "^1.9.12",
"@types/node": "^24.6.0", "@types/node": "^24.6.0",
"@types/react": "^19.1.16", "@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
@@ -1064,6 +1067,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1071,6 +1081,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "24.7.1", "version": "24.7.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz",
@@ -2623,6 +2643,17 @@
"json-buffer": "3.0.1" "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": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+3
View File
@@ -13,12 +13,15 @@
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
"@types/leaflet": "^1.9.12",
"@types/node": "^24.6.0", "@types/node": "^24.6.0",
"@types/react": "^19.1.16", "@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
+81 -66
View File
@@ -1,9 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Badge } from '@ui/badge' import L, { type LayerGroup, type LeafletMouseEvent, type Map as LeafletMap } from 'leaflet'
import { Button } from '@ui/button' import 'leaflet.heat'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@ui/card'
import { Separator } from '@ui/separator' import { Badge } from '@/components/ui/badge'
import { cn, distanceInKm, formatCoordinate, formatRelativeTime } from '@lib/utils' 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 API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/signals'
const VISIBLE_RADIUS_KM = 5 const VISIBLE_RADIUS_KM = 5
@@ -36,6 +39,25 @@ type ApiResponse = {
updatedAt?: string 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<number, string>
}
type LeafletWithHeat = typeof L & {
heatLayer?: (points: HeatPoint[], options?: HeatLayerOptions) => LeafletHeatLayer
}
interface LatLng { interface LatLng {
lat: number lat: number
lng: number lng: number
@@ -70,12 +92,12 @@ function geolocationErrorMessage(error: GeolocationPositionError): string {
} }
export default function App() { export default function App() {
const mapRef = useRef<any>(null) const mapRef = useRef<LeafletMap | null>(null)
const mapContainerRef = useRef<HTMLDivElement | null>(null) const mapContainerRef = useRef<HTMLDivElement | null>(null)
const heatLayerRef = useRef<any>(null) const heatLayerRef = useRef<LeafletHeatLayer | null>(null)
const markersLayerRef = useRef<any>(null) const markersLayerRef = useRef<LayerGroup | null>(null)
const zonesLayerRef = useRef<any>(null) const zonesLayerRef = useRef<LayerGroup | null>(null)
const userLayerRef = useRef<any>(null) const userLayerRef = useRef<LayerGroup | null>(null)
const locationWatchIdRef = useRef<number | null>(null) const locationWatchIdRef = useRef<number | null>(null)
const statusRef = useRef<Status>('loading') const statusRef = useRef<Status>('loading')
const initialLoadRef = useRef(true) const initialLoadRef = useRef(true)
@@ -229,15 +251,11 @@ export default function App() {
return return
} }
const leaflet = (window as any).L const leaflet = L as LeafletWithHeat
if (!leaflet) { const container = mapContainerRef.current
setErrorMessage('Leaflet failed to load. Refresh the page to try again.')
setStatusSafe('error')
return
}
const map = leaflet const map = leaflet
.map(mapContainerRef.current, { .map(container, {
worldCopyJump: true, worldCopyJump: true,
minZoom: 2, minZoom: 2,
zoomControl: true, zoomControl: true,
@@ -252,20 +270,21 @@ export default function App() {
}) })
.addTo(map) .addTo(map)
const heatLayer = typeof leaflet.heatLayer === 'function' const heatLayer =
? leaflet.heatLayer([], { typeof leaflet.heatLayer === 'function'
radius: 32, ? leaflet.heatLayer([], {
blur: 24, radius: 32,
maxZoom: 12, blur: 24,
gradient: { maxZoom: 12,
0.2: '#38bdf8', gradient: {
0.4: '#0ea5e9', 0.2: '#38bdf8',
0.6: '#fbbf24', 0.4: '#0ea5e9',
0.8: '#f97316', 0.6: '#fbbf24',
1.0: '#ef4444', 0.8: '#f97316',
}, 1.0: '#ef4444',
}) },
: null })
: null
const markersLayer = leaflet.layerGroup().addTo(map) const markersLayer = leaflet.layerGroup().addTo(map)
const zonesLayer = leaflet.layerGroup().addTo(map) const zonesLayer = leaflet.layerGroup().addTo(map)
@@ -275,8 +294,8 @@ export default function App() {
heatLayer.addTo(map) heatLayer.addTo(map)
} }
const onClick = (event: any) => { const onClick = (event: LeafletMouseEvent) => {
const { lat, lng } = event.latlng ?? {} const { lat, lng } = event.latlng
if (typeof lat === 'number' && typeof lng === 'number') { if (typeof lat === 'number' && typeof lng === 'number') {
handleMapClick({ lat, lng }) handleMapClick({ lat, lng })
} }
@@ -314,7 +333,7 @@ export default function App() {
zonesLayerRef.current = null zonesLayerRef.current = null
userLayerRef.current = null userLayerRef.current = null
} }
}, [handleMapClick, setStatusSafe]) }, [handleMapClick])
useEffect(() => { useEffect(() => {
const cleanup = initialiseMap() const cleanup = initialiseMap()
@@ -382,8 +401,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
const heatLayer = heatLayerRef.current const heatLayer = heatLayerRef.current
const leaflet = (window as any).L if (!heatLayer) {
if (!heatLayer || !leaflet) {
return return
} }
@@ -393,14 +411,17 @@ export default function App() {
} }
const maxIntensity = Math.max(...visibleDensity.map((entry) => entry.intensity)) || 1 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) heatLayer.setLatLngs(heatPoints)
}, [visibleDensity]) }, [visibleDensity])
useEffect(() => { useEffect(() => {
const layer = zonesLayerRef.current const layer = zonesLayerRef.current
const leaflet = (window as any).L if (!layer) {
if (!layer || !leaflet) {
return return
} }
@@ -415,7 +436,7 @@ export default function App() {
visibleDangerZones.forEach((zone, index) => { visibleDangerZones.forEach((zone, index) => {
const intensityRatio = maxIntensity ? zone.intensity / maxIntensity : 0.5 const intensityRatio = maxIntensity ? zone.intensity / maxIntensity : 0.5
const radius = 400 + intensityRatio * 1600 const radius = 400 + intensityRatio * 1600
const circle = leaflet.circle([zone.lat, zone.lng], { const circle = L.circle([zone.lat, zone.lng], {
radius, radius,
color: index === 0 ? '#ef4444' : '#f97316', color: index === 0 ? '#ef4444' : '#f97316',
weight: 2, weight: 2,
@@ -432,8 +453,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
const layer = markersLayerRef.current const layer = markersLayerRef.current
const leaflet = (window as any).L if (!layer) {
if (!layer || !leaflet) {
return return
} }
@@ -449,7 +469,7 @@ export default function App() {
latestMap.forEach((point) => { latestMap.forEach((point) => {
const isSelf = clientKey && point.userKey === clientKey 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, radius: isSelf ? 10 : 6,
color: isSelf ? '#38bdf8' : '#94a3b8', color: isSelf ? '#38bdf8' : '#94a3b8',
weight: isSelf ? 3 : 1.5, weight: isSelf ? 3 : 1.5,
@@ -470,8 +490,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
const layer = userLayerRef.current const layer = userLayerRef.current
const leaflet = (window as any).L if (!layer) {
if (!layer || !leaflet) {
return return
} }
@@ -481,28 +500,24 @@ export default function App() {
return return
} }
leaflet L.circle([userLocation.lat, userLocation.lng], {
.circle([userLocation.lat, userLocation.lng], { radius: VISIBLE_RADIUS_KM * 1000,
radius: VISIBLE_RADIUS_KM * 1000, color: '#38bdf8',
color: '#38bdf8', weight: 1.5,
weight: 1.5, opacity: 0.6,
opacity: 0.6, dashArray: '6 6',
dashArray: '6 6', fillColor: '#38bdf8',
fillColor: '#38bdf8', fillOpacity: 0.05,
fillOpacity: 0.05, }).addTo(layer)
})
.addTo(layer)
leaflet L.circleMarker([userLocation.lat, userLocation.lng], {
.circleMarker([userLocation.lat, userLocation.lng], { radius: 7,
radius: 7, color: '#38bdf8',
color: '#38bdf8', weight: 2,
weight: 2, opacity: 0.9,
opacity: 0.9, fillColor: '#38bdf8',
fillColor: '#38bdf8', fillOpacity: 0.5,
fillOpacity: 0.5, }).addTo(layer)
})
.addTo(layer)
}, [userLocation]) }, [userLocation])
useEffect(() => { useEffect(() => {
+1 -1
View File
@@ -1,7 +1,7 @@
import * as React from 'react' import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@lib/utils' import { cn } from '@/lib/utils'
const badgeVariants = cva( 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', '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',
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from 'react'
import { Slot } from '@radix-ui/react-slot' import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@lib/utils' import { cn } from '@/lib/utils'
const buttonVariants = cva( 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', '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',
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from 'react' import * as React from 'react'
import { cn } from '@lib/utils' import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from 'react' import * as React from 'react'
import { cn } from '@lib/utils' import { cn } from '@/lib/utils'
const Separator = React.forwardRef< const Separator = React.forwardRef<
HTMLDivElement, HTMLDivElement,
+2
View File
@@ -1,5 +1,7 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import 'leaflet/dist/leaflet.css'
import '@/index.css' import '@/index.css'
import App from '@/App.tsx' import App from '@/App.tsx'
+22
View File
@@ -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<number, string>
}
export default function heatLayer(
latlngs: Array<[number, number, number?]>,
options?: HeatLayerOptions,
): HeatLayer
}
+1 -3
View File
@@ -9,9 +9,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {
"@/*": ["src/*"], "@/*": ["src/*"]
"@ui/*": ["src/components/ui/*"],
"@lib/*": ["src/lib/*"]
}, },
/* Bundler mode */ /* Bundler mode */
-2
View File
@@ -14,8 +14,6 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)), '@': 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)),
}, },
}, },
}) })