Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PCP - map feature #684

Open
wants to merge 21 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
68a3aec
Initial Backend Support for Longitude / Latitude of Buildings
shiva-menta Oct 1, 2024
912d326
basic map functionality implementation using google maps api, hardcod…
jamesdoh0109 Oct 5, 2024
424915a
Fix No Room Bug
shiva-menta Oct 5, 2024
1895569
map impl using leaflet + multiple location for courses fixed
jamesdoh0109 Oct 12, 2024
d4317da
Add Fetch Locations Management Command
shiva-menta Oct 12, 2024
3c085f9
Add Query Parameter for Location
shiva-menta Oct 13, 2024
39a2a60
map feature impl done (hyperlink on building name + map view for sche…
jamesdoh0109 Nov 1, 2024
e73fccb
map rerender bug fix
jamesdoh0109 Nov 2, 2024
e2f7ea2
map color coded pins done + fix minor bugs w not displaying correct s…
jamesdoh0109 Nov 3, 2024
a48e542
ts warning fix
jamesdoh0109 Nov 3, 2024
bf97bec
ts ignore leaflet types
jamesdoh0109 Nov 4, 2024
18b7b88
leaflet types upgrade + ts ignore
jamesdoh0109 Nov 4, 2024
58f5ecf
bump typescript
jamesdoh0109 Nov 10, 2024
26c168e
ts error fixed, search bar missing children
jamesdoh0109 Nov 10, 2024
ccf0f89
forbidden dark magic
jamesdoh0109 Nov 10, 2024
723640b
Merge In Master
shiva-menta Nov 11, 2024
a816f77
Merge branch 'pcp_doh_newbie_location' of github.com:pennlabs/penn-co…
shiva-menta Nov 11, 2024
ce14e3e
Fix Scraper for New Website Format
shiva-menta Nov 11, 2024
9d7377d
Backend Lint
shiva-menta Nov 11, 2024
f0e2a3b
Remove Redundant meetings__room__building
shiva-menta Nov 11, 2024
a4b8ff1
frontend bug fixes + colored dot next to course items in map view
jamesdoh0109 Nov 19, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
120 changes: 120 additions & 0 deletions frontend/plan/components/map/Map.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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);
}

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 = Math.atan2(y, x);
const centralSquareRoot = Math.sqrt(x * x + y * y);
const centralLatitude = Math.atan2(z, centralSquareRoot);

return [toDegrees(centralLatitude), toDegrees(centralLongitude)];
}

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.reduce((acc, point) => {
const key = `${point.lat},${point.lng}`;
(acc[key] ||= []).push(point);
return acc;
}, {} as Record<string, Location[]>);

// adjust overlapping points
const adjustedPoints = Object.values(groupedPoints).flatMap((group) =>
group.length === 1
? group
: group.map((point, index) => {
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, ...points.filter((p) => p.lat === null || p.lng === null)];
}

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

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
Loading