|
1 | 1 | import React, { useEffect, useRef } from "react"; |
2 | 2 | import L from "leaflet"; |
3 | 3 | import "leaflet/dist/leaflet.css"; |
| 4 | +import "leaflet.markercluster"; |
| 5 | +import "leaflet.markercluster/dist/MarkerCluster.css"; |
| 6 | +import "leaflet.markercluster/dist/MarkerCluster.Default.css"; |
4 | 7 | import type { Profile } from "@/types"; |
5 | 8 | import { useTheme } from "@/components/theme-provider"; |
6 | 9 | import { escapeHtml } from "@/lib/profile-service"; |
@@ -48,7 +51,7 @@ export const CongoMap = React.memo(function CongoMap({ profiles, onProfileClick, |
48 | 51 | const mapRef = useRef<L.Map | null>(null); |
49 | 52 | const containerRef = useRef<HTMLDivElement>(null); |
50 | 53 | const tileLayerRef = useRef<L.TileLayer | null>(null); |
51 | | - const markersRef = useRef<L.LayerGroup | null>(null); |
| 54 | + const markersRef = useRef<L.MarkerClusterGroup | null>(null); |
52 | 55 | const markersMapRef = useRef<Map<string, L.Marker>>(new Map()); |
53 | 56 | const { theme } = useTheme(); |
54 | 57 |
|
@@ -89,6 +92,22 @@ export const CongoMap = React.memo(function CongoMap({ profiles, onProfileClick, |
89 | 92 | .leaflet-popup-content { |
90 | 93 | margin: 12px !important; |
91 | 94 | } |
| 95 | + .congo-cluster { background: transparent; } |
| 96 | + .congo-cluster-inner { |
| 97 | + display: flex; |
| 98 | + align-items: center; |
| 99 | + justify-content: center; |
| 100 | + border-radius: 50%; |
| 101 | + background: rgba(16, 185, 129, 0.9); |
| 102 | + color: #ffffff; |
| 103 | + font-weight: 700; |
| 104 | + font-family: system-ui, sans-serif; |
| 105 | + font-size: 13px; |
| 106 | + border: 2px solid #ffffff; |
| 107 | + box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.22), 0 2px 8px rgba(0, 0, 0, 0.45); |
| 108 | + transition: transform 0.15s ease; |
| 109 | + } |
| 110 | + .congo-cluster:hover .congo-cluster-inner { transform: scale(1.08); } |
92 | 111 | `; |
93 | 112 | document.head.appendChild(style); |
94 | 113 | } |
@@ -122,11 +141,33 @@ export const CongoMap = React.memo(function CongoMap({ profiles, onProfileClick, |
122 | 141 | maxZoom: 13, |
123 | 142 | }).addTo(mapRef.current); |
124 | 143 |
|
125 | | - markersRef.current = L.layerGroup().addTo(mapRef.current); |
| 144 | + markersRef.current = L.markerClusterGroup({ |
| 145 | + showCoverageOnHover: false, |
| 146 | + maxClusterRadius: 50, |
| 147 | + spiderfyDistanceMultiplier: 1.6, |
| 148 | + iconCreateFunction: (cluster) => { |
| 149 | + const count = cluster.getChildCount(); |
| 150 | + const size = count < 10 ? 36 : count < 50 ? 44 : 52; |
| 151 | + return L.divIcon({ |
| 152 | + html: `<div class="congo-cluster-inner" style="width:${size}px;height:${size}px;">${count}</div>`, |
| 153 | + className: "congo-cluster", |
| 154 | + iconSize: L.point(size, size), |
| 155 | + }); |
| 156 | + }, |
| 157 | + }).addTo(mapRef.current); |
| 158 | + |
| 159 | + // Leaflet mis-positions tiles and markers when its container is resized |
| 160 | + // while hidden (mobile list↔map toggle, sidebar, fullscreen), which throws |
| 161 | + // off centering. Recompute the size on any resize so flyTo lands correctly. |
| 162 | + const resizeObserver = new ResizeObserver(() => { |
| 163 | + mapRef.current?.invalidateSize(); |
| 164 | + }); |
| 165 | + resizeObserver.observe(containerRef.current); |
126 | 166 |
|
127 | 167 | onMapReady?.(mapRef.current); |
128 | 168 |
|
129 | 169 | return () => { |
| 170 | + resizeObserver.disconnect(); |
130 | 171 | mapRef.current?.remove(); |
131 | 172 | mapRef.current = null; |
132 | 173 | }; |
@@ -199,21 +240,32 @@ export const CongoMap = React.memo(function CongoMap({ profiles, onProfileClick, |
199 | 240 | }, [profiles, onProfileClick, focusedProfileId]); |
200 | 241 |
|
201 | 242 | useEffect(() => { |
202 | | - if (!focusedProfileId || !mapRef.current || !markersMapRef.current) return; |
| 243 | + const map = mapRef.current; |
| 244 | + const cluster = markersRef.current; |
| 245 | + if (!focusedProfileId || !map || !cluster) return; |
203 | 246 |
|
204 | 247 | const marker = markersMapRef.current.get(focusedProfileId); |
205 | | - if (marker) { |
206 | | - mapRef.current.flyTo(marker.getLatLng(), 11, { |
207 | | - animate: true, |
208 | | - duration: 1.5, |
209 | | - }); |
| 248 | + if (!marker) return; |
210 | 249 |
|
211 | | - const timer = setTimeout(() => { |
212 | | - marker.openPopup(); |
213 | | - }, 800); |
| 250 | + // The container may have just become visible (mobile/fullscreen toggle), so |
| 251 | + // make sure Leaflet knows its real size before computing the centering. |
| 252 | + map.invalidateSize(); |
214 | 253 |
|
215 | | - return () => clearTimeout(timer); |
216 | | - } |
| 254 | + const reveal = () => { |
| 255 | + // Profiles share a single city coordinate, so the focused one is usually |
| 256 | + // inside a cluster — zoomToShowLayer spiderfies it open, then we show its card. |
| 257 | + cluster.zoomToShowLayer(marker, () => marker.openPopup()); |
| 258 | + }; |
| 259 | + |
| 260 | + map.flyTo(marker.getLatLng(), Math.max(map.getZoom(), 11), { |
| 261 | + animate: true, |
| 262 | + duration: 1.2, |
| 263 | + }); |
| 264 | + map.once("moveend", reveal); |
| 265 | + |
| 266 | + return () => { |
| 267 | + map.off("moveend", reveal); |
| 268 | + }; |
217 | 269 | }, [focusedProfileId]); |
218 | 270 |
|
219 | 271 | return ( |
|
0 commit comments