Skip to content

Commit 83d95fb

Browse files
committed
feat: integrate FitBounds component for automatic map bounds adjustment and update dependencies
1 parent 9d062e3 commit 83d95fb

File tree

4 files changed

+134
-93
lines changed

4 files changed

+134
-93
lines changed

bun.lock

Lines changed: 11 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@
3636
"@mui/x-license": "^8.6.0",
3737
"@tracktor/design-system": "^4.3.1",
3838
"@tracktor/react-utils": "^1.24.0",
39-
"mapbox-gl": "^3.12.0"
39+
"@types/react-map-gl": "^6.1.7",
40+
"mapbox-gl": "^3.16.0",
41+
"react-map-gl": "7.0.20"
4042
},
4143
"devDependencies": {
4244
"@tracktor/biome-config-react": "^1.3.0",

src/Features/Bounds/FitsBounds.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import mapboxgl from "mapbox-gl";
2+
import { useEffect } from "react";
3+
import { useMap } from "react-map-gl";
4+
import { MarkerProps } from "@/types/MarkerProps";
5+
6+
interface FitBoundsProps {
7+
markers: MarkerProps[];
8+
padding?: number;
9+
duration?: number;
10+
}
11+
12+
const FitBounds = ({ markers, padding = 50, duration = 1000 }: FitBoundsProps) => {
13+
const { current: map } = useMap();
14+
15+
useEffect(() => {
16+
if (!map || markers.length === 0) {
17+
return;
18+
}
19+
20+
const bounds = new mapboxgl.LngLatBounds();
21+
for (const marker of markers) {
22+
bounds.extend([marker.lng, marker.lat]);
23+
}
24+
25+
if (bounds.isEmpty()) {
26+
return;
27+
}
28+
29+
map.fitBounds(bounds, {
30+
duration,
31+
padding,
32+
});
33+
}, [map, markers, padding, duration]);
34+
35+
return null;
36+
};
37+
38+
export default FitBounds;

src/components/MarkerMap/MarkerMap.tsx

Lines changed: 82 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,45 @@
1-
import { Box, GlobalStyles, Skeleton } from "@tracktor/design-system";
2-
import mapboxgl from "mapbox-gl";
3-
import { memo, ReactElement } from "react";
4-
import useMarkerMap from "@/components/MarkerMap/useMarkerMap";
5-
import { MarkerMapProps } from "@/types/MarkerMapProps.ts";
1+
import { Box, GlobalStyles, Skeleton, useTheme } from "@tracktor/design-system";
2+
import { memo, ReactElement, useMemo, useState } from "react";
3+
import MapboxMap, { Marker, Popup } from "react-map-gl";
4+
import { MarkerMapProps } from "@/types/MarkerMapProps";
5+
import "mapbox-gl/dist/mapbox-gl.css";
6+
import { isArray } from "@tracktor/react-utils";
7+
import FitBounds from "@/Features/Bounds/FitsBounds.ts";
68

7-
/**
8-
* MarkerMap is a reusable React component that displays an interactive Mapbox map
9-
* with customizable markers and behavior.
10-
*
11-
* It supports features like:
12-
* - Auto-fitting bounds to markers
13-
* - Custom marker icons and tooltips
14-
* - Light/dark theming
15-
* - Fly animations and zooming
16-
* - Popup display (on click or hover)
17-
* - Custom styling for the map container
18-
* - Manual or automatic control of map centering and zoom
19-
*
20-
* @param {object} props - Props used to configure the map rendering.
21-
* @param {boolean} [props.fitBounds] - If true, automatically adjusts the viewport to fit all markers.
22-
* @param {number} [props.fitBoundsPadding] - Padding in pixels when fitting bounds to markers.
23-
* @param {LngLatLike | number[]} [props.center] - Initial center of the map [lng, lat].
24-
* @param {string} [props.mapStyle] - Mapbox style URL or identifier (e.g. "mapbox://styles/mapbox/streets-v11").
25-
* @param {number} [props.zoom] - Initial zoom level of the map.
26-
* @param {string} [props.popupMaxWidth] - Maximum width of popups (e.g., "200px").
27-
* @param {number | string} [props.width="100%"] - Width of the map container.
28-
* @param {number | string} [props.height=300] - Height of the map container.
29-
* @param {boolean} [props.loading] - Optional flag indicating if the map is in loading state.
30-
* @param {string} [props.markerImageURL] - URL of a custom image used for default marker icons.
31-
* @param {SxProps} [props.containerStyle] - Style object (MUI `sx`) to customize the map container.
32-
* @param {number} [props.fitBoundDuration] - Duration of fitBounds animation in milliseconds.
33-
* @param {boolean} [props.square] - If true, forces the map container to be a square.
34-
* @param {number | string} [props.openPopup] - ID of the marker whose popup should be open by default.
35-
* @param {boolean} [props.openPopupOnHover] - If true, opens the popup on marker hover instead of click.
36-
* @param {MarkerProps[]} [props.markers] - Array of marker objects to render on the map.
37-
* @param {(lng: number, lat: number) => void} [props.onMapClick] - Callback triggered when the map is clicked.
38-
* @param {"light" | "dark"} [props.theme] - Optional theme override for map rendering.
39-
*
40-
* @returns {ReactElement} The rendered map component with optional markers and behavior.
41-
*
42-
* @example
43-
* ```tsx
44-
* <MarkerMap
45-
* center={[2.3488, 48.8534]}
46-
* zoom={13}
47-
* fitBounds
48-
* openPopupOnHover
49-
* popupMaxWidth="250px"
50-
* mapStyle="mapbox://styles/mapbox/light-v10"
51-
* theme="light"
52-
* markers={[
53-
* { id: 1, lat: 48.8534, lng: 2.3488, name: "Marker 1" },
54-
* { id: 2, lat: 48.8566, lng: 2.3522, name: "Marker 2" },
55-
* ]}
56-
* />
57-
* ```
58-
*/
59-
const MarkerMap = ({ containerStyle, square, loading, height = 300, width = "100%", ...props }: MarkerMapProps): ReactElement => {
60-
const { containerRef, currentTheme } = useMarkerMap(props);
9+
const MarkerMap = ({
10+
containerStyle,
11+
square,
12+
loading,
13+
height = 300,
14+
width = "100%",
15+
center = [2.3522, 48.8566],
16+
zoom = 5,
17+
popupMaxWidth,
18+
openPopup,
19+
openPopupOnHover,
20+
markers = [],
21+
fitBounds = true,
22+
fitBoundsPadding,
23+
fitBoundDuration,
24+
onMapClick,
25+
mapStyle = "mapbox://styles/mapbox/streets-v11",
26+
}: MarkerMapProps): ReactElement => {
27+
const theme = useTheme();
28+
const [selected, setSelected] = useState<string | number | null>(openPopup ?? null);
29+
30+
const selectedMarker = useMemo(() => (selected ? (markers.find((m) => m.id === selected) ?? null) : null), [selected, markers]);
31+
32+
const handleMarkerClick = (id: string | number) => {
33+
if (!openPopupOnHover) {
34+
setSelected(id);
35+
}
36+
};
37+
38+
const handleMarkerHover = (id: string | number | null) => {
39+
if (openPopupOnHover) {
40+
setSelected(id);
41+
}
42+
};
6143

6244
return (
6345
<Box sx={{ height, position: "relative", width, ...containerStyle }}>
@@ -71,42 +53,51 @@ const MarkerMap = ({ containerStyle, square, loading, height = 300, width = "100
7153
width: "fit-content!important",
7254
},
7355
".mapboxgl-popup-tip": {
74-
borderTopColor: currentTheme === "dark" ? "#1e1e1e !important" : '#ffffff !important"',
56+
borderTopColor: theme.palette.mode === "dark" ? "#1e1e1e !important" : '#ffffff !important"',
7557
},
7658
}}
7759
/>
78-
{mapboxgl.supported() ? (
79-
<Box
80-
ref={containerRef}
81-
sx={{
82-
alignItems: "center",
83-
borderRadius: square ? 0 : 1,
84-
height,
85-
justifyContent: "center",
86-
left: 0,
87-
overflow: "hidden",
88-
position: "absolute",
89-
top: 0,
90-
visibility: loading ? "hidden !important" : "visible",
91-
width,
92-
zIndex: 1,
93-
}}
94-
/>
95-
) : (
96-
<Box
97-
sx={{
98-
left: "50%",
99-
position: "absolute",
100-
textAlign: "center",
101-
top: "50%",
102-
transform: "translate(-50%, -50%)",
103-
}}
104-
>
105-
WebGL is not enabled in your browser. This technology is required to display the interactive map.
106-
</Box>
107-
)}
10860

109-
{/* Loading skeleton */}
61+
<MapboxMap
62+
initialViewState={{
63+
latitude: isArray(center) ? center[1] : center.lat,
64+
longitude: isArray(center) ? center[0] : center.lng,
65+
zoom,
66+
}}
67+
style={{ height: "100%", width: "100%" }}
68+
mapStyle={mapStyle}
69+
mapboxAccessToken={import.meta.env.VITE_MAPBOX_ACCESS_TOKEN}
70+
onClick={(e) => onMapClick?.(e.lngLat.lng, e.lngLat.lat)}
71+
>
72+
{markers.map((m) => (
73+
<Marker
74+
key={m.id}
75+
longitude={m.lng}
76+
latitude={m.lat}
77+
anchor="bottom"
78+
onClick={() => handleMarkerClick(m.id)}
79+
onMouseEnter={() => handleMarkerHover(m.id)}
80+
onMouseLeave={() => handleMarkerHover(null)}
81+
>
82+
{m.IconComponent ? <m.IconComponent {...m.iconProps} /> : <div>📍</div>}
83+
</Marker>
84+
))}
85+
86+
{selectedMarker && (
87+
<Popup
88+
longitude={selectedMarker.lng}
89+
latitude={selectedMarker.lat}
90+
anchor="top"
91+
onClose={() => setSelected(null)}
92+
maxWidth={popupMaxWidth}
93+
>
94+
{selectedMarker.Tooltip ?? <div>Marker {selectedMarker.id}</div>}
95+
</Popup>
96+
)}
97+
98+
{fitBounds && markers.length > 1 && <FitBounds markers={markers} padding={fitBoundsPadding} duration={fitBoundDuration} />}
99+
</MapboxMap>
100+
110101
{loading && (
111102
<Skeleton
112103
width={width}

0 commit comments

Comments
 (0)