Skip to content

Commit dcdfd0d

Browse files
committed
feat: add NearestMarkerExample and integrate nearest marker functionality in MarkerMap
1 parent cfe6257 commit dcdfd0d

File tree

15 files changed

+562
-45
lines changed

15 files changed

+562
-45
lines changed

.biomeignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules
2+
dist
3+
example/dist

biome.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
{
2-
"extends": ["@tracktor/biome-config-react"]
2+
"extends": ["@tracktor/biome-config-react"],
3+
"files": {
4+
"includes": ["src/**", "!example/dist"]
5+
}
36
}

example/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ThemeProvider } from "@tracktor/design-system";
22
import FeaturesExample from "example/FeaturesExample.tsx";
33
import LandingPage from "example/LandingPage.tsx";
44
import MarkersExample from "example/MarkersExample";
5+
import NearestMarkerExample from "example/NearestMarkerExample.tsx";
56
import RouteExample from "example/RoutesExample";
67
import { useState } from "react";
78
import { Route, Routes } from "react-router-dom";
@@ -19,6 +20,7 @@ const App = () => {
1920
<Route path="/markers" element={<MarkersExample themeMode={themeMode} setThemeMode={setThemeMode} />} />
2021
<Route path="/features" element={<FeaturesExample themeMode={themeMode} setThemeMode={setThemeMode} />} />
2122
<Route path="/route" element={<RouteExample themeMode={themeMode} setThemeMode={setThemeMode} />} />
23+
<Route path="/nearest-marker" element={<NearestMarkerExample themeMode={themeMode} setThemeMode={setThemeMode} />} />
2224
</Routes>
2325
</MapProvider>
2426
</ThemeProvider>

example/LandingPage.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from "@tracktor/design-system";
1414
import FeaturesPreview from "example/public/assets/features-preview.png";
1515
import MarkerPreview from "example/public/assets/markers-preview.png";
16+
import NearestPreview from "example/public/assets/nearest-preview.png";
1617
import RoutePreview from "example/public/assets/route-preview.png";
1718
import type { PrismTheme } from "prism-react-renderer";
1819
import { Highlight } from "prism-react-renderer";
@@ -76,6 +77,12 @@ const cardData = [
7677
path: "/features",
7778
title: "🗺 Features Example",
7879
},
80+
{
81+
description: "Find and highlight the nearest marker from a given origin point.",
82+
image: NearestPreview,
83+
path: "/nearest-marker",
84+
title: "📌 Nearest Marker Example",
85+
},
7986
];
8087

8188
const PropsTable = () => (

example/Navbar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const navItems = [
66
{ icon: "📍", label: "Markers", path: "/markers" },
77
{ icon: "🧭", label: "Route", path: "/route" },
88
{ icon: "🗺️", label: "Features", path: "/features" },
9+
{ icon: "🔎", label: "Nearest Marker", path: "/nearest-marker" },
910
];
1011

1112
const Navbar = () => {

example/NearestMarkerExample.tsx

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { Box, Button, FormControl, InputLabel, MenuItem, Select, Stack, Switch, Typography } from "@tracktor/design-system";
2+
import Navbar from "example/Navbar.tsx";
3+
import { useMemo, useState } from "react";
4+
import type { ProjectionSpecification } from "react-map-gl";
5+
import MarkerMap from "@/Features/MarkerMap/MarkerMap";
6+
7+
const predefinedOrigins = [
8+
{ coords: [2.3522, 48.8566] as [number, number], id: "origin-paris", name: "Paris (origin)" },
9+
{ coords: [4.8357, 45.764] as [number, number], id: "origin-lyon", name: "Lyon (origin)" },
10+
{ coords: [-0.5792, 44.8378] as [number, number], id: "origin-bordeaux", name: "Bordeaux (origin)" },
11+
{ coords: [5.3698, 43.2965] as [number, number], id: "origin-marseille", name: "Marseille (origin)" },
12+
{ coords: [1.4442, 43.6047] as [number, number], id: "origin-toulouse", name: "Toulouse (origin)" },
13+
];
14+
15+
const predefinedDestinations = [
16+
{ id: 1, lat: 50.6292, lng: 3.0573, name: "Lille" },
17+
{ id: 2, lat: 43.2965, lng: 5.375, name: "Marseille Old Port" },
18+
{ id: 3, lat: 44.8378, lng: -0.5792, name: "Bordeaux" },
19+
{ id: 4, lat: 45.764, lng: 4.8357, name: "Lyon" },
20+
{ id: 5, lat: 43.604, lng: 1.444, name: "Toulouse Capitole" },
21+
{ id: 6, lat: 43.6108, lng: 3.8767, name: "Montpellier" },
22+
{ id: 7, lat: 43.7102, lng: 7.262, name: "Nice" },
23+
{ id: 8, lat: 47.2184, lng: -1.5536, name: "Nantes" },
24+
{ id: 9, lat: 48.5734, lng: 7.7521, name: "Strasbourg" },
25+
{ id: 10, lat: 49.2583, lng: 4.0317, name: "Reims" },
26+
{ id: 11, lat: 47.322, lng: 5.0415, name: "Dijon" },
27+
{ id: 12, lat: 45.7833, lng: 3.0833, name: "Clermont-Ferrand" },
28+
];
29+
30+
interface NearestMarkerExampleProps {
31+
themeMode: "light" | "dark";
32+
setThemeMode: (mode: "light" | "dark") => void;
33+
}
34+
35+
const NearestMarkerExample = ({ themeMode, setThemeMode }: NearestMarkerExampleProps) => {
36+
const [projection, setProjection] = useState<ProjectionSpecification>({ name: "mercator" });
37+
const [profile, setProfile] = useState<"driving" | "walking" | "cycling">("driving");
38+
const [cooperativeGestures, setCooperativeGestures] = useState(true);
39+
const [doubleClickZoom, setDoubleClickZoom] = useState(true);
40+
const [selectedOrigin, setSelectedOrigin] = useState(predefinedOrigins[0]);
41+
const [searchRadius, setSearchRadius] = useState(3_000_000);
42+
const [nearestId, setNearestId] = useState<number | null>(null);
43+
const [nearestInfo, setNearestInfo] = useState<{ name: string; distance: number } | null>(null);
44+
45+
const originMarker = useMemo(
46+
() => [
47+
{
48+
id: "origin",
49+
lat: selectedOrigin.coords[1],
50+
lng: selectedOrigin.coords[0],
51+
popup: selectedOrigin.name,
52+
variant: "success",
53+
},
54+
],
55+
[selectedOrigin],
56+
);
57+
58+
const destinationMarkers = useMemo(
59+
() =>
60+
predefinedDestinations.map((m) => ({
61+
color: m.id === nearestId ? "#2563eb" : "#f59e0b", // 🔵 blue if nearest, 🟠 orange otherwise
62+
id: m.id,
63+
lat: m.lat,
64+
lng: m.lng,
65+
popup: `🎯 ${m.name}`,
66+
})),
67+
[nearestId],
68+
);
69+
70+
const allMarkers = useMemo(() => [...destinationMarkers, ...originMarker], [originMarker, destinationMarkers]);
71+
72+
return (
73+
<>
74+
<Navbar />
75+
<Stack direction="row" sx={{ height: "100vh", overflow: "hidden", width: "100vw" }}>
76+
<Box sx={{ flex: 1 }}>
77+
<MarkerMap
78+
markers={allMarkers}
79+
profile={profile}
80+
cooperativeGestures={cooperativeGestures}
81+
doubleClickZoom={doubleClickZoom}
82+
projection={projection}
83+
fitBounds
84+
height="100%"
85+
width="100%"
86+
itineraryLineStyle={{
87+
color: "#2563eb",
88+
opacity: 0.8,
89+
width: 2,
90+
}}
91+
findNearestMarker={{
92+
destinations: predefinedDestinations,
93+
maxDistanceMeters: searchRadius,
94+
origin: selectedOrigin.coords,
95+
}}
96+
onNearestFound={(id, _coords, distanceMeters) => {
97+
setNearestId(id as number);
98+
const info = predefinedDestinations.find((d) => d.id === id);
99+
if (info) {
100+
setNearestInfo({ distance: Math.round(distanceMeters), name: info.name });
101+
}
102+
}}
103+
/>
104+
</Box>
105+
106+
<Box
107+
sx={{
108+
backgroundColor: "background.paper",
109+
borderColor: "divider",
110+
borderLeft: "1px solid",
111+
color: "text.primary",
112+
display: "flex",
113+
flexDirection: "column",
114+
gap: 2,
115+
p: 2,
116+
width: 300,
117+
}}
118+
>
119+
<Typography variant="h6">🧭 Nearest marker test</Typography>
120+
121+
<Typography variant="body2" color="text.secondary">
122+
Theme
123+
</Typography>
124+
<Button variant="outlined" onClick={() => setThemeMode(themeMode === "dark" ? "light" : "dark")}>
125+
{themeMode === "dark" ? "Light mode" : "Dark mode"}
126+
</Button>
127+
128+
<Typography variant="body2" color="text.secondary">
129+
Origin
130+
</Typography>
131+
<Select
132+
value={selectedOrigin.id}
133+
onChange={(e) => {
134+
const origin = predefinedOrigins.find((o) => o.id === e.target.value);
135+
if (origin) {
136+
setSelectedOrigin(origin);
137+
}
138+
setNearestId(null);
139+
setNearestInfo(null);
140+
}}
141+
size="small"
142+
>
143+
{predefinedOrigins.map((o) => (
144+
<MenuItem key={o.id} value={o.id}>
145+
{o.name}
146+
</MenuItem>
147+
))}
148+
</Select>
149+
150+
<Typography variant="body2" color="text.secondary">
151+
Profile
152+
</Typography>
153+
<Select value={profile} onChange={(e) => setProfile(e.target.value as "driving" | "walking" | "cycling")} size="small">
154+
<MenuItem value="driving">🚗 Driving</MenuItem>
155+
<MenuItem value="walking">🚶 Walking</MenuItem>
156+
<MenuItem value="cycling">🚴 Cycling</MenuItem>
157+
</Select>
158+
159+
<Typography variant="body2" color="text.secondary">
160+
Interactions
161+
</Typography>
162+
<Stack spacing={1}>
163+
<Stack direction="row" alignItems="center" justifyContent="space-between">
164+
<Typography variant="body2">Cooperative gestures</Typography>
165+
<Switch checked={cooperativeGestures} onChange={(e) => setCooperativeGestures(e.target.checked)} />
166+
</Stack>
167+
<Stack direction="row" alignItems="center" justifyContent="space-between">
168+
<Typography variant="body2">Double click zoom</Typography>
169+
<Switch checked={doubleClickZoom} onChange={(e) => setDoubleClickZoom(e.target.checked)} />
170+
</Stack>
171+
</Stack>
172+
173+
<Typography variant="body2" color="text.secondary">
174+
Projection
175+
</Typography>
176+
<Select
177+
value={projection.name}
178+
onChange={(e) => setProjection({ name: e.target.value as ProjectionSpecification["name"] })}
179+
size="small"
180+
>
181+
<MenuItem value="mercator">Mercator</MenuItem>
182+
<MenuItem value="globe">Globe</MenuItem>
183+
<MenuItem value="albers">Albers</MenuItem>
184+
<MenuItem value="equalEarth">Equal Earth</MenuItem>
185+
<MenuItem value="equirectangular">Equirectangular</MenuItem>
186+
<MenuItem value="naturalEarth">Natural Earth</MenuItem>
187+
</Select>
188+
189+
<FormControl size="small">
190+
<InputLabel>Max distance (m)</InputLabel>
191+
<Select value={searchRadius} label="Max distance (m)" onChange={(e) => setSearchRadius(Number(e.target.value))}>
192+
<MenuItem value={10000}>10 km</MenuItem>
193+
<MenuItem value={50000}>50 km</MenuItem>
194+
<MenuItem value={100000}>100 km</MenuItem>
195+
<MenuItem value={500000}>500 km</MenuItem>
196+
<MenuItem value={1000000}>1000 km</MenuItem>
197+
<MenuItem value={3000000}>∞ (no limit)</MenuItem>
198+
</Select>
199+
</FormControl>
200+
201+
{nearestInfo && (
202+
<Box>
203+
<Typography variant="body2" fontWeight="bold">
204+
Nearest destination:
205+
</Typography>
206+
<Typography variant="body2">{nearestInfo.name}</Typography>
207+
<Typography variant="body2" color="text.secondary">
208+
{(nearestInfo.distance / 1000).toFixed(1)} km away
209+
</Typography>
210+
</Box>
211+
)}
212+
213+
<Stack direction="column" spacing={0.5}>
214+
<Typography variant="caption" color="text.secondary">
215+
🟢 Origin = green
216+
</Typography>
217+
<Typography variant="caption" color="text.secondary">
218+
🟠 Destination = orange
219+
</Typography>
220+
<Typography variant="caption" color="text.secondary">
221+
🔵 Nearest = blue
222+
</Typography>
223+
</Stack>
224+
</Box>
225+
</Stack>
226+
</>
227+
);
228+
};
229+
230+
export default NearestMarkerExample;
175 KB
Loading
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { Feature, GeoJsonProperties, LineString } from "geojson";
2+
import { useEffect, useState } from "react";
3+
import { Layer, Source } from "react-map-gl";
4+
import mapboxRoute from "@/services/Mapbox/mapboxRoute.ts";
5+
import OSRMRoute from "@/services/OSRM/OSRMRoute.ts";
6+
import { ItineraryLineStyle } from "@/types/MarkerMapProps.ts";
7+
8+
type ItineraryProps = {
9+
from?: [number, number];
10+
to?: [number, number];
11+
profile?: "driving" | "walking" | "cycling";
12+
routeService?: "OSRM" | "Mapbox";
13+
itineraryLineStyle?: Partial<ItineraryLineStyle>;
14+
};
15+
16+
const Itinerary = ({ profile, routeService, to, from, itineraryLineStyle }: ItineraryProps) => {
17+
const [route, setRoute] = useState<Feature<LineString, GeoJsonProperties> | null>(null);
18+
19+
useEffect(() => {
20+
if (!(from && to)) {
21+
return;
22+
}
23+
24+
(async () => {
25+
try {
26+
const r = routeService === "OSRM" ? await OSRMRoute(from, to, profile) : await mapboxRoute(from, to, profile);
27+
28+
if (r) {
29+
setRoute(r);
30+
} else {
31+
console.warn("No route found between the specified points.");
32+
setRoute(null);
33+
}
34+
} catch (error) {
35+
console.error("Error fetching route:", error);
36+
setRoute(null);
37+
}
38+
})();
39+
}, [from, to, profile, routeService]);
40+
41+
if (!route) {
42+
return null;
43+
}
44+
45+
return (
46+
<Source type="geojson" data={route}>
47+
<Layer
48+
type="line"
49+
paint={{
50+
"line-color": itineraryLineStyle?.color ?? "#9c3333",
51+
"line-opacity": itineraryLineStyle?.opacity ?? 0.8,
52+
"line-width": itineraryLineStyle?.width ?? 4,
53+
}}
54+
layout={{
55+
"line-cap": "round",
56+
"line-join": "round",
57+
}}
58+
/>
59+
</Source>
60+
);
61+
};
62+
63+
export default Itinerary;

0 commit comments

Comments
 (0)