Skip to content

Commit 90b4d2e

Browse files
iobuhovclaude
andcommitted
feat(maps-web): complete leaflet mobx migration with viewmodel, extract marker utils, fix linting
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 71e336d commit 90b4d2e

18 files changed

Lines changed: 311 additions & 284 deletions

packages/pluggableWidgets/maps-web/src/Maps.editorPreview.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ReactNode } from "react";
2-
import { MapsPreviewProps } from "../typings/MapsProps";
32
import { Alert } from "@mendix/widget-plugin-component-kit/Alert";
43
import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style";
4+
import { MapsPreviewProps } from "../typings/MapsProps";
55

66
export const preview = (props: MapsPreviewProps): ReactNode => {
77
return (

packages/pluggableWidgets/maps-web/src/components/GoogleMap.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { ReactElement, useEffect, useRef, useState } from "react";
2-
import classNames from "classnames";
31
import {
42
AdvancedMarker,
53
APIProvider,
@@ -11,9 +9,11 @@ import {
119
useApiIsLoaded,
1210
useMap
1311
} from "@vis.gl/react-google-maps";
12+
import classNames from "classnames";
13+
import { ReactElement, useEffect, useRef, useState } from "react";
14+
import { getDimensions } from "@mendix/widget-plugin-platform/utils/get-dimensions";
1415
import { Marker, SharedProps } from "../../typings/shared";
1516
import { translateZoom } from "../utils/zoom";
16-
import { getDimensions } from "@mendix/widget-plugin-platform/utils/get-dimensions";
1717

1818
export interface GoogleMapsProps extends SharedProps {
1919
mapId: string;

packages/pluggableWidgets/maps-web/src/components/LeafletMap.tsx

Lines changed: 24 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -1,179 +1,43 @@
11
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";
113
import { getDimensions } from "@mendix/widget-plugin-platform/utils/get-dimensions";
124
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";
167

178
export interface LeafletProps extends SharedProps {
189
mapProvider: MapProviderEnum;
1910
attributionControl: boolean;
2011
}
2112

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-
7813
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+
};
16730
}
168-
}
169-
}, [locations, currentLocation, autoZoom]);
31+
},
32+
[vm]
33+
);
17034

17135
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) }}>
17337
<div className="widget-leaflet-maps-wrapper">
17438
<div
17539
className="widget-leaflet-maps"
176-
ref={mapNodeRef}
40+
ref={refCallback}
17741
style={{ top: 0, bottom: 0, left: 0, right: 0, position: "absolute", zIndex: 1 }}
17842
/>
17943
</div>

packages/pluggableWidgets/maps-web/src/components/__tests__/GoogleMap.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import "@testing-library/jest-dom";
2+
import { initialize } from "@googlemaps/jest-mocks";
23
import { act, render, RenderResult } from "@testing-library/react";
34
import { GoogleMapContainer, GoogleMapsProps } from "../GoogleMap";
4-
import { initialize } from "@googlemaps/jest-mocks";
55

66
describe("Google maps", () => {
77
const defaultProps: GoogleMapsProps = {

0 commit comments

Comments
 (0)