(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) => (
+
+ ))}
+
= MAX_ZOOM}
onClick={() => mapRef.current?.zoomIn()}
>
mapRef.current?.zoomOut()}
>
+
+ {mapOverlay.width > 0 && mapOverlay.height > 0 && (
+
+ )}
-
-
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() {
- {/*
{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",