diff --git a/apps/backend/src/bootstrap/loaders/passport.ts b/apps/backend/src/bootstrap/loaders/passport.ts index 772c6318d..909113e20 100644 --- a/apps/backend/src/bootstrap/loaders/passport.ts +++ b/apps/backend/src/bootstrap/loaders/passport.ts @@ -29,6 +29,9 @@ const CACHE_PREFIX = "user-session:"; const ANONYMOUS_SESSION_TTL = 1000 * 60 * 60 * 12; const AUTHENTICATED_SESSION_TTL = 1000 * 60 * 60 * 24 * 365; +const DEV_USER_EMAIL = "dev@berkeleytime.local"; +const DEV_USER_GOOGLE_ID = "local-dev-user"; +const DEV_USER_NAME = "Local Dev User"; export default async (app: Application, redis: RedisClientType) => { // init @@ -205,7 +208,28 @@ export default async (app: Application, redis: RedisClientType) => { const DEV_LOGIN_ROUTE = "/dev/login"; const DEV_USERS_ROUTE = "/dev/users"; + const getOrCreateDevUser = async (userId?: string) => { + if (userId) return await UserModel.findById(userId); + + return await UserModel.findOneAndUpdate( + { email: DEV_USER_EMAIL }, + { + $set: { + lastSeenAt: new Date(), + name: DEV_USER_NAME, + staff: true, + }, + $setOnInsert: { + email: DEV_USER_EMAIL, + googleId: DEV_USER_GOOGLE_ID, + }, + }, + { new: true, upsert: true } + ); + }; + // GET /dev/login?userId=xxx&redirect_uri=/ + // Omitting userId logs in as a deterministic local dev user. app.get(DEV_LOGIN_ROUTE, async (req, res) => { const { userId, redirect_uri: redirectURI } = req.query; @@ -226,12 +250,12 @@ export default async (app: Application, redis: RedisClientType) => { res.redirect(redirectWithDevAuthError(reason)); }; - if (!userId || typeof userId !== "string") { + if (userId && typeof userId !== "string") { failDevLogin("invalid_user_id"); return; } - const user = await UserModel.findById(userId); + const user = await getOrCreateDevUser(userId); if (!user) { failDevLogin("user_not_found"); return; diff --git a/apps/frontend/package.json b/apps/frontend/package.json index c9464324b..e8bb8bc63 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@apollo/client": "4.0.7", + "@floating-ui/dom": "^1.7.4", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-web": "^0.44.0", "@opentelemetry/context-zone": "^1.29.0", @@ -21,12 +22,10 @@ "@opentelemetry/resources": "^1.29.0", "@opentelemetry/sdk-trace-base": "^1.29.0", "@opentelemetry/sdk-trace-web": "^1.29.0", - "@floating-ui/dom": "^1.7.4", - "@mapbox/mapbox-gl-directions": "^4.3.1", + "@repo/BtLL": "*", "@repo/common": "*", "@repo/shared": "*", "@repo/theme": "*", - "@repo/BtLL": "*", "@shopify/draggable": "^1.1.4", "@tanstack/react-virtual": "^3.13.12", "@types/suncalc": "^1.9.2", @@ -34,7 +33,6 @@ "framer-motion": "^12.23.24", "graphql": "^16.11.0", "iconoir-react": "^7.11.0", - "mapbox-gl": "^3.15.0", "maplibre-gl": "^5.13.0", "moment": "^2.30.1", "patch-package": "^8.0.1", @@ -55,7 +53,6 @@ "@repo/gql-typedefs": "*", "@repo/typescript-config": "*", "@types/lodash": "^4.17.20", - "@types/mapbox-gl": "^3.4.1", "@types/node": "^24.7.0", "@types/react": "^19.2.1", "@types/react-dom": "^19.2.0", diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 443959924..7eb247427 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -46,7 +46,7 @@ const Schedule = lazy(() => import("@/app/Schedule")); const Compare = lazy(() => import("@/app/Schedule/Comparison")); const Manage = lazy(() => import("@/app/Schedule/Editor")); const Schedules = lazy(() => import("@/app/Schedules")); -// const Map = lazy(() => import("@/app/Map")); +const Map = lazy(() => import("@/app/Map")); const GradTrak = lazy(() => import("@/app/GradTrak")); const GradTrakOnboarding = lazy(() => import("@/app/GradTrak/Onboarding")); const GradTrakDashboard = lazy(() => import("@/app/GradTrak/Dashboard")); @@ -190,6 +190,14 @@ const router = createBrowserRouter([ { element: , children: [ + { + element: ( + + + + ), + path: "map", + }, { element: ( diff --git a/apps/frontend/src/app/Map/Map.module.scss b/apps/frontend/src/app/Map/Map.module.scss index 1360625e2..710655ea1 100644 --- a/apps/frontend/src/app/Map/Map.module.scss +++ b/apps/frontend/src/app/Map/Map.module.scss @@ -1,61 +1,93 @@ .root { - background-color: var(--background-color); + height: calc(100dvh - var(--header-height, 90px)); + min-height: 520px; position: relative; - flex-grow: 1; - display: flex; - flex-direction: column; + overflow: hidden; +} + +.selector { + position: absolute; + top: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 2; + padding: 10px 12px; + background-color: var(--foreground-color); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(15, 23, 42, 0.1); - .container { - flex-grow: 1; + label { + display: flex; + align-items: center; + gap: 10px; } - .menu { - position: absolute; - bottom: 24px; - left: 24px; - z-index: 1; - padding: 12px; + span { + font-size: var(--text-12); + font-weight: var(--font-medium); + color: var(--paragraph-color); + } + + select { + min-width: 220px; + max-width: min(360px, 60vw); border: 1px solid var(--border-color); - background-color: var(--foreground-color); - border-radius: 8px; - display: flex; - gap: 8px; + border-radius: 6px; + background-color: var(--background-color); + color: var(--heading-color); + font: inherit; + font-size: var(--text-14); + padding: 6px 10px; } +} + +@media (max-width: 640px) { + .selector { + width: calc(100% - 32px); - .overlay { - width: 384px; - top: 0; - right: 0; - position: absolute; - background: linear-gradient(to left, var(--background-color), transparent); - height: 100%; - padding: 24px; - pointer-events: none; - - .panel { - background-color: var(--foreground-color); - border-radius: 8px; - border: 1px solid var(--border-color); - pointer-events: all; - height: 100%; + label { + justify-content: space-between; + } + + select { + min-width: 0; + max-width: 100%; + flex: 1; } } } -.marker { - width: 24px; - height: 24px; - border-radius: 50%; - background-color: var(--blue-500); +.centered { + min-height: calc(100dvh - var(--header-height, 90px)); display: grid; place-items: center; - color: white; - font-weight: var(--font-medium); - font-size: var(--text-14); - line-height: 1; - border: 1px solid var(--blue-600); + padding: 24px; + background-color: var(--background-color); } -:global(.mapboxgl-control-container) { - display: none; +.message { + width: min(420px, 100%); + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + text-align: center; + + > svg { + width: 32px; + height: 32px; + color: var(--blue-500); + } + + h1 { + font-size: 2rem; + font-weight: var(--font-bold); + color: var(--heading-color); + } + + p { + color: var(--paragraph-color); + line-height: 1.5; + } } diff --git a/apps/frontend/src/app/Map/index.tsx b/apps/frontend/src/app/Map/index.tsx index b4adfb42e..181e77126 100644 --- a/apps/frontend/src/app/Map/index.tsx +++ b/apps/frontend/src/app/Map/index.tsx @@ -1,132 +1,148 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo } from "react"; -import { Position, ZoomIn, ZoomOut } from "iconoir-react"; -import mapboxgl from "mapbox-gl"; -import "mapbox-gl/dist/mapbox-gl.css"; +import { ArrowRight, Calendar, Plus } from "iconoir-react"; +import { Link, useSearchParams } from "react-router-dom"; -import { IconButton, useColorScheme, useTheme } from "@repo/theme"; +import { Button, LoadingIndicator } from "@repo/theme"; -import { buildings } from "@/lib/location"; +import RouteMap from "@/app/Schedule/Editor/Map"; +import { + getNextClassColor, + getSelectedSections, +} from "@/app/Schedule/schedule"; +import { useReadSchedule, useReadSchedules } from "@/hooks/api"; +import useUser from "@/hooks/useUser"; +import { signIn } from "@/lib/api"; +import { Color } from "@/lib/generated/graphql"; import styles from "./Map.module.scss"; -const TOKEN = - "pk.eyJ1IjoibWF0aGh1bGsiLCJhIjoiY2t6bTFhcDU2M2prOTJwa3VwcTJ2d2dpMiJ9.WEJWEP_qrKGXkYOgbIsaGg"; - -const MAX_ZOOM = 18; -const MIN_ZOOM = 14; -const DEFAULT_ZOOM = 15.5; -// const OFFSET: [number, number] = [-156, 0]; +export default function Map() { + const { user, loading: userLoading } = useUser(); + const [searchParams, setSearchParams] = useSearchParams(); -mapboxgl.accessToken = TOKEN; + const { data: schedules, loading: schedulesLoading } = useReadSchedules({ + skip: !user, + }); -export default function Map() { - const { theme } = useTheme(); + const availableSchedules = useMemo( + () => schedules?.filter(Boolean) ?? [], + [schedules] + ); - const scheme = useColorScheme(); + const selectedScheduleId = searchParams.get("schedule"); + const selectedScheduleExists = useMemo( + () => + Boolean( + selectedScheduleId && + availableSchedules.some( + (schedule) => schedule._id === selectedScheduleId + ) + ), + [availableSchedules, selectedScheduleId] + ); - const currentTheme = useMemo(() => theme ?? scheme, [theme, scheme]); + useEffect(() => { + if (selectedScheduleExists || availableSchedules.length === 0) return; - const containerRef = useRef(null); + const firstSchedule = availableSchedules[0]; + if (firstSchedule) setSearchParams({ schedule: firstSchedule._id }); + }, [availableSchedules, selectedScheduleExists, setSearchParams]); - const [zoom, setZoom] = useState(DEFAULT_ZOOM); - const mapRef = useRef(null); + const { data: scheduleData, loading: scheduleLoading } = useReadSchedule( + selectedScheduleId ?? "", + { skip: !selectedScheduleExists } + ); - useEffect(() => { - if (!containerRef.current) return; - - const map = new mapboxgl.Map({ - container: containerRef.current, - style: - currentTheme === "dark" - ? "mapbox://styles/mathhulk/clvblbtkd005k01rd1n28b2xt" - : "mapbox://styles/mathhulk/clbznbvgs000314k8gtwa9q60", - center: [-122.2592173, 37.8721508], - zoom: DEFAULT_ZOOM, - minZoom: MIN_ZOOM, - maxZoom: MAX_ZOOM, - }); - - map.on("load", async () => { - map.addSource("campus", { - type: "geojson", - data: "/geojson/campus.geojson", - }); - - map.addLayer({ - id: "campus-fill", - type: "line", - source: "campus", - layout: {}, - paint: { - "line-width": 1, - "line-color": "var(--blue-500)", - "line-opacity": 0.5, - "line-dasharray": [2, 2], - }, - }); - - map.addLayer({ - id: "campus-line", - type: "fill", - source: "campus", - layout: {}, - paint: { - "fill-color": "var(--blue-500)", - "fill-opacity": 0.05, - }, - }); - - for (const building of Object.values(buildings)) { - if (!building.location) continue; - - // Create a marker for each building - const el = document.createElement("div"); - el.className = styles.marker; - el.innerText = building.name[0].toUpperCase(); - - // Add marker to map - new mapboxgl.Marker(el).setLngLat(building.location).addTo(map); - } - }); - - map.on("zoomend", () => { - setZoom(map.getZoom()); - }); - - mapRef.current = map; - - return () => { - mapRef.current?.remove(); + const schedule = useMemo(() => { + if (!scheduleData) return undefined; + + return { + ...scheduleData, + classes: scheduleData.classes.map((cls, index) => ({ + ...cls, + color: cls.color ?? getNextClassColor(index), + })), + events: scheduleData.events.map((event) => ({ + ...event, + color: event.color ?? Color.Gray, + })), }; - }, [currentTheme]); + }, [scheduleData]); + + const selectedSections = useMemo( + () => getSelectedSections(schedule), + [schedule] + ); + + if (userLoading || (user && schedulesLoading)) { + return ( +
+ +
+ ); + } + + if (!user) { + return ( +
+
+ +

Map routes

+

Sign in to choose a schedule and view class-to-class routes.

+ +
+
+ ); + } + + if (availableSchedules.length === 0) { + return ( +
+
+ +

No schedules yet

+

Create a schedule, add classes, then return here to see routes.

+ + + +
+
+ ); + } return (
-
- mapRef.current?.zoomIn()} - > - - - mapRef.current?.zoomOut()} - > - - - mapRef.current?.zoomIn()} - > - - -
-
-
-
+
+
+ {!selectedScheduleExists || scheduleLoading || !schedule ? ( +
+ +
+ ) : ( + + )}
); } diff --git a/apps/frontend/src/app/Schedule/Editor/ExportDialog/index.tsx b/apps/frontend/src/app/Schedule/Editor/ExportDialog/index.tsx new file mode 100644 index 000000000..806238806 --- /dev/null +++ b/apps/frontend/src/app/Schedule/Editor/ExportDialog/index.tsx @@ -0,0 +1,102 @@ +import { ReactNode, useMemo, useRef, useState } from "react"; + +import { Calendar, ClipboardCheck, Hashtag, Xmark } from "iconoir-react"; + +import { Button, Dialog, Flex, Heading, IconButton, Text } from "@repo/theme"; + +import useSchedule from "@/hooks/useSchedule"; +import { copyTextToClipboard } from "@/lib/clipboard"; + +import exportToCalendar from "../exportToCalendar"; + +interface ExportDialogProps { + children: ReactNode; +} + +export default function ExportDialog({ children }: ExportDialogProps) { + const { schedule } = useSchedule(); + const timeoutRef = useRef>(null); + const [copiedClassNumbers, setCopiedClassNumbers] = useState(false); + + const selectedClassNumbers = useMemo( + () => + schedule.classes + .filter((selectedClass) => !selectedClass.hidden) + .flatMap((selectedClass) => { + const sections = [ + selectedClass.class.primarySection, + ...selectedClass.class.sections, + ]; + + return selectedClass.selectedSections.map(({ sectionId }) => { + const section = sections.find( + (currentSection) => currentSection?.sectionId === sectionId + ); + const label = section + ? `${selectedClass.class.subject} ${selectedClass.class.courseNumber} ${section.component} ${section.number}` + : `${selectedClass.class.subject} ${selectedClass.class.courseNumber}`; + + return `${label}: ${sectionId}`; + }); + }), + [schedule.classes] + ); + + const handleCopyClassNumbers = async () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + + await copyTextToClipboard(selectedClassNumbers.join("\n")); + setCopiedClassNumbers(true); + + timeoutRef.current = setTimeout(() => { + setCopiedClassNumbers(false); + }, 1200); + }; + + return ( + + {children} + + + + + + + Export schedule + + + Download your calendar or copy enrollment numbers + + + + + + + + + + + + + + + + ); +} diff --git a/apps/frontend/src/app/Schedule/Editor/Map/Map.module.scss b/apps/frontend/src/app/Schedule/Editor/Map/Map.module.scss index 68c3229b3..d6c0c308c 100644 --- a/apps/frontend/src/app/Schedule/Editor/Map/Map.module.scss +++ b/apps/frontend/src/app/Schedule/Editor/Map/Map.module.scss @@ -8,61 +8,182 @@ width: 100%; } + :global(.maplibregl-marker) { + z-index: 2; + } + .toolBar { position: absolute; top: 24px; left: 24px; - z-index: 1; + z-index: 3; padding: 12px; border: 1px solid var(--border-color); background-color: var(--foreground-color); border-radius: 8px; display: flex; gap: 8px; + align-items: center; + box-shadow: 0 4px 16px rgba(15, 23, 42, 0.1); + + .dayTabs { + display: flex; + gap: 2px; + padding-right: 8px; + border-right: 1px solid var(--border-color); + } + } + + .mapControls { + position: absolute; + left: 24px; + bottom: 24px; + z-index: 3; + padding: 8px; + border: 1px solid var(--border-color); + background-color: var(--foreground-color); + border-radius: 8px; + display: flex; + gap: 8px; + align-items: center; + box-shadow: 0 4px 16px rgba(15, 23, 42, 0.1); + } + + .mapModeToggle { + display: flex; + gap: 2px; + padding: 2px; + border: 1px solid var(--border-color); + border-radius: 6px; + background-color: var(--background-color); + } + + .mapModeButton { + min-height: 32px; + padding: 0 12px; + border: 0; + border-radius: 5px; + background-color: transparent; + color: var(--paragraph-color); + cursor: pointer; + font-size: var(--text-14); + font-weight: var(--font-medium); + line-height: 1; + white-space: nowrap; + } + + .mapModeButtonActive { + background-color: #003262; + color: #fdb515; + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.16); + } + + .googleMapsLink { + height: 38px; + padding: 0 12px; + border-radius: 6px; + background-color: var(--blue-500); + color: white; + display: flex; + gap: 6px; + align-items: center; + justify-content: center; + font-size: var(--text-14); + font-weight: var(--font-medium); + line-height: 1; + text-decoration: none; + white-space: nowrap; + + svg { + width: 16px; + height: 16px; + flex-shrink: 0; + } + } + + .googleMapsLinkDisabled { + pointer-events: none; + cursor: not-allowed; + opacity: 0.48; } .sideBar { - width: 256px; + width: min(320px, calc(100% - 48px)); background-color: var(--foreground-color); border-radius: 8px; border: 1px solid var(--border-color); flex-shrink: 0; padding: 16px; position: absolute; + z-index: 3; top: 24px; right: 24px; max-height: calc(100% - 48px); overflow: auto; + box-shadow: 0 4px 16px rgba(15, 23, 42, 0.1); + + .sideBarHeader { + padding-bottom: 16px; + margin-bottom: 16px; + border-bottom: 1px solid var(--border-color); + + .heading { + font-size: var(--text-18); + line-height: 1; + font-weight: var(--font-bold); + color: var(--heading-color); + } + + .summary { + margin-top: 6px; + font-size: var(--text-14); + color: var(--paragraph-color); + } + } + + .empty { + font-size: var(--text-14); + color: var(--paragraph-color); + line-height: 1.5; + } + + .timelineItem:not(:last-child) { + margin-bottom: 2px; + } .leg { - height: 32px; + min-height: 32px; display: flex; gap: 8px; align-items: center; - background-color: var(--slate-100); - width: fit-content; - border-radius: 16px; - padding: 0 12px; color: var(--paragraph-color); - margin: 24px 0; + margin: 8px 0 8px 2px; + padding-left: 40px; position: relative; - &::after { + &::before { left: 14px; - bottom: -24px; + top: -10px; width: 4px; border-right: 4px dashed var(--slate-100); - height: 24px; + height: calc(100% + 20px); content: ""; position: absolute; } + svg { + width: 16px; + height: 16px; + color: var(--blue-500); + } + .value { font-size: var(--text-14); - line-height: 1; + line-height: 1.3; .distance { - color: var(--red-500); + color: var(--blue-500); + font-weight: var(--font-medium); } } } @@ -73,16 +194,6 @@ color: var(--paragraph-color); position: relative; - &:not(:last-child)::after { - left: 14px; - top: 32px; - width: 4px; - border-right: 4px dashed var(--slate-100); - height: calc(100% - 8px); - content: ""; - position: absolute; - } - .number { width: 32px; height: 32px; @@ -94,64 +205,133 @@ align-items: center; justify-content: center; color: white; + flex-shrink: 0; } .text { flex-grow: 1; + min-width: 0; font-size: var(--text-14); .label { font-size: var(--text-12); + color: var(--paragraph-color); } .heading { font-weight: var(--font-bold); color: var(--heading-color); margin-top: 4px; + overflow-wrap: anywhere; } .description { margin-top: 4px; + line-height: 1.35; } } } } + + @media (max-width: 720px) { + .toolBar { + top: 12px; + right: 12px; + left: 12px; + overflow-x: auto; + } + + .mapControls { + right: 12px; + bottom: calc(min(300px, 46%) + 24px); + left: 12px; + flex-wrap: wrap; + } + + .mapModeToggle { + flex: 1 1 180px; + } + + .mapModeButton { + flex: 1; + } + + .googleMapsLink { + flex: 1 1 130px; + } + + .sideBar { + right: 12px; + bottom: 12px; + left: 12px; + top: auto; + width: auto; + max-height: min(300px, 46%); + } + } +} + +.mapOverlay { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + pointer-events: none; } -:global(.marker) { - width: 32px; +.routeHalo, +.routeLine { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; +} + +.routeHalo { + stroke: rgba(15, 23, 42, 0.9); + stroke-width: 10px; +} + +.routeLine { + stroke: #60a5fa; + stroke-width: 5px; + filter: drop-shadow(0 2px 6px rgba(15, 23, 42, 0.4)); +} + +.mapMarker { + min-width: 32px; height: 32px; - border-radius: 50%; + padding: 0 8px; + border-radius: 999px; background-color: var(--blue-500); + border: 2px solid white; + box-shadow: 0 2px 10px rgba(15, 23, 42, 0.35); display: flex; align-items: center; justify-content: center; color: white; font-weight: var(--font-bold); font-size: var(--text-14); + line-height: 1; + cursor: pointer; +} - &:global(.marker-red) { - background-color: var(--red-500); - width: 8px; - height: 8px; - } +.popup { + color: #111827; + min-width: 180px; } -:global(.tooltip) { - position: absolute; - border-radius: 4px; - padding: 12px; - background-color: var(--background-color); - width: 256px; - animation: fadeIn 100ms ease-in; - color: white; +.popupStop:not(:last-child) { + padding-bottom: 8px; + margin-bottom: 8px; + border-bottom: 1px solid #e5e7eb; } -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } +.popupTitle { + font-weight: 700; + margin-bottom: 4px; +} + +:global(.maplibregl-control-container) { + display: none; } diff --git a/apps/frontend/src/app/Schedule/Editor/Map/index.tsx b/apps/frontend/src/app/Schedule/Editor/Map/index.tsx index 76e44f025..182634222 100644 --- a/apps/frontend/src/app/Schedule/Editor/Map/index.tsx +++ b/apps/frontend/src/app/Schedule/Editor/Map/index.tsx @@ -1,331 +1,1140 @@ import { useEffect, useMemo, useRef, useState } from "react"; -// @ts-expect-error - MapboxDirections does not provide types -import MapboxDirections from "@mapbox/mapbox-gl-directions/dist/mapbox-gl-directions"; -import "@mapbox/mapbox-gl-directions/dist/mapbox-gl-directions.css"; -import { ArrowSeparateVertical, Walking, ZoomIn, ZoomOut } from "iconoir-react"; -import mapboxgl from "mapbox-gl"; -import "mapbox-gl/dist/mapbox-gl.css"; +import type { FeatureCollection, Point } from "geojson"; +import { ArrowUpRight, Walking, ZoomIn, ZoomOut } from "iconoir-react"; +import maplibregl from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; -import { Button, IconButton, useColorScheme, useTheme } from "@repo/theme"; +import { IconButton, MenuItem, useColorScheme, useTheme } from "@repo/theme"; -import { buildings } from "@/lib/location"; +import { buildings, findBuildingForLocation } from "@/lib/location"; import { SectionColor } from "../../schedule"; import styles from "./Map.module.scss"; -const TOKEN = - "pk.eyJ1IjoibWF0aGh1bGsiLCJhIjoiY2t6bTFhcDU2M2prOTJwa3VwcTJ2d2dpMiJ9.WEJWEP_qrKGXkYOgbIsaGg"; - const MAX_ZOOM = 18; const MIN_ZOOM = 14; const DEFAULT_ZOOM = 15.5; -// const OFFSET: [number, number] = [-156, 0]; +const CAMPUS_CENTER: [number, number] = [-122.2592173, 37.8721508]; +const WALKING_ROUTE_ENDPOINT = + "https://routing.openstreetmap.de/routed-foot/route/v1/foot"; + +type MapMode = "minimal" | "satellite"; + +interface BerkeleyMapLabel { + coordinates: [number, number]; + name: string; + priority?: number; +} + +const MAP_MODE_OPTIONS: { label: string; value: MapMode }[] = [ + { label: "Minimal", value: "minimal" }, + { label: "Satellite", value: "satellite" }, +]; + +const ADDITIONAL_BERKELEY_LABELS: BerkeleyMapLabel[] = [ + { + coordinates: [-122.2592358273592, 37.8722145126222], + name: "Morrison Library", + priority: 2, + }, + { + coordinates: [-122.26220645353504, 37.87142462552724], + name: "Bioscience, Natural Resources & Public Health Library", + }, + { + coordinates: [-122.25825, 37.87533], + name: "Kresge Engineering Library", + }, + { + coordinates: [-122.25534011894594, 37.872593339541446], + name: "Chemistry & Chemical Engineering Library", + }, + { + coordinates: [-122.25967790769536, 37.87409189531322], + name: "Earth Sciences & Map Library", + }, + { + coordinates: [-122.25489197607627, 37.87074044380782], + name: "Environmental Design Library", + }, + { + coordinates: [-122.25759413529791, 37.873621559931614], + name: "Mathematics Statistics Library", + }, + { + coordinates: [-122.25682010086159, 37.872480670347336], + name: "Physics-Astronomy Library", + }, + { + coordinates: [-122.25401475499672, 37.86950825416665], + name: "Law Library", + }, + { + coordinates: [-122.25882193535269, 37.87602971606913], + name: "Jacobs Institute for Design Innovation", + priority: 2, + }, + { + coordinates: [-122.25831352863513, 37.87503515928838], + name: "CITRIS Invention Lab", + priority: 2, + }, + { + coordinates: [-122.26083662991704, 37.872543936658765], + name: "Moffitt Makerspace", + priority: 2, + }, + { + coordinates: [-122.257886, 37.875098], + name: "Bechtel Engineering Center", + }, + { + coordinates: [-122.257814, 37.872065], + name: "Sather Tower", + priority: 2, + }, + { + coordinates: [-122.254609, 37.873594], + name: "Hearst Greek Theatre", + }, + { + coordinates: [-122.26033260383534, 37.86955061087345], + name: "Martin Luther King Jr. Student Union", + }, +]; + +const BERKELEY_CUSTOM_LABELS = Array.from( + new Map( + [ + ...Object.values(buildings).flatMap((building) => + building.location && building.name !== "Off campus" + ? [ + { + coordinates: building.location, + name: building.name, + priority: 1, + }, + ] + : [] + ), + ...ADDITIONAL_BERKELEY_LABELS, + ].map((label) => [label.name, label]) + ).values() +); + +const BERKELEY_AFFILIATED_LABEL_NAMES = Array.from( + new Set([ + ...BERKELEY_CUSTOM_LABELS.map((label) => label.name), + "Berkeley Art Museum and Pacific Film Archive", + "C. V. Starr East Asian Library", + "California Hall", + "Campanile", + "Doe Memorial Library", + "Engineering Library", + "Greek Theatre", + "Haas School of Business", + "International House", + "Martin Luther King Jr. Student Union", + "Music Library", + "The Bancroft Library", + "UC Berkeley School of Law", + "University of California, Berkeley", + "UC Berkeley", + ]) +); + +const BERKELEY_LABEL_KEYWORDS = [ + "berkeley", + "campanile", + "doe", + "library", + "maker", + "makerspace", + "invention lab", + "student union", + "hall", + "center", + "institute", + "school", + "college", + "museum", + "gym", + "field", + "lab", + "laboratory", + "auditorium", + "theatre", + "theater", +]; + +const BERKELEY_LABEL_GEOJSON: FeatureCollection< + Point, + { name: string; priority: number } +> = { + features: BERKELEY_CUSTOM_LABELS.map((label) => ({ + geometry: { + coordinates: label.coordinates, + type: "Point", + }, + properties: { + name: label.name, + priority: label.priority ?? 1, + }, + type: "Feature", + })), + type: "FeatureCollection", +}; + +const DAYS = [ + { index: 0, label: "Monday", short: "M" }, + { index: 1, label: "Tuesday", short: "Tu" }, + { index: 2, label: "Wednesday", short: "W" }, + { index: 3, label: "Thursday", short: "Th" }, + { index: 4, label: "Friday", short: "F" }, + { index: 5, label: "Saturday", short: "Sa" }, + { index: 6, label: "Sunday", short: "Su" }, +]; -mapboxgl.accessToken = TOKEN; +const getBaseMapStyle = ( + currentTheme: string, + mapMode: MapMode +): maplibregl.StyleSpecification => { + const isDark = currentTheme === "dark"; + const isSatellite = mapMode === "satellite"; + const bgColor = isDark ? "#18181b" : "#ffffff"; + const buildingColor = isDark ? "#27272a" : "#e2e8f0"; + const buildingOutline = isDark ? "#3f3f46" : "#cbd5e1"; + const roadColor = isSatellite ? "rgba(255, 255, 255, 0.56)" : bgColor; + const roadOutline = isSatellite + ? "rgba(15, 23, 42, 0.65)" + : isDark + ? "#3f3f46" + : "#d4d4d4"; + const satelliteLabelColor = "#003262"; + const satelliteLabelHalo = "#fff9f0"; + const satelliteTextFont = ["Noto Sans Bold"]; + const textColor = isSatellite + ? satelliteLabelColor + : isDark + ? "#f8fafc" + : "#0f172a"; + const textHalo = isSatellite + ? satelliteLabelHalo + : isDark + ? "rgba(3, 7, 18, 0.88)" + : "rgba(255, 255, 255, 0.94)"; + const academicTextColor = isSatellite + ? satelliteLabelColor + : isDark + ? "#ffd166" + : "#001f4e"; + const academicTextHalo = isSatellite + ? satelliteLabelHalo + : isDark + ? "rgba(3, 7, 18, 0.98)" + : "rgba(255, 255, 255, 0.98)"; + const standardLabelSize = isSatellite ? 12 : 12; + const campusLabelSize = isSatellite ? 13.5 : 13.25; + const standardHaloWidth = isSatellite ? 2.1 : 1.35; + const campusHaloWidth = isSatellite ? 2.4 : 1.75; + const customLabelSize = isSatellite ? [12.25, 14, 16] : [12, 13.5, 15.5]; + const labelName = ["coalesce", ["get", "name"], ""]; + const lowerLabelName = ["downcase", labelName]; + const exactBerkeleyLabel = [ + "in", + labelName, + ["literal", BERKELEY_AFFILIATED_LABEL_NAMES], + ]; + const keywordBerkeleyLabel = [ + "any", + ...BERKELEY_LABEL_KEYWORDS.map((keyword) => [ + ">=", + ["index-of", keyword, lowerLabelName], + 0, + ]), + ]; + const berkeleyLabel = ["any", exactBerkeleyLabel, keywordBerkeleyLabel]; + + return { + glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", + layers: [ + { + id: "background", + paint: { "background-color": bgColor }, + type: "background", + }, + ...(isSatellite + ? [ + { + id: "satellite", + paint: { + "raster-opacity": 0.86, + }, + source: "satellite", + type: "raster" as const, + }, + ] + : [ + { + id: "buildings", + paint: { + "fill-color": buildingColor, + "fill-outline-color": buildingOutline, + }, + source: "openmaptiles", + "source-layer": "building", + type: "fill" as const, + }, + ]), + { + filter: ["==", ["get", "class"], "path"], + id: "paths-outline", + paint: { + "line-color": isSatellite ? "rgba(15, 23, 42, 0.72)" : roadOutline, + "line-width": 4, + }, + source: "openmaptiles", + "source-layer": "transportation", + type: "line", + }, + { + id: "roads-outline", + paint: { + "line-color": roadOutline, + "line-width": 4, + }, + source: "openmaptiles", + "source-layer": "transportation", + type: "line", + }, + { + id: "roads", + paint: { + "line-color": roadColor, + "line-width": 2, + }, + source: "openmaptiles", + "source-layer": "transportation", + type: "line", + }, + { + filter: ["all", ["has", "name"], ["!", berkeleyLabel]], + id: "place-labels", + layout: { + ...(isSatellite ? { "text-font": satelliteTextFont } : {}), + "text-anchor": "top", + "text-field": ["get", "name"], + "text-size": standardLabelSize, + }, + paint: { + "text-color": textColor, + "text-halo-blur": 0, + "text-halo-color": textHalo, + "text-halo-width": standardHaloWidth, + }, + source: "openmaptiles", + "source-layer": "poi", + type: "symbol", + }, + { + filter: [ + "all", + ["has", "name"], + ["!", exactBerkeleyLabel], + berkeleyLabel, + ], + id: "berkeley-affiliated-poi-labels", + layout: { + ...(isSatellite ? { "text-font": satelliteTextFont } : {}), + "text-anchor": "center", + "text-field": ["get", "name"], + "text-padding": 4, + "text-size": campusLabelSize, + }, + paint: { + "text-color": academicTextColor, + "text-halo-blur": 0, + "text-halo-color": academicTextHalo, + "text-halo-width": campusHaloWidth, + }, + source: "openmaptiles", + "source-layer": "poi", + type: "symbol", + }, + { + id: "berkeley-custom-labels", + layout: { + ...(isSatellite ? { "text-font": satelliteTextFont } : {}), + "symbol-sort-key": ["get", "priority"], + "text-anchor": "center", + "text-field": ["get", "name"], + "text-padding": 3, + "text-radial-offset": 0.35, + "text-size": [ + "interpolate", + ["linear"], + ["zoom"], + 14, + customLabelSize[0], + 16, + customLabelSize[1], + 18, + customLabelSize[2], + ], + "text-variable-anchor": ["top", "bottom", "left", "right"], + }, + paint: { + "text-color": academicTextColor, + "text-halo-blur": 0, + "text-halo-color": academicTextHalo, + "text-halo-width": campusHaloWidth, + }, + source: "berkeleyLabels", + type: "symbol", + }, + ], + sources: { + berkeleyLabels: { + data: BERKELEY_LABEL_GEOJSON, + type: "geojson", + }, + openmaptiles: { + type: "vector", + url: "https://tiles.openfreemap.org/planet", + }, + ...(isSatellite + ? { + satellite: { + attribution: "© Esri", + tileSize: 256, + tiles: [ + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + ], + type: "raster" as const, + }, + } + : {}), + }, + version: 8, + }; +}; interface MapProps { selectedSections: SectionColor[]; } -export default function Map({ selectedSections }: MapProps) { - const { theme } = useTheme(); +interface ScheduleMapStop { + buildingName?: string; + coordinates?: [number, number]; + courseLabel: string; + endTime: string; + key: string; + location?: string | null; + sectionLabel: string; + startTime: string; +} - const scheme = useColorScheme(); +interface LocatedScheduleMapStop extends ScheduleMapStop { + coordinates: [number, number]; +} +interface RouteSegment { + from: LocatedScheduleMapStop; + to: LocatedScheduleMapStop; +} + +interface RouteLeg { + distance: number; + duration: number; +} + +interface WalkingRouteResponse { + code: string; + message?: string; + routes?: { + distance: number; + duration: number; + geometry?: { + coordinates?: [number, number][]; + type: "LineString"; + }; + legs?: RouteLeg[]; + }[]; +} + +interface MapOverlay { + height: number; + routePaths: string[]; + width: number; +} + +type RouteStatus = "idle" | "loading" | "ready" | "error"; + +const emptyMapOverlay: MapOverlay = { + height: 0, + routePaths: [], + width: 0, +}; + +const getY = (time: string) => { + const [hour, minute] = time.split(":"); + return parseInt(hour) * 60 + parseInt(minute); +}; + +const formatTime = (time: string) => { + const [hourValue, minuteValue] = time.split(":").map(Number); + const suffix = hourValue < 12 ? "AM" : "PM"; + const hour = hourValue % 12 || 12; + + return `${hour}:${String(minuteValue).padStart(2, "0")} ${suffix}`; +}; + +const formatDistance = (meters: number) => { + const miles = meters / 1609.344; + + return miles < 0.1 + ? `${Math.round(meters * 3.28084)} ft` + : `${miles.toFixed(1)} mi`; +}; + +const formatDuration = (seconds: number) => { + const minutes = Math.max(1, Math.round(seconds / 60)); + + return `${minutes} min`; +}; + +const getDistanceMeters = ( + [fromLongitude, fromLatitude]: [number, number], + [toLongitude, toLatitude]: [number, number] +) => { + const radius = 6371000; + const fromLatitudeRadians = (fromLatitude * Math.PI) / 180; + const toLatitudeRadians = (toLatitude * Math.PI) / 180; + const latitudeDelta = ((toLatitude - fromLatitude) * Math.PI) / 180; + const longitudeDelta = ((toLongitude - fromLongitude) * Math.PI) / 180; + + const haversine = + Math.sin(latitudeDelta / 2) ** 2 + + Math.cos(fromLatitudeRadians) * + Math.cos(toLatitudeRadians) * + Math.sin(longitudeDelta / 2) ** 2; + + return ( + radius * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)) + ); +}; + +const estimateRouteLegFromCoordinates = ( + from: [number, number], + to: [number, number] +): RouteLeg => { + const distance = getDistanceMeters(from, to); + const walkingDistance = distance * 1.25; + + return { + distance: walkingDistance, + duration: walkingDistance / 1.35, + }; +}; + +const estimateRouteLeg = ( + from: LocatedScheduleMapStop, + to: LocatedScheduleMapStop +): RouteLeg => + estimateRouteLegFromCoordinates(from.coordinates, to.coordinates); + +const getCoordinateKey = ([longitude, latitude]: [number, number]) => + `${longitude.toFixed(6)},${latitude.toFixed(6)}`; + +const isLocatedStop = (stop: ScheduleMapStop): stop is LocatedScheduleMapStop => + Boolean(stop.coordinates); + +const getSvgPath = (points: { x: number; y: number }[]) => + points + .map( + (point, index) => + `${index === 0 ? "M" : "L"} ${point.x.toFixed(1)} ${point.y.toFixed(1)}` + ) + .join(" "); + +const getWalkingRouteUrl = (coordinates: [number, number][]) => { + const routeCoordinates = coordinates + .map(([longitude, latitude]) => `${longitude},${latitude}`) + .join(";"); + const query = new URLSearchParams({ + geometries: "geojson", + overview: "full", + steps: "false", + }); + + return `${WALKING_ROUTE_ENDPOINT}/${routeCoordinates}?${query.toString()}`; +}; + +const getGoogleMapsRouteUrl = (stops: ScheduleMapStop[]) => { + const coordinates = stops.flatMap((stop) => + stop.coordinates ? [stop.coordinates] : [] + ); + + if (coordinates.length < 2) return undefined; + + const [originLongitude, originLatitude] = coordinates[0]; + const [destinationLongitude, destinationLatitude] = + coordinates[coordinates.length - 1]; + const query = new URLSearchParams({ + api: "1", + destination: `${destinationLatitude},${destinationLongitude}`, + origin: `${originLatitude},${originLongitude}`, + travelmode: "walking", + }); + const waypoints = coordinates + .slice(1, -1) + .map(([longitude, latitude]) => `${latitude},${longitude}`) + .join("|"); + + if (waypoints) query.set("waypoints", waypoints); + + return `https://www.google.com/maps/dir/?${query.toString()}`; +}; + +const sameCoordinates = (first?: [number, number], second?: [number, number]) => + Boolean( + first && second && getCoordinateKey(first) === getCoordinateKey(second) + ); + +const fitMapToCoordinates = ( + map: maplibregl.Map, + coordinates: [number, number][] +) => { + if (coordinates.length === 0) { + map.easeTo({ center: CAMPUS_CENTER, zoom: DEFAULT_ZOOM, duration: 300 }); + return; + } + + if (coordinates.length === 1 && coordinates[0]) { + map.easeTo({ center: coordinates[0], zoom: 16.5, duration: 300 }); + return; + } + + const bounds = coordinates.reduce( + (bounds, coordinate) => bounds.extend(coordinate), + new maplibregl.LngLatBounds(coordinates[0], coordinates[0]) + ); + + map.fitBounds(bounds, { + duration: 400, + maxZoom: 16.5, + padding: { + bottom: 96, + left: 96, + right: 360, + top: 96, + }, + }); +}; + +export default function RouteMap({ selectedSections }: MapProps) { + const { theme } = useTheme(); + const scheme = useColorScheme(); const currentTheme = useMemo(() => theme ?? scheme, [theme, scheme]); const containerRef = useRef(null); - const markersRef = useRef([]); + const markersRef = useRef([]); + const mapRef = useRef(null); + const [activeDay, setActiveDay] = useState(DAYS[0].index); + const [mapOverlay, setMapOverlay] = useState(emptyMapOverlay); + const [mapLoaded, setMapLoaded] = useState(false); + const [mapMode, setMapMode] = useState("minimal"); + const [routeCoordinateGroups, setRouteCoordinateGroups] = useState< + [number, number][][] + >([]); + const [routeLegs, setRouteLegs] = useState([]); + const [routeStatus, setRouteStatus] = useState("idle"); const [zoom, setZoom] = useState(DEFAULT_ZOOM); - const [directions, setDirections] = useState(null); - const mapRef = useRef(null); - const waypoints = useMemo( + const stopsByDay = useMemo(() => { + const nextStopsByDay = DAYS.reduce( + (acc, day) => ({ ...acc, [day.index]: [] as ScheduleMapStop[] }), + {} as Record + ); + + selectedSections.forEach(({ section }) => { + section.meetings?.forEach((meeting, meetingIndex) => { + if (!meeting.startTime || !meeting.endTime) return; + + DAYS.forEach((day) => { + if (!meeting.days?.[day.index]) return; + + const building = findBuildingForLocation(meeting.location); + const sectionLabel = `${section.component} ${section.number}`; + + nextStopsByDay[day.index].push({ + buildingName: building?.name, + coordinates: building?.location, + courseLabel: `${section.subject} ${section.courseNumber}`, + endTime: meeting.endTime, + key: `${section.sectionId}-${meetingIndex}-${day.index}`, + location: meeting.location, + sectionLabel, + startTime: meeting.startTime, + }); + }); + }); + }); + + DAYS.forEach((day) => { + nextStopsByDay[day.index].sort( + (a, b) => + getY(a.startTime) - getY(b.startTime) || + getY(a.endTime) - getY(b.endTime) + ); + }); + + return nextStopsByDay; + }, [selectedSections]); + + useEffect(() => { + const firstDayWithStops = + DAYS.find((day) => stopsByDay[day.index].length > 0)?.index ?? + DAYS[0].index; + + if (stopsByDay[activeDay].length === 0 && firstDayWithStops !== activeDay) { + setActiveDay(firstDayWithStops); + } + }, [activeDay, stopsByDay]); + + const activeDayLabel = DAYS.find((day) => day.index === activeDay)?.label; + const activeStops = useMemo( + () => stopsByDay[activeDay] ?? [], + [activeDay, stopsByDay] + ); + const locatedStops = useMemo( + () => activeStops.filter(isLocatedStop), + [activeStops] + ); + const routeSegments = useMemo( () => - selectedSections - .filter((section) => section.section.meetings[0].location) - .map( - ({ - section: { - meetings: [{ location }], - }, - }) => { - const building = location!.split(" ").slice(0, -1).join(" "); - return buildings[building].location; - } - ), - [selectedSections] + activeStops.slice(0, -1).flatMap((stop, index) => { + const nextStop = activeStops[index + 1]; + + if ( + !nextStop || + !isLocatedStop(stop) || + !isLocatedStop(nextStop) || + sameCoordinates(stop.coordinates, nextStop.coordinates) + ) { + return []; + } + + return [{ from: stop, to: nextStop }]; + }), + [activeStops] ); + const routeStops = useMemo(() => { + const firstSegment = routeSegments[0]; + if (!firstSegment) return []; + + const stops: LocatedScheduleMapStop[] = [firstSegment.from]; + + routeSegments.forEach((segment) => { + const previousStop = stops[stops.length - 1]; + + if ( + !sameCoordinates(previousStop.coordinates, segment.from.coordinates) + ) { + return; + } + + stops.push(segment.to); + }); + + return stops; + }, [routeSegments]); + const googleMapsRouteUrl = useMemo( + () => getGoogleMapsRouteUrl(routeStops), + [routeStops] + ); + + const markerGroups = useMemo(() => { + const groups = new Map< + string, + { coordinates: [number, number]; stops: ScheduleMapStop[] } + >(); + + locatedStops.forEach((stop) => { + if (!stop.coordinates) return; + + const key = getCoordinateKey(stop.coordinates); + const currentGroup = groups.get(key); + + if (currentGroup) { + currentGroup.stops.push(stop); + } else { + groups.set(key, { coordinates: stop.coordinates, stops: [stop] }); + } + }); + + return Array.from(groups.values()); + }, [locatedStops]); + + const legByStopPair = useMemo(() => { + const legMap = new Map(); + + routeLegs.forEach((leg, index) => { + const segment = routeSegments[index]; + + if (segment) legMap.set(`${segment.from.key}:${segment.to.key}`, leg); + }); + + return legMap; + }, [routeLegs, routeSegments]); useEffect(() => { if (!containerRef.current) return; - // let destructor: (() => void) | null = null; + setMapLoaded(false); - const map = new mapboxgl.Map({ + const map = new maplibregl.Map({ + attributionControl: false, + center: CAMPUS_CENTER, container: containerRef.current, - style: - currentTheme === "dark" - ? "mapbox://styles/mathhulk/clvblbtkd005k01rd1n28b2xt" - : "mapbox://styles/mathhulk/clbznbvgs000314k8gtwa9q60", - center: [-122.2592173, 37.8721508], - zoom: DEFAULT_ZOOM, - minZoom: MIN_ZOOM, maxZoom: MAX_ZOOM, + minZoom: MIN_ZOOM, + style: getBaseMapStyle(currentTheme, mapMode), + zoom: DEFAULT_ZOOM, }); - map.on("load", async () => { - const directions = new MapboxDirections({ - styles: [ - { - id: "directions-route-line-casing", - type: "line", - source: "directions", - layout: { - "line-cap": "round", - "line-join": "round", - }, - paint: { - "line-color": "#3b82f6", - "line-width": 4, - }, - filter: [ - "all", - ["in", "$type", "LineString"], - ["in", "route", "selected"], - ], - }, - ], - accessToken: TOKEN, - unit: "imperial", - profile: "mapbox/walking", - controls: { - inputs: false, - instructions: false, - profileSwitcher: false, - }, - interactive: false, - instructions: false, - }); + map.on("load", () => { + setMapLoaded(true); + }); - map.addControl(directions); - - // @ts-expect-error - MapboxDirections does not provide types - directions.on("route", ({ route }) => { - for (let index = 0; index < route[0].legs.length; index++) { - const { steps } = route[0].legs[index]; - - const start = document.createElement("div"); - start.className = "marker"; - start.textContent = (index + 1).toLocaleString(); - - /*const tooltip = document.createElement("div"); - tooltip.className = "tooltip"; - tooltip.textContent = "Stop";*/ - - const originMarker = new mapboxgl.Marker(start) - .setLngLat(steps[0].maneuver.location) - .addTo(map); - - markersRef.current.push(originMarker); - - /*const showTooltip = () => { - document.body.appendChild(tooltip); - - destructor = autoUpdate( - start, - tooltip, - () => { - computePosition(start, tooltip, { - placement: "top", - middleware: [ - flip(), - offset(8), - shift({ - padding: 8, - boundary: document.getElementById("boundary") as Element, - }), - ], - }).then(({ x, y }) => { - Object.assign(tooltip.style, { - left: `${x}px`, - top: `${y}px`, - }); - }); - }, - { - animationFrame: true, - } - ); - }; - - const hideTooltip = () => { - tooltip.remove(); - - destructor?.(); - }; - - [ - ["mouseenter", showTooltip], - ["mouseleave", hideTooltip], - ["focus", showTooltip], - ["blur", hideTooltip], - ].forEach(([event, listener]) => { - start.addEventListener( - event as keyof HTMLElementEventMap, - listener as () => void - ); - });*/ + map.addControl( + new maplibregl.AttributionControl({ compact: true }), + "bottom-right" + ); - if (index !== route[0].legs.length - 1) continue; + map.on("zoomend", () => { + setZoom(map.getZoom()); + }); - const end = document.createElement("div"); - end.className = "marker"; - end.textContent = (index + 2).toLocaleString(); + mapRef.current = map; - const destinationMarker = new mapboxgl.Marker(end) - .setLngLat(steps[steps.length - 1].maneuver.location) - .addTo(map); + return () => { + markersRef.current.forEach((marker) => marker.remove()); + markersRef.current = []; + setMapOverlay(emptyMapOverlay); + map.remove(); + }; + }, [currentTheme, mapMode]); - markersRef.current.push(destinationMarker); + useEffect(() => { + setRouteCoordinateGroups([]); + setRouteLegs([]); + + if (routeSegments.length === 0) { + setRouteStatus("idle"); + return; + } + + const controller = new AbortController(); + + setRouteStatus("loading"); + + Promise.all( + routeSegments.map(async (segment) => { + const response = await fetch( + getWalkingRouteUrl([ + segment.from.coordinates, + segment.to.coordinates, + ]), + { signal: controller.signal } + ); + + if (!response.ok) throw new Error(`Routing failed: ${response.status}`); + + const data = (await response.json()) as WalkingRouteResponse; + const route = data.routes?.[0]; + const routedCoordinates = route?.geometry?.coordinates; + + if ( + data.code !== "Ok" || + !route || + !routedCoordinates || + routedCoordinates.length < 2 + ) { + throw new Error(data.message ?? "Routing failed"); } - map.jumpTo({ center: [-122.2592173, 37.8721508] }); + return { + coordinates: routedCoordinates, + leg: route.legs?.[0] ?? estimateRouteLeg(segment.from, segment.to), + }; + }) + ) + .then((routes) => { + setRouteCoordinateGroups(routes.map((route) => route.coordinates)); + setRouteLegs(routes.map((route) => route.leg)); + setRouteStatus("ready"); + }) + .catch(() => { + if (controller.signal.aborted) return; - // Remove unnecessary layers - map.removeLayer("directions-route-line"); - map.removeLayer("directions-waypoint-point-casing"); - map.removeLayer("directions-waypoint-point"); - map.removeLayer("directions-origin-point"); - map.removeLayer("directions-destination-point"); - map.removeLayer("directions-origin-label"); - map.removeLayer("directions-destination-label"); + setRouteCoordinateGroups([]); + setRouteLegs([]); + setRouteStatus("error"); }); - map.addSource("campus", { - type: "geojson", - data: "/geojson/campus.geojson", - }); + return () => controller.abort(); + }, [routeSegments]); - map.addLayer({ - id: "campus-fill", - type: "line", - source: "campus", - layout: {}, - paint: { - "line-width": 1, - "line-color": "#3b82f6", - "line-opacity": 0.5, - "line-dasharray": [2, 2], - }, - }); + useEffect(() => { + const map = mapRef.current; + if (!map || !mapLoaded) return; - map.addLayer({ - id: "campus-line", - type: "fill", - source: "campus", - layout: {}, - paint: { - "fill-color": "#3b82f6", - "fill-opacity": 0.05, - }, - }); + const updateMapOverlay = () => { + const canvas = map.getCanvas(); + const routePaths = routeCoordinateGroups.map((coordinates) => { + const routePoints = coordinates.map((coordinate) => { + const projected = map.project(coordinate); - setDirections(directions); - }); + return { x: projected.x, y: projected.y }; + }); - map.on("zoomend", () => { - setZoom(map.getZoom()); - }); + return getSvgPath(routePoints); + }); - mapRef.current = map; + setMapOverlay({ + height: canvas.clientHeight, + routePaths, + width: canvas.clientWidth, + }); + }; + + updateMapOverlay(); + map.on("move", updateMapOverlay); + map.on("zoom", updateMapOverlay); + window.addEventListener("resize", updateMapOverlay); return () => { - mapRef.current?.remove(); + map.off("move", updateMapOverlay); + map.off("zoom", updateMapOverlay); + window.removeEventListener("resize", updateMapOverlay); }; - }, [currentTheme]); + }, [mapLoaded, routeCoordinateGroups]); useEffect(() => { - if (!directions) return; + const map = mapRef.current; + if (!map || !mapLoaded) return; markersRef.current.forEach((marker) => marker.remove()); + markersRef.current = []; - const length = directions.getWaypoints().length; + markerGroups.forEach((group) => { + const firstStop = group.stops[0]; + if (!firstStop) return; - for (let index = 0; index < length; index++) { - directions.removeWaypoint(index); - } + const markerElement = document.createElement("div"); + markerElement.className = styles.mapMarker; + markerElement.textContent = + group.stops.length === 1 + ? String(activeStops.indexOf(firstStop) + 1) + : `${activeStops.indexOf(firstStop) + 1}+`; - if (waypoints.length < 2) return; + const popupContent = document.createElement("div"); + popupContent.className = styles.popup; - directions.setOrigin(waypoints[0]); - directions.setDestination(waypoints[waypoints.length - 1]); + group.stops.forEach((stop) => { + const stopElement = document.createElement("div"); + stopElement.className = styles.popupStop; - for (let index = 1; index < waypoints.length - 1; index++) { - directions.addWaypoint(index, waypoints[index]); - } - }, [waypoints, directions]); + const title = document.createElement("p"); + title.className = styles.popupTitle; + title.textContent = `${stop.courseLabel} ${stop.sectionLabel}`; + + const details = document.createElement("p"); + details.textContent = `${formatTime(stop.startTime)} - ${formatTime(stop.endTime)}`; + + stopElement.append(title, details); + popupContent.append(stopElement); + }); + + const marker = new maplibregl.Marker({ + element: markerElement, + }) + .setLngLat(group.coordinates) + .setPopup( + new maplibregl.Popup({ + closeButton: false, + offset: 16, + }).setDOMContent(popupContent) + ) + .addTo(map); + + markersRef.current.push(marker); + }); + + fitMapToCoordinates( + map, + markerGroups.map((group) => group.coordinates) + ); + }, [activeStops, mapLoaded, markerGroups]); return (
- +
+ {DAYS.map((day) => ( + setActiveDay(day.index)} + title={`${day.label} route`} + > + {day.short} + + ))} +
= MAX_ZOOM} onClick={() => mapRef.current?.zoomIn()} > mapRef.current?.zoomOut()} >
+
+
+ {MAP_MODE_OPTIONS.map((option) => ( + + ))} +
+ { + if (!googleMapsRouteUrl) event.preventDefault(); + }} + rel="noopener noreferrer" + target="_blank" + > + + Google Maps + +
+ {mapOverlay.width > 0 && mapOverlay.height > 0 && ( + + {mapOverlay.routePaths.map((routePath, index) => ( + + + + + ))} + + )}
-
-
1
-
-

8:30 AM

-

STAT 154

-

Dwinelle Hall 117

-
-
-
- -
- 5 min. (0.5 mi.) -
-
-
-
2
-
-

8:30 AM

-

STAT 154

-

Dwinelle Hall 117

-
-
-
- -
- 5 min. (0.5 mi.) -
-
-
-
3
-
-

8:30 AM

-

STAT 154

-

Dwinelle Hall 117

-
+
+

{activeDayLabel}

+

+ {activeStops.length === 0 + ? "No in-person meetings" + : `${activeStops.length} meeting${activeStops.length === 1 ? "" : "s"}`} +

+ {activeStops.length === 0 ? ( +

+ Select another day or add classes with scheduled locations. +

+ ) : ( + activeStops.map((stop, index) => { + const nextStop = activeStops[index + 1]; + const routeLeg = + nextStop && legByStopPair.get(`${stop.key}:${nextStop.key}`); + const sameLocation = sameCoordinates( + stop.coordinates, + nextStop?.coordinates + ); + const routePending = + nextStop && + stop.coordinates && + nextStop.coordinates && + !sameLocation && + routeStatus === "loading"; + const routeUnavailable = + nextStop && + (!stop.coordinates || + !nextStop.coordinates || + routeStatus === "error" || + !routeLeg) && + !sameLocation && + !routePending; + + return ( +
+
+
{index + 1}
+
+

+ {formatTime(stop.startTime)} - {formatTime(stop.endTime)} +

+

+ {stop.courseLabel} {stop.sectionLabel} +

+

+ {stop.buildingName ?? stop.location ?? "Location TBD"} +

+
+
+ {nextStop && ( +
+ +
+ {sameLocation ? ( + "Same location" + ) : routeLeg ? ( + <> + + {formatDuration(routeLeg.duration)} + {" "} + ({formatDistance(routeLeg.distance)}) + + ) : routeUnavailable ? ( + "Route unavailable" + ) : routePending ? ( + "Calculating route" + ) : ( + "Route unavailable" + )} +
+
+ )} +
+ ); + }) + )}
); diff --git a/apps/frontend/src/app/Schedule/Editor/ShareDialog/index.tsx b/apps/frontend/src/app/Schedule/Editor/ShareDialog/index.tsx index 64133f06c..2dd4c2ad0 100644 --- a/apps/frontend/src/app/Schedule/Editor/ShareDialog/index.tsx +++ b/apps/frontend/src/app/Schedule/Editor/ShareDialog/index.tsx @@ -15,6 +15,7 @@ import { import { useUpdateSchedule } from "@/hooks/api"; import useSchedule from "@/hooks/useSchedule"; +import { copyTextToClipboard } from "@/lib/clipboard"; interface ShareDialogProps { children: ReactNode; @@ -24,33 +25,39 @@ interface ShareDialogProps { // TODO: Invite collaborators export default function ShareDialog({ children }: ShareDialogProps) { - const { schedule } = useSchedule(); + const { schedule, editing } = useSchedule(); const [updateSchedule, { loading }] = useUpdateSchedule(); const timeoutRef = useRef>(null); const [copied, setCopied] = useState(false); + const shareUrl = useMemo( + () => `${window.location.origin}/schedules/${schedule._id}`, + [schedule._id] + ); + const content = useMemo( () => ({ - url: window.location.href, + url: shareUrl, title: schedule.name, text: `View my ${schedule.semester} ${schedule.year} schedule on Berkeleytime`, }), - [schedule] + [schedule, shareUrl] ); - const canShare = navigator.canShare && navigator.canShare?.(content); + const canShare = + typeof navigator.share === "function" && + (!navigator.canShare || navigator.canShare(content)); - const copy = () => { + const copy = async () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); + await copyTextToClipboard(shareUrl); setCopied(true); - navigator.clipboard.writeText(window.location.href); - timeoutRef.current = setTimeout(() => { setCopied(false); - }, 1000); + }, 1200); }; const handleCheckedChange = async (checked: boolean) => { @@ -88,12 +95,7 @@ export default function ShareDialog({ children }: ShareDialogProps) { - + @@ -101,16 +103,20 @@ export default function ShareDialog({ children }: ShareDialogProps) { )} - + {editing ? ( + + ) : ( + This public schedule is view-only. + )} diff --git a/apps/frontend/src/app/Schedule/Editor/index.tsx b/apps/frontend/src/app/Schedule/Editor/index.tsx index 75f4974f2..8c5af57bf 100644 --- a/apps/frontend/src/app/Schedule/Editor/index.tsx +++ b/apps/frontend/src/app/Schedule/Editor/index.tsx @@ -29,11 +29,11 @@ import Calendar from "./Calendar"; import CloneDialog from "./CloneDialog"; import EditDialog from "./EditDialog"; import styles from "./Editor.module.scss"; +import ExportDialog from "./ExportDialog"; import GenerateSchedulesDialog from "./GenerateSchedulesDialog"; import Map from "./Map"; import ShareDialog from "./ShareDialog"; import SideBar from "./SideBar"; -import exportToCalendar from "./exportToCalendar"; export default function Editor() { const { schedule, editing } = useSchedule(); @@ -1184,9 +1184,9 @@ export default function Editor() { setTab(1)}> Calendar - {/* setTab(2)}> + setTab(2)}> Map - */} +
{editing && ( @@ -1208,18 +1208,18 @@ export default function Editor() { Clone - - {editing && ( - - - - )} + + + + + +
{!sidebarCollapsed && ( diff --git a/apps/frontend/src/components/NavigationBar/SideBar/index.tsx b/apps/frontend/src/components/NavigationBar/SideBar/index.tsx index 911f41797..3b21d3ff7 100644 --- a/apps/frontend/src/components/NavigationBar/SideBar/index.tsx +++ b/apps/frontend/src/components/NavigationBar/SideBar/index.tsx @@ -53,6 +53,9 @@ export default function SideBar({ children }: SideBarProps) { Scheduler + + Map + Gradtrak diff --git a/apps/frontend/src/components/NavigationBar/index.tsx b/apps/frontend/src/components/NavigationBar/index.tsx index d8c957d71..92c5517f3 100644 --- a/apps/frontend/src/components/NavigationBar/index.tsx +++ b/apps/frontend/src/components/NavigationBar/index.tsx @@ -155,6 +155,7 @@ export default function NavigationBar({ {[ { to: "/catalog", label: "Catalog" }, { to: "/schedules", label: "Scheduler" }, + { to: "/map", label: "Map" }, { to: "/gradtrak", label: "Gradtrak" }, { to: gradesPath, label: "Grades" }, { to: enrollmentPath, label: "Enrollment" }, @@ -203,6 +204,13 @@ export default function NavigationBar({ )} + + {({ isActive }) => ( + + Map + + )} + {({ isActive }) => ( diff --git a/apps/frontend/src/lib/api/users.ts b/apps/frontend/src/lib/api/users.ts index a1149474c..94be35084 100644 --- a/apps/frontend/src/lib/api/users.ts +++ b/apps/frontend/src/lib/api/users.ts @@ -1,5 +1,7 @@ import { gql } from "@apollo/client"; +import { DEV_AUTH_LOGIN_ROUTE } from "@/utils/devAuth"; + import { GetUserQuery } from "../generated/graphql"; export type IUser = GetUserQuery["user"]; @@ -37,6 +39,12 @@ export const signIn = (redirectURI?: string) => { redirectURI ?? window.location.origin + window.location.pathname + window.location.search; + if (import.meta.env.DEV) { + const localRedirectURI = window.location.pathname + window.location.search; + window.location.href = `${DEV_AUTH_LOGIN_ROUTE}?redirect_uri=${encodeURIComponent(localRedirectURI)}`; + return; + } + window.location.href = `${window.location.origin}/api/login?redirect_uri=${redirectURI}`; }; diff --git a/apps/frontend/src/lib/clipboard.ts b/apps/frontend/src/lib/clipboard.ts new file mode 100644 index 000000000..2a1fdf6f4 --- /dev/null +++ b/apps/frontend/src/lib/clipboard.ts @@ -0,0 +1,22 @@ +export async function copyTextToClipboard(value: string) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + try { + document.execCommand("copy"); + } finally { + textarea.remove(); + } +} diff --git a/apps/frontend/src/lib/location.test.ts b/apps/frontend/src/lib/location.test.ts new file mode 100644 index 000000000..d6ca35a47 --- /dev/null +++ b/apps/frontend/src/lib/location.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { findBuildingForLocation } from "./location"; + +describe("findBuildingForLocation", () => { + it("matches a building from a room-qualified catalog location", () => { + expect(findBuildingForLocation("Dwinelle 145")?.name).toBe("Dwinelle Hall"); + }); + + it("prefers the longest matching building prefix", () => { + expect(findBuildingForLocation("Hearst Gym North Field 1")?.name).toBe( + "Hearst North Field" + ); + }); + + it("returns undefined for online or unrecognized locations", () => { + expect(findBuildingForLocation("Online")).toBeUndefined(); + }); +}); diff --git a/apps/frontend/src/lib/location.ts b/apps/frontend/src/lib/location.ts index 3573ab258..052868280 100644 --- a/apps/frontend/src/lib/location.ts +++ b/apps/frontend/src/lib/location.ts @@ -84,12 +84,31 @@ Gilman 2240 Piedmont */ -interface Building { +export interface Building { location?: [number, number]; name: string; link?: string; } +const normalizeLocation = (location: string) => + location.trim().replace(/\s+/g, " "); + +export const findBuildingForLocation = ( + location?: string | null +): Building | undefined => { + if (!location) return undefined; + + const normalizedLocation = normalizeLocation(location); + const buildingName = Object.keys(buildings) + .filter( + (name) => + normalizedLocation === name || normalizedLocation.startsWith(`${name} `) + ) + .sort((a, b) => b.length - a.length)[0]; + + return buildingName ? buildings[buildingName] : undefined; +}; + export const buildings: Record = { "Berkeley Way West": { location: [-122.26840181226721, 37.87341082732256], diff --git a/apps/frontend/src/providers/UserProvider.tsx b/apps/frontend/src/providers/UserProvider.tsx index 41302c58e..d764e1b47 100644 --- a/apps/frontend/src/providers/UserProvider.tsx +++ b/apps/frontend/src/providers/UserProvider.tsx @@ -50,12 +50,15 @@ export default function UserProvider({ children }: UserProviderProps) { if (autoLoginAttempted.current) return; const savedUserId = getStoredDevUserId(); - if (savedUserId) { - autoLoginAttempted.current = true; - const redirectUri = window.location.pathname + window.location.search; - window.location.href = `${DEV_AUTH_LOGIN_ROUTE}?userId=${savedUserId}&redirect_uri=${encodeURIComponent(redirectUri)}`; - } - }, [loading, user]); + if (error) return; + + autoLoginAttempted.current = true; + const redirectUri = window.location.pathname + window.location.search; + + window.location.href = savedUserId + ? `${DEV_AUTH_LOGIN_ROUTE}?userId=${savedUserId}&redirect_uri=${encodeURIComponent(redirectUri)}` + : `${DEV_AUTH_LOGIN_ROUTE}?redirect_uri=${encodeURIComponent(redirectUri)}`; + }, [error, loading, user]); return ( diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 2a2bf2eff..dc702d582 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -9,12 +9,23 @@ const require = createRequire(import.meta.url); // Recharts imports `react-is`; resolution can fail under esbuild when deps are hoisted // to the monorepo root (e.g. Docker + turbo prune). Resolve the real install path. const reactIsRoot = dirname(require.resolve("react-is/package.json")); +const devPortPrefix = process.env.DEV_PORT_PREFIX ?? "30"; +const apiProxyTarget = + process.env.FRONTEND_API_PROXY_TARGET ?? + `http://localhost:${devPortPrefix}00`; export default defineConfig({ server: { host: true, port: 3000, allowedHosts: ["frontend", "localhost", ".localhost"], + proxy: { + "/api": { + target: apiProxyTarget, + changeOrigin: true, + secure: false, + }, + }, }, optimizeDeps: { include: ["react-is", "recharts"], @@ -25,9 +36,6 @@ export default defineConfig({ "react-is": reactIsRoot, }, }, - optimizeDeps: { - include: ["react-is"], - }, plugins: [ react(), // TODO: Not really necessary for now, but could be useful for restrictions later diff --git a/package-lock.json b/package-lock.json index 124423d5c..10bae2d89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -216,7 +216,6 @@ "dependencies": { "@apollo/client": "4.0.7", "@floating-ui/dom": "^1.7.4", - "@mapbox/mapbox-gl-directions": "^4.3.1", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-web": "^0.44.0", "@opentelemetry/context-zone": "^1.29.0", @@ -236,7 +235,6 @@ "framer-motion": "^12.23.24", "graphql": "^16.11.0", "iconoir-react": "^7.11.0", - "mapbox-gl": "^3.15.0", "maplibre-gl": "^5.13.0", "moment": "^2.30.1", "patch-package": "^8.0.1", @@ -257,7 +255,6 @@ "@repo/gql-typedefs": "*", "@repo/typescript-config": "*", "@types/lodash": "^4.17.20", - "@types/mapbox-gl": "^3.4.1", "@types/node": "^24.7.0", "@types/react": "^19.2.1", "@types/react-dom": "^19.2.0", @@ -5341,41 +5338,10 @@ "node": ">= 0.6" } }, - "node_modules/@mapbox/mapbox-gl-directions": { - "version": "4.3.1", - "license": "ISC", - "dependencies": { - "@mapbox/polyline": "^1.1.1", - "lodash.debounce": "^4.0.6", - "lodash.isequal": "^4.2.0", - "lodash.template": "^4.2.5", - "merge-options": "^3.0.4", - "redux": "^4.2.0", - "redux-thunk": "^2.4.2", - "suggestions": "^1.7.1", - "turf-extent": "^1.0.4" - }, - "peerDependencies": { - "mapbox-gl": "^1 || ^2 || ^3" - } - }, - "node_modules/@mapbox/mapbox-gl-supported": { - "version": "3.0.0", - "license": "BSD-3-Clause" - }, "node_modules/@mapbox/point-geometry": { "version": "1.1.0", "license": "ISC" }, - "node_modules/@mapbox/polyline": { - "version": "1.2.1", - "dependencies": { - "meow": "^9.0.0" - }, - "bin": { - "polyline": "bin/polyline.bin.js" - } - }, "node_modules/@mapbox/tiny-sdf": { "version": "2.0.7", "license": "BSD-2-Clause" @@ -10642,18 +10608,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mapbox__point-geometry": { - "version": "0.1.4", - "license": "MIT" - }, - "node_modules/@types/mapbox-gl": { - "version": "3.4.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/geojson": "*" - } - }, "node_modules/@types/mdast": { "version": "4.0.4", "license": "MIT", @@ -10677,10 +10631,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/minimist": { - "version": "1.2.5", - "license": "MIT" - }, "node_modules/@types/ms": { "version": "2.1.0", "license": "MIT" @@ -10715,10 +10665,6 @@ "form-data": "^4.0.4" } }, - "node_modules/@types/normalize-package-data": { - "version": "2.4.4", - "license": "MIT" - }, "node_modules/@types/oauth": { "version": "0.9.6", "dev": true, @@ -10766,10 +10712,6 @@ "@types/passport": "*" } }, - "node_modules/@types/pbf": { - "version": "3.0.5", - "license": "MIT" - }, "node_modules/@types/pg": { "version": "8.6.1", "license": "MIT", @@ -11613,13 +11555,6 @@ "node": ">=8" } }, - "node_modules/arrify": { - "version": "1.0.1", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/asap": { "version": "2.0.6", "dev": true, @@ -12260,28 +12195,6 @@ "tslib": "^2.0.3" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-keys": { - "version": "6.2.2", - "license": "MIT", - "dependencies": { - "camelcase": "^5.3.1", - "map-obj": "^4.0.0", - "quick-lru": "^4.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001748", "funding": [ @@ -12433,10 +12346,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cheap-ruler": { - "version": "4.0.0", - "license": "ISC" - }, "node_modules/check-error": { "version": "2.1.1", "license": "MIT", @@ -12983,10 +12892,6 @@ "version": "1.5.1", "license": "MIT" }, - "node_modules/csscolorparser": { - "version": "1.0.3", - "license": "MIT" - }, "node_modules/cssesc": { "version": "3.0.0", "dev": true, @@ -13143,34 +13048,6 @@ } } }, - "node_modules/decamelize": { - "version": "1.2.0", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decamelize-keys": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "decamelize": "^1.1.0", - "map-obj": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decamelize-keys/node_modules/map-obj": { - "version": "1.0.1", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/decimal.js-light": { "version": "2.5.1", "license": "MIT" @@ -14428,12 +14305,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fuzzy": { - "version": "0.1.3", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/gaxios": { "version": "6.7.1", "license": "Apache-2.0", @@ -14811,17 +14682,6 @@ } } }, - "node_modules/grid-index": { - "version": "1.1.0", - "license": "ISC" - }, - "node_modules/hard-rejection": { - "version": "2.1.0", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/has-flag": { "version": "4.0.0", "license": "MIT", @@ -14935,30 +14795,6 @@ "version": "1.12.1", "license": "MIT" }, - "node_modules/hosted-git-info": { - "version": "4.1.0", - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "6.0.0", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/hosted-git-info/node_modules/yallist": { - "version": "4.0.0", - "license": "ISC" - }, "node_modules/hpagent": { "version": "1.2.0", "license": "MIT", @@ -15356,13 +15192,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-plain-obj": { - "version": "2.1.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-port-reachable": { "version": "4.0.0", "dev": true, @@ -15720,13 +15549,6 @@ "@keyv/serialize": "^1.1.1" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/klaw-sync": { "version": "6.0.0", "license": "MIT", @@ -15902,20 +15724,13 @@ "version": "4.17.21", "license": "MIT" }, - "node_modules/lodash._reinterpolate": { - "version": "3.0.0", - "license": "MIT" - }, "node_modules/lodash.camelcase": { "version": "4.3.0", "license": "MIT" }, "node_modules/lodash.debounce": { "version": "4.0.8", - "license": "MIT" - }, - "node_modules/lodash.isequal": { - "version": "4.5.0", + "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -15927,21 +15742,6 @@ "version": "4.7.0", "license": "MIT" }, - "node_modules/lodash.template": { - "version": "4.5.0", - "license": "MIT", - "dependencies": { - "lodash._reinterpolate": "^3.0.0", - "lodash.templatesettings": "^4.0.0" - } - }, - "node_modules/lodash.templatesettings": { - "version": "4.2.0", - "license": "MIT", - "dependencies": { - "lodash._reinterpolate": "^3.0.0" - } - }, "node_modules/log-symbols": { "version": "4.1.0", "dev": true, @@ -16134,53 +15934,6 @@ "node": ">=0.10.0" } }, - "node_modules/map-obj": { - "version": "4.3.0", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mapbox-gl": { - "version": "3.15.0", - "license": "SEE LICENSE IN LICENSE.txt", - "workspaces": [ - "src/style-spec", - "test/build/typings" - ], - "dependencies": { - "@mapbox/jsonlint-lines-primitives": "^2.0.2", - "@mapbox/mapbox-gl-supported": "^3.0.0", - "@mapbox/point-geometry": "^1.1.0", - "@mapbox/tiny-sdf": "^2.0.6", - "@mapbox/unitbezier": "^0.0.1", - "@mapbox/vector-tile": "^2.0.4", - "@mapbox/whoots-js": "^3.1.0", - "@types/geojson": "^7946.0.16", - "@types/geojson-vt": "^3.2.5", - "@types/mapbox__point-geometry": "^0.1.4", - "@types/pbf": "^3.0.5", - "@types/supercluster": "^7.1.3", - "cheap-ruler": "^4.0.0", - "csscolorparser": "~1.0.3", - "earcut": "^3.0.1", - "geojson-vt": "^4.0.2", - "gl-matrix": "^3.4.4", - "grid-index": "^1.1.0", - "kdbush": "^4.0.2", - "martinez-polygon-clipping": "^0.7.4", - "murmurhash-js": "^1.0.0", - "pbf": "^4.0.1", - "potpack": "^2.0.0", - "quickselect": "^3.0.0", - "serialize-to-js": "^3.1.2", - "supercluster": "^8.0.1", - "tinyqueue": "^3.0.0" - } - }, "node_modules/maplibre-gl": { "version": "5.13.0", "license": "BSD-3-Clause", @@ -16217,19 +15970,6 @@ "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" } }, - "node_modules/martinez-polygon-clipping": { - "version": "0.7.4", - "license": "MIT", - "dependencies": { - "robust-predicates": "^2.0.4", - "splaytree": "^0.1.4", - "tinyqueue": "^1.2.0" - } - }, - "node_modules/martinez-polygon-clipping/node_modules/tinyqueue": { - "version": "1.2.3", - "license": "ISC" - }, "node_modules/math-intrinsics": { "version": "1.1.0", "license": "MIT", @@ -16389,40 +16129,6 @@ "version": "1.5.0", "license": "MIT" }, - "node_modules/meow": { - "version": "9.0.0", - "license": "MIT", - "dependencies": { - "@types/minimist": "^1.2.0", - "camelcase-keys": "^6.2.2", - "decamelize": "^1.2.0", - "decamelize-keys": "^1.1.0", - "hard-rejection": "^2.1.0", - "minimist-options": "4.1.0", - "normalize-package-data": "^3.0.0", - "read-pkg-up": "^7.0.1", - "redent": "^3.0.0", - "trim-newlines": "^3.0.0", - "type-fest": "^0.18.0", - "yargs-parser": "^20.2.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/meow/node_modules/type-fest": { - "version": "0.18.1", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/merge-descriptors": { "version": "2.0.0", "license": "MIT", @@ -16433,16 +16139,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-options": { - "version": "3.0.4", - "license": "MIT", - "dependencies": { - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/merge-stream": { "version": "2.0.0", "dev": true, @@ -16956,25 +16652,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minimist-options": { - "version": "4.1.0", - "license": "MIT", - "dependencies": { - "arrify": "^1.0.1", - "is-plain-obj": "^1.1.0", - "kind-of": "^6.0.3" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/minimist-options/node_modules/is-plain-obj": { - "version": "1.1.0", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/minipass": { "version": "7.1.2", "license": "ISC", @@ -17322,19 +16999,6 @@ "version": "2.0.23", "license": "MIT" }, - "node_modules/normalize-package-data": { - "version": "3.0.3", - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^4.0.1", - "is-core-module": "^2.5.0", - "semver": "^7.3.4", - "validate-npm-package-license": "^3.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/normalize-path": { "version": "2.1.1", "dev": true, @@ -17670,13 +17334,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-try": { - "version": "2.2.0", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "license": "BlueOak-1.0.0" @@ -18507,13 +18164,6 @@ "version": "4.0.4", "license": "MIT" }, - "node_modules/quick-lru": { - "version": "4.0.1", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/quickselect": { "version": "3.0.0", "license": "ISC" @@ -18911,120 +18561,6 @@ "react-dom": ">=16.6.0" } }, - "node_modules/read-pkg": { - "version": "5.2.0", - "license": "MIT", - "dependencies": { - "@types/normalize-package-data": "^2.4.0", - "normalize-package-data": "^2.5.0", - "parse-json": "^5.0.0", - "type-fest": "^0.6.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up": { - "version": "7.0.1", - "license": "MIT", - "dependencies": { - "find-up": "^4.1.0", - "read-pkg": "^5.2.0", - "type-fest": "^0.8.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg-up/node_modules/find-up": { - "version": "4.1.0", - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/locate-path": { - "version": "5.0.0", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/p-limit": { - "version": "2.3.0", - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg-up/node_modules/p-locate": { - "version": "4.1.0", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/path-exists": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/type-fest": { - "version": "0.8.1", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg/node_modules/hosted-git-info": { - "version": "2.8.9", - "license": "ISC" - }, - "node_modules/read-pkg/node_modules/normalize-package-data": { - "version": "2.5.0", - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" - } - }, - "node_modules/read-pkg/node_modules/semver": { - "version": "5.7.2", - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/read-pkg/node_modules/type-fest": { - "version": "0.6.0", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "license": "MIT", @@ -19137,20 +18673,6 @@ "node": ">= 18" } }, - "node_modules/redux": { - "version": "4.2.1", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.9.2" - } - }, - "node_modules/redux-thunk": { - "version": "2.4.2", - "license": "MIT", - "peerDependencies": { - "redux": "^4" - } - }, "node_modules/reftools": { "version": "1.1.9", "dev": true, @@ -19442,10 +18964,6 @@ "dev": true, "license": "MIT" }, - "node_modules/robust-predicates": { - "version": "2.0.4", - "license": "Unlicense" - }, "node_modules/rollup": { "version": "4.52.4", "license": "MIT", @@ -19635,13 +19153,6 @@ "upper-case-first": "^2.0.2" } }, - "node_modules/serialize-to-js": { - "version": "3.1.2", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/serve": { "version": "14.2.5", "dev": true, @@ -20120,34 +19631,6 @@ "memory-pager": "^1.0.2" } }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.22", - "license": "CC0-1.0" - }, - "node_modules/splaytree": { - "version": "0.1.4", - "license": "MIT" - }, "node_modules/split2": { "version": "4.2.0", "license": "ISC", @@ -20461,14 +19944,6 @@ "node": ">= 12" } }, - "node_modules/suggestions": { - "version": "1.7.1", - "license": "ISC", - "dependencies": { - "fuzzy": "^0.1.1", - "xtend": "^4.0.0" - } - }, "node_modules/suncalc": { "version": "1.9.0" }, @@ -20844,13 +20319,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/trim-newlines": { - "version": "3.0.1", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/trough": { "version": "2.2.0", "license": "MIT", @@ -21046,17 +20514,6 @@ "win32" ] }, - "node_modules/turf-extent": { - "version": "1.0.4", - "license": "MIT", - "dependencies": { - "turf-meta": "^1.0.2" - } - }, - "node_modules/turf-meta": { - "version": "1.0.2", - "license": "ISC" - }, "node_modules/type-check": { "version": "0.4.0", "dev": true, @@ -21540,14 +20997,6 @@ "uuid": "dist/esm/bin/uuid" } }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, "node_modules/vary": { "version": "1.1.2", "license": "MIT", @@ -22049,13 +21498,6 @@ "node": ">=12" } }, - "node_modules/yargs-parser": { - "version": "20.2.9", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yargs/node_modules/yargs-parser": { "version": "21.1.1", "license": "ISC",