-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #684 from pennlabs/pcp_doh_newbie_location
PCP - map feature
- Loading branch information
Showing
31 changed files
with
1,691 additions
and
541 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
124 changes: 124 additions & 0 deletions
124
backend/courses/management/commands/fetch_building_locations.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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='© <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); |
Oops, something went wrong.