Skip to content

Commit fd4bcae

Browse files
updated map to use mapbox api, location pins are clickable
1 parent 17842c8 commit fd4bcae

9 files changed

Lines changed: 510 additions & 47 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 5.0.2 on 2026-02-15 19:17
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("degree", "0003_alter_degree_program_alter_fulfillment_legal_and_more"),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name="degree",
15+
name="program",
16+
field=models.CharField(
17+
choices=[
18+
("EU_BSE", "Engineering BSE"),
19+
("EU_BAS", "Engineering BAS"),
20+
("AU_BA", "College BA"),
21+
("WU_BS", "Wharton BS"),
22+
("NU_BSN", "Nursing BSN"),
23+
],
24+
help_text="\nThe program code for this degree, e.g., EU_BSE\n",
25+
max_length=16,
26+
),
27+
),
28+
]

frontend/plan/components/map/Map.tsx

Lines changed: 171 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1-
import React, { useEffect } from "react";
2-
import { MapContainer, TileLayer, useMap } from "react-leaflet";
3-
import Marker from "../map/Marker";
1+
import React, { useEffect, useMemo, useRef, useState } from "react";
2+
import MapGL, {
3+
Layer,
4+
NavigationControl,
5+
Popup,
6+
Source,
7+
type LayerProps,
8+
type MapRef,
9+
} from "react-map-gl/mapbox";
10+
import type { FeatureCollection, Point } from "geojson";
411
import { Location } from "../../types";
512

613
interface MapProps {
@@ -20,6 +27,7 @@ function toDegrees(radians: number): number {
2027
function getGeographicCenter(
2128
locations: Location[]
2229
): [number, number] {
30+
if (!locations.length) return [39.9515, -75.191];
2331
let x = 0;
2432
let y = 0;
2533
let z = 0;
@@ -79,48 +87,174 @@ function separateOverlappingPoints(points: Location[], offset = 0.0001) {
7987
return adjustedPoints;
8088
}
8189

82-
interface InnerMapProps {
83-
locations: Location[];
84-
center: [number, number]
85-
}
90+
function Map({ locations, zoom }: MapProps) {
91+
const mapRef = useRef<MapRef | null>(null);
92+
const mapboxToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN;
93+
const mapboxStyleId = process.env.NEXT_PUBLIC_MAPBOX_STYLE_ID || "mapbox/streets-v12";
94+
const [cursor, setCursor] = useState<string>("");
95+
const [selected, setSelected] = useState<{
96+
longitude: number;
97+
latitude: number;
98+
id?: string;
99+
room?: string;
100+
start?: number;
101+
end?: number;
102+
color?: string;
103+
} | null>(null);
86104

87-
// need inner child component to use useMap hook to run on client
88-
function InnerMap({ locations, center } :InnerMapProps) {
89-
const map = useMap();
105+
const mapStyle = useMemo(() => {
106+
if (mapboxStyleId.startsWith("mapbox://")) return mapboxStyleId;
107+
return `mapbox://styles/${mapboxStyleId}`;
108+
}, [mapboxStyleId]);
90109

91-
useEffect(() => {
92-
map.flyTo({ lat: center[0], lng: center[1]})
93-
}, [center[0], center[1]])
110+
const center = useMemo(() => getGeographicCenter(locations), [locations]);
111+
const points = useMemo(() => separateOverlappingPoints(locations), [locations]);
112+
const markerGeoJson = useMemo<
113+
FeatureCollection<Point, { color?: string; id?: string; room?: string; start?: number; end?: number }>
114+
>(() => {
115+
return {
116+
type: "FeatureCollection",
117+
features: points.map((p) => ({
118+
type: "Feature",
119+
properties: {
120+
color: p.color,
121+
id: p.id,
122+
room: p.room,
123+
start: p.start,
124+
end: p.end,
125+
},
126+
geometry: { type: "Point", coordinates: [p.lng, p.lat] },
127+
})),
128+
};
129+
}, [points]);
94130

95-
return (
96-
<>
97-
<TileLayer
98-
// @ts-ignore
99-
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
100-
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
101-
/>
102-
{separateOverlappingPoints(locations).map(({ lat, lng, color }, i) => (
103-
<Marker key={i} lat={lat} lng={lng} color={color}/>
104-
))}
105-
</>
106-
)
131+
const markerLayer = useMemo<LayerProps>(
132+
() => ({
133+
id: "pcp-course-markers",
134+
type: "circle",
135+
paint: {
136+
"circle-radius": 6,
137+
"circle-color": ["coalesce", ["get", "color"], "#878ED8"],
138+
"circle-stroke-color": "rgba(0,0,0,0.35)",
139+
"circle-stroke-width": 2,
140+
},
141+
}),
142+
[]
143+
);
107144

108-
}
109145

110-
function Map({ locations, zoom }: MapProps) {
111-
const center = getGeographicCenter(locations);
112-
146+
const formatTime = (t?: number) => {
147+
if (t == null) return "";
148+
const hours24 = Math.floor(t);
149+
const minutes = Math.round((t % 1) * 100);
150+
const period = hours24 >= 12 ? "PM" : "AM";
151+
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
152+
return `${hours12}:${minutes.toString().padStart(2, "0")} ${period}`;
153+
};
154+
155+
useEffect(() => {
156+
if (!mapRef.current) return;
157+
mapRef.current.flyTo({
158+
center: [center[1], center[0]],
159+
zoom,
160+
essential: true,
161+
});
162+
}, [center, zoom]);
163+
164+
if (!mapboxToken) {
165+
return (
166+
<div
167+
style={{
168+
height: "100%",
169+
width: "100%",
170+
display: "flex",
171+
alignItems: "center",
172+
justifyContent: "center",
173+
color: "#6b7280",
174+
fontSize: "0.9rem",
175+
background: "#f9fafb",
176+
borderRadius: 8,
177+
}}
178+
>
179+
Missing `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN`
180+
</div>
181+
);
182+
}
183+
113184
return (
114-
<MapContainer
115-
// @ts-ignore
116-
center={center}
117-
zoom={zoom}
118-
zoomControl={false}
119-
scrollWheelZoom={true}
185+
<MapGL
186+
ref={mapRef}
187+
mapboxAccessToken={mapboxToken}
188+
mapStyle={mapStyle}
189+
initialViewState={{
190+
latitude: center[0],
191+
longitude: center[1],
192+
zoom,
193+
pitch: 0,
194+
bearing: 0,
195+
}}
120196
style={{ height: "100%", width: "100%" }}
197+
attributionControl
198+
dragRotate
199+
touchPitch
200+
maxPitch={70}
201+
interactiveLayerIds={["pcp-course-markers"]}
202+
cursor={cursor}
203+
onMouseMove={(e) => {
204+
const hovering = (e.features?.length || 0) > 0;
205+
setCursor(hovering ? "pointer" : "");
206+
}}
207+
onClick={(e) => {
208+
const f = e.features?.[0];
209+
if (!f || f.geometry.type !== "Point") {
210+
setSelected(null);
211+
return;
212+
}
213+
const [lng, lat] = f.geometry.coordinates as [number, number];
214+
const props = (f.properties || {}) as Record<string, unknown>;
215+
setSelected({
216+
longitude: lng,
217+
latitude: lat,
218+
id: typeof props.id === "string" ? props.id : undefined,
219+
room: typeof props.room === "string" ? props.room : undefined,
220+
start: typeof props.start === "number" ? props.start : undefined,
221+
end: typeof props.end === "number" ? props.end : undefined,
222+
color: typeof props.color === "string" ? props.color : undefined,
223+
});
224+
}}
121225
>
122-
<InnerMap locations={locations} center={center}/>
123-
</MapContainer>
226+
<NavigationControl showCompass showZoom visualizePitch position="top-left" />
227+
228+
<Source id="pcp-course-markers-source" type="geojson" data={markerGeoJson}>
229+
<Layer {...markerLayer} />
230+
</Source>
231+
232+
{selected && (
233+
<Popup
234+
longitude={selected.longitude}
235+
latitude={selected.latitude}
236+
anchor="top"
237+
closeButton
238+
closeOnClick={false}
239+
onClose={() => setSelected(null)}
240+
maxWidth="260px"
241+
>
242+
<div style={{ fontSize: "0.85rem", lineHeight: 1.25 }}>
243+
{selected.id && (
244+
<div style={{ fontWeight: 700, marginBottom: 4 }}>
245+
{selected.id.replace(/-/g, " ")}
246+
</div>
247+
)}
248+
{(selected.start != null || selected.end != null) && (
249+
<div style={{ marginBottom: 2 }}>
250+
{formatTime(selected.start)}{selected.end != null ? `–${formatTime(selected.end)}` : ""}
251+
</div>
252+
)}
253+
{selected.room && <div>{selected.room}</div>}
254+
</div>
255+
</Popup>
256+
)}
257+
</MapGL>
124258
);
125259
};
126260

frontend/plan/components/map/MapTab.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import MapCourseItem from "./MapCourseItem";
77
import { scheduleContainsSection } from "../meetUtil";
88
import { DAYS_TO_DAYSTRINGS } from "../../constants/constants";
99
import { Section, Meeting, Day, Weekdays } from "../../types";
10-
import "leaflet/dist/leaflet.css";
10+
import "mapbox-gl/dist/mapbox-gl.css";
1111
import { ThunkDispatch } from "redux-thunk";
1212
import { fetchCourseDetails } from "../../actions";
1313

@@ -128,6 +128,10 @@ function MapTab({
128128
lat: meeting.latitude,
129129
lng: meeting.longitude,
130130
color: meeting.color,
131+
id: meeting.id,
132+
room: meeting.room,
133+
start: meeting.start,
134+
end: meeting.end,
131135
}))
132136
.filter(
133137
(locData) =>

frontend/plan/components/map/Marker.jsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ const Marker = ({ color = "#878ED8", lat, lng }) => {
66
const icon = divIcon({
77
html: `
88
<svg
9-
width="20"
10-
height="28"
9+
width="14"
10+
height="20"
1111
viewBox="0 0 20 28"
1212
fill="none"
1313
xmlns="http://www.w3.org/2000/svg"
@@ -19,8 +19,8 @@ const Marker = ({ color = "#878ED8", lat, lng }) => {
1919
</svg>
2020
`,
2121
className: "svg-icon",
22-
iconSize: [24, 40],
23-
iconAnchor: [12, 40],
22+
iconSize: [18, 30],
23+
iconAnchor: [9, 30],
2424
});
2525

2626
return <MarkerLeaflet position={[lat, lng]} icon={icon} />;

frontend/plan/components/modals/MapModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from "react";
22
import styled from "styled-components";
33
import Map from "../map/Map";
4-
import "leaflet/dist/leaflet.css";
4+
import "mapbox-gl/dist/mapbox-gl.css";
55

66
interface MapModalProps {
77
lat: number;

frontend/plan/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"ics": "^2.37.0",
3030
"leaflet": "^1.9.4",
3131
"libphonenumber-js": "^1.10.57",
32+
"mapbox-gl": "^3.18.1",
3233
"next": "13.2.1",
3334
"next-transpile-modules": "^3.3.0",
3435
"pcx-shared-components": "0.1.0",
@@ -42,6 +43,7 @@
4243
"react-ga": "^2.7.0",
4344
"react-hook-form": "^7.51.1",
4445
"react-leaflet": "^4.2.1",
46+
"react-map-gl": "^8.1.0",
4547
"react-redux": "^7.2.9",
4648
"react-spring": "^8.0.27",
4749
"react-swipeable-views": "^0.13.3",

frontend/plan/tsconfig.tsbuildinfo

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

frontend/plan/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,5 +262,9 @@ export type FilterType =
262262
lat: number;
263263
lng: number;
264264
color?: string;
265+
id?: string;
266+
room?: string;
267+
start?: number;
268+
end?: number;
265269
}
266270

0 commit comments

Comments
 (0)