Skip to content

Commit

Permalink
map feature impl done (hyperlink on building name + map view for sche…
Browse files Browse the repository at this point in the history
…dule
  • Loading branch information
jamesdoh0109 committed Nov 1, 2024
1 parent 3c085f9 commit 39a2a60
Show file tree
Hide file tree
Showing 13 changed files with 729 additions and 74 deletions.
2 changes: 1 addition & 1 deletion frontend/plan/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@ const rateLimitedFetch = (url, init) =>
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((course) => dispatch(updateCourseInfo(course)))
.catch((error) => dispatch(sectionInfoSearchError(error)));
Expand Down
117 changes: 105 additions & 12 deletions frontend/plan/components/map/Map.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,117 @@
import { MapContainer, TileLayer } from "react-leaflet";
import React, { useEffect } from "react";
import { MapContainer, TileLayer, useMap } from "react-leaflet";
import Marker from "../map/Marker";
import { Location } from "../../types";

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

export default function Map({ lat, lng }: MapProps) {
function toRadians(degrees: number): number {
return degrees * (Math.PI / 180);
}

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

function getGeographicCenter(
locations: { lat: number; lng: number }[]
): [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]})
}, [locations])

return (
<MapContainer
center={[lat, lng]}
zoom={15}
scrollWheelZoom={false}
style={{ height: "100%", width: "100%" }}
>
<>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker lat={lat} lng={lng} />
{separateOverlappingPoints(locations).map(({ lat, lng }, i) => (
<Marker key={i} lat={lat} lng={lng} />
))}
</>
)

}

function Map({ locations, zoom }: MapProps) {
const center = getGeographicCenter(locations)
return (
<MapContainer
center={center}
zoom={zoom}
zoomControl={false}
scrollWheelZoom={true}
style={{ height: "100%", width: "100%" }}
>
<InnerMap locations={locations} center={center}/>
</MapContainer>
);
}
};

export default React.memo(Map);
135 changes: 135 additions & 0 deletions frontend/plan/components/map/MapCourseItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import styled from "styled-components";
import { isMobile } from "react-device-detect";

const CourseCartItem = styled.div<{ $isMobile: boolean }>`
background: white;
transition: 250ms ease background;
cursor: pointer;
user-select: none;
flex-direction: row;
padding: 0.8rem;
border-bottom: 1px solid #e5e8eb;
grid-template-columns: 20% 50% 15% 15%;
* {
user-select: none;
}
&:hover {
background: #f5f5ff;
}
&:active {
background: #efeffe;
}
&:hover i {
color: #d3d3d8;
}
`;

const CourseDetailsContainer = styled.div``;

interface CourseDetailsProps {
id: string;
start: number;
end: number;
room: string;
overlap: boolean;
}

const getTimeString = (start: number, end: number) => {
const intToTime = (t: number) => {
let hour = Math.floor(t % 12);
const min = Math.round((t % 1) * 100);
if (hour === 0) {
hour = 12;
}
const minStr = min === 0 ? "00" : min.toString();
return `${hour}:${minStr}`;
};

const startTime = intToTime(start);
const endTime = intToTime(end);

return `${startTime}-${endTime}`;
};

const CourseDetails = ({
id,
start,
end,
room,
overlap,
}: CourseDetailsProps) => (
<CourseDetailsContainer>
<b>
<span>{id.replace(/-/g, " ")}</span>
</b>
<div style={{ fontSize: "0.8rem" }}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<div>
{overlap && (
<div className="popover is-popover-right">
<i
style={{
paddingRight: "5px",
color: "#c6c6c6",
}}
className="fas fa-calendar-times"
/>
<span className="popover-content">
Conflicts with schedule!
</span>
</div>
)}
{getTimeString(start, end)}
</div>
<div>{room ? room : "No room data"}</div>
</div>
</div>
</CourseDetailsContainer>
);

interface CartSectionProps {
id: string;
lat?: number;
lng?: number;
start: number;
end: number;
room: string;
overlap: boolean;
focusSection: (id: string) => void;
}

function MapCourseItem({
id,
lat,
lng,
start,
end,
room,
overlap,
focusSection,
}: CartSectionProps) {
return (
<CourseCartItem
role="switch"
id={id}
aria-checked="false"
$isMobile={isMobile}
onClick={() => {
const split = id.split("-");
focusSection(`${split[0]}-${split[1]}`);
}}
>
<CourseDetails
id={id}
start={start}
end={end}
room={room}
overlap={overlap}
/>
</CourseCartItem>
);
}

export default MapCourseItem;
Loading

0 comments on commit 39a2a60

Please sign in to comment.