Skip to content

Commit 203cf51

Browse files
committed
feat: map in lab form and dynamic map zoom and center
1 parent 6a9b91e commit 203cf51

6 files changed

Lines changed: 181 additions & 73 deletions

File tree

src/components/react/LivingLabsMapSection.tsx

Lines changed: 38 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import React, { useState, useEffect } from "react";
1+
import React, { useEffect, useState } from "react";
22
import { RButton } from "./ui";
33
import { getUrl } from "../../lib/helpers";
4+
import { MapViewer, type MarkerData } from "./MapViewer";
45

56
type LivingLab = {
67
id: string;
@@ -21,30 +22,19 @@ type Props = {
2122

2223
export function LivingLabsMapSection({ labs }: Props) {
2324
const [selectedLab, setSelectedLab] = useState<LivingLab | null>(null);
24-
25-
// state to hold dynamically imported components
26-
const [leafletComponents, setLeafletComponents] = useState<any | null>(null);
27-
25+
const [mapCenter, setMapCenter] = useState<[number, number]>([50, 10]);
26+
const [mapZoom, setMapZoom] = useState<number>(4);
27+
const mapKey = mapCenter ? `${mapCenter[0]},${mapCenter[1]}` : "no-center";
2828
useEffect(() => {
29-
let mounted = true;
30-
// load react-leaflet components and CSS on the client only
31-
async function loadLeaflet() {
32-
if (typeof window === "undefined") return;
33-
try {
34-
const comps = await import("react-leaflet");
35-
// dynamically load css so server doesn't try to process it at build time
36-
await import("leaflet/dist/leaflet.css");
37-
if (mounted) setLeafletComponents(comps);
38-
} catch (e) {
39-
// optional: handle or log load failure
40-
if (mounted) setLeafletComponents(null);
41-
}
29+
if (
30+
selectedLab &&
31+
selectedLab.coordinates?.lat &&
32+
selectedLab.coordinates?.lng
33+
) {
34+
setMapCenter([selectedLab.coordinates.lat, selectedLab.coordinates.lng]);
35+
setMapZoom(6);
4236
}
43-
loadLeaflet();
44-
return () => {
45-
mounted = false;
46-
};
47-
}, []);
37+
}, [selectedLab]);
4838

4939
const getStatusColor = (status: LivingLab["status"]) => {
5040
switch (status) {
@@ -57,8 +47,17 @@ export function LivingLabsMapSection({ labs }: Props) {
5747
}
5848
};
5949

50+
// convert labs to MarkerData for MapViewer
51+
const markers: MarkerData[] = labs.map((lab) => ({
52+
id: lab.id,
53+
name: lab.name,
54+
coordinates: lab.coordinates,
55+
radius: lab.radius * 1000, // convert km to meters
56+
meta: { lab },
57+
}));
58+
6059
return (
61-
<section className="bg-light py-12 px-4 sm:px-8">
60+
<section className="py-12 px-4 sm:px-8">
6261
<div className="max-w-7xl mx-auto">
6362
{/* Section Title */}
6463
<div className="mb-8">
@@ -75,49 +74,22 @@ export function LivingLabsMapSection({ labs }: Props) {
7574
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
7675
{/* Map */}
7776
<div className="lg:col-span-2 h-[600px] rounded shadow overflow-hidden">
78-
{/* <div className=""> */}
79-
{!leafletComponents && (
80-
<div className="flex items-center justify-center h-full w-full bg-gray-100 text-gray-600">
81-
Loading map...
82-
</div>
83-
)}
84-
85-
{leafletComponents &&
86-
(() => {
87-
const { MapContainer, TileLayer, Marker, Popup, Circle } =
88-
leafletComponents as any;
89-
return (
90-
<MapContainer
91-
center={[48.85, 2.35]}
92-
zoom={5}
93-
scrollWheelZoom={false}
94-
className="h-full w-full z-0"
95-
>
96-
<TileLayer
97-
attribution='&copy; <a href="https://osm.org">OpenStreetMap</a>'
98-
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
99-
/>
77+
<MapViewer
78+
key={mapKey}
79+
markers={markers}
80+
center={mapCenter}
81+
zoom={mapZoom}
82+
className="h-full w-full z-0"
83+
onMarkerClick={(m) => {
84+
// prefer passing the original lab if available in meta
85+
if (m.meta && m.meta.lab) setSelectedLab(m.meta.lab);
86+
else {
87+
const found = labs.find((l) => l.id === m.id);
88+
if (found) setSelectedLab(found);
89+
}
90+
}}
91+
/>
10092

101-
{labs.map((lab) => (
102-
<>
103-
<Marker
104-
key={lab.id}
105-
position={[lab.coordinates.lat, lab.coordinates.lng]}
106-
eventHandlers={{
107-
click: () => setSelectedLab(lab),
108-
}}
109-
/>
110-
<Circle
111-
center={[lab.coordinates.lat, lab.coordinates.lng]}
112-
radius={lab.radius}
113-
pathOptions={{ color: "#2563eb", fillOpacity: 0.2 }}
114-
/>
115-
</>
116-
))}
117-
</MapContainer>
118-
);
119-
})()}
120-
{/* </div> */}
12193
{/* Slide-up Detail Panel */}
12294
{selectedLab && (
12395
<div className=" lg:mr-80 bg-white rounded-lg p-3 shadow border border-primary sticky bottom-0 left-0 z-50">
@@ -177,9 +149,6 @@ export function LivingLabsMapSection({ labs }: Props) {
177149
>
178150
🔍 Explore {selectedLab.name} Living Lab
179151
</RButton>
180-
{/* <button className="bg-secondary text-white px-4 py-2 rounded hover:bg-dark transition">
181-
➕ More About This Lab
182-
</button> */}
183152
</div>
184153
</div>
185154
)}

src/components/react/MapViewer.tsx

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import React, { useEffect, useState } from "react";
2+
import { COLOR_WARNING } from "../../types";
3+
4+
export type MarkerData = {
5+
id: string;
6+
name?: string;
7+
coordinates: { lat: number; lng: number };
8+
radius?: number;
9+
// allow attaching any payload if needed
10+
meta?: Record<string, any>;
11+
};
12+
13+
type Props = {
14+
center?: [number, number];
15+
zoom?: number;
16+
markers: MarkerData[];
17+
className?: string;
18+
scrollWheelZoom?: boolean;
19+
onMarkerClick?: (m: MarkerData) => void;
20+
};
21+
22+
export function MapViewer({
23+
center = [50, 10],
24+
zoom = 5,
25+
markers,
26+
className = "h-full w-full",
27+
scrollWheelZoom = false,
28+
onMarkerClick,
29+
}: Props) {
30+
const [leafletComponents, setLeafletComponents] = useState<any | null>(null);
31+
const [markersState, setMarkersState] = useState<MarkerData[]>(markers);
32+
33+
useEffect(() => {
34+
let mounted = true;
35+
async function loadLeaflet() {
36+
if (typeof window === "undefined") return;
37+
try {
38+
const comps = await import("react-leaflet");
39+
await import("leaflet/dist/leaflet.css");
40+
if (mounted) setLeafletComponents(comps);
41+
} catch (e) {
42+
if (mounted) setLeafletComponents(null);
43+
}
44+
}
45+
loadLeaflet();
46+
return () => {
47+
mounted = false;
48+
};
49+
}, []);
50+
51+
// respond to markers changes: fit bounds when markers present
52+
useEffect(() => {
53+
setMarkersState(markers);
54+
}, [markers]);
55+
56+
if (!leafletComponents) {
57+
return (
58+
<div className="flex items-center justify-center h-full w-full bg-gray-100 text-gray-600">
59+
Loading map...
60+
</div>
61+
);
62+
}
63+
64+
const { MapContainer, TileLayer, Marker, Circle, Popup } =
65+
leafletComponents as any;
66+
67+
return (
68+
<MapContainer
69+
center={center}
70+
zoom={zoom}
71+
scrollWheelZoom={scrollWheelZoom}
72+
className={className}
73+
>
74+
<TileLayer
75+
attribution='&copy; <a href="https://osm.org">OpenStreetMap</a>'
76+
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
77+
/>
78+
79+
{markersState?.map((m) => (
80+
<React.Fragment key={m.id}>
81+
<Marker
82+
position={[m.coordinates.lat, m.coordinates.lng]}
83+
eventHandlers={{
84+
click: () => onMarkerClick && onMarkerClick(m),
85+
}}
86+
>
87+
{m.name && (
88+
<Popup>
89+
<strong>{m.name}</strong>
90+
</Popup>
91+
)}
92+
</Marker>
93+
94+
{typeof m.radius === "number" && (
95+
<Circle
96+
center={[m.coordinates.lat, m.coordinates.lng]}
97+
radius={m.radius}
98+
pathOptions={{ color: COLOR_WARNING, fillOpacity: 0.2 }}
99+
/>
100+
)}
101+
</React.Fragment>
102+
))}
103+
</MapContainer>
104+
);
105+
}

src/components/react/form/LivingLabForm.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import React, { useState } from "react";
1+
import React, { useEffect, useState } from "react";
22
import { Input } from "../../react-catalyst-ui-kit/typescript/input";
33
import { Label } from "../../react-catalyst-ui-kit/typescript/fieldset";
44
import { RButton } from "../ui/RButton";
55
import { getUrl } from "../../../lib/helpers";
6+
import { MapViewer, type MarkerData } from "../MapViewer";
67

78
export default function LivingLabForm() {
89
const [name, setName] = useState("");
@@ -12,6 +13,13 @@ export default function LivingLabForm() {
1213
const [area, setArea] = useState("");
1314
const [population, setPopulation] = useState("");
1415

16+
const [mapMarker, setMapMarker] = useState<MarkerData | null>(null);
17+
const [mapCenter, setMapCenter] = useState<[number, number]>([50, 10]);
18+
const [mapZoom, setMapZoom] = useState<number>(4);
19+
20+
// derive a key from center so MapViewer remounts whenever center changes
21+
const mapKey = mapCenter ? `${mapCenter[0]},${mapCenter[1]}` : "no-center";
22+
1523
function handleSubmit(e: React.FormEvent) {
1624
e.preventDefault();
1725
const payload = {
@@ -26,10 +34,22 @@ export default function LivingLabForm() {
2634
// For now we just log the captured values. Replace with real API call later.
2735
// Keep output minimal so it's easy to replace with fetch/axios when needed.
2836
// eslint-disable-next-line no-console
29-
console.log("Living Lab payload:", payload);
3037
window.location.href = getUrl("/lab");
3138
}
3239

40+
useEffect(() => {
41+
if (latitude && longitude) {
42+
setMapMarker({
43+
id: "lab-marker",
44+
name,
45+
coordinates: { lat: parseFloat(latitude), lng: parseFloat(longitude) },
46+
radius: radius ? parseFloat(radius) * 1000 : undefined, // convert km to meters
47+
});
48+
setMapCenter([parseFloat(latitude), parseFloat(longitude)]);
49+
setMapZoom(8);
50+
}
51+
}, [latitude, longitude, radius]);
52+
3353
return (
3454
<form onSubmit={handleSubmit} className="space-y-6">
3555
<div>
@@ -101,6 +121,16 @@ export default function LivingLabForm() {
101121
</div>
102122
</div>
103123

124+
<div className="h-[400px] rounded shadow ">
125+
<MapViewer
126+
key={mapKey}
127+
markers={mapMarker ? [mapMarker] : []}
128+
center={mapCenter}
129+
zoom={mapZoom ?? 8}
130+
className="h-full w-full z-0"
131+
/>
132+
</div>
133+
104134
<div className="flex gap-4">
105135
<RButton
106136
type="submit"

src/components/react/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export * from "./LivingLabKPIs";
1111
export * from "./KpiTypeBadge";
1212
export * from "./TransportTypeBadge";
1313
export * from "./LivingLabsMapSection";
14+
export * from "./MapViewer";

src/pages/index.astro

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const labs = livingLabsData.map((lab) => ({
2424
id: lab.id,
2525
name: lab.name,
2626
coordinates: { lat: lab.lat, lng: lab.lng },
27-
radius: lab.radius ?? 100,
27+
radius: lab.radius ?? 50,
2828
status: lab.status,
2929
totalMeasures: lab.measures.length,
3030
kpisBefore: lab.kpi_results.filter((kpi) => kpi.result_before).length,
@@ -59,7 +59,6 @@ const labs = livingLabsData.map((lab) => ({
5959
<TransportBadge type="scooter" size="lg" color="warning" />
6060
</div>
6161

62-
<LivingLabsMapSection labs={labs} client:only="react" />
6362
<StatsSection
6463
titleHighlight="SEAMLESS"
6564
title="Shared Urban Mobility"
@@ -83,6 +82,8 @@ const labs = livingLabsData.map((lab) => ({
8382
},
8483
]}
8584
/>
85+
<LivingLabsMapSection labs={labs} client:only="react" />
86+
8687
<CTASection
8788
title="Monitoring measures around New Shared Mobility"
8889
description="From pre-implementation surveys to real-world impact analysis, here's how data flows through the SUM project."

src/types/Constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
export const COLOR_PRIMARY = "#004494";
2+
export const COLOR_SECONDARY = "#98c33a";
23
export const COLOR_SUCCESS = "#98c33a";
4+
export const COLOR_WARNING = "#ff632f";
35
export const COLOR_DANGER = "#ff442f";
46
export const COLOR_SUCCESS_OPACITY_50 = "#98c33a80"; // 50% opacity
57
export const COLOR_DANGER_OPACITY_50 = "#ff442f80"; // 50% opacity

0 commit comments

Comments
 (0)