From 896e9ecd435086b21ee66db9fcd4fcfc5e0ee5be Mon Sep 17 00:00:00 2001 From: "Jihun (James) Doh" <dohj0109@sas.upenn.edu> Date: Sat, 21 Dec 2024 19:22:59 -0500 Subject: [PATCH] dropdown to radio for map view + comments added & minor bu g fixes --- frontend/plan/components/map/Map.tsx | 41 ++--- .../plan/components/map/MapCourseItem.tsx | 58 ++++--- frontend/plan/components/map/MapDropdown.tsx | 143 ------------------ frontend/plan/components/map/MapRadio.tsx | 72 +++++++++ frontend/plan/components/map/MapTab.tsx | 27 ++-- frontend/plan/components/meetUtil.tsx | 4 +- frontend/plan/constants/constants.js | 2 - frontend/plan/pages/index.tsx | 8 +- frontend/plan/types.ts | 7 +- 9 files changed, 152 insertions(+), 210 deletions(-) delete mode 100644 frontend/plan/components/map/MapDropdown.tsx create mode 100644 frontend/plan/components/map/MapRadio.tsx diff --git a/frontend/plan/components/map/Map.tsx b/frontend/plan/components/map/Map.tsx index 9694ad68..66a91aaa 100644 --- a/frontend/plan/components/map/Map.tsx +++ b/frontend/plan/components/map/Map.tsx @@ -16,6 +16,7 @@ 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] { @@ -38,39 +39,44 @@ function getGeographicCenter( y /= total; z /= total; - const centralLongitude = Math.atan2(y, x); + const centralLongitude = toDegrees(Math.atan2(y, x)); const centralSquareRoot = Math.sqrt(x * x + y * y); - const centralLatitude = Math.atan2(z, centralSquareRoot); + const centralLatitude = toDegrees(Math.atan2(z, centralSquareRoot)); - return [toDegrees(centralLatitude), toDegrees(centralLongitude)]; + 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.reduce((acc, point) => { + const groupedPoints: Record<string, Location[]> = {}; + validPoints.forEach((point) => { const key = `${point.lat},${point.lng}`; - (acc[key] ||= []).push(point); - return acc; - }, {} as Record<string, Location[]>); + (groupedPoints[key] ||= []).push(point); + }); // adjust overlapping points const adjustedPoints = Object.values(groupedPoints).flatMap((group) => group.length === 1 - ? group + ? group // no adjustment needed if class in map view doesnt share locations with others : 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), - }; - }) + /* + 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, ...points.filter((p) => p.lat === null || p.lng === null)]; + return adjustedPoints; } interface InnerMapProps { @@ -78,6 +84,7 @@ interface InnerMapProps { center: [number, number] } +// need inner child component to use useMap hook to run on client function InnerMap({ locations, center } :InnerMapProps) { const map = useMap(); @@ -101,7 +108,7 @@ function InnerMap({ locations, center } :InnerMapProps) { } function Map({ locations, zoom }: MapProps) { - const center = getGeographicCenter(locations) + const center = getGeographicCenter(locations); return ( <MapContainer diff --git a/frontend/plan/components/map/MapCourseItem.tsx b/frontend/plan/components/map/MapCourseItem.tsx index 46adfaa3..f43702db 100644 --- a/frontend/plan/components/map/MapCourseItem.tsx +++ b/frontend/plan/components/map/MapCourseItem.tsx @@ -1,7 +1,10 @@ import styled from "styled-components"; import { isMobile } from "react-device-detect"; -const CourseCartItem = styled.div<{ $isMobile: boolean }>` +const CourseCartItem = styled.div<{ + $isMobile: boolean; + $hasLocationData: boolean; +}>` background: white; transition: 250ms ease background; cursor: pointer; @@ -24,10 +27,13 @@ const CourseCartItem = styled.div<{ $isMobile: boolean }>` &:hover i { color: #d3d3d8; } + ${({ $hasLocationData }) => + !$hasLocationData && + ` + opacity: 0.5; + `} `; -const CourseDetailsContainer = styled.div``; - const Dot = styled.span<{ $color: string }>` height: 5px; width: 5px; @@ -42,7 +48,7 @@ interface CourseDetailsProps { start: number; end: number; room: string; - overlap: boolean; + hasLocationData: boolean; } const getTimeString = (start: number, end: number) => { @@ -67,35 +73,24 @@ const CourseDetails = ({ start, end, room, - overlap, + hasLocationData, }: CourseDetailsProps) => ( - <CourseDetailsContainer> + <div> <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 + style={{ + display: "flex", + fontSize: "0.8rem", + }} + > + <div>{getTimeString(start, end)}</div> + + <div>{room && hasLocationData ? room : "No room data"}</div> </div> - </CourseDetailsContainer> + </div> ); interface CartSectionProps { @@ -104,7 +99,7 @@ interface CartSectionProps { start: number; end: number; room: string; - overlap: boolean; + hasLocationData: boolean; focusSection: (id: string) => void; } @@ -114,7 +109,7 @@ function MapCourseItem({ start, end, room, - overlap, + hasLocationData, focusSection, }: CartSectionProps) { return ( @@ -123,6 +118,7 @@ function MapCourseItem({ id={id} aria-checked="false" $isMobile={isMobile} + $hasLocationData={hasLocationData} onClick={() => { const split = id.split("-"); focusSection(`${split[0]}-${split[1]}`); @@ -134,7 +130,7 @@ function MapCourseItem({ start={start} end={end} room={room} - overlap={overlap} + hasLocationData={hasLocationData} /> </CourseCartItem> ); diff --git a/frontend/plan/components/map/MapDropdown.tsx b/frontend/plan/components/map/MapDropdown.tsx deleted file mode 100644 index 13602314..00000000 --- a/frontend/plan/components/map/MapDropdown.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; -import styled from "styled-components"; -import { Icon } from "../bulma_derived_components"; -import { DAYS_TO_DAYSTRINGS } from "../../constants/constants"; -import { Day } from "../../types"; - -const DropdownContainer = styled.div` - margin-top: 5px; - border-radius: 0.5rem; - border: 0; - outline: none; - display: inline-flex; - vertical-align: top; - width: 100%; - position: relative; - * { - border: 0; - outline: none; - } -`; - -const DropdownTriggerContainer = styled.div<{ $isActive: boolean }>` - padding-left: 0.5rem; - display: flex; - - align-items: center; - background: ${({ $isActive }: { $isActive: boolean }) => - $isActive ? "rgba(162, 180, 237, 0.38) !important" : "none"}; - - :hover { - background: rgba(175, 194, 255, 0.27); - } -`; - -const DropdownTrigger = styled.div` - margin-left: 1rem; - height: 1.5rem; - - text-align: center; - outline: none !important; - border: none !important; - background: transparent; -`; - -const DropdownText = styled.div` - font-size: 1vw; - font-weight: 600; -`; - -const DropdownMenu = styled.div<{ $isActive: boolean }>` - margin-top: 0.1rem !important; - display: ${({ $isActive }) => ($isActive ? "block" : "none")}; - left: 0; - min-width: 9rem; - padding-top: 4px; - position: absolute; - top: 100%; - z-index: 2000 !important; -`; - -const DropdownContent = styled.div` - background-color: #fff; - box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1); - padding: 0; -`; - -const DropdownButton = styled.div` - padding-left: 6px; - &:hover { - background: #f5f6f8; - } -`; - -interface MapDropdownProps { - selectedDay: Day; - setSelectedDay: Dispatch<SetStateAction<Day>>; -} -export default function MapDropdown({ - selectedDay, - setSelectedDay, -}: MapDropdownProps) { - const [isActive, setIsActive] = useState(false); - - const ref = useRef<HTMLDivElement>(null); - - useEffect(() => { - const listener = (event: Event) => { - if ( - ref.current && - !ref.current.contains(event.target as HTMLElement) - ) { - setIsActive(false); - } - }; - document.addEventListener("click", listener); - return () => { - document.removeEventListener("click", listener); - }; - }); - - return ( - <DropdownContainer ref={ref}> - <DropdownTriggerContainer - $isActive={isActive} - onClick={() => { - setIsActive(!isActive); - }} - role="button" - > - <DropdownText>{DAYS_TO_DAYSTRINGS[selectedDay]}</DropdownText> - <DropdownTrigger> - <div aria-haspopup={true} aria-controls="dropdown-menu"> - <Icon> - <i - className={`fa fa-chevron-${ - isActive ? "up" : "down" - }`} - aria-hidden="true" - /> - </Icon> - </div> - </DropdownTrigger> - </DropdownTriggerContainer> - <DropdownMenu $isActive={isActive} role="menu"> - <DropdownContent> - {Object.entries(DAYS_TO_DAYSTRINGS).map( - ([day, dayString]) => ( - <DropdownButton - key={day} - onClick={() => { - setSelectedDay(day as Day); - setIsActive(false); - }} - > - {dayString} - </DropdownButton> - ) - )} - </DropdownContent> - </DropdownMenu> - </DropdownContainer> - ); -} diff --git a/frontend/plan/components/map/MapRadio.tsx b/frontend/plan/components/map/MapRadio.tsx new file mode 100644 index 00000000..d0b3a1b4 --- /dev/null +++ b/frontend/plan/components/map/MapRadio.tsx @@ -0,0 +1,72 @@ +import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; +import styled from "styled-components"; +import { Icon } from "../bulma_derived_components"; +import { DAYS_TO_DAYSTRINGS } from "../../constants/constants"; +import { Day, Weekdays } from "../../types"; + +const RadioContainer = styled.div` + display: flex; + justify-content: space-between; + padding: 9px 8px 5px 8px; + width: 100%; +`; + +const Radio = styled.div<{ $isSelected: boolean }>` + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + width: 25px; + height: 25px; + cursor: pointer; + font-weight: 500; + font-size: 12px; + background: ${({ $isSelected }: { $isSelected: boolean }) => + $isSelected ? "#868ED8 !important" : "#F4F4F4 !important"}; + color: ${({ $isSelected }: { $isSelected: boolean }) => + $isSelected && "white !important"}; +`; + +interface MapDropdownProps { + selectedDay: Day; + setSelectedDay: Dispatch<SetStateAction<Day>>; +} +export default function MapDropdown({ + selectedDay, + setSelectedDay, +}: MapDropdownProps) { + const [isActive, setIsActive] = useState(false); + + const ref = useRef<HTMLDivElement>(null); + + useEffect(() => { + const listener = (event: Event) => { + if ( + ref.current && + !ref.current.contains(event.target as HTMLElement) + ) { + setIsActive(false); + } + }; + document.addEventListener("click", listener); + return () => { + document.removeEventListener("click", listener); + }; + }); + + return ( + <RadioContainer> + {Object.keys(DAYS_TO_DAYSTRINGS).map((day) => ( + <Radio + $isSelected={selectedDay === day} + onClick={() => { + setSelectedDay(day as Day); + setIsActive(false); + }} + > + <span>{day}</span> + </Radio> + ))} + </RadioContainer> + ); +} diff --git a/frontend/plan/components/map/MapTab.tsx b/frontend/plan/components/map/MapTab.tsx index b95d5f17..aa93b057 100644 --- a/frontend/plan/components/map/MapTab.tsx +++ b/frontend/plan/components/map/MapTab.tsx @@ -2,11 +2,11 @@ import React, { useState } from "react"; import { connect } from "react-redux"; import dynamic from "next/dynamic"; import styled from "styled-components"; -import MapDropdown from "./MapDropdown"; +import MapRadio from "./MapRadio"; import MapCourseItem from "./MapCourseItem"; import { scheduleContainsSection } from "../meetUtil"; import { DAYS_TO_DAYSTRINGS } from "../../constants/constants"; -import { Section, Meeting, Day } from "../../types"; +import { Section, Meeting, Day, Weekdays } from "../../types"; import "leaflet/dist/leaflet.css"; import { ThunkDispatch } from "redux-thunk"; import { fetchCourseDetails } from "../../actions"; @@ -49,9 +49,8 @@ const Box = styled.section<{ $length: number }>` const MapContainer = styled.div` height: 40%; - padding-left: 7px; - padding-right: 7px; - margin-right: 5px; + margin-right: 8px; + margin-left: 8px; margin-top: 5px; `; @@ -87,7 +86,9 @@ const DayEmpty = ({ day }: DayEmptyProps) => ( marginBottom: "0.5rem", }} > - {`You don't have classes on ${DAYS_TO_DAYSTRINGS[day as Day]}!`} + {`You don't have classes on ${ + DAYS_TO_DAYSTRINGS[day as Weekdays] + }!`} </h3> Click a course section's + icon to add it to the schedule. <br /> @@ -96,12 +97,12 @@ const DayEmpty = ({ day }: DayEmptyProps) => ( ); function MapTab({ meetingsByDay, focusSection }: MabTapProps) { - const [selectedDay, setSelectedDay] = useState<Day>(Day.M); + const [selectedDay, setSelectedDay] = useState<Day>(Weekdays.M); const meeetingsForDay = meetingsByDay[selectedDay]; return ( <Box $length={meeetingsForDay.length} id="cart"> - <MapDropdown + <MapRadio selectedDay={selectedDay} setSelectedDay={setSelectedDay} /> @@ -122,7 +123,7 @@ function MapTab({ meetingsByDay, focusSection }: MabTapProps) { locData.lat != null && locData.lng != null )} - zoom={15} + zoom={14} /> </MapContainer> <MapCourseItemcontainer> @@ -131,10 +132,11 @@ function MapTab({ meetingsByDay, focusSection }: MabTapProps) { { id, color, + latitude, + longitude, start, end, room, - overlap, }: Meeting, i: number ) => { @@ -146,7 +148,10 @@ function MapTab({ meetingsByDay, focusSection }: MabTapProps) { start={start} end={end} room={room} - overlap={overlap!} + hasLocationData={ + latitude != null && + longitude != null + } focusSection={focusSection} /> ); diff --git a/frontend/plan/components/meetUtil.tsx b/frontend/plan/components/meetUtil.tsx index 8344516f..a4fbf8f6 100644 --- a/frontend/plan/components/meetUtil.tsx +++ b/frontend/plan/components/meetUtil.tsx @@ -134,7 +134,7 @@ export const getTimeString = (meetings: Meeting[]) => { }); let daySet = ""; - Object.values(Day).forEach((day) => { + ["M", "T", "W", "R", "F", "S", "U"].forEach((day) => { times[maxrange].forEach((d) => { if (d === day) { daySet += day; @@ -145,4 +145,4 @@ export const getTimeString = (meetings: Meeting[]) => { return `${intToTime(parseFloat(maxrange.split("-")[0]))}-${intToTime( parseFloat(maxrange.split("-")[1]) )} ${daySet}`; -}; \ No newline at end of file +}; diff --git a/frontend/plan/constants/constants.js b/frontend/plan/constants/constants.js index 3eb17d64..b4d23878 100644 --- a/frontend/plan/constants/constants.js +++ b/frontend/plan/constants/constants.js @@ -6,6 +6,4 @@ export const DAYS_TO_DAYSTRINGS = { W: "Wednesday", R: "Thursday", F: "Friday", - S: "Saturday", - U: "Sunday", }; diff --git a/frontend/plan/pages/index.tsx b/frontend/plan/pages/index.tsx index a285140d..bc8193b4 100644 --- a/frontend/plan/pages/index.tsx +++ b/frontend/plan/pages/index.tsx @@ -68,13 +68,13 @@ const CustomTabs = styled(Tabs)` } `; -const CartTab = styled.a<{ active: boolean }>` +const CartTab = styled.a<{ active: string }>` display: inline-flex; font-weight: bold; margin-bottom: 0.5rem; cursor: pointer; margin-right: 1rem; - color: ${(props) => (props.active ? "black" : "gray")}; + color: ${(props) => (props.active === "true" ? "black" : "gray")}; `; const Box = styled.div` @@ -441,7 +441,9 @@ function Index() { <CartTab key={item} href={`/#${item}`} - active={selectedTab === item} + active={( + selectedTab === item + ).toString()} onClick={() => setSelectedTab(item) } diff --git a/frontend/plan/types.ts b/frontend/plan/types.ts index cab43fe6..9d036593 100644 --- a/frontend/plan/types.ts +++ b/frontend/plan/types.ts @@ -42,12 +42,17 @@ export interface CUFilter { 1.5: boolean; } -export enum Day { +export type Day = Weekdays | Weekends; + +export enum Weekdays { M = "M", T = "T", W = "W", R = "R", F = "F", +} + +export enum Weekends { S = "S", U = "U", }