|
1 | 1 | import classNames from "classnames"; |
2 | | -import { |
3 | | - DivIcon, |
4 | | - Icon as LeafletIcon, |
5 | | - latLngBounds, |
6 | | - Map as LeafletMapInstance, |
7 | | - Marker as LeafletMarker, |
8 | | - TileLayer |
9 | | -} from "leaflet"; |
10 | | -import { ReactElement, useEffect, useRef } from "react"; |
| 2 | +import { ReactElement, useCallback, useRef } from "react"; |
11 | 3 | import { getDimensions } from "@mendix/widget-plugin-platform/utils/get-dimensions"; |
12 | 4 | import { MapProviderEnum } from "../../typings/MapsProps"; |
13 | | -import { Marker, SharedProps } from "../../typings/shared"; |
14 | | -import { baseMapLayer } from "../utils/leaflet"; |
15 | | -import { translateZoom } from "../utils/zoom"; |
| 5 | +import { SharedProps } from "../../typings/shared"; |
| 6 | +import { useLeafletMapVM } from "../model/hooks/injection-hooks"; |
16 | 7 |
|
17 | 8 | export interface LeafletProps extends SharedProps { |
18 | 9 | mapProvider: MapProviderEnum; |
19 | 10 | attributionControl: boolean; |
20 | 11 | } |
21 | 12 |
|
22 | | -/** |
23 | | - * Leaflet fails to properly resolve the icon urls of the default marker implementation when the |
24 | | - * library is bundled (the urls are derived from the stylesheet location at runtime). Instead of |
25 | | - * patching `Icon.Default`, we always set the `icon` option explicitly. So if a custom icon is set, |
26 | | - * we use that. If not, we reuse a leaflet icon that's the same as the default implementation |
27 | | - * should be. |
28 | | - */ |
29 | | -const defaultMarkerIcon = new LeafletIcon({ |
30 | | - // eslint-disable-next-line @typescript-eslint/no-require-imports |
31 | | - iconRetinaUrl: require("leaflet/dist/images/marker-icon.png"), |
32 | | - // eslint-disable-next-line @typescript-eslint/no-require-imports |
33 | | - iconUrl: require("leaflet/dist/images/marker-icon.png"), |
34 | | - // eslint-disable-next-line @typescript-eslint/no-require-imports |
35 | | - shadowUrl: require("leaflet/dist/images/marker-shadow.png"), |
36 | | - iconSize: [25, 41], |
37 | | - iconAnchor: [12, 41] |
38 | | -}); |
39 | | - |
40 | | -function createMarkerIcon(marker: Marker): DivIcon | LeafletIcon { |
41 | | - return marker.url |
42 | | - ? new DivIcon({ |
43 | | - html: `<img src="${marker.url}" class="custom-leaflet-map-icon-marker-icon" alt="map marker" />`, |
44 | | - className: "custom-leaflet-map-icon-marker" |
45 | | - }) |
46 | | - : defaultMarkerIcon; |
47 | | -} |
48 | | - |
49 | | -function createPopupContent(marker: Marker): HTMLElement { |
50 | | - const content = document.createElement("span"); |
51 | | - content.textContent = marker.title ?? ""; |
52 | | - content.style.cursor = marker.onClick ? "pointer" : "none"; |
53 | | - if (marker.onClick) { |
54 | | - content.addEventListener("click", marker.onClick); |
55 | | - } |
56 | | - return content; |
57 | | -} |
58 | | - |
59 | | -function createLeafletMarker(marker: Marker): LeafletMarker { |
60 | | - const leafletMarker = new LeafletMarker( |
61 | | - { lat: marker.latitude, lng: marker.longitude }, |
62 | | - { |
63 | | - icon: createMarkerIcon(marker), |
64 | | - interactive: !!marker.title || !!marker.onClick, |
65 | | - title: marker.title |
66 | | - } |
67 | | - ); |
68 | | - |
69 | | - if (marker.title) { |
70 | | - leafletMarker.bindPopup(createPopupContent(marker)); |
71 | | - } else if (marker.onClick) { |
72 | | - leafletMarker.on("click", marker.onClick); |
73 | | - } |
74 | | - |
75 | | - return leafletMarker; |
76 | | -} |
77 | | - |
78 | 13 | export function LeafletMap(props: LeafletProps): ReactElement { |
79 | | - const center = { lat: 51.906688, lng: 4.48837 }; |
80 | | - const { |
81 | | - autoZoom, |
82 | | - attributionControl, |
83 | | - className, |
84 | | - currentLocation, |
85 | | - locations, |
86 | | - mapProvider, |
87 | | - mapsToken, |
88 | | - optionScroll: scrollWheelZoom, |
89 | | - optionZoomControl: zoomControl, |
90 | | - style, |
91 | | - zoomLevel: zoom, |
92 | | - optionDrag: dragging |
93 | | - } = props; |
94 | | - |
95 | | - const mapNodeRef = useRef<HTMLDivElement>(null); |
96 | | - const mapRef = useRef<LeafletMapInstance | undefined>(undefined); |
97 | | - const tileLayerRef = useRef<TileLayer | undefined>(undefined); |
98 | | - const markersRef = useRef<LeafletMarker[]>([]); |
99 | | - |
100 | | - // Create the map instance once on mount. Like react-leaflet's MapContainer, |
101 | | - // these options are immutable for the lifetime of the component. |
102 | | - useEffect(() => { |
103 | | - if (!mapNodeRef.current) { |
104 | | - return; |
105 | | - } |
106 | | - |
107 | | - const map = new LeafletMapInstance(mapNodeRef.current, { |
108 | | - attributionControl, |
109 | | - center, |
110 | | - dragging, |
111 | | - maxZoom: 18, |
112 | | - minZoom: 1, |
113 | | - scrollWheelZoom, |
114 | | - zoom: autoZoom ? translateZoom("city") : zoom, |
115 | | - zoomControl |
116 | | - }); |
117 | | - |
118 | | - mapRef.current = map; |
119 | | - |
120 | | - return () => { |
121 | | - mapRef.current = undefined; |
122 | | - tileLayerRef.current = undefined; |
123 | | - markersRef.current = []; |
124 | | - map.remove(); |
125 | | - }; |
126 | | - // eslint-disable-next-line react-hooks/exhaustive-deps |
127 | | - }, []); |
128 | | - |
129 | | - // Keep the base tile layer in sync with the map provider and token. |
130 | | - useEffect(() => { |
131 | | - const map = mapRef.current; |
132 | | - if (!map) { |
133 | | - return; |
134 | | - } |
135 | | - |
136 | | - const { url, ...options } = baseMapLayer(mapProvider, mapsToken); |
137 | | - const tileLayer = new TileLayer(url, options); |
138 | | - |
139 | | - tileLayerRef.current?.remove(); |
140 | | - tileLayerRef.current = tileLayer; |
141 | | - tileLayer.addTo(map); |
142 | | - }, [mapProvider, mapsToken]); |
143 | | - |
144 | | - // Sync markers and viewport with the resolved locations. |
145 | | - useEffect(() => { |
146 | | - const map = mapRef.current; |
147 | | - if (!map) { |
148 | | - return; |
149 | | - } |
150 | | - |
151 | | - const markers = locations.concat(currentLocation ? [currentLocation] : []).filter(m => !!m); |
152 | | - |
153 | | - markersRef.current.forEach(marker => marker.remove()); |
154 | | - markersRef.current = markers.map(marker => { |
155 | | - const leafletMarker = createLeafletMarker(marker); |
156 | | - leafletMarker.addTo(map); |
157 | | - return leafletMarker; |
158 | | - }); |
159 | | - |
160 | | - const bounds = latLngBounds(markers.map(m => [m.latitude, m.longitude])); |
161 | | - |
162 | | - if (bounds.isValid()) { |
163 | | - if (autoZoom) { |
164 | | - map.flyToBounds(bounds, { padding: [0.5, 0.5], animate: false }).invalidateSize(); |
165 | | - } else { |
166 | | - map.panTo(bounds.getCenter(), { animate: false }); |
| 14 | + const vm = useLeafletMapVM(); |
| 15 | + const cleanupRef = useRef<(() => void) | undefined>(undefined); |
| 16 | + |
| 17 | + const refCallback = useCallback( |
| 18 | + (node: HTMLDivElement | null) => { |
| 19 | + cleanupRef.current?.(); |
| 20 | + cleanupRef.current = undefined; |
| 21 | + |
| 22 | + if (node) { |
| 23 | + cleanupRef.current = vm.setupMap(node); |
| 24 | + // React 19: returned cleanup is called on unmount. |
| 25 | + // React 18: ignored (cleanup happens via null-call above). |
| 26 | + return () => { |
| 27 | + cleanupRef.current?.(); |
| 28 | + cleanupRef.current = undefined; |
| 29 | + }; |
167 | 30 | } |
168 | | - } |
169 | | - }, [locations, currentLocation, autoZoom]); |
| 31 | + }, |
| 32 | + [vm] |
| 33 | + ); |
170 | 34 |
|
171 | 35 | return ( |
172 | | - <div className={classNames("widget-maps", className)} style={{ ...style, ...getDimensions(props) }}> |
| 36 | + <div className={classNames("widget-maps", props.className)} style={{ ...props.style, ...getDimensions(props) }}> |
173 | 37 | <div className="widget-leaflet-maps-wrapper"> |
174 | 38 | <div |
175 | 39 | className="widget-leaflet-maps" |
176 | | - ref={mapNodeRef} |
| 40 | + ref={refCallback} |
177 | 41 | style={{ top: 0, bottom: 0, left: 0, right: 0, position: "absolute", zIndex: 1 }} |
178 | 42 | /> |
179 | 43 | </div> |
|
0 commit comments