Refactor client Leaflet integration to avoid conflicts
This commit is contained in:
@@ -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>
|
||||
|
||||
Generated
+31
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,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',
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
Vendored
+22
@@ -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
|
||||
}
|
||||
@@ -9,9 +9,7 @@
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@ui/*": ["src/components/ui/*"],
|
||||
"@lib/*": ["src/lib/*"]
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Bundler mode */
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user