-
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.
map feature impl done (hyperlink on building name + map view for sche…
…dule
- Loading branch information
1 parent
3c085f9
commit 39a2a60
Showing
13 changed files
with
729 additions
and
74 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
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 |
---|---|---|
@@ -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='© <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); |
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,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; |
Oops, something went wrong.