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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
<body>
<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>
</body>
</html>
+31
View File
@@ -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",
+3
View File
@@ -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",
+81 -66
View File
@@ -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<number, string>
}
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<any>(null)
const mapRef = useRef<LeafletMap | null>(null)
const mapContainerRef = useRef<HTMLDivElement | null>(null)
const heatLayerRef = useRef<any>(null)
const markersLayerRef = useRef<any>(null)
const zonesLayerRef = useRef<any>(null)
const userLayerRef = useRef<any>(null)
const heatLayerRef = useRef<LeafletHeatLayer | null>(null)
const markersLayerRef = useRef<LayerGroup | null>(null)
const zonesLayerRef = useRef<LayerGroup | null>(null)
const userLayerRef = useRef<LayerGroup | null>(null)
const locationWatchIdRef = useRef<number | null>(null)
const statusRef = useRef<Status>('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(() => {
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from 'react'
import { cn } from '@lib/utils'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from 'react'
import { cn } from '@lib/utils'
import { cn } from '@/lib/utils'
const Separator = React.forwardRef<
HTMLDivElement,
+2
View File
@@ -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'
+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,
"baseUrl": "./",
"paths": {
"@/*": ["src/*"],
"@ui/*": ["src/components/ui/*"],
"@lib/*": ["src/lib/*"]
"@/*": ["src/*"]
},
/* Bundler mode */
-2
View File
@@ -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)),
},
},
})