Skip to content

Commit

Permalink
Merge pull request #684 from pennlabs/pcp_doh_newbie_location
Browse files Browse the repository at this point in the history
PCP - map feature
  • Loading branch information
jamesdoh0109 authored Jan 19, 2025
2 parents cb694bf + 896e9ec commit b247fba
Show file tree
Hide file tree
Showing 31 changed files with 1,691 additions and 541 deletions.
1 change: 1 addition & 0 deletions backend/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ python-dateutil = "*"
docutils = "*"
ics = "*"
drf-nested-routers = "*"
google-api-python-client = "*"
asyncio = "*"
aiohttp = "*"

Expand Down
772 changes: 435 additions & 337 deletions backend/Pipfile.lock

Large diffs are not rendered by default.

124 changes: 124 additions & 0 deletions backend/courses/management/commands/fetch_building_locations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import json
from os import getenv
from textwrap import dedent
from typing import Dict, Optional, Tuple

import requests
from bs4 import BeautifulSoup
from django.core.management.base import BaseCommand
from googleapiclient.discovery import build
from tqdm import tqdm

from courses.models import Building


def fetch_code_to_building_map() -> Dict[str, str]:
response = requests.get(
"https://provider.www.upenn.edu/computing/da/dw/student/building.e.html"
)
soup = BeautifulSoup(response.text, "html.parser")
building_data = {}
pre_tag = soup.find("pre")

if pre_tag:
text = pre_tag.get_text()
lines = text.strip().split("\n")
for line in lines:
line = line.strip()
parts = line.split(" ")
building_data[parts[0].strip()] = " ".join(parts[1:]).strip()

return building_data


def get_address(link: str) -> str:
response = requests.get(link)
soup = BeautifulSoup(response.text, "html.parser")

address_div = soup.find("div", class_="field-content my-3")
return address_div.get_text(separator=" ", strip=True) if address_div else ""


def get_top_result_link(search_term: str) -> Optional[str]:
api_key = getenv("GSEARCH_API_KEY")
search_engine_id = getenv("GSEARCH_ENGINE_ID")

full_query = f"upenn facilities {search_term} building"
service = build("customsearch", "v1", developerKey=api_key)
res = service.cse().list(q=full_query, cx=search_engine_id).execute()

if "items" not in res:
return None

return res["items"][0]["link"]


def convert_address_to_lat_lon(address: str) -> Tuple[float, float]:
encoded_address = "+".join(address.split(" "))
api_key = getenv("GMAPS_API_KEY")

response = requests.get(
f"https://maps.googleapis.com/maps/api/geocode/json?address={encoded_address}&key={api_key}"
)
response_dict = json.loads(response.text)
try:
geometry_results = response_dict["results"][0]["geometry"]["location"]
except BaseException:
return None

return {key: geometry_results[key] for key in ["lat", "lng"]}


def fetch_building_data():
all_buildings = Building.objects.all()
code_to_name = fetch_code_to_building_map()

for building in tqdm(all_buildings):
if not building.code:
continue

if building.latitude and building.longitude:
continue

query = code_to_name.get(building.code, building.code)
link = get_top_result_link(query)
if not link:
continue

address = get_address(link)
if not address:
continue

location = convert_address_to_lat_lon(address)
if not location:
continue

building.latitude = location["lat"]
building.longitude = location["lng"]

Building.objects.bulk_update(all_buildings, ["latitude", "longitude"])


class Command(BaseCommand):
help = dedent(
"""
Fetch coordinate data for building models (e.g. JMHH).
Expects GSEARCH_API_KEY, GSEARCH_ENGINE_ID, and GMAPS_API_KEY env vars
to be set.
Instructions on how to retrieve the environment variables.
GSEARCH_API_KEY: https://developers.google.com/custom-search/v1/overview
GSEARCH_ENGINE_ID: https://programmablesearchengine.google.com/controlpanel/all
GMAPS_API_KEY: https://developers.google.com/maps/documentation/geocoding/overview
"""
)

def handle(self, *args, **kwargs):
if not all(
[getenv(var) for var in ["GSEARCH_API_KEY", "GSEARCH_ENGINE_ID", "GMAPS_API_KEY"]]
):
raise ValueError("Env vars not set properly.")

fetch_building_data()
46 changes: 44 additions & 2 deletions backend/courses/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,40 @@ class Meta:
fields = ("day", "start", "end", "room")


class MeetingWithBuildingSerializer(serializers.ModelSerializer):
room = serializers.StringRelatedField(
help_text=dedent(
"""
The room in which the meeting is taking place, in the form '{building code} {room number}'.
"""
)
)
latitude = serializers.SerializerMethodField(
read_only=True,
help_text="Latitude of building.",
)
longitude = serializers.SerializerMethodField(
read_only=True,
help_text="Longitude of building.",
)

@staticmethod
def get_latitude(obj):
if obj.room and obj.room.building:
return obj.room.building.latitude
return None

@staticmethod
def get_longitude(obj):
if obj.room and obj.room.building:
return obj.room.building.longitude
return None

class Meta:
model = Meeting
fields = ("day", "start", "end", "room", "latitude", "longitude")


class SectionIdSerializer(serializers.ModelSerializer):
id = serializers.CharField(source="full_code")

Expand Down Expand Up @@ -123,8 +157,7 @@ class SectionDetailSerializer(serializers.ModelSerializer):
"""
),
)
meetings = MeetingSerializer(
many=True,
meetings = serializers.SerializerMethodField(
read_only=True,
help_text=dedent(
"""
Expand Down Expand Up @@ -186,6 +219,15 @@ class Meta:
]
read_only_fields = fields

def get_meetings(self, obj):
include_location = self.context.get("include_location", False)
if include_location:
meetings_serializer = MeetingWithBuildingSerializer(obj.meetings, many=True)
else:
meetings_serializer = MeetingSerializer(obj.meetings, many=True)

return meetings_serializer.data


class PreNGSSRequirementListSerializer(serializers.ModelSerializer):
id = serializers.SerializerMethodField(
Expand Down
27 changes: 20 additions & 7 deletions backend/courses/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,24 +277,37 @@ class CourseDetail(generics.RetrieveAPIView, BaseCourseMixin):
lookup_field = "full_code"
queryset = Course.with_reviews.all() # included redundantly for docs

def get_serializer_context(self):
context = super().get_serializer_context()
include_location_str = self.request.query_params.get("include_location", "False")
context.update({"include_location": eval(include_location_str)})
return context

def get_queryset(self):
queryset = Course.with_reviews.all()
include_location = self.request.query_params.get("include_location", False)

prefetch_list = [
"course",
"meetings",
"associated_sections",
"meetings__room",
"instructors",
]
if include_location:
prefetch_list.append("meetings__room__building")

queryset = queryset.prefetch_related(
Prefetch(
"sections",
Section.with_reviews.all()
.filter(credits__isnull=False)
.filter(Q(status="O") | Q(status="C"))
.distinct()
.prefetch_related(
"course",
"meetings",
"associated_sections",
"meetings__room",
"instructors",
),
.prefetch_related(*prefetch_list),
)
)

check_offered_in = self.request.query_params.get("check_offered_in")
if check_offered_in:
if "@" not in check_offered_in:
Expand Down
5 changes: 5 additions & 0 deletions backend/plan/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,11 @@ class ScheduleViewSet(AutoPrefetchViewSetMixin, viewsets.ModelViewSet):
http_method_names = ["get", "post", "delete", "put"]
permission_classes = [IsAuthenticated]

def get_serializer_context(self):
context = super().get_serializer_context()
context.update({"include_location": True})
return context

@staticmethod
def get_semester(data):
semester = normalize_semester(data.get("semester"))
Expand Down
2 changes: 1 addition & 1 deletion frontend/plan/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,7 @@ export const deduplicateCourseMeetings = (course) => {
export function fetchCourseDetails(courseId) {
return (dispatch) => {
dispatch(updateCourseInfoRequest());
doAPIRequest(`/base/current/courses/${courseId}/`)
doAPIRequest(`/base/current/courses/${courseId}/?include_location=True`)
.then((res) => res.json())
.then((data) => deduplicateCourseMeetings(data))
.then((course) => dispatch(updateCourseInfo(course)))
Expand Down
127 changes: 127 additions & 0 deletions frontend/plan/components/map/Map.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React, { useEffect } from "react";
import { MapContainer, TileLayer, useMap } from "react-leaflet";
import Marker from "../map/Marker";
import { Location } from "../../types";

interface MapProps {
locations: Location[];
zoom: number;
}

function toRadians(degrees: number): number {
return degrees * (Math.PI / 180);
}

function toDegrees(radians: number): number {
return radians * (180 / Math.PI);
}

// calculate the center of all locations on the map view to center the map
function getGeographicCenter(
locations: Location[]
): [number, number] {
let x = 0;
let y = 0;
let z = 0;

locations.forEach((coord) => {
const lat = toRadians(coord.lat);
const lon = toRadians(coord.lng);

x += Math.cos(lat) * Math.cos(lon);
y += Math.cos(lat) * Math.sin(lon);
z += Math.sin(lat);
});

const total = locations.length;

x /= total;
y /= total;
z /= total;

const centralLongitude = toDegrees(Math.atan2(y, x));
const centralSquareRoot = Math.sqrt(x * x + y * y);
const centralLatitude = toDegrees(Math.atan2(z, centralSquareRoot));

return [centralLatitude ? centralLatitude : 39.9515, centralLongitude ? centralLongitude : -75.1910];
}

function separateOverlappingPoints(points: Location[], offset = 0.0001) {
const validPoints = points.filter((p) => p.lat !== null && p.lng !== null) as Location[];

// group points by coordinates
const groupedPoints: Record<string, Location[]> = {};
validPoints.forEach((point) => {
const key = `${point.lat},${point.lng}`;
(groupedPoints[key] ||= []).push(point);
});

// adjust overlapping points
const adjustedPoints = Object.values(groupedPoints).flatMap((group) =>
group.length === 1
? group // no adjustment needed if class in map view doesnt share locations with others
: group.map((point, index) => {
/*
At a high level, if there are multiple classes in map view that have the exact same location,
we try to evenly distribute them around a "circle" centered on the original location.
The size of the circle is determined by offset.
*/
const angle = (2 * Math.PI * index) / group.length;
return {
...point,
lat: point.lat! + offset * Math.cos(angle),
lng: point.lng! + offset * Math.sin(angle),
};
})
);

// include points with null values
return adjustedPoints;
}

interface InnerMapProps {
locations: Location[];
center: [number, number]
}

// need inner child component to use useMap hook to run on client
function InnerMap({ locations, center } :InnerMapProps) {
const map = useMap();

useEffect(() => {
map.flyTo({ lat: center[0], lng: center[1]})
}, [center[0], center[1]])

return (
<>
<TileLayer
// @ts-ignore
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{separateOverlappingPoints(locations).map(({ lat, lng, color }, i) => (
<Marker key={i} lat={lat} lng={lng} color={color}/>
))}
</>
)

}

function Map({ locations, zoom }: MapProps) {
const center = getGeographicCenter(locations);

return (
<MapContainer
// @ts-ignore
center={center}
zoom={zoom}
zoomControl={false}
scrollWheelZoom={true}
style={{ height: "100%", width: "100%" }}
>
<InnerMap locations={locations} center={center}/>
</MapContainer>
);
};

export default React.memo(Map);
Loading

0 comments on commit b247fba

Please sign in to comment.