feat: map page (#390)
* feat: add boilerplate
* feat: add working paths
* feat: improve building selection controls and add week schedule
Signed-off-by: doprz <52579214+doprz@users.noreply.github.com>
* fix: sort week schedule
Signed-off-by: doprz <52579214+doprz@users.noreply.github.com>
* feat(testing): improve pathfinding
* Revert "feat(testing): improve pathfinding"
This reverts commit 87998cedbf.
* feat: add pathfinding with building selection controls
Signed-off-by: doprz <52579214+doprz@users.noreply.github.com>
* feat: improve path finding algorithm thresholds
* feat: add DaySelector, PathStats, and WeekSchedule components
* feat: add working stats and daily schedule
* chore: refactor everything
* feat: add linear walkway node generation
* feat: add bezier curve walkway node generation
* feat: add circular walkway node generation
* docs: add docs
* feat: add individual path selection and bump version
* fix: tsdoc and updated components/utils
* chore(deps): update deps
* feat: add UTRP Map and Debug Page to Settings > Developer Mode
* chore: fix pr review comments
* chore: add showDebugNodes
* chore: add all buildings around the UT tower
* chore: add stadium POIs
* chore: add east mall buildings
* chore: update DaySelector to use proper button styling
* chore: add university ave walkway
* feat: add zoom, pan, and dev controls functionality
- Fix SVG Overlay Alignment
- Use SVG for map
- Add Dev Controls
- Fix day selector position
- Update the SVG's `preserveAspectRatio` attribute to `xMidYMid` meet to
ensure proper scaling
- Use `useCallback` for event handlers to prevent unnecessary re-renders
- Remove old PNG map
* feat: add dynamic rendering"
* feat: add dynamicRendering dev toggle and fullscreen support
* chore: update deps
* chore: disable viewport svg overlay culling if dynamic rendering is off
* chore: update pnpm-lock.yaml
* chore: add north mall buildings
* chore: add buildings next to JES
* refactor: map components into individual files
* fix: missing import
---------
Signed-off-by: doprz <52579214+doprz@users.noreply.github.com>
This commit is contained in:
100
package.json
100
package.json
@@ -32,61 +32,61 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@octokit/rest": "^21.0.2",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@phosphor-icons/react": "^2.1.7",
|
||||
"@sentry/react": "^8.38.0",
|
||||
"@sentry/react": "^8.55.0",
|
||||
"@unocss/vite": "^0.63.6",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"chrome-extension-toolkit": "^0.0.54",
|
||||
"clsx": "^2.1.1",
|
||||
"conventional-changelog": "^6.0.0",
|
||||
"highcharts": "^11.4.8",
|
||||
"highcharts-react-official": "^3.2.1",
|
||||
"html-to-image": "^1.11.11",
|
||||
"husky": "^9.1.6",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"kc-dabr-wasm": "^0.1.2",
|
||||
"nanoid": "^5.0.8",
|
||||
"nanoid": "^5.1.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-markdown": "^9.1.0",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sass": "^1.81.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sass": "^1.85.1",
|
||||
"simple-git": "^3.27.0",
|
||||
"sql.js": "1.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^2.0.2",
|
||||
"@commitlint/cli": "^19.5.0",
|
||||
"@commitlint/config-conventional": "^19.5.0",
|
||||
"@commitlint/cli": "^19.7.1",
|
||||
"@commitlint/config-conventional": "^19.7.1",
|
||||
"@commitlint/types": "^19.5.0",
|
||||
"@crxjs/vite-plugin": "2.0.0-beta.21",
|
||||
"@iconify-json/bi": "^1.2.1",
|
||||
"@iconify-json/ic": "^1.2.1",
|
||||
"@iconify-json/iconoir": "^1.2.4",
|
||||
"@iconify-json/material-symbols": "^1.2.6",
|
||||
"@iconify-json/ri": "^1.2.3",
|
||||
"@iconify-json/streamline": "^1.2.1",
|
||||
"@iconify-json/bi": "^1.2.2",
|
||||
"@iconify-json/ic": "^1.2.2",
|
||||
"@iconify-json/iconoir": "^1.2.7",
|
||||
"@iconify-json/material-symbols": "^1.2.14",
|
||||
"@iconify-json/ri": "^1.2.5",
|
||||
"@iconify-json/streamline": "^1.2.2",
|
||||
"@semantic-release/exec": "^6.0.3",
|
||||
"@sentry/types": "^8.38.0",
|
||||
"@storybook/addon-designs": "^8.0.4",
|
||||
"@storybook/addon-essentials": "^8.4.4",
|
||||
"@storybook/addon-links": "^8.4.4",
|
||||
"@storybook/blocks": "^8.4.4",
|
||||
"@storybook/react": "^8.4.4",
|
||||
"@storybook/react-vite": "^8.4.4",
|
||||
"@storybook/test": "^8.4.4",
|
||||
"@sentry/types": "^8.55.0",
|
||||
"@storybook/addon-designs": "^8.2.0",
|
||||
"@storybook/addon-essentials": "^8.6.0",
|
||||
"@storybook/addon-links": "^8.6.0",
|
||||
"@storybook/blocks": "^8.6.0",
|
||||
"@storybook/react": "^8.6.0",
|
||||
"@storybook/react-vite": "^8.6.0",
|
||||
"@storybook/test": "^8.6.0",
|
||||
"@svgr/core": "^8.1.0",
|
||||
"@svgr/plugin-jsx": "^8.1.0",
|
||||
"@types/chrome": "^0.0.273",
|
||||
"@types/conventional-changelog": "^3.1.5",
|
||||
"@types/gulp": "^4.0.17",
|
||||
"@types/gulp-zip": "^4.0.4",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/semantic-release": "^20.0.6",
|
||||
"@types/semver": "^7.5.8",
|
||||
@@ -100,48 +100,48 @@
|
||||
"@unocss/reset": "^0.63.6",
|
||||
"@unocss/transformer-directives": "^0.63.6",
|
||||
"@unocss/transformer-variant-group": "^0.63.6",
|
||||
"@vitejs/plugin-react-swc": "^3.7.1",
|
||||
"@vitest/coverage-v8": "^2.1.5",
|
||||
"@vitest/ui": "^2.1.5",
|
||||
"chalk": "^5.3.0",
|
||||
"chromatic": "^11.18.1",
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"@vitest/ui": "^2.1.9",
|
||||
"chalk": "^5.4.1",
|
||||
"chromatic": "^11.26.0",
|
||||
"cssnano": "^7.0.6",
|
||||
"cssnano-preset-advanced": "^7.0.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"es-module-lexer": "^1.5.4",
|
||||
"dotenv": "^16.4.7",
|
||||
"es-module-lexer": "^1.6.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.3",
|
||||
"eslint-import-resolver-typescript": "^3.8.3",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-import-essentials": "^0.2.1",
|
||||
"eslint-plugin-jsdoc": "^50.5.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-jsdoc": "^50.6.3",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-prefer-function-component": "^3.3.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"eslint-plugin-react-prefer-function-component": "^3.4.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-storybook": "^0.9.0",
|
||||
"eslint-plugin-tsdoc": "^0.3.0",
|
||||
"gulp": "^5.0.0",
|
||||
"gulp-execa": "^7.0.1",
|
||||
"gulp-zip": "^6.0.0",
|
||||
"gulp-zip": "^6.1.0",
|
||||
"path": "^0.12.7",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.3.3",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.2",
|
||||
"react-dev-utils": "^12.0.1",
|
||||
"semantic-release": "^24.2.0",
|
||||
"storybook": "^8.4.4",
|
||||
"typescript": "^5.6.3",
|
||||
"semantic-release": "^24.2.3",
|
||||
"storybook": "^8.6.0",
|
||||
"typescript": "^5.7.3",
|
||||
"unocss": "^0.63.6",
|
||||
"unocss-preset-primitives": "0.0.2-beta.1",
|
||||
"unplugin-icons": "^0.19.3",
|
||||
"vite": "^5.4.11",
|
||||
"vite-plugin-inspect": "^0.8.7",
|
||||
"vitest": "^2.1.5"
|
||||
"vite": "^5.4.14",
|
||||
"vite-plugin-inspect": "^0.8.9",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
|
||||
2747
pnpm-lock.yaml
generated
2747
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
5075
src/assets/UT-Map.svg
Normal file
5075
src/assets/UT-Map.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 347 KiB |
17
src/pages/map/Map.tsx
Normal file
17
src/pages/map/Map.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
|
||||
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
|
||||
import Map from '@views/components/map/Map';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Renders the map page for the UTRP (UT Registration Plus) extension.
|
||||
*/
|
||||
export default function MapPage() {
|
||||
return (
|
||||
<ExtensionRoot>
|
||||
<DialogProvider>
|
||||
<Map />
|
||||
</DialogProvider>
|
||||
</ExtensionRoot>
|
||||
);
|
||||
}
|
||||
16
src/pages/map/index.html
Normal file
16
src/pages/map/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<title>UTRP Map</title>
|
||||
</head>
|
||||
|
||||
<body style="min-height: 100vh; height: 0; margin: 0">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="./index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
6
src/pages/map/index.tsx
Normal file
6
src/pages/map/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import Map from './Map';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<Map />);
|
||||
@@ -5,6 +5,7 @@ export const CRX_PAGES = {
|
||||
DEBUG: '/debug.html',
|
||||
CALENDAR: '/calendar.html',
|
||||
OPTIONS: '/options.html',
|
||||
MAP: '/map.html',
|
||||
REPORT: '/report.html',
|
||||
} as const;
|
||||
|
||||
|
||||
1486
src/shared/types/MainCampusBuildings.ts
Normal file
1486
src/shared/types/MainCampusBuildings.ts
Normal file
File diff suppressed because it is too large
Load Diff
795
src/views/components/map/CampusMap.tsx
Normal file
795
src/views/components/map/CampusMap.tsx
Normal file
@@ -0,0 +1,795 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import DaySelector from './DaySelector';
|
||||
import DevToggles from './DevToggles';
|
||||
import FullscreenButton from './FullscreenButton';
|
||||
import { graphNodes } from './graphNodes';
|
||||
import type { ProcessInPersonMeetings } from './Map';
|
||||
import { Path } from './Path';
|
||||
import { calcDirectPathStats, PathStats } from './PathStats';
|
||||
import TimeWarningLabel from './TimeWarningLabel';
|
||||
import type { DayCode, NodeId, NodeType } from './types';
|
||||
import { DAY_MAPPING } from './types';
|
||||
import { getMidpoint } from './utils';
|
||||
import ZoomPanControls from './ZoomPanControls';
|
||||
|
||||
// Image: 783x753
|
||||
const UTMapURL = new URL('/src/assets/UT-Map.svg', import.meta.url).href;
|
||||
|
||||
const minZoom = 0.5;
|
||||
const maxZoom = 5;
|
||||
const zoomStep = 1.2;
|
||||
|
||||
// Define zoom level thresholds for showing different levels of detail
|
||||
const ZOOM_LEVELS = {
|
||||
LOW: 0.8, // Show minimal buildings at this zoom level and below
|
||||
MEDIUM: 1.5, // Show moderate amount of buildings
|
||||
HIGH: 2.5, // Show all buildings with full details
|
||||
} as const;
|
||||
|
||||
type SelectedBuildings = {
|
||||
start: NodeId | null;
|
||||
end: NodeId | null;
|
||||
};
|
||||
|
||||
type CampusMapProps = {
|
||||
processedCourses: ProcessInPersonMeetings[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Component representing the campus map with interactive features.
|
||||
*
|
||||
* @param processedCourses - Array of processed courses.
|
||||
* @returns The rendered CampusMap component.
|
||||
*
|
||||
* @remarks
|
||||
* This component renders a map of the campus with interactive features such as:
|
||||
* - Selecting buildings to create a path.
|
||||
* - Displaying daily paths between sequential classes.
|
||||
* - Highlighting paths with less than 15 minutes transition time.
|
||||
* - Zooming and panning the map.
|
||||
* - Toggling visibility of different map elements.
|
||||
*
|
||||
* The rendered output includes:
|
||||
* - An image of the campus map.
|
||||
* - An SVG overlay with paths and buildings.
|
||||
* - Controls for selecting the day and displaying path information.
|
||||
* - Dev controls for toggling element visibility.
|
||||
* - Zoom and pan controls.
|
||||
*/
|
||||
export default function CampusMap({ processedCourses }: CampusMapProps): JSX.Element {
|
||||
// Core state
|
||||
const [selected, setSelected] = useState<SelectedBuildings>({
|
||||
start: null,
|
||||
end: null,
|
||||
});
|
||||
const [selectedDay, setSelectedDay] = useState<DayCode | null>(null);
|
||||
const [hoveredPathIndex, setHoveredPathIndex] = useState<number | null>(null);
|
||||
const [toggledPathIndex, setToggledPathIndex] = useState<number | null>(null);
|
||||
|
||||
// Dev toggle state
|
||||
const [dynamicRendering, setDynamicRendering] = useState<boolean>(true);
|
||||
const [showBuildings, setShowBuildings] = useState<boolean>(true);
|
||||
const [showBuildingText, setShowBuildingText] = useState<boolean>(true);
|
||||
const [showPrioritizedOnly, setShowPrioritizedOnly] = useState<boolean>(false);
|
||||
const [showIntersections, setShowIntersections] = useState<boolean>(false);
|
||||
const [showWalkways, setShowWalkways] = useState<boolean>(false);
|
||||
|
||||
// Zoom and pan state
|
||||
const [zoomLevel, setZoomLevel] = useState<number>(1);
|
||||
const [panPosition, setPanPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const [dragStart, setDragStart] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
|
||||
// Refs
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Function to calculate the current viewport in SVG coordinates
|
||||
const calculateViewport = useCallback(() => {
|
||||
if (!mapContainerRef.current) return null;
|
||||
|
||||
const container = mapContainerRef.current;
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
||||
// SVG dimensions from viewBox
|
||||
const svgWidth = 783;
|
||||
const svgHeight = 753;
|
||||
|
||||
// Calculate visible area in SVG coordinates
|
||||
const scaleFactor = 1 / zoomLevel;
|
||||
const visibleWidth = rect.width * scaleFactor;
|
||||
const visibleHeight = rect.height * scaleFactor;
|
||||
|
||||
// Calculate the center point in SVG coordinates after pan
|
||||
const centerX = svgWidth / 2 - panPosition.x * scaleFactor;
|
||||
const centerY = svgHeight / 2 - panPosition.y * scaleFactor;
|
||||
|
||||
return {
|
||||
left: centerX - visibleWidth / 2,
|
||||
right: centerX + visibleWidth / 2,
|
||||
top: centerY - visibleHeight / 2,
|
||||
bottom: centerY + visibleHeight / 2,
|
||||
width: visibleWidth,
|
||||
height: visibleHeight,
|
||||
};
|
||||
}, [zoomLevel, panPosition]);
|
||||
|
||||
// Check if a node is in the viewport
|
||||
const isNodeInViewport = useCallback(
|
||||
(
|
||||
node: { x: number; y: number },
|
||||
viewport: {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
} | null
|
||||
) => {
|
||||
if (!dynamicRendering) return true;
|
||||
if (!viewport) return true;
|
||||
|
||||
return (
|
||||
node.x >= viewport.left &&
|
||||
node.x <= viewport.right &&
|
||||
node.y >= viewport.top &&
|
||||
node.y <= viewport.bottom
|
||||
);
|
||||
},
|
||||
[dynamicRendering]
|
||||
);
|
||||
|
||||
// Path calculations
|
||||
const getDailyPaths = useCallback((courses: ProcessInPersonMeetings[]) => {
|
||||
const sortedCourses = [...courses].sort((a, b) => a.normalizedStartTime - b.normalizedStartTime);
|
||||
|
||||
const paths = [];
|
||||
|
||||
for (let i = 0; i < sortedCourses.length - 1; i++) {
|
||||
const currentCourse = sortedCourses[i];
|
||||
const nextCourse = sortedCourses[i + 1];
|
||||
|
||||
if (currentCourse && nextCourse && currentCourse.location?.building && nextCourse.location?.building) {
|
||||
paths.push({
|
||||
start: currentCourse.location.building,
|
||||
end: nextCourse.location.building,
|
||||
startTime: currentCourse.normalizedEndTime,
|
||||
endTime: nextCourse.normalizedStartTime,
|
||||
colors: currentCourse.colors,
|
||||
startCourseName: currentCourse.fullName,
|
||||
endCourseName: nextCourse.fullName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}, []);
|
||||
|
||||
const relevantPaths = useMemo(() => {
|
||||
if (!selectedDay) return [];
|
||||
|
||||
const coursesForDay = processedCourses.filter(course => course.day === DAY_MAPPING[selectedDay]);
|
||||
|
||||
const paths = getDailyPaths(coursesForDay);
|
||||
|
||||
return paths.map(path => ({
|
||||
...path,
|
||||
timeBetweenClasses: Math.floor(path.endTime - path.startTime),
|
||||
}));
|
||||
}, [selectedDay, processedCourses, getDailyPaths]);
|
||||
|
||||
// Memoized set of important buildings - buildings in active paths or daily routes
|
||||
const importantBuildings = useMemo(() => {
|
||||
const result = new Set<NodeId>();
|
||||
|
||||
// Add selected buildings
|
||||
if (selected.start) result.add(selected.start);
|
||||
if (selected.end) result.add(selected.end);
|
||||
|
||||
// Add buildings in the daily paths
|
||||
relevantPaths?.forEach(path => {
|
||||
result.add(path.start);
|
||||
result.add(path.end);
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [selected.start, selected.end, relevantPaths]);
|
||||
|
||||
// Memoized set of buildings to show based on zoom level and grid clustering
|
||||
const visibleBuildings = useMemo(() => {
|
||||
// Start with important buildings (selected or in active paths)
|
||||
const result = new Set<NodeId>(importantBuildings);
|
||||
const viewport = calculateViewport();
|
||||
|
||||
if (!dynamicRendering) {
|
||||
Object.entries(graphNodes).forEach(([id, node]) => {
|
||||
if (node.type === 'building' && isNodeInViewport(node, viewport)) {
|
||||
result.add(id);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// If showing prioritized buildings only, return just the important ones
|
||||
if (showPrioritizedOnly) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// If we're zoomed in enough, show all buildings in viewport
|
||||
if (zoomLevel >= ZOOM_LEVELS.HIGH) {
|
||||
Object.entries(graphNodes).forEach(([id, node]) => {
|
||||
if (node.type === 'building' && isNodeInViewport(node, viewport)) {
|
||||
result.add(id);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// At medium zoom, show more buildings but still cluster them
|
||||
if (zoomLevel >= ZOOM_LEVELS.MEDIUM) {
|
||||
// Create a grid-based clustering with medium density
|
||||
const gridSize = 40;
|
||||
const grid: Record<string, NodeId[]> = {};
|
||||
|
||||
Object.entries(graphNodes).forEach(([id, node]) => {
|
||||
if (node.type === 'building' && isNodeInViewport(node, viewport)) {
|
||||
const gridX = Math.floor(node.x / gridSize);
|
||||
const gridY = Math.floor(node.y / gridSize);
|
||||
const gridId = `${gridX}-${gridY}`;
|
||||
|
||||
if (!grid[gridId]) {
|
||||
grid[gridId] = [];
|
||||
}
|
||||
grid[gridId].push(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Select one building per grid cell
|
||||
Object.values(grid).forEach(buildings => {
|
||||
if (buildings.length > 0) {
|
||||
// Sort to ensure consistent selection
|
||||
const sorted = [...buildings].sort();
|
||||
if (sorted[0]) {
|
||||
result.add(sorted[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// At low zoom, create a sparser grid
|
||||
const gridSize = 70;
|
||||
const grid: Record<string, NodeId[]> = {};
|
||||
|
||||
Object.entries(graphNodes).forEach(([id, node]) => {
|
||||
if (node.type === 'building' && isNodeInViewport(node, viewport)) {
|
||||
const gridX = Math.floor(node.x / gridSize);
|
||||
const gridY = Math.floor(node.y / gridSize);
|
||||
const gridId = `${gridX}-${gridY}`;
|
||||
|
||||
if (!grid[gridId]) {
|
||||
grid[gridId] = [];
|
||||
}
|
||||
grid[gridId].push(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Select one building per grid cell
|
||||
Object.values(grid).forEach(buildings => {
|
||||
if (buildings.length > 0) {
|
||||
// Sort to ensure consistent selection
|
||||
const sorted = [...buildings].sort();
|
||||
if (sorted[0]) {
|
||||
result.add(sorted[0]);
|
||||
}
|
||||
|
||||
// For grid cells with many buildings, maybe show a second one too
|
||||
if (sorted.length > 3 && zoomLevel > ZOOM_LEVELS.LOW && sorted[1]) {
|
||||
result.add(sorted[1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [importantBuildings, calculateViewport, dynamicRendering, showPrioritizedOnly, zoomLevel, isNodeInViewport]);
|
||||
|
||||
// Determine which intersections to show based on zoom level
|
||||
const visibleIntersections = useMemo(() => {
|
||||
const result = new Set<NodeId>();
|
||||
const viewport = calculateViewport();
|
||||
|
||||
// Only process if intersections should be shown
|
||||
if (!showIntersections) return result;
|
||||
|
||||
if (!dynamicRendering) {
|
||||
Object.entries(graphNodes).forEach(([id, node]) => {
|
||||
if (node.type === 'intersection' && isNodeInViewport(node, viewport)) {
|
||||
result.add(id);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Show all intersections at high zoom
|
||||
if (zoomLevel >= ZOOM_LEVELS.HIGH) {
|
||||
Object.entries(graphNodes).forEach(([id, node]) => {
|
||||
if (node.type === 'intersection' && isNodeInViewport(node, viewport)) {
|
||||
result.add(id);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// At medium zoom, show a subset
|
||||
if (zoomLevel >= ZOOM_LEVELS.MEDIUM) {
|
||||
Object.entries(graphNodes).forEach(([id, node]) => {
|
||||
if (node.type === 'intersection' && isNodeInViewport(node, viewport)) {
|
||||
// Show every 2nd intersection
|
||||
const nodeIndex = parseInt(id.replace(/\D/g, '') || '0', 10);
|
||||
if (nodeIndex % 2 === 0) {
|
||||
result.add(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// At low zoom, show very few intersections
|
||||
Object.entries(graphNodes).forEach(([id, node]) => {
|
||||
if (node.type === 'intersection' && isNodeInViewport(node, viewport)) {
|
||||
// Show only every 4th intersection
|
||||
const nodeIndex = parseInt(id.replace(/\D/g, '') || '0', 10);
|
||||
if (nodeIndex % 4 === 0) {
|
||||
result.add(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [calculateViewport, dynamicRendering, showIntersections, zoomLevel, isNodeInViewport]);
|
||||
|
||||
// Determine which walkways to show based on zoom level
|
||||
const visibleWalkways = useMemo(() => {
|
||||
const result = new Set<NodeId>();
|
||||
const viewport = calculateViewport();
|
||||
|
||||
// Only process if walkways should be shown
|
||||
if (!showWalkways) return result;
|
||||
|
||||
if (!dynamicRendering) {
|
||||
Object.entries(graphNodes).forEach(([id, node]) => {
|
||||
if (node.type === 'walkway' && isNodeInViewport(node, viewport)) {
|
||||
result.add(id);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Show all walkways at high zoom
|
||||
if (zoomLevel >= ZOOM_LEVELS.HIGH) {
|
||||
Object.entries(graphNodes).forEach(([id, node]) => {
|
||||
if (node.type === 'walkway' && isNodeInViewport(node, viewport)) {
|
||||
result.add(id);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// At medium zoom, show a subset
|
||||
if (zoomLevel >= ZOOM_LEVELS.MEDIUM) {
|
||||
Object.entries(graphNodes).forEach(([id, node]) => {
|
||||
if (node.type === 'walkway' && isNodeInViewport(node, viewport)) {
|
||||
// Show every 3rd walkway
|
||||
const nodeIndex = parseInt(id.replace(/\D/g, '') || '0', 10);
|
||||
if (nodeIndex % 3 === 0) {
|
||||
result.add(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// At low zoom, show very few walkways
|
||||
Object.entries(graphNodes).forEach(([id, node]) => {
|
||||
if (node.type === 'walkway' && isNodeInViewport(node, viewport)) {
|
||||
// Show only every 5th walkway
|
||||
const nodeIndex = parseInt(id.replace(/\D/g, '') || '0', 10);
|
||||
if (nodeIndex % 5 === 0) {
|
||||
result.add(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [calculateViewport, dynamicRendering, showWalkways, zoomLevel, isNodeInViewport]);
|
||||
|
||||
// Determine if a node should be shown based on type and zoom level
|
||||
const shouldShowNode = useCallback(
|
||||
(type: NodeType, id: NodeId): boolean => {
|
||||
// Always show selected buildings
|
||||
if (id === selected.start || id === selected.end) return true;
|
||||
|
||||
switch (type) {
|
||||
case 'building':
|
||||
return showBuildings && visibleBuildings.has(id);
|
||||
case 'intersection':
|
||||
return visibleIntersections.has(id);
|
||||
case 'walkway':
|
||||
return visibleWalkways.has(id);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[showBuildings, selected, visibleBuildings, visibleIntersections, visibleWalkways]
|
||||
);
|
||||
|
||||
// Get the appropriate node size based on zoom level with maximum cap
|
||||
const getNodeSize = useCallback(
|
||||
(type: NodeType): number => {
|
||||
const baseSize = type === 'building' ? 6 : 4;
|
||||
const minSize = baseSize * 0.8; // Minimum size at low zoom
|
||||
const maxSize = baseSize * 0.5; // Maximum size cap
|
||||
|
||||
// If below minimum zoom level
|
||||
if (zoomLevel <= ZOOM_LEVELS.LOW) {
|
||||
return minSize;
|
||||
}
|
||||
|
||||
// If above maximum zoom level, cap the size
|
||||
if (zoomLevel >= ZOOM_LEVELS.HIGH) {
|
||||
return maxSize;
|
||||
}
|
||||
|
||||
// Scale size gradually between LOW and HIGH zoom levels
|
||||
const zoomRatio = (zoomLevel - ZOOM_LEVELS.LOW) / (ZOOM_LEVELS.HIGH - ZOOM_LEVELS.LOW);
|
||||
return minSize + zoomRatio * (maxSize - minSize);
|
||||
},
|
||||
[zoomLevel]
|
||||
);
|
||||
|
||||
// Get the appropriate text size based on zoom level with maximum cap
|
||||
const getTextSize = useCallback((): number => {
|
||||
const minSize = 12; // Minimum text size at low zoom
|
||||
const maxSize = 8; // Maximum text size cap
|
||||
|
||||
// If below minimum zoom level
|
||||
if (zoomLevel <= ZOOM_LEVELS.LOW) {
|
||||
return minSize;
|
||||
}
|
||||
|
||||
// If above maximum zoom level, cap the size
|
||||
if (zoomLevel >= ZOOM_LEVELS.HIGH) {
|
||||
return maxSize;
|
||||
}
|
||||
|
||||
// Scale text size gradually between LOW and HIGH zoom levels
|
||||
const zoomRatio = (zoomLevel - ZOOM_LEVELS.LOW) / (ZOOM_LEVELS.HIGH - ZOOM_LEVELS.LOW);
|
||||
return minSize + zoomRatio * (maxSize - minSize);
|
||||
}, [zoomLevel]);
|
||||
|
||||
// Determine if text should be shown for a node
|
||||
const shouldShowText = useCallback(
|
||||
(type: NodeType, id: NodeId): boolean => {
|
||||
// If building text is disabled in dev controls, don't show any text
|
||||
if (!showBuildingText) return false;
|
||||
|
||||
if (type !== 'building') return false;
|
||||
|
||||
// Always show text for selected buildings
|
||||
if (id === selected.start || id === selected.end) return true;
|
||||
|
||||
// Show text based on zoom level
|
||||
return zoomLevel >= ZOOM_LEVELS.LOW;
|
||||
},
|
||||
[zoomLevel, selected, showBuildingText]
|
||||
);
|
||||
|
||||
// Zoom and pan handlers
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setZoomLevel(prev => Math.min(prev * zoomStep, maxZoom));
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setZoomLevel(prev => Math.max(prev / zoomStep, minZoom));
|
||||
}, []);
|
||||
|
||||
const handleResetZoomPan = useCallback(() => {
|
||||
setZoomLevel(1);
|
||||
setPanPosition({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.deltaY < 0) {
|
||||
setZoomLevel(prev => Math.min(prev * zoomStep, maxZoom));
|
||||
} else {
|
||||
setZoomLevel(prev => Math.max(prev / zoomStep, minZoom));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return; // Only handle left mouse button
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const dx = e.clientX - dragStart.x;
|
||||
const dy = e.clientY - dragStart.y;
|
||||
|
||||
setPanPosition(prev => ({ x: prev.x + dx, y: prev.y + dy }));
|
||||
setDragStart({ x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[isDragging, dragStart]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
// Event handlers
|
||||
const handleDaySelect = useCallback((day: DayCode) => {
|
||||
setSelectedDay(prevDay => (prevDay === day ? null : day));
|
||||
setHoveredPathIndex(null);
|
||||
setToggledPathIndex(null);
|
||||
}, []);
|
||||
|
||||
const handleBuildingSelect = useCallback((buildingId: NodeId) => {
|
||||
setSelected(prev => {
|
||||
if (!prev.start) return { ...prev, start: buildingId };
|
||||
if (!prev.end) return { ...prev, end: buildingId };
|
||||
return { start: buildingId, end: null };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePathClick = useCallback((index: number) => {
|
||||
setToggledPathIndex(prevIndex => (prevIndex === index ? null : index));
|
||||
}, []);
|
||||
|
||||
const shouldShowPath = useCallback(
|
||||
(index: number) => {
|
||||
if (hoveredPathIndex !== null) {
|
||||
return hoveredPathIndex === index;
|
||||
}
|
||||
if (toggledPathIndex !== null) {
|
||||
return toggledPathIndex === index;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[hoveredPathIndex, toggledPathIndex]
|
||||
);
|
||||
|
||||
// Global mouse up handler
|
||||
useEffect(() => {
|
||||
const handleGlobalMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
window.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='relative h-full w-full overflow-hidden' ref={mapContainerRef}>
|
||||
{/* Map container with zoom and pan applied */}
|
||||
<div
|
||||
className={`relative h-full w-full ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||||
style={{
|
||||
transform: `scale(${zoomLevel}) translate(${panPosition.x / zoomLevel}px, ${panPosition.y / zoomLevel}px)`,
|
||||
transformOrigin: 'center center',
|
||||
transition: isDragging ? 'none' : 'transform 0.1s ease-out',
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Map Image */}
|
||||
<img src={UTMapURL} alt='UT Campus Map' className='h-full w-full object-contain' draggable={false} />
|
||||
|
||||
{/* SVG Overlay - ensuring it matches the image dimensions */}
|
||||
<svg
|
||||
className='absolute left-0 top-0 h-full w-full'
|
||||
viewBox='0 0 783 753'
|
||||
preserveAspectRatio='xMidYMid meet'
|
||||
>
|
||||
{/* Render buildings, intersections, and walkways */}
|
||||
{Object.entries(graphNodes).map(
|
||||
([id, node]) =>
|
||||
shouldShowNode(node.type, id) && (
|
||||
<g key={id}>
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={getNodeSize(node.type)}
|
||||
fill={
|
||||
id === selected.start
|
||||
? '#579D42'
|
||||
: id === selected.end
|
||||
? '#D10000'
|
||||
: node.type === 'building'
|
||||
? '#BF5700'
|
||||
: node.type === 'intersection'
|
||||
? '#9CADB7'
|
||||
: '#D6D2C400'
|
||||
}
|
||||
stroke={node.type !== 'walkway' ? 'white' : 'green'}
|
||||
strokeWidth={zoomLevel < ZOOM_LEVELS.MEDIUM ? '1.5' : '2'}
|
||||
className='cursor-pointer opacity-90'
|
||||
onClick={() => handleBuildingSelect(id)}
|
||||
/>
|
||||
{node.type === 'building' && shouldShowText(node.type, id) && (
|
||||
<text
|
||||
x={node.x + 12}
|
||||
y={node.y + 4}
|
||||
fill='#000000'
|
||||
fontSize={getTextSize()}
|
||||
className='font-bold'
|
||||
style={{
|
||||
// Fade in text based on zoom level for smooth transition
|
||||
opacity: zoomLevel < ZOOM_LEVELS.LOW ? zoomLevel / ZOOM_LEVELS.LOW : 1,
|
||||
}}
|
||||
>
|
||||
{id}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Render daily schedule paths */}
|
||||
{relevantPaths.map(
|
||||
(path, index) =>
|
||||
shouldShowPath(index) && (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<g key={`${path.start}-${path.end}-${index}`}>
|
||||
<Path
|
||||
startId={path.start}
|
||||
endId={path.end}
|
||||
graph={graphNodes}
|
||||
color={path.colors?.primaryColor || '#BF5700'}
|
||||
className='stroke-4 opacity-50 transition-opacity duration-300 hover:opacity-80'
|
||||
/>
|
||||
{path.timeBetweenClasses < 15 &&
|
||||
(() => {
|
||||
const midpoint = getMidpoint(path.start, path.end);
|
||||
return midpoint ? (
|
||||
<TimeWarningLabel
|
||||
x={midpoint.x}
|
||||
y={midpoint.y}
|
||||
minutes={path.timeBetweenClasses}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</g>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Render user-selected path */}
|
||||
{selected.start && selected.end && (
|
||||
<Path
|
||||
startId={selected.start}
|
||||
endId={selected.end}
|
||||
graph={graphNodes}
|
||||
color='#BF5700'
|
||||
className='opacity-75 transition-opacity duration-300 hover:opacity-100'
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Fixed position controls that don't move with zoom/pan */}
|
||||
<div className='absolute left-8 top-8 z-10 flex flex-col gap-4'>
|
||||
{/* Day Selector */}
|
||||
<DaySelector selectedDay={selectedDay} onDaySelect={handleDaySelect} />
|
||||
|
||||
{/* Zoom and Pan Controls */}
|
||||
<ZoomPanControls
|
||||
zoomIn={handleZoomIn}
|
||||
zoomOut={handleZoomOut}
|
||||
resetZoomPan={handleResetZoomPan}
|
||||
zoomLevel={zoomLevel}
|
||||
/>
|
||||
|
||||
{/* Fullscreen Button */}
|
||||
<FullscreenButton containerRef={mapContainerRef} />
|
||||
|
||||
{/* Dev Toggles */}
|
||||
<DevToggles
|
||||
dynamicRendering={dynamicRendering}
|
||||
showBuildings={showBuildings}
|
||||
showIntersections={showIntersections}
|
||||
showWalkways={showWalkways}
|
||||
showBuildingText={showBuildingText}
|
||||
showPrioritizedOnly={showPrioritizedOnly}
|
||||
onToggleDynamicRendering={() => setDynamicRendering(prev => !prev)}
|
||||
onToggleBuildings={() => setShowBuildings(prev => !prev)}
|
||||
onToggleIntersections={() => setShowIntersections(prev => !prev)}
|
||||
onToggleWalkways={() => setShowWalkways(prev => !prev)}
|
||||
onToggleBuildingText={() => setShowBuildingText(prev => !prev)}
|
||||
onTogglePrioritizedOnly={() => setShowPrioritizedOnly(prev => !prev)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Path information */}
|
||||
<div className='absolute right-8 top-8 z-10 max-h-[calc(100vh-120px)] flex flex-col gap-4 overflow-y-auto'>
|
||||
{/* Path Statistics - show when a path is selected */}
|
||||
{selected.start && selected.end && <PathStats startId={selected.start} endId={selected.end} />}
|
||||
|
||||
{/* Daily Paths Statistics - show when day is selected */}
|
||||
{relevantPaths.length > 0 && (
|
||||
<div className='rounded-md bg-white/90 p-3 shadow-sm'>
|
||||
<div className='mb-2'>
|
||||
<p className='text-sm font-medium'>Daily Transitions</p>
|
||||
<p className='text-xs text-gray-600'>
|
||||
Total time:{' '}
|
||||
{relevantPaths.reduce(
|
||||
(total, path) =>
|
||||
total +
|
||||
(calcDirectPathStats({ startId: path.start, endId: path.end })
|
||||
?.walkingTimeMinutes || 0),
|
||||
0
|
||||
)}{' '}
|
||||
min
|
||||
</p>
|
||||
<p className='text-xs text-gray-600'>
|
||||
Total distance:{' '}
|
||||
{relevantPaths.reduce(
|
||||
(total, path) =>
|
||||
total +
|
||||
(calcDirectPathStats({ startId: path.start, endId: path.end })
|
||||
?.distanceInFeet || 0),
|
||||
0
|
||||
)}{' '}
|
||||
ft
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-4'>
|
||||
{relevantPaths.map((path, index) => (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`path-info-${index}`}
|
||||
className={`cursor-pointer space-y-1 text-xs transition-colors duration-200 ${
|
||||
toggledPathIndex === index ? 'bg-gray-100' : ''
|
||||
}`}
|
||||
style={{
|
||||
borderLeft: `3px solid ${path.colors?.primaryColor || '#BF5700'}`,
|
||||
}}
|
||||
onMouseEnter={() => setHoveredPathIndex(index)}
|
||||
onMouseLeave={() => setHoveredPathIndex(null)}
|
||||
onClick={() => handlePathClick(index)}
|
||||
>
|
||||
<p className='ml-2'>{path.startCourseName}</p>
|
||||
<p className='ml-2'>
|
||||
(
|
||||
{
|
||||
calcDirectPathStats({ startId: path.start, endId: path.end })
|
||||
?.walkingTimeMinutes
|
||||
}{' '}
|
||||
min,{' '}
|
||||
{calcDirectPathStats({ startId: path.start, endId: path.end })?.distanceInFeet}{' '}
|
||||
ft)
|
||||
{' - '}
|
||||
{path.timeBetweenClasses} min transition
|
||||
{path.timeBetweenClasses < 15 && ' ⚠️'}
|
||||
</p>
|
||||
<p className='ml-2'>{path.endCourseName}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/views/components/map/DaySelector.tsx
Normal file
38
src/views/components/map/DaySelector.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '../common/Button';
|
||||
import type { DayCode } from './types';
|
||||
import { DAY_MAPPING } from './types';
|
||||
|
||||
interface DaySelectorProps {
|
||||
selectedDay: DayCode | null;
|
||||
onDaySelect: (day: DayCode) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* DaySelector component allows users to select a day from a list of days.
|
||||
*
|
||||
* @param selectedDay - The currently selected day.
|
||||
* @param onDaySelect - Callback function to handle day selection.
|
||||
*
|
||||
* @returns The rendered DaySelector component.
|
||||
*/
|
||||
// const DaySelector = ({ selectedDay, onDaySelect }: DaySelectorProps): JSX.Element => (
|
||||
export default function DaySelector({ selectedDay, onDaySelect }: DaySelectorProps): JSX.Element {
|
||||
return (
|
||||
<div className='flex gap-2 rounded-md bg-white/90 p-2 shadow-sm'>
|
||||
{(Object.keys(DAY_MAPPING) as DayCode[]).map(day => (
|
||||
<Button
|
||||
key={day}
|
||||
onClick={() => onDaySelect(day)}
|
||||
color='ut-burntorange'
|
||||
variant={selectedDay === day ? 'filled' : 'minimal'}
|
||||
size='mini'
|
||||
className='px-3 py-1'
|
||||
>
|
||||
{day}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/views/components/map/DevToggles.tsx
Normal file
101
src/views/components/map/DevToggles.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface DevTogglesProps {
|
||||
dynamicRendering: boolean;
|
||||
showBuildings: boolean;
|
||||
showIntersections: boolean;
|
||||
showWalkways: boolean;
|
||||
showBuildingText: boolean;
|
||||
showPrioritizedOnly: boolean;
|
||||
onToggleDynamicRendering: () => void;
|
||||
onToggleBuildings: () => void;
|
||||
onToggleIntersections: () => void;
|
||||
onToggleWalkways: () => void;
|
||||
onToggleBuildingText: () => void;
|
||||
onTogglePrioritizedOnly: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* DevToggles component allows developers to toggle visibility of map elements.
|
||||
*
|
||||
* @param dynamicRendering - Whether to enable dynamic rendering.
|
||||
* @param showBuildings - Whether to show buildings on the map.
|
||||
* @param showIntersections - Whether to show intersections on the map.
|
||||
* @param showWalkways - Whether to show walkways on the map.
|
||||
* @param onToggleDynamicRendering - Callback function to toggle dynamic rendering.
|
||||
* @param onToggleBuildings - Callback function to toggle buildings visibility.
|
||||
* @param onToggleIntersections - Callback function to toggle intersections visibility.
|
||||
* @param onToggleWalkways - Callback function to toggle walkways visibility.
|
||||
*
|
||||
* @returns The rendered DevToggles component.
|
||||
*/
|
||||
export default function DevToggles({
|
||||
dynamicRendering,
|
||||
showBuildings,
|
||||
showIntersections,
|
||||
showWalkways,
|
||||
showBuildingText,
|
||||
showPrioritizedOnly,
|
||||
onToggleDynamicRendering,
|
||||
onToggleBuildings,
|
||||
onToggleIntersections,
|
||||
onToggleWalkways,
|
||||
onToggleBuildingText,
|
||||
onTogglePrioritizedOnly,
|
||||
}: DevTogglesProps): JSX.Element {
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-2 rounded-md bg-white/90 p-2 shadow-sm'>
|
||||
<div className='flex items-center justify-between text-xs text-gray-700 font-semibold'>
|
||||
<span>Dev Controls</span>
|
||||
<button
|
||||
onClick={() => setIsCollapsed(prev => !prev)}
|
||||
className='ml-2 p-1 text-gray-500 hover:text-gray-800'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='14'
|
||||
height='14'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
{isCollapsed ? <polyline points='6 9 12 15 18 9' /> : <polyline points='18 15 12 9 6 15' />}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label className='flex cursor-pointer items-center gap-2 text-xs'>
|
||||
<input type='checkbox' checked={dynamicRendering} onChange={onToggleDynamicRendering} />
|
||||
Dynamic Rendering
|
||||
</label>
|
||||
<label className='flex cursor-pointer items-center gap-2 text-xs'>
|
||||
<input type='checkbox' checked={showBuildings} onChange={onToggleBuildings} />
|
||||
Show Buildings
|
||||
</label>
|
||||
<label className='flex cursor-pointer items-center gap-2 text-xs'>
|
||||
<input type='checkbox' checked={showBuildingText} onChange={onToggleBuildingText} />
|
||||
Show Building Text
|
||||
</label>
|
||||
<label className='flex cursor-pointer items-center gap-2 text-xs'>
|
||||
<input type='checkbox' checked={showPrioritizedOnly} onChange={onTogglePrioritizedOnly} />
|
||||
Prioritized Buildings Only
|
||||
</label>
|
||||
<label className='flex cursor-pointer items-center gap-2 text-xs'>
|
||||
<input type='checkbox' checked={showIntersections} onChange={onToggleIntersections} />
|
||||
Show Intersections
|
||||
</label>
|
||||
<label className='flex cursor-pointer items-center gap-2 text-xs'>
|
||||
<input type='checkbox' checked={showWalkways} onChange={onToggleWalkways} />
|
||||
Show Walkways
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
src/views/components/map/FullscreenButton.tsx
Normal file
72
src/views/components/map/FullscreenButton.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { Button } from '../common/Button';
|
||||
|
||||
interface FullscreenButtonProps {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* FullscreenButton component provides a toggle for fullscreen mode
|
||||
*
|
||||
* @param containerRef - Reference to the container element to make fullscreen.
|
||||
*
|
||||
* @returns The rendered FullscreenButton component.
|
||||
*/
|
||||
export default function FullscreenButton({ containerRef }: FullscreenButtonProps): JSX.Element {
|
||||
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement && containerRef.current) {
|
||||
containerRef.current.requestFullscreen().catch(err => {
|
||||
console.error(`Error attempting to enable fullscreen: ${err.message}`);
|
||||
});
|
||||
} else if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(err => {
|
||||
console.error(`Error attempting to exit fullscreen: ${err.message}`);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='rounded-md bg-white/90 p-2 shadow-sm'>
|
||||
<Button
|
||||
onClick={toggleFullscreen}
|
||||
color='ut-burntorange'
|
||||
variant='minimal'
|
||||
size='mini'
|
||||
className='flex items-center gap-1 px-3 py-1'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='16'
|
||||
height='16'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<path d='M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3' />
|
||||
) : (
|
||||
<path d='M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3' />
|
||||
)}
|
||||
</svg>
|
||||
{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
287
src/views/components/map/Map.tsx
Normal file
287
src/views/components/map/Map.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import type { Course, StatusType } from '@shared/types/Course';
|
||||
import type { CourseMeeting } from '@shared/types/CourseMeeting';
|
||||
import { Button } from '@views/components/common/Button';
|
||||
import Divider from '@views/components/common/Divider';
|
||||
import { LargeLogo } from '@views/components/common/LogoIcon';
|
||||
import Text from '@views/components/common/Text/Text';
|
||||
import useChangelog from '@views/hooks/useChangelog';
|
||||
import useSchedules from '@views/hooks/useSchedules';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
|
||||
import IconoirGitFork from '~icons/iconoir/git-fork';
|
||||
|
||||
import CalendarFooter from '../calendar/CalendarFooter';
|
||||
import { CalendarSchedules } from '../calendar/CalendarSchedules';
|
||||
import ImportantLinks from '../calendar/ImportantLinks';
|
||||
import TeamLinks from '../calendar/TeamLinks';
|
||||
import CampusMap from './CampusMap';
|
||||
import { type DAY, DAYS } from './types';
|
||||
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
const LDIconURL = new URL('/src/assets/LD-icon.png', import.meta.url).href;
|
||||
|
||||
const dayToNumber = {
|
||||
Monday: 0,
|
||||
Tuesday: 1,
|
||||
Wednesday: 2,
|
||||
Thursday: 3,
|
||||
Friday: 4,
|
||||
Saturday: 5,
|
||||
Sunday: 6,
|
||||
} as const satisfies Record<string, number>;
|
||||
|
||||
/**
|
||||
* Represents the details of an in-person meeting process.
|
||||
*
|
||||
* day - The day of the meeting.
|
||||
* dayIndex - The index of the day in the week.
|
||||
* fullName - The full name of the person.
|
||||
* uid - The unique identifier of the person.
|
||||
* time - The time of the meeting.
|
||||
* normalizedStartTime - The normalized start time of the meeting.
|
||||
* normalizedEndTime - The normalized end time of the meeting.
|
||||
* startIndex - The start index of the meeting.
|
||||
* endIndex - The end index of the meeting.
|
||||
* location - The location of the meeting.
|
||||
* status - The status of the meeting.
|
||||
* colors - The colors associated with the course.
|
||||
* course - The course details.
|
||||
*/
|
||||
export type ProcessInPersonMeetings = {
|
||||
day: DAY;
|
||||
dayIndex: number;
|
||||
fullName: string;
|
||||
uid: number;
|
||||
time: string;
|
||||
normalizedStartTime: number;
|
||||
normalizedEndTime: number;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
location: CourseMeeting['location'];
|
||||
status: StatusType;
|
||||
colors: Course['colors'];
|
||||
course: Course;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts minutes to an index value.
|
||||
* @param minutes - The number of minutes.
|
||||
* @returns The index value.
|
||||
*/
|
||||
const convertMinutesToIndex = (minutes: number): number => Math.floor((minutes - 420) / 30);
|
||||
|
||||
/**
|
||||
* Renders the map component for the UTRP (UT Registration Plus) extension.
|
||||
*/
|
||||
export default function Map(): JSX.Element {
|
||||
const handleChangelogOnClick = useChangelog();
|
||||
const [activeSchedule] = useSchedules();
|
||||
|
||||
/**
|
||||
* Function to extract and format basic course information
|
||||
*/
|
||||
function extractCourseInfo(course: Course) {
|
||||
const {
|
||||
status,
|
||||
schedule: { meetings },
|
||||
} = course;
|
||||
|
||||
let courseDeptAndInstr = `${course.department} ${course.number}`;
|
||||
|
||||
const mainInstructor = course.instructors[0];
|
||||
if (mainInstructor) {
|
||||
courseDeptAndInstr += ` – ${mainInstructor.toString({ format: 'first_last' })}`;
|
||||
}
|
||||
|
||||
return { status, courseDeptAndInstr, meetings, course };
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Function to process each in-person class into its distinct meeting objects for calendar grid
|
||||
// */
|
||||
// function processAsyncCourses({
|
||||
// courseDeptAndInstr,
|
||||
// status,
|
||||
// course,
|
||||
// }: {
|
||||
// courseDeptAndInstr: string;
|
||||
// status: StatusType;
|
||||
// course: Course;
|
||||
// }): CalendarGridCourse[] {
|
||||
// return [
|
||||
// {
|
||||
// calendarGridPoint: {
|
||||
// dayIndex: -1,
|
||||
// startIndex: -1,
|
||||
// endIndex: -1,
|
||||
// },
|
||||
// componentProps: {
|
||||
// courseDeptAndInstr,
|
||||
// status,
|
||||
// colors: course.colors,
|
||||
// },
|
||||
// course,
|
||||
// async: true,
|
||||
// },
|
||||
// ];
|
||||
// }
|
||||
|
||||
/**
|
||||
* Function to process each in-person class into its distinct meeting objects for calendar grid
|
||||
*/
|
||||
function processInPersonMeetings(
|
||||
meeting: CourseMeeting,
|
||||
courseDeptAndInstr: string,
|
||||
status: StatusType,
|
||||
course: Course
|
||||
) {
|
||||
const { days, location, startTime, endTime } = meeting;
|
||||
const time = meeting.getTimeString({ separator: '-' });
|
||||
const timeAndLocation = `${time}${location ? ` - ${location.building} ${location.room}` : ''}`;
|
||||
|
||||
const midnightIndex = 1440;
|
||||
const normalizingTimeFactor = 720;
|
||||
const normalizedStartTime = startTime >= midnightIndex ? startTime - normalizingTimeFactor : startTime;
|
||||
const normalizedEndTime = endTime >= midnightIndex ? endTime - normalizingTimeFactor : endTime;
|
||||
|
||||
return days.map(day => ({
|
||||
day,
|
||||
dayIndex: dayToNumber[day],
|
||||
// fullName: `${courseDeptAndInstr} - ${timeAndLocation}`,
|
||||
fullName: `${timeAndLocation} - ${courseDeptAndInstr}`,
|
||||
uid: course.uniqueId,
|
||||
time,
|
||||
normalizedStartTime,
|
||||
normalizedEndTime,
|
||||
startIndex: convertMinutesToIndex(normalizedStartTime),
|
||||
endIndex: convertMinutesToIndex(normalizedEndTime),
|
||||
location,
|
||||
status,
|
||||
colors: course.colors,
|
||||
course,
|
||||
}));
|
||||
}
|
||||
|
||||
const processedCourses: ProcessInPersonMeetings[] = activeSchedule.courses.flatMap(course => {
|
||||
const { status, courseDeptAndInstr, meetings } = extractCourseInfo(course);
|
||||
|
||||
// if (meetings.length === 0) {
|
||||
// return processAsyncCourses({ courseDeptAndInstr, status, course });
|
||||
// }
|
||||
|
||||
return meetings.flatMap(meeting =>
|
||||
// if (meeting.days.includes(DAY_MAP.S) || meeting.startTime < 480) {
|
||||
// return processAsyncCourses({ courseDeptAndInstr, status, course });
|
||||
// }
|
||||
|
||||
processInPersonMeetings(meeting, courseDeptAndInstr, status, course)
|
||||
);
|
||||
});
|
||||
|
||||
const generateWeekSchedule = useCallback((): Record<DAY, string[]> => {
|
||||
const weekSchedule: Record<string, string[]> = {};
|
||||
|
||||
processedCourses.forEach(course => {
|
||||
const { day } = course;
|
||||
|
||||
// Add the course to the day's schedule
|
||||
if (!weekSchedule[day]) weekSchedule[day] = [];
|
||||
weekSchedule[day].push(course.fullName);
|
||||
});
|
||||
|
||||
// TODO: Not the best way to do this
|
||||
// currently weekSchedule is an object with keys as days and values as an array of courses
|
||||
// we want to display the days in order, so we create a new object with the days in order
|
||||
|
||||
const orderedWeekSchedule: Record<DAY, string[]> = {
|
||||
Monday: [],
|
||||
Tuesday: [],
|
||||
Wednesday: [],
|
||||
Thursday: [],
|
||||
Friday: [],
|
||||
Saturday: [],
|
||||
Sunday: [],
|
||||
};
|
||||
|
||||
DAYS.forEach(day => {
|
||||
if (weekSchedule[day]) {
|
||||
orderedWeekSchedule[day] = weekSchedule[day];
|
||||
}
|
||||
});
|
||||
|
||||
// Sort each day based on the start time of the course
|
||||
Object.entries(orderedWeekSchedule).forEach(([day, courses]) => {
|
||||
orderedWeekSchedule[day as DAY] = courses.sort((courseA, courseB) => {
|
||||
const courseAStartTime = processedCourses.find(
|
||||
course => course.fullName === courseA
|
||||
)?.normalizedStartTime;
|
||||
const courseBStartTime = processedCourses.find(
|
||||
course => course.fullName === courseB
|
||||
)?.normalizedStartTime;
|
||||
|
||||
return (courseAStartTime ?? 0) - (courseBStartTime ?? 0);
|
||||
});
|
||||
});
|
||||
|
||||
return orderedWeekSchedule;
|
||||
}, [processedCourses]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Active Schedule: ', activeSchedule);
|
||||
console.log('processedCourses:', processedCourses);
|
||||
console.log('generateWeekSchedule():', generateWeekSchedule());
|
||||
}, [activeSchedule, processedCourses, generateWeekSchedule]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className='flex items-center gap-5 overflow-x-auto overflow-y-hidden border-b border-ut-offwhite px-7 py-4 md:overflow-x-hidden'>
|
||||
<LargeLogo />
|
||||
<Divider className='mx-2 self-center md:mx-4' size='2.5rem' orientation='vertical' />
|
||||
<Text variant='h1' className='flex-1 text-ut-burntorange'>
|
||||
UTRP Map
|
||||
</Text>
|
||||
<div className='hidden flex-row items-center justify-end gap-6 screenshot:hidden lg:flex'>
|
||||
<Button variant='minimal' color='theme-black' onClick={handleChangelogOnClick}>
|
||||
<IconoirGitFork className='h-6 w-6 text-ut-gray' />
|
||||
<Text variant='small' className='text-ut-gray font-normal'>
|
||||
v{manifest.version} - {process.env.NODE_ENV}
|
||||
</Text>
|
||||
</Button>
|
||||
<img src={LDIconURL} alt='LD Icon' className='h-10 w-10 rounded-lg' />
|
||||
</div>
|
||||
</header>
|
||||
<div className='h-full flex flex-row'>
|
||||
<div className='h-full flex flex-none flex-col justify-between pb-5 screenshot:hidden'>
|
||||
<div className='mb-3 h-full w-fit flex flex-col overflow-auto pb-2 pl-4.5 pr-4 pt-5'>
|
||||
<CalendarSchedules />
|
||||
<Divider orientation='horizontal' size='100%' className='my-5' />
|
||||
<ImportantLinks />
|
||||
<Divider orientation='horizontal' size='100%' className='my-5' />
|
||||
<TeamLinks />
|
||||
</div>
|
||||
<CalendarFooter />
|
||||
</div>
|
||||
<div className='flex p-12'>
|
||||
<CampusMap processedCourses={processedCourses} />
|
||||
</div>
|
||||
|
||||
{/* Show week schedule */}
|
||||
<div className='flex flex-col py-12'>
|
||||
<p className='text-lg font-medium'>Week Schedule:</p>
|
||||
{Object.entries(generateWeekSchedule()).map(([day, courses]) => (
|
||||
<div key={day} className='flex flex-col pb-4'>
|
||||
<p className='text-sm font-medium'>{day}</p>
|
||||
<ul>
|
||||
{courses.map(course => (
|
||||
<li key={course} className='text-xs'>
|
||||
{course}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/views/components/map/Path.tsx
Normal file
61
src/views/components/map/Path.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
|
||||
import { PathFinder } from './PathFinder';
|
||||
import { type Graph, isValidNode, type NodeId } from './types';
|
||||
|
||||
type PathProps = {
|
||||
startId: NodeId;
|
||||
endId: NodeId;
|
||||
graph: Graph;
|
||||
color: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a path between two nodes in a graph.
|
||||
*
|
||||
* @param startId - The ID of the starting node.
|
||||
* @param endId - The ID of the ending node.
|
||||
* @param graph - The graph object containing nodes and edges.
|
||||
* @param color - The color of the path.
|
||||
* @param className - Additional CSS classes for the path.
|
||||
*
|
||||
* @returns The rendered path as a series of SVG lines, or null if an error occurs.
|
||||
*/
|
||||
export const Path = ({ startId, endId, graph, color, className = '' }: PathProps): JSX.Element | null => {
|
||||
try {
|
||||
const pathFinder = new PathFinder(graph);
|
||||
const path = pathFinder.findPath(startId, endId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{path.slice(0, -1).map((nodeId, index) => {
|
||||
const nextNodeId = path[index + 1];
|
||||
if (!nextNodeId) return null;
|
||||
|
||||
const start = graph[nodeId];
|
||||
const end = graph[nextNodeId];
|
||||
|
||||
if (!isValidNode(start) || !isValidNode(end)) return null;
|
||||
|
||||
return (
|
||||
<line
|
||||
key={`${nodeId}-${nextNodeId}`}
|
||||
x1={start.x}
|
||||
y1={start.y}
|
||||
x2={end.x}
|
||||
y2={end.y}
|
||||
strokeLinecap='round'
|
||||
stroke={color}
|
||||
// TODO: use clsx
|
||||
className={`stroke-8 ${className} opacity-50`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error rendering path:', error instanceof Error ? error.message : 'Unknown error');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
193
src/views/components/map/PathFinder.ts
Normal file
193
src/views/components/map/PathFinder.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
import {
|
||||
DIRECT_PATH_THRESHOLD,
|
||||
type DistanceMap,
|
||||
type Graph,
|
||||
isValidNode,
|
||||
type MapNode,
|
||||
NEIGHBOR_DISTANCE_THRESHOLD,
|
||||
type NodeId,
|
||||
type PreviousMap,
|
||||
} from './types';
|
||||
import { calculateDistance, getNeighbors } from './utils';
|
||||
|
||||
/**
|
||||
* Custom error class for handling pathfinding errors.
|
||||
*/
|
||||
export class PathFindingError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'PathFindingError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `PathFinder` class is responsible for finding the shortest path between nodes in a graph.
|
||||
* It uses Dijkstra's algorithm to compute the shortest path and supports bidirectional connections.
|
||||
*/
|
||||
export class PathFinder {
|
||||
private graph: Graph;
|
||||
private nodeConnections: Map<NodeId, Set<NodeId>>;
|
||||
|
||||
constructor(graph: Graph) {
|
||||
this.graph = graph;
|
||||
this.nodeConnections = this.buildNodeConnections();
|
||||
}
|
||||
|
||||
private buildNodeConnections(): Map<NodeId, Set<NodeId>> {
|
||||
const connections = new Map<NodeId, Set<NodeId>>();
|
||||
|
||||
// Initialize connections for each node
|
||||
Object.keys(this.graph).forEach(nodeId => {
|
||||
connections.set(nodeId, new Set<NodeId>());
|
||||
});
|
||||
|
||||
// Build bidirectional connections
|
||||
Object.keys(this.graph).forEach(nodeId => {
|
||||
const neighbors = getNeighbors(nodeId, this.graph);
|
||||
neighbors.forEach(neighbor => {
|
||||
connections.get(nodeId)?.add(neighbor);
|
||||
connections.get(neighbor)?.add(nodeId);
|
||||
});
|
||||
});
|
||||
|
||||
return connections;
|
||||
}
|
||||
|
||||
public findPath(startId: NodeId, endId: NodeId): NodeId[] {
|
||||
const startNode = this.graph[startId];
|
||||
const endNode = this.graph[endId];
|
||||
|
||||
// Validate input
|
||||
if (!isValidNode(startNode)) {
|
||||
throw new PathFindingError(`Invalid start node: ${startId}`);
|
||||
}
|
||||
if (!isValidNode(endNode)) {
|
||||
throw new PathFindingError(`Invalid end node: ${endId}`);
|
||||
}
|
||||
|
||||
// Check for direct path possibility
|
||||
if (this.shouldUseDirectPath(startNode, endNode)) {
|
||||
return [startId, endId];
|
||||
}
|
||||
|
||||
// Initialize Dijkstra's algorithm data structures
|
||||
const distances = Object.keys(this.graph).reduce<DistanceMap>((acc, nodeId) => {
|
||||
acc[nodeId] = nodeId === startId ? 0 : Infinity;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const previous = Object.keys(this.graph).reduce<PreviousMap>((acc, nodeId) => {
|
||||
acc[nodeId] = null;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const unvisited = new Set(Object.keys(this.graph));
|
||||
|
||||
// Main Dijkstra's algorithm loop
|
||||
while (unvisited.size > 0) {
|
||||
const current = this.getMinDistanceNode(distances, unvisited);
|
||||
if (current === null) break;
|
||||
|
||||
if (current === endId) break;
|
||||
|
||||
unvisited.delete(current);
|
||||
|
||||
// Use pre-computed connections
|
||||
const neighbors = this.nodeConnections.get(current) ?? new Set<NodeId>();
|
||||
neighbors.forEach(neighbor => {
|
||||
if (!unvisited.has(neighbor)) return;
|
||||
|
||||
const currentNode = this.graph[current];
|
||||
const neighborNode = this.graph[neighbor];
|
||||
|
||||
if (!isValidNode(currentNode) || !isValidNode(neighborNode)) return;
|
||||
|
||||
const distance = calculateDistance(currentNode, neighborNode);
|
||||
const totalDistance = distances[current]! + distance;
|
||||
|
||||
if (totalDistance < distances[neighbor]!) {
|
||||
distances[neighbor] = totalDistance;
|
||||
previous[neighbor] = current;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Reconstruct and validate path
|
||||
const path = this.reconstructPath(previous, startId, endId);
|
||||
|
||||
// Verify path distance is reasonable
|
||||
const totalPathDistance = this.calculatePathDistance(path);
|
||||
if (totalPathDistance > NEIGHBOR_DISTANCE_THRESHOLD * path.length) {
|
||||
console.warn(`Long path detected (${totalPathDistance.toFixed(2)} units) from ${startId} to ${endId}`);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private calculatePathDistance(path: NodeId[]): number {
|
||||
let totalDistance = 0;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const currentNode = this.graph[path[i]!];
|
||||
const nextNode = this.graph[path[i + 1]!];
|
||||
if (isValidNode(currentNode) && isValidNode(nextNode)) {
|
||||
totalDistance += calculateDistance(currentNode, nextNode);
|
||||
}
|
||||
}
|
||||
return totalDistance;
|
||||
}
|
||||
|
||||
private shouldUseDirectPath(start: MapNode, end: MapNode): boolean {
|
||||
const distance = calculateDistance(start, end);
|
||||
return distance <= DIRECT_PATH_THRESHOLD;
|
||||
}
|
||||
|
||||
private getMinDistanceNode(distances: DistanceMap, unvisited: Set<NodeId>): NodeId | null {
|
||||
let minDistance = Infinity;
|
||||
let minNode: NodeId | null = null;
|
||||
|
||||
unvisited.forEach(nodeId => {
|
||||
const distance = distances[nodeId] ?? Infinity;
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
minNode = nodeId;
|
||||
}
|
||||
});
|
||||
|
||||
return minNode;
|
||||
}
|
||||
|
||||
private reconstructPath(previous: PreviousMap, startId: NodeId, endId: NodeId): NodeId[] {
|
||||
const path: NodeId[] = [];
|
||||
let currentNode: NodeId | null = endId;
|
||||
|
||||
// Keep track of visited nodes to prevent infinite loops
|
||||
const visited = new Set<NodeId>();
|
||||
|
||||
while (currentNode !== null) {
|
||||
// Prevent infinite loops
|
||||
if (visited.has(currentNode)) {
|
||||
throw new PathFindingError('Circular path detected during reconstruction');
|
||||
}
|
||||
visited.add(currentNode);
|
||||
|
||||
path.unshift(currentNode);
|
||||
const prevNode: NodeId | null = previous[currentNode] ?? null;
|
||||
|
||||
// If we can't find the previous node and we haven't reached the start,
|
||||
// then the path is broken
|
||||
if (prevNode === undefined) {
|
||||
throw new PathFindingError('Path reconstruction failed: broken path chain');
|
||||
}
|
||||
|
||||
currentNode = prevNode;
|
||||
}
|
||||
|
||||
// Verify that we actually found a path to the start
|
||||
if (path[0] !== startId) {
|
||||
throw new PathFindingError('No valid path found between the specified nodes');
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
86
src/views/components/map/PathStats.tsx
Normal file
86
src/views/components/map/PathStats.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
|
||||
import { graphNodes } from './graphNodes';
|
||||
import { isValidNode, PIXELS_TO_FEET, WALKING_SPEED } from './types';
|
||||
|
||||
type PathStatsProps = {
|
||||
startId: string;
|
||||
endId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the direct path statistics between two nodes.
|
||||
*
|
||||
* @param startId - The ID of the starting node.
|
||||
* @param endId - The ID of the ending node.
|
||||
*
|
||||
* @returns The distance in feet and walking time in minutes between the two nodes.
|
||||
*/
|
||||
export const calcDirectPathStats = ({ startId, endId }: PathStatsProps) => {
|
||||
const startNode = graphNodes[startId];
|
||||
const endNode = graphNodes[endId];
|
||||
|
||||
if (!isValidNode(startNode) || !isValidNode(endNode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate distance in pixels
|
||||
const distanceInPixels = Math.sqrt((endNode.x - startNode.x) ** 2 + (endNode.y - startNode.y) ** 2);
|
||||
|
||||
// Convert to feet and calculate time
|
||||
const distanceInFeet = Math.round(distanceInPixels * PIXELS_TO_FEET);
|
||||
const walkingTimeMinutes = Math.ceil(distanceInFeet / WALKING_SPEED);
|
||||
|
||||
return {
|
||||
distanceInFeet,
|
||||
walkingTimeMinutes,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Component to display statistics about a path between two nodes on a map.
|
||||
*
|
||||
* @param startId - The ID of the starting node.
|
||||
* @param endId - The ID of the ending node.
|
||||
*
|
||||
* @returns A JSX element displaying the path statistics, or null if the nodes are invalid.
|
||||
*/
|
||||
export const PathStats = ({ startId, endId }: PathStatsProps): JSX.Element | null => {
|
||||
const startNode = graphNodes[startId];
|
||||
const endNode = graphNodes[endId];
|
||||
|
||||
if (!isValidNode(startNode) || !isValidNode(endNode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate distance in pixels
|
||||
const distanceInPixels = Math.sqrt((endNode.x - startNode.x) ** 2 + (endNode.y - startNode.y) ** 2);
|
||||
|
||||
// Convert to feet and calculate time
|
||||
const distanceInFeet = Math.round(distanceInPixels * PIXELS_TO_FEET);
|
||||
const walkingTimeMinutes = Math.ceil(distanceInFeet / WALKING_SPEED);
|
||||
|
||||
return (
|
||||
<div className='rounded-md bg-white/90 p-3 shadow-sm space-y-2'>
|
||||
<h3 className='text-sm font-medium'>Path Statistics</h3>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex justify-between text-xs'>
|
||||
<span>Distance:</span>
|
||||
<span className='font-medium'>{distanceInFeet} ft</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-xs'>
|
||||
<span>Walking Time:</span>
|
||||
<span className='font-medium'>{walkingTimeMinutes} min</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-xs'>
|
||||
<span>From:</span>
|
||||
<span className='font-medium'>{startId}</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-xs'>
|
||||
<span>To:</span>
|
||||
<span className='font-medium'>{endId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
36
src/views/components/map/TimeWarningLabel.tsx
Normal file
36
src/views/components/map/TimeWarningLabel.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TimeWarningLabelProps {
|
||||
x: number;
|
||||
y: number;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* TimeWarningLabel component that renders a warning label on a map.
|
||||
* The label consists of a circle with a text inside it, indicating the number of minutes.
|
||||
*
|
||||
* @param x - The x-coordinate for the center of the circle.
|
||||
* @param y - The y-coordinate for the center of the circle.
|
||||
* @param minutes - The number of minutes to display inside the circle.
|
||||
*
|
||||
* @returns A JSX element representing the warning label.
|
||||
*/
|
||||
export default function TimeWarningLabel({ x, y, minutes }: TimeWarningLabelProps): JSX.Element {
|
||||
return (
|
||||
<g>
|
||||
<circle cx={x} cy={y} r={12} fill='white' stroke='#FF4444' strokeWidth={2} />
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
textAnchor='middle'
|
||||
dominantBaseline='middle'
|
||||
fill='#FF4444'
|
||||
fontSize='10'
|
||||
fontWeight='bold'
|
||||
>
|
||||
{minutes}'
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
84
src/views/components/map/ZoomPanControls.tsx
Normal file
84
src/views/components/map/ZoomPanControls.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '../common/Button';
|
||||
|
||||
interface ZoomPanControlsProps {
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
resetZoomPan: () => void;
|
||||
zoomLevel: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ZoomPanControls component provides buttons for zooming and panning.
|
||||
*
|
||||
* @param zoomIn - Function to zoom in the map.
|
||||
* @param zoomOut - Function to zoom out the map.
|
||||
* @param resetZoomPan - Function to reset zoom and pan to default.
|
||||
* @param zoomLevel - Current zoom level.
|
||||
*
|
||||
* @returns The rendered ZoomPanControls component.
|
||||
*/
|
||||
export default function ZoomPanControls({
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoomPan,
|
||||
zoomLevel,
|
||||
}: ZoomPanControlsProps): JSX.Element {
|
||||
return (
|
||||
<div className='flex gap-2 rounded-md bg-white/90 p-2 shadow-sm'>
|
||||
<Button onClick={zoomIn} color='ut-burntorange' variant='minimal' size='mini' className='px-3 py-1'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='16'
|
||||
height='16'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
<circle cx='11' cy='11' r='8' />
|
||||
<line x1='21' y1='21' x2='16.65' y2='16.65' />
|
||||
<line x1='11' y1='8' x2='11' y2='14' />
|
||||
<line x1='8' y1='11' x2='14' y2='11' />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button onClick={zoomOut} color='ut-burntorange' variant='minimal' size='mini' className='px-3 py-1'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='16'
|
||||
height='16'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
<circle cx='11' cy='11' r='8' />
|
||||
<line x1='21' y1='21' x2='16.65' y2='16.65' />
|
||||
<line x1='8' y1='11' x2='14' y2='11' />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button onClick={resetZoomPan} color='ut-burntorange' variant='minimal' size='mini' className='px-3 py-1'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='16'
|
||||
height='16'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
<path d='M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z' />
|
||||
<polyline points='9 22 9 12 15 12 15 22' />
|
||||
</svg>
|
||||
</Button>
|
||||
<span className='flex items-center text-xs font-medium'>{Math.round(zoomLevel * 100)}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1052
src/views/components/map/graphNodes.ts
Normal file
1052
src/views/components/map/graphNodes.ts
Normal file
File diff suppressed because it is too large
Load Diff
124
src/views/components/map/types.ts
Normal file
124
src/views/components/map/types.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// Constants
|
||||
export const WALKING_SPEED = 246.4; // ~2.8 mph in feet per minute
|
||||
export const PIXELS_TO_FEET = 9.3895; // Approximate scale factor
|
||||
|
||||
export const DIRECT_PATH_THRESHOLD = 50; // Units for direct path calculation
|
||||
export const NEIGHBOR_DISTANCE_THRESHOLD = 25; // Increased threshold for neighbor connections
|
||||
|
||||
/**
|
||||
* Represents the type of a node in the map.
|
||||
*
|
||||
* - 'building': A node representing a building.
|
||||
* - 'intersection': A node representing an intersection.
|
||||
* - 'walkway': A node representing a walkway.
|
||||
*/
|
||||
export type NodeType = 'building' | 'intersection' | 'walkway';
|
||||
|
||||
/**
|
||||
* Represents the coordinates of a node on a map.
|
||||
*
|
||||
* @typeparam x - The x-coordinate of the node.
|
||||
* @typeparam y - The y-coordinate of the node.
|
||||
*/
|
||||
export type NodeCoordinates = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a map node with specific coordinates and a type.
|
||||
*
|
||||
* @typeparam type - The type of the node.
|
||||
*/
|
||||
export type MapNode = NodeCoordinates & {
|
||||
type: NodeType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a graph structure where each key is a string identifier
|
||||
* and the value is a MapNode. This type is used to define the overall
|
||||
* structure of a graph in the application.
|
||||
*/
|
||||
export type Graph = Record<string, MapNode>;
|
||||
|
||||
/**
|
||||
* Represents a distance measurement.
|
||||
*/
|
||||
export type Distance = number;
|
||||
|
||||
/**
|
||||
* Represents the unique identifier for a node in the map.
|
||||
*/
|
||||
export type NodeId = string;
|
||||
|
||||
/**
|
||||
* A map that associates a node identifier with a distance.
|
||||
*
|
||||
* @typeparam NodeId - The unique identifier for a node.
|
||||
* @typeparam Distance - The distance associated with the node.
|
||||
*/
|
||||
export type DistanceMap = Record<NodeId, Distance>;
|
||||
|
||||
/**
|
||||
* A type representing a mapping of node identifiers to their previous node identifiers.
|
||||
*
|
||||
* @typeparam NodeId - The identifier of the current node.
|
||||
* @typeparam NodeId - The identifier of the previous node, or null if there is no previous node.
|
||||
*/
|
||||
export type PreviousMap = Record<NodeId, NodeId | null>;
|
||||
|
||||
/**
|
||||
* type guard to check if the given node is a valid MapNode.
|
||||
*
|
||||
* A valid MapNode is defined as:
|
||||
* - Not undefined
|
||||
* - Has numeric `x` and `y` properties
|
||||
* - Has a `type` property that is either 'building', 'intersection', or 'walkway'
|
||||
*
|
||||
* @param node - The node to validate.
|
||||
* @returns True if the node is a valid MapNode, false otherwise.
|
||||
*/
|
||||
export const isValidNode = (node: MapNode | undefined): node is MapNode =>
|
||||
node !== undefined &&
|
||||
typeof node.x === 'number' &&
|
||||
typeof node.y === 'number' &&
|
||||
(node.type === 'building' || node.type === 'intersection' || node.type === 'walkway');
|
||||
|
||||
/**
|
||||
* Represents the code for a day of the week.
|
||||
*
|
||||
* - 'M' : Monday
|
||||
* - 'T' : Tuesday
|
||||
* - 'W' : Wednesday
|
||||
* - 'TH' : Thursday
|
||||
* - 'F' : Friday
|
||||
*/
|
||||
export type DayCode = 'M' | 'T' | 'W' | 'TH' | 'F';
|
||||
|
||||
/**
|
||||
* An array of strings representing the days of the week.
|
||||
* The days are ordered starting from Monday to Sunday.
|
||||
*/
|
||||
export const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] as const;
|
||||
|
||||
/**
|
||||
* Represents a day of the week.
|
||||
*
|
||||
* @remarks
|
||||
* This type is derived from the `DAYS` array, representing one of its elements.
|
||||
* It is used to ensure that only valid days of the week are assigned to variables of this type.
|
||||
*/
|
||||
export type DAY = (typeof DAYS)[number];
|
||||
|
||||
type DayMapping = Record<DayCode, DAY>;
|
||||
|
||||
/**
|
||||
* A constant object that maps single-letter day abbreviations to their full names.
|
||||
*/
|
||||
export const DAY_MAPPING = {
|
||||
M: 'Monday',
|
||||
T: 'Tuesday',
|
||||
W: 'Wednesday',
|
||||
TH: 'Thursday',
|
||||
F: 'Friday',
|
||||
} as const satisfies DayMapping;
|
||||
111
src/views/components/map/utils.ts
Normal file
111
src/views/components/map/utils.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { graphNodes } from './graphNodes';
|
||||
import type { Graph, MapNode, NodeCoordinates, NodeId } from './types';
|
||||
import { isValidNode, NEIGHBOR_DISTANCE_THRESHOLD } from './types';
|
||||
|
||||
/**
|
||||
* Calculates the Euclidean distance between two points.
|
||||
*
|
||||
* @param point1 - The coordinates of the first point.
|
||||
* @param point2 - The coordinates of the second point.
|
||||
* @returns The distance between the two points.
|
||||
*/
|
||||
export const calculateDistance = (point1: NodeCoordinates, point2: NodeCoordinates): number =>
|
||||
Math.sqrt((point2.x - point1.x) ** 2 + (point2.y - point1.y) ** 2);
|
||||
|
||||
/**
|
||||
* Finds the nearest nodes to a given node in a graph.
|
||||
*
|
||||
* @param nodeId - The ID of the node for which to find the nearest nodes.
|
||||
* @param graph - The graph containing all nodes.
|
||||
* @returns An array of node IDs representing the nearest nodes.
|
||||
*
|
||||
* The function first checks if the current node is valid. If not, it returns an empty array.
|
||||
* It then calculates the distances to all other valid nodes in the graph.
|
||||
* The nodes are sorted by distance, and the function first attempts to connect to the nearest intersections.
|
||||
* If no intersections are found, it connects to the nearest buildings.
|
||||
*/
|
||||
const findNearestNodes = (nodeId: NodeId, graph: Graph): NodeId[] => {
|
||||
const currentNode = graph[nodeId];
|
||||
if (!isValidNode(currentNode)) return [];
|
||||
|
||||
// Calculate distances to all other nodes
|
||||
const distances = Object.entries(graph)
|
||||
.filter((entry): entry is [string, MapNode] => {
|
||||
const [id, node] = entry;
|
||||
return id !== nodeId && isValidNode(node);
|
||||
})
|
||||
.map(([id, node]) => ({
|
||||
id,
|
||||
distance: calculateDistance(currentNode, node),
|
||||
isIntersection: node.type === 'intersection',
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
// First try to connect to nearest intersections
|
||||
const nearestIntersections = distances
|
||||
.filter(node => node.isIntersection)
|
||||
.slice(0, 2)
|
||||
.map(node => node.id);
|
||||
|
||||
if (nearestIntersections.length > 0) {
|
||||
return nearestIntersections;
|
||||
}
|
||||
|
||||
// If no intersections found, connect to nearest buildings
|
||||
return distances.slice(0, 2).map(node => node.id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the neighboring nodes of a given node within a graph.
|
||||
*
|
||||
* @param nodeId - The ID of the node for which neighbors are to be found.
|
||||
* @param graph - The graph containing all nodes and their connections.
|
||||
* @returns An array of node IDs representing the neighbors of the given node.
|
||||
*
|
||||
* This function first checks if the current node is valid. If not, it returns an empty array.
|
||||
* It then filters the graph to find all valid neighboring nodes within a specified distance threshold.
|
||||
* If no direct neighbors are found, it attempts to connect to the nearest intersection or building.
|
||||
*/
|
||||
export const getNeighbors = (nodeId: NodeId, graph: Graph): NodeId[] => {
|
||||
const currentNode = graph[nodeId];
|
||||
if (!isValidNode(currentNode)) return [];
|
||||
|
||||
// Get all possible neighbors within the increased threshold
|
||||
const neighbors = Object.entries(graph)
|
||||
.filter((entry): entry is [string, MapNode] => {
|
||||
const [id, node] = entry;
|
||||
if (!isValidNode(node) || id === nodeId) return false;
|
||||
const distance = calculateDistance(currentNode, node);
|
||||
return distance < NEIGHBOR_DISTANCE_THRESHOLD;
|
||||
})
|
||||
.map(([id]) => id);
|
||||
|
||||
// If no direct neighbors found, connect to the nearest intersection or building
|
||||
if (neighbors.length === 0) {
|
||||
const nearestNodes = findNearestNodes(nodeId, graph);
|
||||
return nearestNodes;
|
||||
}
|
||||
|
||||
return neighbors;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the midpoint between two nodes identified by their IDs.
|
||||
*
|
||||
* @param startId - The ID of the starting node.
|
||||
* @param endId - The ID of the ending node.
|
||||
* @returns An object containing the x and y coordinates of the midpoint, or null if either node is invalid.
|
||||
*/
|
||||
export const getMidpoint = (startId: string, endId: string) => {
|
||||
const startNode = graphNodes[startId];
|
||||
const endNode = graphNodes[endId];
|
||||
|
||||
if (!isValidNode(startNode) || !isValidNode(endNode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: (startNode.x + endNode.x) / 2,
|
||||
y: (startNode.y + endNode.y) / 2,
|
||||
};
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { CalendarDots, Trash } from '@phosphor-icons/react';
|
||||
import { background } from '@shared/messages';
|
||||
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
|
||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||
import { CRX_PAGES } from '@shared/types/CRXPages';
|
||||
import MIMEType from '@shared/types/MIMEType';
|
||||
import { downloadBlob } from '@shared/util/downloadBlob';
|
||||
// import { addCourseByUrl } from '@shared/util/courseUtils';
|
||||
@@ -507,6 +508,60 @@ export default function Settings(): JSX.Element {
|
||||
<h2 className='mb-4 text-xl text-ut-black font-semibold' onClick={toggleDevMode}>
|
||||
Developer Mode
|
||||
</h2>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='max-w-xs'>
|
||||
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
||||
UTRP Map
|
||||
</Text>
|
||||
<span className='mx-2 border border-ut-burntorange rounded px-2 py-0.5 text-xs text-ut-burntorange font-medium'>
|
||||
BETA
|
||||
</span>
|
||||
<p className='text-sm text-gray-600'>
|
||||
Navigate campus efficiently with our interactive map tool that integrates with your
|
||||
schedule
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='ut-burntorange'
|
||||
onClick={() => {
|
||||
const mapPageUrl = chrome.runtime.getURL(CRX_PAGES.MAP);
|
||||
background.openNewTab({ url: mapPageUrl });
|
||||
}}
|
||||
>
|
||||
Try UTRP Map
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Divider size='auto' orientation='horizontal' />
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='max-w-xs'>
|
||||
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
||||
Debug Page
|
||||
</Text>
|
||||
<span className='mx-2 border border-ut-gray rounded px-2 py-0.5 text-xs text-ut-gray font-medium'>
|
||||
DEV
|
||||
</span>
|
||||
<p className='text-sm text-gray-600'>
|
||||
Open the developer debug page to view extension storage and debug logs
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='ut-burntorange'
|
||||
onClick={() => {
|
||||
const debugPageUrl = chrome.runtime.getURL(CRX_PAGES.DEBUG);
|
||||
background.openNewTab({ url: debugPageUrl });
|
||||
}}
|
||||
>
|
||||
Open Debug Page
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Divider size='auto' orientation='horizontal' />
|
||||
|
||||
<Button variant='filled' color='ut-black' onClick={() => addCourseByURL(activeSchedule)}>
|
||||
Add course by link
|
||||
</Button>
|
||||
|
||||
@@ -168,6 +168,7 @@ export default defineConfig({
|
||||
renameFile('src/pages/options/index.html', 'options.html'),
|
||||
renameFile('src/pages/calendar/index.html', 'calendar.html'),
|
||||
renameFile('src/pages/report/index.html', 'report.html'),
|
||||
renameFile('src/pages/map/index.html', 'map.html'),
|
||||
renameFile('src/pages/404/index.html', '404.html'),
|
||||
vitePluginRunCommandOnDemand({
|
||||
// afterServerStart: 'pnpm gulp forceDisableUseDynamicUrl',
|
||||
@@ -225,6 +226,10 @@ export default defineConfig({
|
||||
target: 'http://localhost:5173',
|
||||
rewrite: path => path.replace('report', 'src/pages/report/index'),
|
||||
},
|
||||
'/map.html': {
|
||||
target: 'http://localhost:5173',
|
||||
rewrite: path => path.replace('map', 'src/pages/map/index'),
|
||||
},
|
||||
'/404.html': {
|
||||
target: 'http://localhost:5173',
|
||||
rewrite: path => path.replace('404', 'src/pages/404/index'),
|
||||
@@ -244,6 +249,7 @@ export default defineConfig({
|
||||
calendar: 'src/pages/calendar/index.html',
|
||||
options: 'src/pages/options/index.html',
|
||||
report: 'src/pages/report/index.html',
|
||||
map: 'src/pages/map/index.html',
|
||||
404: 'src/pages/404/index.html',
|
||||
},
|
||||
output: {
|
||||
|
||||
Reference in New Issue
Block a user