From 2d413977092dac313aa07199624d0423e1a1c5a3 Mon Sep 17 00:00:00 2001 From: khalillabban Date: Sun, 22 Mar 2026 23:24:34 -0400 Subject: [PATCH 01/15] Added accessibility routing functionality --- .vscode/tasks.json | 92 ++++++++++++++++++++++++ __tests__/CampusMapScreen.test.tsx | 36 +++++++++- __tests__/NavigationBar.test.tsx | 75 ++++++++++++++----- __tests__/NavigationBarCoverage.test.tsx | 1 + app/CampusMapScreen.tsx | 12 +++- app/IndoorMapScreen.tsx | 17 +++-- components/IndoorRouteOverlay.tsx | 9 ++- components/NavigationBar.tsx | 32 +++++++++ 8 files changed, 247 insertions(+), 27 deletions(-) create mode 100644 .vscode/tasks.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..d613ece --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,92 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run npm test", + "type": "shell", + "command": "npm", + "args": [ + "test" + ], + "isBackground": false, + "problemMatcher": [ + "$mocha" + ], + "group": "test" + }, + { + "label": "Run npm test after test updates", + "type": "shell", + "command": "npm", + "args": [ + "test" + ], + "isBackground": false, + "problemMatcher": [ + "$mocha" + ], + "group": "test" + }, + { + "label": "Run npm test after fixing accessibility label tests", + "type": "shell", + "command": "npm", + "args": [ + "test" + ], + "isBackground": false, + "problemMatcher": [ + "$mocha" + ], + "group": "test" + }, + { + "label": "Run npm test after fixing test nesting and params", + "type": "shell", + "command": "npm", + "args": [ + "test" + ], + "isBackground": false, + "problemMatcher": [ + "$mocha" + ], + "group": "test" + }, + { + "label": "Run npm test with verbose output for diagnostics", + "type": "shell", + "command": "npm", + "args": [ + "test", + "--", + "--verbose" + ], + "isBackground": false, + "problemMatcher": [ + "$mocha" + ], + "group": "test" + }, + { + "label": "Run Jest tests with verbose output", + "type": "shell", + "command": "npm test -- --verbose", + "isBackground": false, + "problemMatcher": [ + "$tsc" + ], + "group": "test" + }, + { + "label": "Run Jest tests with verbose output", + "type": "shell", + "command": "npm test -- --verbose", + "isBackground": false, + "problemMatcher": [ + "$tsc" + ], + "group": "test" + } + ] +} \ No newline at end of file diff --git a/__tests__/CampusMapScreen.test.tsx b/__tests__/CampusMapScreen.test.tsx index 03ed751..66a179e 100644 --- a/__tests__/CampusMapScreen.test.tsx +++ b/__tests__/CampusMapScreen.test.tsx @@ -198,12 +198,25 @@ jest.mock("../components/NavigationBar", () => { mode: "walking", label: "Walk", icon: "walk", - }) + }, null, null, false) } > Confirm + + props.onConfirm("H", "MB", { + mode: "walking", + label: "Walk", + icon: "walk", + }, null, null, true) + } + > + Confirm Accessible + + @@ -211,7 +224,7 @@ jest.mock("../components/NavigationBar", () => { mode: "walking", label: "Walk", icon: "walk", - }) + }, null, null, false) } > Confirm Null Start @@ -917,6 +930,7 @@ describe("CampusMapScreen", () => { floors: JSON.stringify([1, 2, 8]), navOrigin: "H-110", navDest: "H-920", + accessibleOnly: "false", }, }); expect(screen.getByTestId("nav-visible").props.children).toBe("hidden"); @@ -934,6 +948,7 @@ describe("CampusMapScreen", () => { buildingName: "H", floors: JSON.stringify([1, 2, 8]), roomQuery: "H-920", + accessibleOnly: "false", }, }); expect(screen.getByTestId("nav-visible").props.children).toBe("hidden"); @@ -1140,6 +1155,7 @@ describe("CampusMapScreen", () => { buildingName: "MB", floors: JSON.stringify([1, -2]), roomQuery: "MB-1.210", + accessibleOnly: "false", }, }); }); @@ -1156,6 +1172,7 @@ describe("CampusMapScreen", () => { params: { buildingName: "H", floors: JSON.stringify([1, 2, 8]), + accessibleOnly: "false", }, }); }); @@ -1295,10 +1312,24 @@ describe("CampusMapScreen", () => { params: { buildingName: "H", floors: JSON.stringify([1, 2, 8]), + accessibleOnly: "false", }, }); }); + it("passes accessibleOnly true to IndoorMapScreen when accessibility mode is on", async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({}); + await renderScreen(); + fireEvent.press(screen.getByTestId("nav-confirm-accessible")); + fireEvent.press(screen.getByTestId("trigger-popup-open-indoor")); + expect(router.push).toHaveBeenCalledWith({ + pathname: "/IndoorMapScreen", + params: expect.objectContaining({ + accessibleOnly: "true", + }), + }); + }); + it("uses the same normalized indoor route when opening from the building popup flow", async () => { (useLocalSearchParams as jest.Mock).mockReturnValue({}); await renderScreen(); @@ -1310,6 +1341,7 @@ describe("CampusMapScreen", () => { params: { buildingName: "H", floors: JSON.stringify([1, 2, 8]), + accessibleOnly: "false", }, }); }); diff --git a/__tests__/NavigationBar.test.tsx b/__tests__/NavigationBar.test.tsx index 7517953..04bb312 100644 --- a/__tests__/NavigationBar.test.tsx +++ b/__tests__/NavigationBar.test.tsx @@ -216,7 +216,8 @@ describe("NavigationBar", () => { null, // dest expect.objectContaining({ mode: "driving", label: "Car" }), null, // startRoom - null // endRoom + null, // endRoom + false ); }); }); @@ -440,7 +441,8 @@ describe("NavigationBar", () => { }), expect.objectContaining({ mode: "walking", label: "Walk", icon: "walk" }), null, - null + null, + false ); }); @@ -476,33 +478,45 @@ describe("NavigationBar", () => { null, expect.objectContaining({ mode: "walking", label: "Walk", icon: "walk" }), null, - null + null, + false ); }); - }); - describe("Overlay Interaction", () => { - it("should dismiss keyboard when overlay is pressed", () => { - const dismissSpy = jest.spyOn(Keyboard, "dismiss"); - - const { getByTestId, UNSAFE_getAllByType } = render( + it("should not crash when autoStartBuilding is null", () => { + const { getByPlaceholderText } = render( , ); - const touchables = UNSAFE_getAllByType( - require("react-native").TouchableWithoutFeedback, - ); - - if (touchables.length > 0) { - fireEvent.press(touchables[0]); - expect(dismissSpy).toHaveBeenCalled(); - } + expect(getByPlaceholderText(/From/).props.value).toBe(""); }); + it("should dismiss keyboard when overlay is pressed", () => { + const dismissSpy = jest.spyOn(Keyboard, "dismiss"); + + const { UNSAFE_getAllByType } = render( + , + ); + + const touchables = UNSAFE_getAllByType( + require("react-native").TouchableWithoutFeedback, + ); + + if (touchables.length > 0) { + fireEvent.press(touchables[0]); + expect(dismissSpy).toHaveBeenCalled(); + } + }); + it("should call onClose when overlay is pressed", () => { const { UNSAFE_getAllByType } = render( { /> ); + // Use the correct accessibility label for the building picker button const listButton = getAllByLabelText("Pick from list")[0]; fireEvent.press(listButton); @@ -1157,7 +1172,29 @@ describe("NavigationBar", () => { null, expect.objectContaining({ mode: "walking", label: "Walk", icon: "walk" }), null, - null + null, + false + ); + }); + + it("should call onConfirm with accessibleOnly true when accessible route toggle is on", () => { + const { getByTestId, getByText } = render( + , + ); + // Toggle accessible route + fireEvent.press(getByTestId("accessible-mode-toggle")); + fireEvent.press(getByText("Get Directions")); + expect(mockOnConfirm).toHaveBeenCalledWith( + null, + null, + expect.objectContaining({ mode: "walking", label: "Walk", icon: "walk" }), + null, + null, + true ); }); @@ -1240,6 +1277,7 @@ describe("NavigationBar", () => { /> ); + // Use the correct accessibility label for the building picker button const listButton = getAllByLabelText("Pick from list")[0]; // First press: opens picker @@ -1262,6 +1300,7 @@ describe("NavigationBar", () => { /> ); + // Use the correct accessibility label for the building picker button const listButton = getAllByLabelText("Pick from list")[1]; fireEvent.press(listButton); diff --git a/__tests__/NavigationBarCoverage.test.tsx b/__tests__/NavigationBarCoverage.test.tsx index b6b84d5..9262b48 100644 --- a/__tests__/NavigationBarCoverage.test.tsx +++ b/__tests__/NavigationBarCoverage.test.tsx @@ -135,6 +135,7 @@ describe("NavigationBar coverage branches", () => { expect.objectContaining({ mode: "walking" }), expect.objectContaining({ label: "H-110" }), expect.objectContaining({ label: "H-220" }), + false, ); expect(onClose).toHaveBeenCalledTimes(1); }, 15000); diff --git a/app/CampusMapScreen.tsx b/app/CampusMapScreen.tsx index b9c8a58..03430d3 100644 --- a/app/CampusMapScreen.tsx +++ b/app/CampusMapScreen.tsx @@ -39,6 +39,8 @@ function normalizeRoomQuery(buildingCode: string, room: string): string { } export default function CampusMapScreen() { + // Accessibility mode state + const [accessibleOnly, setAccessibleOnly] = useState(false); const { campus } = useLocalSearchParams<{ campus?: CampusKey }>(); const findNearestBuilding = useCallback((lat: number, lon: number) => { @@ -136,6 +138,7 @@ export default function CampusMapScreen() { roomQuery?: string, navOrigin?: string, navDest?: string, + accessibleOnlyOverride?: boolean ) => { const params = buildIndoorMapRouteParams(buildingCode, roomQuery); if (!params) return; @@ -145,10 +148,13 @@ export default function CampusMapScreen() { ...params, ...(navOrigin ? { navOrigin } : {}), ...(navDest ? { navDest } : {}), + accessibleOnly: String( + accessibleOnlyOverride !== undefined ? accessibleOnlyOverride : accessibleOnly + ), }, }); }, - [], + [accessibleOnly], ); const handleConfirmRoute = useCallback( @@ -158,6 +164,7 @@ export default function CampusMapScreen() { strategy: RouteStrategy, startRoom?: IndoorRoomRecord | null, endRoom?: IndoorRoomRecord | null, + accessible?: boolean ) => { if (start?.name && dest?.name && start.name === dest.name && startRoom && endRoom) { setIsNavVisible(false); @@ -175,6 +182,7 @@ export default function CampusMapScreen() { setSelectedStrategy(strategy); setIsNavVisible(false); setRouteFocusTrigger((c) => (start ? c + 1 : c)); + setAccessibleOnly(!!accessible); }, [openIndoorMap], ); @@ -410,6 +418,8 @@ export default function CampusMapScreen() { onInitialDestinationApplied={() => setInitialDestination(null)} currentCampus={currentCampus} onUseMyLocation={() => demoCurrentBuilding ?? autoStartBuilding ?? null} + accessibleOnly={accessibleOnly} + onAccessibleOnlyChange={setAccessibleOnly} /> (); + // Accessibility mode state + const [accessibleOnly, setAccessibleOnly] = useState( + accessibleOnlyParam === 'true' + ); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const availableFloors = useMemo(() => parseFloors(floors), [floors]); const [selectedFloor, setSelectedFloor] = useState(availableFloors[0] || 1); @@ -364,7 +369,7 @@ export default function IndoorMapScreen() { navOriginQuery, navDestQuery, { - accessibleOnly: false, + accessibleOnly: accessibleOnly, }, ); @@ -375,7 +380,7 @@ export default function IndoorMapScreen() { setNavError(result.message); setActiveRoute(null); } - }, [buildingName, navOriginQuery, navDestQuery]); + }, [buildingName, navOriginQuery, navDestQuery, accessibleOnly]); useEffect(() => { if ( @@ -428,7 +433,11 @@ export default function IndoorMapScreen() { {navError && ( - {navError} + + {accessibleOnly && navError.includes('No path found') + ? 'No accessible route exists between the selected rooms.' + : navError} + )} diff --git a/components/IndoorRouteOverlay.tsx b/components/IndoorRouteOverlay.tsx index 9f19fb9..13dd586 100644 --- a/components/IndoorRouteOverlay.tsx +++ b/components/IndoorRouteOverlay.tsx @@ -31,6 +31,8 @@ interface IndoorRouteOverlayProps { const ROUTE_COLOR = "#3B82F6"; const ROUTE_COLOR_ALPHA = "#3B82F640"; +const ACCESSIBLE_ROUTE_COLOR = "#2563eb"; // blue-600 +const ACCESSIBLE_ROUTE_COLOR_ALPHA = "#2563eb40"; const DESTINATION_COLOR = "#EF4444"; const ORIGIN_COLOR = "#22C55E"; const STROKE_WIDTH = 3.5; @@ -66,11 +68,14 @@ export function IndoorRouteOverlay({ if (!points || !originPoint) return null; + const isAccessible = route.fullyAccessible; + const mainColor = isAccessible ? ACCESSIBLE_ROUTE_COLOR : ROUTE_COLOR; + const alphaColor = isAccessible ? ACCESSIBLE_ROUTE_COLOR_ALPHA : ROUTE_COLOR_ALPHA; return ( void; autoStartBuilding?: Buildings | null; initialStart?: Buildings | null; @@ -110,6 +111,8 @@ interface NavigationBarProps { onInitialDestinationApplied?: () => void; currentCampus?: CampusKey; onUseMyLocation?: () => Buildings | null; + accessibleOnly?: boolean; + onAccessibleOnlyChange?: (value: boolean) => void; } export default function NavigationBar({ @@ -123,6 +126,8 @@ export default function NavigationBar({ onInitialDestinationApplied, currentCampus = "sgw", onUseMyLocation, + accessibleOnly = false, + onAccessibleOnlyChange, }: Readonly) { const translateY = useRef(new Animated.Value(SCREEN_HEIGHT)).current; const [shouldRender, setShouldRender] = useState(visible); @@ -145,6 +150,7 @@ export default function NavigationBar({ distance?: string; } | null>(null); const [routeSummaryLoading, setRouteSummaryLoading] = useState(false); + const [localAccessibleOnly, setLocalAccessibleOnly] = useState(accessibleOnly); const search = useCallback((text: string) => { if (!text.trim()) { @@ -247,6 +253,7 @@ export default function NavigationBar({ selectedStrategy, startRoom, endRoom, + localAccessibleOnly ); onClose(); }; @@ -534,6 +541,31 @@ export default function NavigationBar({ ); })} + + { + setLocalAccessibleOnly(!localAccessibleOnly); + onAccessibleOnlyChange?.(!localAccessibleOnly); + }} + style={[ + styles.modeButton, + localAccessibleOnly && styles.activeModeButton, + { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12 } + ]} + accessibilityRole="switch" + accessibilityState={{ checked: localAccessibleOnly }} + testID="accessible-mode-toggle" + > + + + Accessible Route + + + {(routeSummaryLoading || routeSummary) && ( {routeSummaryLoading From 188dcf61363d9973ccef660ba19e6fa3a38bce3c Mon Sep 17 00:00:00 2001 From: Jessicuta Date: Mon, 23 Mar 2026 00:44:58 -0400 Subject: [PATCH 02/15] fix the multiple floor issues --- app/CampusMapScreen.tsx | 51 +++++++++++++------ components/NavigationBar.tsx | 52 +++++++++++++++---- utils/indoorNavigation.ts | 99 +++++++++++------------------------- utils/indoorPathFinding.ts | 35 ++++++++----- 4 files changed, 127 insertions(+), 110 deletions(-) diff --git a/app/CampusMapScreen.tsx b/app/CampusMapScreen.tsx index 03430d3..db94ff8 100644 --- a/app/CampusMapScreen.tsx +++ b/app/CampusMapScreen.tsx @@ -18,13 +18,13 @@ import { useShuttleAvailability } from "../hooks/useShuttleAvailability"; import { RouteStrategy } from "../services/Routing"; import { styles } from "../styles/CampusMapScreen.styles"; import { - buildIndoorMapRouteParams, - getIndoorAccessState, + buildIndoorMapRouteParams, + getIndoorAccessState, } from "../utils/indoorAccess"; import { IndoorRoomRecord } from "../utils/indoorBuildingPlan"; import { - getNextClassFromItems, - loadCachedSchedule, + getNextClassFromItems, + loadCachedSchedule, } from "../utils/parseCourseEvents"; import { getDistanceToPolygon } from "../utils/pointInPolygon"; @@ -39,8 +39,8 @@ function normalizeRoomQuery(buildingCode: string, room: string): string { } export default function CampusMapScreen() { - // Accessibility mode state - const [accessibleOnly, setAccessibleOnly] = useState(false); + // Accessibility mode state + const [accessibleOnly, setAccessibleOnly] = useState(false); const { campus } = useLocalSearchParams<{ campus?: CampusKey }>(); const findNearestBuilding = useCallback((lat: number, lon: number) => { @@ -61,9 +61,7 @@ export default function CampusMapScreen() { const [currentCampus, setCurrentCampus] = useState( campus === "loyola" ? "loyola" : "sgw", ); - const [, setSelectedBuilding] = useState( - null, - ); + const [, setSelectedBuilding] = useState(null); const [focusTarget, setFocusTarget] = useState( campus === "loyola" ? "loyola" : "sgw", ); @@ -138,7 +136,7 @@ export default function CampusMapScreen() { roomQuery?: string, navOrigin?: string, navDest?: string, - accessibleOnlyOverride?: boolean + accessibleOnlyOverride?: boolean, ) => { const params = buildIndoorMapRouteParams(buildingCode, roomQuery); if (!params) return; @@ -149,7 +147,9 @@ export default function CampusMapScreen() { ...(navOrigin ? { navOrigin } : {}), ...(navDest ? { navDest } : {}), accessibleOnly: String( - accessibleOnlyOverride !== undefined ? accessibleOnlyOverride : accessibleOnly + accessibleOnlyOverride !== undefined + ? accessibleOnlyOverride + : accessibleOnly, ), }, }); @@ -164,17 +164,37 @@ export default function CampusMapScreen() { strategy: RouteStrategy, startRoom?: IndoorRoomRecord | null, endRoom?: IndoorRoomRecord | null, - accessible?: boolean + accessible?: boolean, ) => { - if (start?.name && dest?.name && start.name === dest.name && startRoom && endRoom) { + setAccessibleOnly(!!accessible); + + if ( + start?.name && + dest?.name && + start.name === dest.name && + startRoom && + endRoom + ) { setIsNavVisible(false); - openIndoorMap(start.name, undefined, startRoom.label, endRoom.label); + openIndoorMap( + start.name, + undefined, + startRoom.label, + endRoom.label, + accessible, + ); return; } if (start?.name && dest?.name && start.name === dest.name && endRoom) { setIsNavVisible(false); - openIndoorMap(dest.name, endRoom.label); + openIndoorMap( + dest.name, + endRoom.label, + undefined, + undefined, + accessible, + ); return; } @@ -182,7 +202,6 @@ export default function CampusMapScreen() { setSelectedStrategy(strategy); setIsNavVisible(false); setRouteFocusTrigger((c) => (start ? c + 1 : c)); - setAccessibleOnly(!!accessible); }, [openIndoorMap], ); diff --git a/components/NavigationBar.tsx b/components/NavigationBar.tsx index 51f4d10..c4c91c7 100644 --- a/components/NavigationBar.tsx +++ b/components/NavigationBar.tsx @@ -31,7 +31,7 @@ import { const { height: SCREEN_HEIGHT } = Dimensions.get("window"); const SHEET_HEIGHT = - Platform.OS === "android" ? SCREEN_HEIGHT * 0.75 : SCREEN_HEIGHT * 0.7; + Platform.OS === "android" ? SCREEN_HEIGHT * 0.8 : SCREEN_HEIGHT * 0.7; const SHEET_TOP = SCREEN_HEIGHT - SHEET_HEIGHT; const SPRING_CONFIG = { useNativeDriver: true, damping: 20, stiffness: 150 }; const MAX_SUGGESTIONS = 20; @@ -102,7 +102,7 @@ interface NavigationBarProps { strategy: RouteStrategy, startRoom?: IndoorRoomRecord | null, endRoom?: IndoorRoomRecord | null, - accessibleOnly?: boolean + accessibleOnly?: boolean, ) => void; autoStartBuilding?: Buildings | null; initialStart?: Buildings | null; @@ -150,7 +150,8 @@ export default function NavigationBar({ distance?: string; } | null>(null); const [routeSummaryLoading, setRouteSummaryLoading] = useState(false); - const [localAccessibleOnly, setLocalAccessibleOnly] = useState(accessibleOnly); + const [localAccessibleOnly, setLocalAccessibleOnly] = + useState(accessibleOnly); const search = useCallback((text: string) => { if (!text.trim()) { @@ -253,7 +254,7 @@ export default function NavigationBar({ selectedStrategy, startRoom, endRoom, - localAccessibleOnly + localAccessibleOnly, ); onClose(); }; @@ -432,7 +433,10 @@ export default function NavigationBar({ Room {startRoom.label} - setStartRoom(null)}> + setStartRoom(null)} + > Room {endRoom.label} - setEndRoom(null)}> + setEndRoom(null)} + > - + { setLocalAccessibleOnly(!localAccessibleOnly); @@ -550,18 +563,35 @@ export default function NavigationBar({ style={[ styles.modeButton, localAccessibleOnly && styles.activeModeButton, - { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12 } + { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 12, + }, ]} accessibilityRole="switch" accessibilityState={{ checked: localAccessibleOnly }} testID="accessible-mode-toggle" > - + Accessible Route diff --git a/utils/indoorNavigation.ts b/utils/indoorNavigation.ts index b69c00c..f1399cb 100644 --- a/utils/indoorNavigation.ts +++ b/utils/indoorNavigation.ts @@ -11,44 +11,29 @@ import { import { findIndoorRoomMatch } from "./indoorRoomSearch"; import { getBuildingPlanAsset } from "./mapAssets"; -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - export type NavigationSegmentKind = - | "walk" // straight hallway walk - | "enter_room" // step through a door into a room - | "exit_room" // leave a room into the hallway - | "stairs" // floor transition via stairs - | "elevator"; // floor transition via elevator + | "walk" + | "enter_room" + | "exit_room" + | "stairs" + | "elevator"; export interface NavigationSegment { kind: NavigationSegmentKind; description: string; - /** Nodes included in this segment (for rendering on the floor plan). */ nodeIds: string[]; - /** Floor this segment takes place on. */ floor: number; - /** Approximate distance of this segment in coordinate units. */ distance: number; } export interface NavigationRoute { - /** Resolved origin room. */ origin: IndoorRoomRecord; - /** Resolved destination room. */ destination: IndoorRoomRecord; - /** Raw pathfinding result. */ path: IndoorPath; - /** Human-readable turn-by-turn segments. */ segments: NavigationSegment[]; - /** Floors the route passes through. */ floors: number[]; - /** Total distance in coordinate units. */ totalDistance: number; - /** True when every edge on the route is wheelchair-accessible. */ fullyAccessible: boolean; - /** Estimated walking time in seconds (assumes ~1.4 coordinate units/sec). */ estimatedSeconds: number; } @@ -63,22 +48,9 @@ export type NavigationResult = | { success: true; route: NavigationRoute } | { success: false; error: NavigationError; message: string }; -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/** Assumed walking speed in coordinate units per second. */ -const WALK_SPEED = 1.4; - -// --------------------------------------------------------------------------- -// Segment builder -// --------------------------------------------------------------------------- +const WALK_SPEED = 1.4; // Average human walking speed in meters per second +const PIXELS_PER_METER = 10; // Scaling factor from map coordinate units to meters -/** - * Convert a raw IndoorPath into human-readable NavigationSegments. - * Groups consecutive hallway waypoints into single "walk" segments and - * identifies room entry/exit and floor transitions. - */ function buildSegments(path: IndoorPath): NavigationSegment[] { const segments: NavigationSegment[] = []; const steps = path.steps; @@ -87,7 +59,7 @@ function buildSegments(path: IndoorPath): NavigationSegment[] { let walkNodeIds: string[] = []; let walkFloor = steps[0].node.floor; - let walkStart = 0; // cumulative distance at start of current walk segment + let walkStart = 0; const flushWalk = (endDistance: number) => { if (walkNodeIds.length < 2) { @@ -95,7 +67,7 @@ function buildSegments(path: IndoorPath): NavigationSegment[] { return; } const dist = endDistance - walkStart; - const meters = Math.round(dist / 10); // rough unit → meters conversion hint + const meters = Math.round(dist / PIXELS_PER_METER); segments.push({ kind: "walk", description: @@ -114,15 +86,25 @@ function buildSegments(path: IndoorPath): NavigationSegment[] { const { node } = step; const prevNode = i > 0 ? steps[i - 1].node : null; - // Floor change if (prevNode && node.floor !== prevNode.floor) { flushWalk(steps[i - 1].cumulativeDistance); walkStart = step.cumulativeDistance; walkFloor = node.floor; - // Detect elevator vs stairs by explicit transition node types + const type1 = (prevNode.type || "").toLowerCase(); + const type2 = (node.type || "").toLowerCase(); + const label1 = (prevNode.label || "").toLowerCase(); + const label2 = (node.label || "").toLowerCase(); + const isElevator = - prevNode.type === "elevator_door" || node.type === "elevator_door"; + prevNode.type === "elevator_door" || + node.type === "elevator_door" || + type1.includes("elevator") || + type2.includes("elevator") || + type1 === "eblock" || + type2 === "eblock" || + label1.includes("elev") || + label2.includes("elev"); segments.push({ kind: isElevator ? "elevator" : "stairs", @@ -138,8 +120,8 @@ function buildSegments(path: IndoorPath): NavigationSegment[] { continue; } - // Room entry (destination) if (node.type === "room" && i === steps.length - 1) { + walkNodeIds.push(node.id); flushWalk(step.cumulativeDistance); segments.push({ kind: "enter_room", @@ -151,7 +133,6 @@ function buildSegments(path: IndoorPath): NavigationSegment[] { continue; } - // Room exit (origin) if (node.type === "room" && i === 0) { segments.push({ kind: "exit_room", @@ -166,17 +147,14 @@ function buildSegments(path: IndoorPath): NavigationSegment[] { continue; } - // Doorway if (node.type === "doorway") { walkNodeIds.push(node.id); continue; } - // Hallway waypoint / building_entry_exit → accumulate walk walkNodeIds.push(node.id); } - // Flush any remaining walk if (walkNodeIds.length >= 2) { flushWalk(steps[steps.length - 1].cumulativeDistance); } @@ -184,17 +162,11 @@ function buildSegments(path: IndoorPath): NavigationSegment[] { return segments; } -// --------------------------------------------------------------------------- -// Main API -// --------------------------------------------------------------------------- - /** - * Compute a navigable indoor route between two rooms in the same building. - * - * @param buildingCode e.g. "CC", "H", "MB" - * @param originQuery Room label / number for the starting room (e.g. "CC-110") - * @param destQuery Room label / number for the destination (e.g. "CC-120") - * @param options Optional pathfinding flags (accessibleOnly, etc.) + * @param buildingCode + * @param originQuery + * @param destQuery + * @param options */ export function getIndoorNavigationRoute( buildingCode: string, @@ -202,7 +174,6 @@ export function getIndoorNavigationRoute( destQuery: string, options: PathfindingOptions = {}, ): NavigationResult { - // 1. Resolve rooms via existing search infrastructure const plan = getNormalizedBuildingPlan(buildingCode); if (!plan) { return { @@ -238,7 +209,6 @@ export function getIndoorNavigationRoute( }; } - // 2. Get the raw graph asset const asset = getBuildingPlanAsset(buildingCode); if (!asset || !asset.edges || asset.edges.length === 0) { return { @@ -248,7 +218,6 @@ export function getIndoorNavigationRoute( }; } - // 3. Map rooms to graph node ids const originNodeId = resolveRoutingNodeId( asset, originMatch.room.id, @@ -272,7 +241,6 @@ export function getIndoorNavigationRoute( }; } - // 4. Run Dijkstra const path = findShortestPath(asset, originNodeId, destNodeId, options); if (!path) { return { @@ -283,7 +251,6 @@ export function getIndoorNavigationRoute( }; } - // 5. Build segments and return const segments = buildSegments(path); return { @@ -296,19 +263,13 @@ export function getIndoorNavigationRoute( floors: path.floors, totalDistance: path.totalDistance, fullyAccessible: path.fullyAccessible, - estimatedSeconds: Math.round(path.totalDistance / WALK_SPEED), + estimatedSeconds: Math.round( + path.totalDistance / PIXELS_PER_METER / WALK_SPEED, + ), }, }; } -// --------------------------------------------------------------------------- -// Convenience: just retrieve ordered path node ids for a floor (for rendering) -// --------------------------------------------------------------------------- - -/** - * Returns the (x, y) waypoints on a specific floor for a computed route. - * Useful for drawing the path SVG overlay on the floor plan image. - */ export function getRouteWaypointsForFloor( route: NavigationRoute, floor: number, diff --git a/utils/indoorPathFinding.ts b/utils/indoorPathFinding.ts index 50942c7..5d4b77d 100644 --- a/utils/indoorPathFinding.ts +++ b/utils/indoorPathFinding.ts @@ -4,10 +4,6 @@ import { BuildingPlanNode, } from "./mapAssets"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - export interface PathNode { id: string; x: number; @@ -124,7 +120,7 @@ function buildAdjacencyList( for (const edge of edges) { // Edges are treated as undirected // Guard against invalid weights - if (!Number.isFinite(edge.weight) || edge.weight <= 0) continue; + if (!Number.isFinite(edge.weight) || edge.weight < 0) continue; add(edge.source, edge.target, edge.weight, edge.accessible); add(edge.target, edge.source, edge.weight, edge.accessible); } @@ -178,7 +174,19 @@ export function findShortestPath( const neighbors = adj.get(currentId) ?? []; for (const { targetId, weight, accessible } of neighbors) { - if (accessibleOnly && !accessible) continue; + const isEdgeAccessible = + accessible !== false && String(accessible) !== "false"; + const targetNode = nodeMap.get(targetId); + const targetType = (targetNode?.type || "").toLowerCase(); + const targetLabel = (targetNode?.label || "").toLowerCase(); + const isStairs = + targetType.includes("stair") || targetLabel.includes("stair"); + const isNodeAccessible = + targetNode?.accessible !== false && + String(targetNode?.accessible) !== "false" && + !isStairs; + + if (accessibleOnly && (!isEdgeAccessible || !isNodeAccessible)) continue; const newCost = currentCost + weight; if (newCost < (dist.get(targetId) ?? Infinity)) { dist.set(targetId, newCost); @@ -188,11 +196,9 @@ export function findShortestPath( } } - // No path found const totalDistance = dist.get(destinationId) ?? Infinity; if (!isFinite(totalDistance)) return null; - // Reconstruct path const pathIds: string[] = []; let cursor: string | null = destinationId; const visited = new Set(); @@ -203,12 +209,10 @@ export function findShortestPath( cursor = prev.get(cursor) ?? null; } - // Build steps let cumulative = 0; let fullyAccessible = true; const floorSet = new Set(); - // Pre-build accessible edge lookup for the reconstruction pass const edgeAccessible = new Map(); for (const edge of asset.edges!) { const key = (a: string, b: string) => `${a}|${b}`; @@ -223,12 +227,14 @@ export function findShortestPath( if (index > 0) { const prevId = pathIds[index - 1]; const edgeKey = `${prevId}|${id}`; - const accessible = edgeAccessible.get(edgeKey) ?? true; - if (!accessible) fullyAccessible = false; + const isEdgeAccessible = edgeAccessible.get(edgeKey) !== false; + if (!isEdgeAccessible || node.accessible === false) + fullyAccessible = false; - // Recompute actual distance for this segment from adjacency list const seg = (adj.get(prevId) ?? []).find((e) => e.targetId === id); cumulative += seg?.weight ?? 0; + } else { + if (node.accessible === false) fullyAccessible = false; } return { @@ -261,7 +267,8 @@ export function resolveRoutingNodeId( floor: number, ): string | null { // Direct match (check floor) - if (asset.nodes.some((n) => n.id === roomId && n.floor === floor)) return roomId; + if (asset.nodes.some((n) => n.id === roomId && n.floor === floor)) + return roomId; // Nearest node on same floor let bestId: string | null = null; From a450cb288dd5257ee98b849dc45ed94d52a3444a Mon Sep 17 00:00:00 2001 From: Jessicuta Date: Mon, 23 Mar 2026 13:20:36 -0400 Subject: [PATCH 03/15] made the stairs and the elevator working --- app/IndoorMapScreen.tsx | 32 +++++----- assets/maps/buildingsPlan/hall.json | 28 ++++----- components/NavigationBar.tsx | 2 +- utils/indoorNavigation.ts | 1 + utils/indoorPathFinding.ts | 91 +++++++++++++++++++---------- 5 files changed, 94 insertions(+), 60 deletions(-) diff --git a/app/IndoorMapScreen.tsx b/app/IndoorMapScreen.tsx index e0b89d0..e0fa674 100644 --- a/app/IndoorMapScreen.tsx +++ b/app/IndoorMapScreen.tsx @@ -7,7 +7,7 @@ import { Text, TextInput, useWindowDimensions, - View + View, } from "react-native"; import { IndoorDirectionsPanel, @@ -180,18 +180,24 @@ function getFloorStageLayout( } export default function IndoorMapScreen() { - const { buildingName, floors, roomQuery, navOrigin, navDest, accessibleOnly: accessibleOnlyParam } = - useLocalSearchParams<{ - buildingName: string; - floors: string; - roomQuery?: string; - navOrigin?: string; - navDest?: string; - accessibleOnly?: string; - }>(); + const { + buildingName, + floors, + roomQuery, + navOrigin, + navDest, + accessibleOnly: accessibleOnlyParam, + } = useLocalSearchParams<{ + buildingName: string; + floors: string; + roomQuery?: string; + navOrigin?: string; + navDest?: string; + accessibleOnly?: string; + }>(); // Accessibility mode state const [accessibleOnly, setAccessibleOnly] = useState( - accessibleOnlyParam === 'true' + accessibleOnlyParam === "true", ); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const availableFloors = useMemo(() => parseFloors(floors), [floors]); @@ -434,8 +440,8 @@ export default function IndoorMapScreen() { {navError && ( - {accessibleOnly && navError.includes('No path found') - ? 'No accessible route exists between the selected rooms.' + {accessibleOnly && navError.includes("No path found") + ? "No accessible route exists between the selected rooms." : navError} diff --git a/assets/maps/buildingsPlan/hall.json b/assets/maps/buildingsPlan/hall.json index b348d0b..291c5ed 100644 --- a/assets/maps/buildingsPlan/hall.json +++ b/assets/maps/buildingsPlan/hall.json @@ -11013,28 +11013,28 @@ "target": "Hall_F2_stair_landing_5", "type": "stair", "weight": 0, - "accessible": true + "accessible": false }, { "source": "Hall_F1_stair_landing_2", "target": "Hall_F2_stair_landing_6", "type": "stair", "weight": 0, - "accessible": true + "accessible": false }, { "source": "Hall_F1_stair_landing_3", "target": "Hall_F2_stair_landing_7", "type": "stair", "weight": 0, - "accessible": true + "accessible": false }, { "source": "Hall_F1_stair_landing_4", "target": "Hall_F2_stair_landing_8", "type": "stair", "weight": 0, - "accessible": true + "accessible": false }, { "source": "Hall_F2_elevator_door_2", @@ -11069,70 +11069,70 @@ "target": "Hall_F8_stair_landing_29", "type": "stair", "weight": 0, - "accessible": true + "accessible": false }, { "source": "Hall_F2_stair_landing_9", "target": "Hall_F8_stair_landing_28", "type": "stair", "weight": 0, - "accessible": true + "accessible": false }, { "source": "Hall_F2_stair_landing_9", "target": "Hall_F9_stair_landing_20", "type": "stair", "weight": 0, - "accessible": true + "accessible": false }, { "source": "Hall_F8_stair_landing_29", "target": "Hall_F9_stair_landing_21", "type": "stair", "weight": 0, - "accessible": true + "accessible": false }, { "source": "Hall_F9_stair_landing_20", "target": "Hall_F8_stair_landing_28", "type": "stair", "weight": 0, - "accessible": true + "accessible": false }, { "source": "Hall_F2_stair_landing_10", "target": "Hall_F9_stair_landing_21", "type": "stair", "weight": 0, - "accessible": true + "accessible": false }, { "source": "Hall_F9_stair_landing_23", "target": "Hall_F8_stair_landing_30", "type": "stair", "weight": 0, - "accessible": true + "accessible": false }, { "source": "Hall_F8_stair_landing_26", "target": "Hall_F9_stair_landing_22", "type": "stair", "weight": 0, - "accessible": true + "accessible": false }, { "source": "Hall_F9_stair_landing_25", "target": "Hall_F8_stair_landing_31", "type": "stair", "weight": 0, - "accessible": true + "accessible": false }, { "source": "Hall_F9_stair_landing_19", "target": "Hall_F8_stair_landing_27", "type": "stair", "weight": 0, - "accessible": true + "accessible": false } ] } \ No newline at end of file diff --git a/components/NavigationBar.tsx b/components/NavigationBar.tsx index c4c91c7..1658cb1 100644 --- a/components/NavigationBar.tsx +++ b/components/NavigationBar.tsx @@ -31,7 +31,7 @@ import { const { height: SCREEN_HEIGHT } = Dimensions.get("window"); const SHEET_HEIGHT = - Platform.OS === "android" ? SCREEN_HEIGHT * 0.8 : SCREEN_HEIGHT * 0.7; + Platform.OS === "android" ? SCREEN_HEIGHT * 0.76 : SCREEN_HEIGHT * 0.7; const SHEET_TOP = SCREEN_HEIGHT - SHEET_HEIGHT; const SPRING_CONFIG = { useNativeDriver: true, damping: 20, stiffness: 150 }; const MAX_SUGGESTIONS = 20; diff --git a/utils/indoorNavigation.ts b/utils/indoorNavigation.ts index f1399cb..7782605 100644 --- a/utils/indoorNavigation.ts +++ b/utils/indoorNavigation.ts @@ -174,6 +174,7 @@ export function getIndoorNavigationRoute( destQuery: string, options: PathfindingOptions = {}, ): NavigationResult { + console.log("findShortestPath options:", options); const plan = getNormalizedBuildingPlan(buildingCode); if (!plan) { return { diff --git a/utils/indoorPathFinding.ts b/utils/indoorPathFinding.ts index 5d4b77d..d0bc81e 100644 --- a/utils/indoorPathFinding.ts +++ b/utils/indoorPathFinding.ts @@ -16,30 +16,20 @@ export interface PathNode { export interface PathStep { node: PathNode; - /** Cumulative distance from origin at this step (in asset coordinate units). */ cumulativeDistance: number; } export interface IndoorPath { - /** Ordered list of waypoints from origin to destination. */ steps: PathStep[]; - /** Total path cost (sum of edge weights). */ totalDistance: number; - /** True when every edge along the path is accessible. */ fullyAccessible: boolean; - /** IDs of the floors touched by this path. */ floors: number[]; } export interface PathfindingOptions { - /** When true, only traverse edges marked accessible:true. */ accessibleOnly?: boolean; } -// --------------------------------------------------------------------------- -// Internal min-heap (priority queue) — avoids O(n²) Dijkstra -// --------------------------------------------------------------------------- - interface HeapEntry { id: string; cost: number; @@ -92,14 +82,11 @@ class MinHeap { } } -// --------------------------------------------------------------------------- -// Graph builder -// --------------------------------------------------------------------------- - interface AdjEntry { targetId: string; weight: number; accessible: boolean; + edgeType: string; } function buildAdjacencyList( @@ -112,17 +99,28 @@ function buildAdjacencyList( to: string, weight: number, accessible: boolean, + edgeType: string, ) => { if (!adj.has(from)) adj.set(from, []); - adj.get(from)!.push({ targetId: to, weight, accessible }); + adj.get(from)!.push({ targetId: to, weight, accessible, edgeType }); }; for (const edge of edges) { - // Edges are treated as undirected - // Guard against invalid weights if (!Number.isFinite(edge.weight) || edge.weight < 0) continue; - add(edge.source, edge.target, edge.weight, edge.accessible); - add(edge.target, edge.source, edge.weight, edge.accessible); + add( + edge.source, + edge.target, + edge.weight, + edge.accessible, + edge.type ?? "", + ); + add( + edge.target, + edge.source, + edge.weight, + edge.accessible, + edge.type ?? "", + ); } return adj; @@ -135,12 +133,14 @@ export function findShortestPath( options: PathfindingOptions = {}, ): IndoorPath | null { const { accessibleOnly = false } = options; + const isAccessibleRoute = + accessibleOnly === true || String(accessibleOnly) === "true"; + console.log("findShortestPath options:", options); if (!asset.edges || asset.edges.length === 0) { return null; } - // Index nodes by id const nodeMap = new Map(); for (const node of asset.nodes) { nodeMap.set(node.id, node); @@ -167,27 +167,48 @@ export function findShortestPath( while (heap.size > 0) { const { id: currentId, cost: currentCost } = heap.pop()!; - // Skip stale heap entries if (currentCost > (dist.get(currentId) ?? Infinity)) continue; if (currentId === destinationId) break; const neighbors = adj.get(currentId) ?? []; - for (const { targetId, weight, accessible } of neighbors) { + for (const { targetId, weight, accessible, edgeType } of neighbors) { const isEdgeAccessible = accessible !== false && String(accessible) !== "false"; const targetNode = nodeMap.get(targetId); const targetType = (targetNode?.type || "").toLowerCase(); const targetLabel = (targetNode?.label || "").toLowerCase(); + const edgeTypeLower = (edgeType || "").toLowerCase(); + const isStairs = - targetType.includes("stair") || targetLabel.includes("stair"); + targetType.includes("stair") || + targetLabel.includes("stair") || + edgeTypeLower.includes("stair"); + + const isElevator = + targetType.includes("elevator") || + targetType === "eblock" || + targetLabel.includes("elev") || + edgeTypeLower.includes("elevator"); + const isNodeAccessible = targetNode?.accessible !== false && - String(targetNode?.accessible) !== "false" && - !isStairs; + String(targetNode?.accessible) !== "false"; + + let penalty = 0; + + if (isAccessibleRoute) { + // Heavily penalize stairs so elevators are prioritized + if (isStairs) penalty += 100000; + // Penalize inaccessible nodes/edges slightly so they are avoided + if (!isElevator && (!isEdgeAccessible || !isNodeAccessible)) + penalty += 50000; + } else { + // Heavily penalize elevators so stairs are prioritized + if (isElevator) penalty += 100000; + } - if (accessibleOnly && (!isEdgeAccessible || !isNodeAccessible)) continue; - const newCost = currentCost + weight; + const newCost = currentCost + weight + penalty; if (newCost < (dist.get(targetId) ?? Infinity)) { dist.set(targetId, newCost); prev.set(targetId, currentId); @@ -228,13 +249,21 @@ export function findShortestPath( const prevId = pathIds[index - 1]; const edgeKey = `${prevId}|${id}`; const isEdgeAccessible = edgeAccessible.get(edgeKey) !== false; - if (!isEdgeAccessible || node.accessible === false) + if ( + !isEdgeAccessible || + node.accessible === false || + node.type?.toLowerCase().includes("stair") + ) fullyAccessible = false; const seg = (adj.get(prevId) ?? []).find((e) => e.targetId === id); cumulative += seg?.weight ?? 0; } else { - if (node.accessible === false) fullyAccessible = false; + if ( + node.accessible === false || + node.type?.toLowerCase().includes("stair") + ) + fullyAccessible = false; } return { @@ -253,7 +282,7 @@ export function findShortestPath( return { steps, - totalDistance, + totalDistance: cumulative, fullyAccessible, floors: [...floorSet].sort((a, b) => a - b), }; @@ -266,11 +295,9 @@ export function resolveRoutingNodeId( roomY: number, floor: number, ): string | null { - // Direct match (check floor) if (asset.nodes.some((n) => n.id === roomId && n.floor === floor)) return roomId; - // Nearest node on same floor let bestId: string | null = null; let bestDist = Infinity; From e3ff6d33b9d4fe6e537750b519167fc55ef06512 Mon Sep 17 00:00:00 2001 From: tinaw1412 Date: Mon, 23 Mar 2026 16:22:01 -0400 Subject: [PATCH 04/15] add accessible button in indoor map view and add placeholder for start&end room search --- app/IndoorMapScreen.tsx | 41 ++++++++++++++++++++++++++++---- styles/IndoorMapScreen.styles.ts | 29 ++++++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/app/IndoorMapScreen.tsx b/app/IndoorMapScreen.tsx index e0fa674..52415c3 100644 --- a/app/IndoorMapScreen.tsx +++ b/app/IndoorMapScreen.tsx @@ -1,3 +1,4 @@ +import { MaterialCommunityIcons } from "@expo/vector-icons"; import { Image as ExpoImage } from "expo-image"; import { useLocalSearchParams } from "expo-router"; import React, { useCallback, useEffect, useMemo, useState } from "react"; @@ -13,7 +14,7 @@ import { IndoorDirectionsPanel, IndoorRouteOverlay, } from "../components/IndoorRouteOverlay"; -import { spacing } from "../constants/theme"; +import { colors, spacing } from "../constants/theme"; import { styles } from "../styles/IndoorMapScreen.styles"; import { getNormalizedBuildingPlan, @@ -415,18 +416,48 @@ export default function IndoorMapScreen() { return ( - {buildingName} Building + + {buildingName} Building + setAccessibleOnly((prev) => !prev)} + style={[ + styles.accessibleToggle, + accessibleOnly && styles.accessibleToggleActive, + ]} + > + + + Accessible + + + + + + {navError && ( diff --git a/styles/IndoorMapScreen.styles.ts b/styles/IndoorMapScreen.styles.ts index 27e513a..954d34f 100644 --- a/styles/IndoorMapScreen.styles.ts +++ b/styles/IndoorMapScreen.styles.ts @@ -194,4 +194,33 @@ export const styles = StyleSheet.create({ justifyContent: "center", alignItems: "center", }, + accessibleToggle: { + flexDirection: "row", + alignItems: "center", + alignSelf: "flex-start", + marginTop: 8, + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 20, + borderWidth: 1, + borderColor: colors.primary, + gap: 6, + }, + accessibleToggleActive: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + accessibleToggleText: { + fontSize: 13, + color: colors.primary, + }, + accessibleToggleTextActive: { + color: colors.white, + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 8, +}, }); From dbaf37e2bb7451827357d032711a89a431fdb0d6 Mon Sep 17 00:00:00 2001 From: tinaw1412 Date: Mon, 23 Mar 2026 17:08:24 -0400 Subject: [PATCH 05/15] fix bug-4.4: indoor cross-floor travel detour via elevator/stairs --- app/IndoorMapScreen.tsx | 35 ++++---- utils/indoorNavigation.ts | 4 +- utils/indoorPathFinding.ts | 167 ++++++++++++++++++++++--------------- 3 files changed, 120 insertions(+), 86 deletions(-) diff --git a/app/IndoorMapScreen.tsx b/app/IndoorMapScreen.tsx index 52415c3..bce3ef3 100644 --- a/app/IndoorMapScreen.tsx +++ b/app/IndoorMapScreen.tsx @@ -63,9 +63,9 @@ function clamp(value: number, min: number, max: number): number { function getFloorImageDimensions( floorImageMetadata: | { - width: number; - height: number; - } + width: number; + height: number; + } | undefined, currentFloorRooms: IndoorRoomRecord[], ) { @@ -94,25 +94,25 @@ function getFloorContentBounds( const rawMinX = clamp( Math.min(...currentFloorRooms.map((room) => room.x)) - - FLOOR_CONTENT_PADDING, + FLOOR_CONTENT_PADDING, 0, floorImageDimensions.width, ); const rawMaxX = clamp( Math.max(...currentFloorRooms.map((room) => room.x)) + - FLOOR_CONTENT_PADDING, + FLOOR_CONTENT_PADDING, 0, floorImageDimensions.width, ); const rawMinY = clamp( Math.min(...currentFloorRooms.map((room) => room.y)) - - FLOOR_CONTENT_PADDING, + FLOOR_CONTENT_PADDING, 0, floorImageDimensions.height, ); const rawMaxY = clamp( Math.max(...currentFloorRooms.map((room) => room.y)) + - FLOOR_CONTENT_PADDING, + FLOOR_CONTENT_PADDING, 0, floorImageDimensions.height, ); @@ -307,12 +307,12 @@ export default function IndoorMapScreen() { left: floorStageLayout.frameLeft + (selectedRoomOnCurrentFloor.x - floorBounds.minX) * - floorStageLayout.scale - + floorStageLayout.scale - MARKER_SIZE / 2, top: floorStageLayout.frameTop + (selectedRoomOnCurrentFloor.y - floorBounds.minY) * - floorStageLayout.scale - + floorStageLayout.scale - MARKER_SIZE / 2, }; }, [ @@ -404,11 +404,10 @@ export default function IndoorMapScreen() { const floorSummaryText = activeRoute ? `${activeRoute.origin.label} → ${activeRoute.destination.label}` : selectedRoomOnCurrentFloor - ? `${selectedRoomOnCurrentFloor.label}${ - selectedRoomOnCurrentFloor.roomName - ? ` - ${selectedRoomOnCurrentFloor.roomName}` - : "" - }` + ? `${selectedRoomOnCurrentFloor.label}${selectedRoomOnCurrentFloor.roomName + ? ` - ${selectedRoomOnCurrentFloor.roomName}` + : "" + }` : normalizedBuildingPlan ? "Search a room to pin it on the floor plan." : "Floor overview"; @@ -468,15 +467,11 @@ export default function IndoorMapScreen() { - + {navError && ( - - {accessibleOnly && navError.includes("No path found") - ? "No accessible route exists between the selected rooms." - : navError} - + {navError} )} diff --git a/utils/indoorNavigation.ts b/utils/indoorNavigation.ts index 7782605..196f397 100644 --- a/utils/indoorNavigation.ts +++ b/utils/indoorNavigation.ts @@ -248,7 +248,9 @@ export function getIndoorNavigationRoute( success: false, error: "NO_PATH_FOUND", message: - "No path found between the two rooms. The rooms may not be connected in the graph.", + options.accessibleOnly + ? "No accessible route found. There may be no elevator connecting these floors." + : "No path found between the two rooms. The rooms may not be connected in the graph.", }; } diff --git a/utils/indoorPathFinding.ts b/utils/indoorPathFinding.ts index d0bc81e..12fa3c1 100644 --- a/utils/indoorPathFinding.ts +++ b/utils/indoorPathFinding.ts @@ -69,7 +69,7 @@ class MinHeap { private _sinkDown(i: number): void { const n = this.data.length; - for (;;) { + for (; ;) { let smallest = i; const l = 2 * i + 1; const r = 2 * i + 2; @@ -82,6 +82,62 @@ class MinHeap { } } +//helpers +function isStairNode(node: BuildingPlanNode): boolean { + const type = (node.type || "").toLowerCase(); + const label = (node.label || "").toLowerCase(); + return ( + type.includes("stair") || + label.includes("stair") || + type === "stair_landing" + ); +} + +function isElevatorNode(node: BuildingPlanNode): boolean { + const type = (node.type || "").toLowerCase(); + const label = (node.label || "").toLowerCase(); + return ( + type.includes("elevator") || + type === "eblock" || + type === "elevator_door" || + label.includes("elev") + ); +} + +function isStairEdge( + edge: BuildingPlanEdge, + nodeMap: Map, +): boolean { + const edgeType = (edge.type || "").toLowerCase(); + if (edgeType.includes("stair")) return true; + const src = nodeMap.get(edge.source); + const tgt = nodeMap.get(edge.target); + return (src != null && isStairNode(src)) || (tgt != null && isStairNode(tgt)); +} + +function isElevatorEdge( + edge: BuildingPlanEdge, + nodeMap: Map, +): boolean { + const edgeType = (edge.type || "").toLowerCase(); + if (edgeType.includes("elevator")) return true; + const src = nodeMap.get(edge.source); + const tgt = nodeMap.get(edge.target); + return ( + (src != null && isElevatorNode(src)) || + (tgt != null && isElevatorNode(tgt)) + ); +} + +function isNodeAccessible(node: BuildingPlanNode): boolean { + return node.accessible !== false && String(node.accessible) !== "false"; +} + +function isEdgeAccessible(edge: BuildingPlanEdge): boolean { + return edge.accessible !== false && String(edge.accessible) !== "false"; +} + + interface AdjEntry { targetId: string; weight: number; @@ -90,7 +146,9 @@ interface AdjEntry { } function buildAdjacencyList( - edges: BuildingPlanEdge[], + asset: BuildingPlanAsset, + nodeMap: Map, + accessibleOnly: boolean, ): Map { const adj = new Map(); @@ -105,8 +163,33 @@ function buildAdjacencyList( adj.get(from)!.push({ targetId: to, weight, accessible, edgeType }); }; - for (const edge of edges) { + for (const edge of asset.edges ?? []) { if (!Number.isFinite(edge.weight) || edge.weight < 0) continue; + + const srcNode = nodeMap.get(edge.source); + const tgtNode = nodeMap.get(edge.target); + + const edgeIsStair = isStairEdge(edge, nodeMap); + const edgeIsElevator = isElevatorEdge(edge, nodeMap); + + if (accessibleOnly) { + // Hard-block stairs and inaccessible edges/nodes + if (edgeIsStair) continue; + if (!isEdgeAccessible(edge)) continue; + if (srcNode && isStairNode(srcNode)) continue; + if (tgtNode && isStairNode(tgtNode)) continue; + // Also block inaccessible non-elevator nodes (e.g. stair_landing marked accessible:false) + if (srcNode && !isElevatorNode(srcNode) && !isNodeAccessible(srcNode)) + continue; + if (tgtNode && !isElevatorNode(tgtNode) && !isNodeAccessible(tgtNode)) + continue; + } else { + // Hard-block elevators for standard routes + if (edgeIsElevator) continue; + if (srcNode && isElevatorNode(srcNode)) continue; + if (tgtNode && isElevatorNode(tgtNode)) continue; + } + add( edge.source, edge.target, @@ -126,16 +209,16 @@ function buildAdjacencyList( return adj; } + export function findShortestPath( asset: BuildingPlanAsset, originId: string, destinationId: string, options: PathfindingOptions = {}, ): IndoorPath | null { - const { accessibleOnly = false } = options; - const isAccessibleRoute = - accessibleOnly === true || String(accessibleOnly) === "true"; - console.log("findShortestPath options:", options); + const accessibleOnly = + options.accessibleOnly === true || + String(options.accessibleOnly) === "true"; if (!asset.edges || asset.edges.length === 0) { return null; @@ -150,7 +233,7 @@ export function findShortestPath( return null; } - const adj = buildAdjacencyList(asset.edges); + const adj = buildAdjacencyList(asset, nodeMap, accessibleOnly); const dist = new Map(); const prev = new Map(); @@ -168,47 +251,10 @@ export function findShortestPath( const { id: currentId, cost: currentCost } = heap.pop()!; if (currentCost > (dist.get(currentId) ?? Infinity)) continue; - if (currentId === destinationId) break; - const neighbors = adj.get(currentId) ?? []; - for (const { targetId, weight, accessible, edgeType } of neighbors) { - const isEdgeAccessible = - accessible !== false && String(accessible) !== "false"; - const targetNode = nodeMap.get(targetId); - const targetType = (targetNode?.type || "").toLowerCase(); - const targetLabel = (targetNode?.label || "").toLowerCase(); - const edgeTypeLower = (edgeType || "").toLowerCase(); - - const isStairs = - targetType.includes("stair") || - targetLabel.includes("stair") || - edgeTypeLower.includes("stair"); - - const isElevator = - targetType.includes("elevator") || - targetType === "eblock" || - targetLabel.includes("elev") || - edgeTypeLower.includes("elevator"); - - const isNodeAccessible = - targetNode?.accessible !== false && - String(targetNode?.accessible) !== "false"; - - let penalty = 0; - - if (isAccessibleRoute) { - // Heavily penalize stairs so elevators are prioritized - if (isStairs) penalty += 100000; - // Penalize inaccessible nodes/edges slightly so they are avoided - if (!isElevator && (!isEdgeAccessible || !isNodeAccessible)) - penalty += 50000; - } else { - // Heavily penalize elevators so stairs are prioritized - if (isElevator) penalty += 100000; - } - - const newCost = currentCost + weight + penalty; + for (const { targetId, weight } of adj.get(currentId) ?? []) { + const newCost = currentCost + weight; if (newCost < (dist.get(targetId) ?? Infinity)) { dist.set(targetId, newCost); prev.set(targetId, currentId); @@ -229,17 +275,16 @@ export function findShortestPath( pathIds.unshift(cursor); cursor = prev.get(cursor) ?? null; } + const edgeAccessibleMap = new Map(); + for (const edge of asset.edges!) { + edgeAccessibleMap.set(`${edge.source}|${edge.target}`, edge.accessible); + edgeAccessibleMap.set(`${edge.target}|${edge.source}`, edge.accessible); + } let cumulative = 0; let fullyAccessible = true; const floorSet = new Set(); - const edgeAccessible = new Map(); - for (const edge of asset.edges!) { - const key = (a: string, b: string) => `${a}|${b}`; - edgeAccessible.set(key(edge.source, edge.target), edge.accessible); - edgeAccessible.set(key(edge.target, edge.source), edge.accessible); - } const steps: PathStep[] = pathIds.map((id, index) => { const node = nodeMap.get(id)!; @@ -248,22 +293,14 @@ export function findShortestPath( if (index > 0) { const prevId = pathIds[index - 1]; const edgeKey = `${prevId}|${id}`; - const isEdgeAccessible = edgeAccessible.get(edgeKey) !== false; - if ( - !isEdgeAccessible || - node.accessible === false || - node.type?.toLowerCase().includes("stair") - ) + const edgeOk = edgeAccessibleMap.get(edgeKey) !== false; + if (!edgeOk || !isNodeAccessible(node) || isStairNode(node)) { fullyAccessible = false; - + } const seg = (adj.get(prevId) ?? []).find((e) => e.targetId === id); cumulative += seg?.weight ?? 0; } else { - if ( - node.accessible === false || - node.type?.toLowerCase().includes("stair") - ) - fullyAccessible = false; + if (!isNodeAccessible(node) || isStairNode(node)) fullyAccessible = false; } return { From 4ad12a29a01786c158e15e62513587df5bbb8d78 Mon Sep 17 00:00:00 2001 From: tinaw1412 Date: Mon, 23 Mar 2026 17:41:26 -0400 Subject: [PATCH 06/15] Fix bug-4.4: add elevator edges between H floor 8 and 9 to allow direct travel with accessibility --- assets/maps/buildingsPlan/hall.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/assets/maps/buildingsPlan/hall.json b/assets/maps/buildingsPlan/hall.json index 291c5ed..2869831 100644 --- a/assets/maps/buildingsPlan/hall.json +++ b/assets/maps/buildingsPlan/hall.json @@ -11064,6 +11064,20 @@ "weight": 0, "accessible": true }, + { + "source": "Hall_F8_elevator_door_12", + "target": "Hall_F9_elevator_door_10", + "type": "elevator", + "weight": 0, + "accessible": true + }, + { + "source": "Hall_F8_elevator_door_13", + "target": "Hall_F9_elevator_door_11", + "type": "elevator", + "weight": 0, + "accessible": true + }, { "source": "Hall_F2_stair_landing_10", "target": "Hall_F8_stair_landing_29", From 3f253b6d892167e2549626a51ccf06b03bfefa94 Mon Sep 17 00:00:00 2001 From: tinaw1412 Date: Mon, 23 Mar 2026 18:27:03 -0400 Subject: [PATCH 07/15] add tests for new code --- __tests__/IndoorMapScreen.test.tsx | 252 ++++++++++++++++++++++++++- __tests__/NavigationBar.test.tsx | 106 +++++++++++ __tests__/indoorNavigation.test.ts | 2 +- __tests__/indoorNavigation.test.tsx | 238 +++++++++++++++++++------ __tests__/indoorPathFinding.test.tsx | 175 ++++++++++++++++++- 5 files changed, 708 insertions(+), 65 deletions(-) diff --git a/__tests__/IndoorMapScreen.test.tsx b/__tests__/IndoorMapScreen.test.tsx index 48f3534..2cb8a0f 100644 --- a/__tests__/IndoorMapScreen.test.tsx +++ b/__tests__/IndoorMapScreen.test.tsx @@ -226,7 +226,7 @@ describe("IndoorMapScreen", () => { }); }); - it("renders room search controls", async () => { + it("renders room search inputs with placeholder text and Go button", async () => { (useLocalSearchParams as jest.Mock).mockReturnValue({ buildingName: "H", floors: JSON.stringify([1, 2, 8, 9]), @@ -235,12 +235,138 @@ describe("IndoorMapScreen", () => { render(); await waitFor(() => { - expect(screen.getByPlaceholderText("From room…")).toBeTruthy(); - expect(screen.getByPlaceholderText("To room…")).toBeTruthy(); + // Placeholders were updated — must match current IndoorMapScreen JSX + expect(screen.getByPlaceholderText("From (H-110)")).toBeTruthy(); + expect(screen.getByPlaceholderText("To (H-920)")).toBeTruthy(); expect(screen.getByText("Go")).toBeTruthy(); }); }); + + it("renders the accessible toggle button", async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ + buildingName: "H", + floors: JSON.stringify([1, 2, 8, 9]), + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("indoor-accessible-mode-toggle")).toBeTruthy(); + expect(screen.getByText("Accessible")).toBeTruthy(); + }); + }); + + it("accessible toggle defaults to false when param is not set", async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ + buildingName: "H", + floors: JSON.stringify([1, 2, 8, 9]), + }); + + render(); + + await waitFor(() => { + const toggle = screen.getByTestId("indoor-accessible-mode-toggle"); + expect(toggle.props.accessibilityState).toEqual({ checked: false }); + }); + }); + + it("accessible toggle defaults to true when accessibleOnly param is 'true'", async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ + buildingName: "H", + floors: JSON.stringify([1, 2, 8, 9]), + accessibleOnly: "true", + }); + + render(); + + await waitFor(() => { + const toggle = screen.getByTestId("indoor-accessible-mode-toggle"); + expect(toggle.props.accessibilityState).toEqual({ checked: true }); + }); + }); + + it("toggling the accessible button flips its checked state", async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ + buildingName: "H", + floors: JSON.stringify([1, 2, 8, 9]), + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("indoor-accessible-mode-toggle")).toBeTruthy(); + }); + + fireEvent.press(screen.getByTestId("indoor-accessible-mode-toggle")); + + await waitFor(() => { + const toggle = screen.getByTestId("indoor-accessible-mode-toggle"); + expect(toggle.props.accessibilityState).toEqual({ checked: true }); + }); + }); + + it("passes accessibleOnly=true to getIndoorNavigationRoute when toggle is on", async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ + buildingName: "H", + floors: JSON.stringify([1, 2, 8, 9]), + navOrigin: "H-110", + navDest: "H-920", + accessibleOnly: "true", + }); + + (getIndoorNavigationRoute as jest.Mock).mockReturnValue({ + success: false, + error: "NO_PATH_FOUND", + message: "No accessible route found. There may be no elevator connecting these floors.", + }); + + render(); + + await waitFor(() => { + expect(getIndoorNavigationRoute).toHaveBeenCalledWith( + "H", "H-110", "H-920", { accessibleOnly: true }, + ); + }); + }); + + + it("switches floor when a floor button is pressed", async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ + buildingName: "MB", + floors: JSON.stringify([1, -2]), + }); + + render(); + + await waitFor(() => expect(screen.getByText("-2")).toBeTruthy()); + + fireEvent.press(screen.getByText("-2")); + + await waitFor(() => { + expect(getFloorImageMetadata).toHaveBeenCalledWith("MB", -2); + }); + }); + + it("resets selectedFloor when available floors change and current floor is no longer valid", async () => { + let params: any = { buildingName: "MB", floors: JSON.stringify([1, -2]) }; + (useLocalSearchParams as jest.Mock).mockImplementation(() => params); + + const { rerender } = render(); + + await waitFor(() => expect(screen.getByText("-2")).toBeTruthy()); + fireEvent.press(screen.getByText("-2")); + await waitFor(() => expect(getFloorImageMetadata).toHaveBeenCalledWith("MB", -2)); + + params = { buildingName: "MB", floors: JSON.stringify([1]) }; + rerender(); + + await waitFor(() => { + expect(getFloorImageMetadata).toHaveBeenCalledWith("MB", 1); + }); + }); + + it("finds a room on another floor and shows a marker on the destination floor", async () => { (useLocalSearchParams as jest.Mock).mockReturnValue({ buildingName: "H", @@ -397,6 +523,23 @@ describe("IndoorMapScreen", () => { }); }); + it("shows a not-found error when room lookup fails", async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ + buildingName: "H", + floors: JSON.stringify([1, 2, 8, 9]), + roomQuery: "H-999", + }); + + (findIndoorRoomMatch as jest.Mock).mockReturnValue(null); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("room-search-error")).toBeTruthy(); + expect(screen.getByText('Room "H-999" was not found in H.')).toBeTruthy(); + }); + }); + it("runs indoor navigation from navOrigin/navDest params and can close directions", async () => { (useLocalSearchParams as jest.Mock).mockReturnValue({ buildingName: "H", @@ -446,6 +589,42 @@ describe("IndoorMapScreen", () => { }); }); + + it("closes the directions panel when the close button is pressed", async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ + buildingName: "H", + floors: JSON.stringify([1, 2, 8, 9]), + navOrigin: "H-110", + navDest: "H-920", + }); + + (getIndoorNavigationRoute as jest.Mock).mockReturnValue({ + success: true, + route: { + origin: { ...mockHallRoom, floor: 2, label: "H-110" }, + destination: { ...mockHallRoom, floor: 9, label: "H-920" }, + path: { steps: [] }, + segments: [ + { kind: "walk", description: "Walk forward", nodeIds: ["a", "b"], floor: 2, distance: 50 }, + ], + floors: [2, 9], + totalDistance: 50, + fullyAccessible: true, + estimatedSeconds: 35, + }, + }); + + render(); + + await waitFor(() => expect(screen.getByText("H-110 → H-920")).toBeTruthy()); + + fireEvent.press(screen.getByText("✕")); + + await waitFor(() => { + expect(screen.queryByText("H-110 → H-920")).toBeNull(); + }); + }); + it("shows navigation error when indoor route lookup fails", async () => { (useLocalSearchParams as jest.Mock).mockReturnValue({ buildingName: "H", @@ -466,4 +645,71 @@ describe("IndoorMapScreen", () => { expect(screen.getByText("Unable to find indoor route")).toBeTruthy(); }); }); + + it("shows accessible-specific error message when accessible route is not found", async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ + buildingName: "H", + floors: JSON.stringify([1, 2, 8, 9]), + navOrigin: "H-110", + navDest: "H-920", + accessibleOnly: "true", + }); + + (getIndoorNavigationRoute as jest.Mock).mockReturnValue({ + success: false, + error: "NO_PATH_FOUND", + message: "No accessible route found. There may be no elevator connecting these floors.", + }); + + render(); + + await waitFor(() => { + expect( + screen.getByText("No accessible route found. There may be no elevator connecting these floors."), + ).toBeTruthy(); + }); + }); + + it("passes accessibleOnly=false to navigation when toggle is off and Go is pressed", async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ + buildingName: "H", + floors: JSON.stringify([1, 2, 8, 9]), + }); + + render(); + + await waitFor(() => expect(screen.getByText("Go")).toBeTruthy()); + + fireEvent.changeText(screen.getByPlaceholderText("From (H-110)"), "H-110"); + fireEvent.changeText(screen.getByPlaceholderText("To (H-920)"), "H-920"); + fireEvent.press(screen.getByText("Go")); + + await waitFor(() => { + expect(getIndoorNavigationRoute).toHaveBeenCalledWith( + "H", "H-110", "H-920", { accessibleOnly: false }, + ); + }); + }); + + it("passes accessibleOnly=true to navigation when toggle is enabled before pressing Go", async () => { + (useLocalSearchParams as jest.Mock).mockReturnValue({ + buildingName: "H", + floors: JSON.stringify([1, 2, 8, 9]), + }); + + render(); + + await waitFor(() => expect(screen.getByTestId("indoor-accessible-mode-toggle")).toBeTruthy()); + + fireEvent.press(screen.getByTestId("indoor-accessible-mode-toggle")); + fireEvent.changeText(screen.getByPlaceholderText("From (H-110)"), "H-110"); + fireEvent.changeText(screen.getByPlaceholderText("To (H-920)"), "H-920"); + fireEvent.press(screen.getByText("Go")); + + await waitFor(() => { + expect(getIndoorNavigationRoute).toHaveBeenCalledWith( + "H", "H-110", "H-920", { accessibleOnly: true }, + ); + }); + }); }); diff --git a/__tests__/NavigationBar.test.tsx b/__tests__/NavigationBar.test.tsx index 04bb312..e6a472a 100644 --- a/__tests__/NavigationBar.test.tsx +++ b/__tests__/NavigationBar.test.tsx @@ -996,6 +996,7 @@ describe("NavigationBar", () => { expect.anything(), expect.objectContaining({ useNativeDriver: true, + damping: 20, bounciness: 4, }), ); @@ -1381,4 +1382,109 @@ describe("NavigationBar", () => { expect(getByPlaceholderText(/From/).props.value).toBe("My Location"); }); }); + + describe("Accessible Route Toggle", () => { + it("renders the accessible route toggle button", () => { + const { getByTestId } = render( + + ); + expect(getByTestId("accessible-mode-toggle")).toBeTruthy(); + }); + + it("defaults to unchecked (false) when no accessibleOnly prop is passed", () => { + const { getByTestId } = render( + + ); + expect(getByTestId("accessible-mode-toggle").props.accessibilityState).toEqual({ checked: false }); + }); + + it("defaults to checked (true) when accessibleOnly prop is true", () => { + const { getByTestId } = render( + + ); + expect(getByTestId("accessible-mode-toggle").props.accessibilityState).toEqual({ checked: true }); + }); + + it("toggles to checked when pressed once", () => { + const { getByTestId } = render( + + ); + fireEvent.press(getByTestId("accessible-mode-toggle")); + expect(getByTestId("accessible-mode-toggle").props.accessibilityState).toEqual({ checked: true }); + }); + + it("toggles back to unchecked when pressed twice", () => { + const { getByTestId } = render( + + ); + fireEvent.press(getByTestId("accessible-mode-toggle")); + fireEvent.press(getByTestId("accessible-mode-toggle")); + expect(getByTestId("accessible-mode-toggle").props.accessibilityState).toEqual({ checked: false }); + }); + + it("calls onAccessibleOnlyChange with true when toggled on", () => { + const mockOnChange = jest.fn(); + const { getByTestId } = render( + + ); + fireEvent.press(getByTestId("accessible-mode-toggle")); + expect(mockOnChange).toHaveBeenCalledWith(true); + }); + + it("calls onAccessibleOnlyChange with false when toggled off", () => { + const mockOnChange = jest.fn(); + const { getByTestId } = render( + + ); + fireEvent.press(getByTestId("accessible-mode-toggle")); + expect(mockOnChange).toHaveBeenCalledWith(false); + }); + + it("passes accessibleOnly=false to onConfirm when toggle is off", () => { + const { getByText } = render( + + ); + fireEvent.press(getByText("Get Directions")); + expect(mockOnConfirm).toHaveBeenCalledWith( + null, null, + expect.objectContaining({ mode: "walking" }), + null, null, + false + ); + }); + + it("passes accessibleOnly=true to onConfirm when toggle is on", () => { + const { getByTestId, getByText } = render( + + ); + fireEvent.press(getByTestId("accessible-mode-toggle")); + fireEvent.press(getByText("Get Directions")); + expect(mockOnConfirm).toHaveBeenCalledWith( + null, null, + expect.objectContaining({ mode: "walking" }), + null, null, + true + ); + }); + + it("toggle is hidden when the suggestion list is showing", () => { + const { getByPlaceholderText, queryByTestId } = render( + + ); + fireEvent.changeText(getByPlaceholderText(/From/), "Science"); + // suggestions are showing — modeSection (including toggle) is hidden + expect(queryByTestId("accessible-mode-toggle")).toBeNull(); + }); +}); }); diff --git a/__tests__/indoorNavigation.test.ts b/__tests__/indoorNavigation.test.ts index 18ad18a..09a4ffd 100644 --- a/__tests__/indoorNavigation.test.ts +++ b/__tests__/indoorNavigation.test.ts @@ -171,7 +171,7 @@ describe("utils/indoorNavigation", () => { expect(result.route.segments.map((segment) => segment.kind)).toEqual( expect.arrayContaining(["exit_room", "walk", "elevator", "enter_room"]), ); - expect(result.route.estimatedSeconds).toBe(Math.round(30 / 1.4)); + expect(result.route.estimatedSeconds).toBe(Math.round(30 / 10/ 1.4)); }); it("returns scaled route waypoints for a specific floor", () => { diff --git a/__tests__/indoorNavigation.test.tsx b/__tests__/indoorNavigation.test.tsx index 9d6b8b5..d43433d 100644 --- a/__tests__/indoorNavigation.test.tsx +++ b/__tests__/indoorNavigation.test.tsx @@ -1,6 +1,6 @@ import { - getIndoorNavigationRoute, - getRouteWaypointsForFloor, + getIndoorNavigationRoute, + getRouteWaypointsForFloor, } from "../utils/indoorNavigation"; import type { BuildingPlanAsset } from "../utils/mapAssets"; @@ -23,8 +23,8 @@ jest.mock("../utils/indoorPathFinding", () => ({ import { getNormalizedBuildingPlan } from "../utils/indoorBuildingPlan"; import { - findShortestPath, - resolveRoutingNodeId, + findShortestPath, + resolveRoutingNodeId, } from "../utils/indoorPathFinding"; import { findIndoorRoomMatch } from "../utils/indoorRoomSearch"; import { getBuildingPlanAsset } from "../utils/mapAssets"; @@ -35,33 +35,62 @@ const mockedGetBuildingPlanAsset = getBuildingPlanAsset as jest.Mock; const mockedResolveRoutingNodeId = resolveRoutingNodeId as jest.Mock; const mockedFindShortestPath = findShortestPath as jest.Mock; +const originRoom = { + id: "room-a", + buildingCode: "H", + floor: 1, + label: "H-101", + roomNumber: "101", + x: 10, + y: 10, + accessible: true, + searchTerms: ["H-101", "101"], + searchKeys: ["H101", "101"], +}; + +const destinationRoom = { + ...originRoom, + id: "room-b", + floor: 2, + label: "H-201", + roomNumber: "201", +}; + +const baseAsset: BuildingPlanAsset = { + meta: { buildingId: "H" }, + nodes: [], + edges: [{ source: "n1", target: "n2", type: "walk", weight: 1, accessible: true }], +}; +const elevatorPath = { + steps: [ + { node: { id: "room-a", x: 10, y: 10, floor: 1, type: "room", label: "H-101", accessible: true }, cumulativeDistance: 0 }, + { node: { id: "door-1", x: 15, y: 10, floor: 1, type: "doorway", label: "Door", accessible: true }, cumulativeDistance: 5 }, + { node: { id: "elev-1", x: 40, y: 10, floor: 1, type: "elevator_door", label: "Elev Lobby", accessible: true }, cumulativeDistance: 20 }, + { node: { id: "elev-2", x: 40, y: 10, floor: 2, type: "elevator_door", label: "Elev Lobby", accessible: true }, cumulativeDistance: 20 }, + { node: { id: "door-2", x: 60, y: 10, floor: 2, type: "doorway", label: "Door", accessible: true }, cumulativeDistance: 25 }, + { node: { id: "room-b", x: 80, y: 10, floor: 2, type: "room", label: "H-201", accessible: true }, cumulativeDistance: 30 }, + ], + totalDistance: 30, + fullyAccessible: true, + floors: [1, 2], +}; + +const stairsPath = { + steps: [ + { node: { id: "room-a", x: 10, y: 10, floor: 1, type: "room", label: "H-101", accessible: true }, cumulativeDistance: 0 }, + { node: { id: "hall-1", x: 20, y: 10, floor: 1, type: "hallway", label: "Hall", accessible: true }, cumulativeDistance: 10 }, + { node: { id: "stair-1", x: 30, y: 10, floor: 1, type: "stair_landing", label: "Stair", accessible: false }, cumulativeDistance: 20 }, + { node: { id: "stair-2", x: 30, y: 10, floor: 2, type: "stair_landing", label: "Stair", accessible: false }, cumulativeDistance: 20 }, + { node: { id: "hall-2", x: 20, y: 10, floor: 2, type: "hallway", label: "Hall", accessible: true }, cumulativeDistance: 30 }, + { node: { id: "room-b", x: 10, y: 10, floor: 2, type: "room", label: "H-201", accessible: true }, cumulativeDistance: 40 }, + ], + totalDistance: 40, + fullyAccessible: false, + floors: [1, 2], +}; + describe("utils/indoorNavigation", () => { - const originRoom = { - id: "room-a", - buildingCode: "H", - floor: 1, - label: "H-101", - roomNumber: "101", - x: 10, - y: 10, - accessible: true, - searchTerms: ["H-101", "101"], - searchKeys: ["H101", "101"], - }; - - const destinationRoom = { - ...originRoom, - id: "room-b", - floor: 2, - label: "H-201", - roomNumber: "201", - }; - - const baseAsset: BuildingPlanAsset = { - meta: { buildingId: "H" }, - nodes: [], - edges: [{ source: "n1", target: "n2", type: "walk", weight: 1, accessible: true }], - }; + beforeEach(() => { jest.clearAllMocks(); @@ -81,19 +110,7 @@ describe("utils/indoorNavigation", () => { if (roomId === "room-b") return "room-b"; return null; }); - mockedFindShortestPath.mockReturnValue({ - steps: [ - { node: { id: "room-a", x: 10, y: 10, floor: 1, type: "room", label: "H-101", accessible: true }, cumulativeDistance: 0 }, - { node: { id: "door-1", x: 15, y: 10, floor: 1, type: "doorway", label: "Door", accessible: true }, cumulativeDistance: 5 }, - { node: { id: "hall-1", x: 40, y: 10, floor: 1, type: "elevator_door", label: "Elev Lobby", accessible: true }, cumulativeDistance: 20 }, - { node: { id: "hall-2", x: 40, y: 10, floor: 2, type: "elevator_door", label: "Elev Lobby", accessible: true }, cumulativeDistance: 20 }, - { node: { id: "door-2", x: 60, y: 10, floor: 2, type: "doorway", label: "Door", accessible: true }, cumulativeDistance: 25 }, - { node: { id: "room-b", x: 80, y: 10, floor: 2, type: "room", label: "H-201", accessible: true }, cumulativeDistance: 30 }, - ], - totalDistance: 30, - fullyAccessible: true, - floors: [1, 2], - }); + mockedFindShortestPath.mockReturnValue(elevatorPath); }); it("returns NO_GRAPH_DATA when plan is missing", () => { @@ -130,6 +147,40 @@ describe("utils/indoorNavigation", () => { ); }); + it("returns NO_GRAPH_DATA when the asset has no edges", () => { + mockedGetBuildingPlanAsset.mockReturnValueOnce({ meta: { buildingId: "H" }, nodes: [], edges: [] }); + expect(getIndoorNavigationRoute("H", "101", "201")).toEqual( + expect.objectContaining({ success: false, error: "NO_GRAPH_DATA" }), + ); + }); + it("returns NO_PATH_FOUND when origin node cannot be resolved to the graph", () => { + mockedResolveRoutingNodeId.mockImplementationOnce((_: unknown, roomId: string) => { + if (roomId === "room-a") return null; + return "room-b"; + }); + expect(getIndoorNavigationRoute("H", "101", "201")).toEqual( + expect.objectContaining({ success: false, error: "NO_PATH_FOUND" }), + ); + }); + + it("returns NO_PATH_FOUND when pathfinding returns null (standard route)", () => { + mockedFindShortestPath.mockReturnValueOnce(null); + const result = getIndoorNavigationRoute("H", "101", "201"); + expect(result).toEqual(expect.objectContaining({ success: false, error: "NO_PATH_FOUND" })); + if (!result.success) { + expect(result.message).toMatch(/not be connected/i); + } + }); + + it("returns accessible-specific error message when pathfinding returns null in accessible mode", () => { + mockedFindShortestPath.mockReturnValueOnce(null); + const result = getIndoorNavigationRoute("H", "101", "201", { accessibleOnly: true }); + expect(result).toEqual(expect.objectContaining({ success: false, error: "NO_PATH_FOUND" })); + if (!result.success) { + expect(result.message).toMatch(/no accessible route/i); + expect(result.message).toMatch(/elevator/i); + } + }); it("returns NO_GRAPH_DATA for missing edges and NO_PATH_FOUND for unresolved node/path", () => { mockedGetBuildingPlanAsset.mockReturnValueOnce({ meta: { buildingId: "H" }, nodes: [], edges: [] }); const missingEdges = getIndoorNavigationRoute("H", "101", "201"); @@ -153,32 +204,61 @@ describe("utils/indoorNavigation", () => { ); }); - it("builds an indoor route with segmented instructions", () => { - const result = getIndoorNavigationRoute("H", "101", "201", { - accessibleOnly: true, - }); + it("builds a route with elevator segments when accessibleOnly=true", () => { + const result = getIndoorNavigationRoute("H", "101", "201", { accessibleOnly: true }); + + expect(result.success).toBe(true); + if (!result.success) return; + + expect(mockedFindShortestPath).toHaveBeenCalledWith( + baseAsset, "room-a", "room-b", { accessibleOnly: true }, + ); + + const kinds = result.route.segments.map((s) => s.kind); + expect(kinds).toEqual(expect.arrayContaining(["exit_room", "walk", "elevator", "enter_room"])); + expect(result.route.fullyAccessible).toBe(true); + expect(result.route.estimatedSeconds).toBe(Math.round(30 / 10 / 1.4)); + }); + + it("builds a route with stair segments on a standard (non-accessible) route", () => { + mockedFindShortestPath.mockReturnValueOnce(stairsPath); + const result = getIndoorNavigationRoute("H", "101", "201"); expect(result.success).toBe(true); if (!result.success) return; expect(mockedFindShortestPath).toHaveBeenCalledWith( - baseAsset, - "room-a", - "room-b", - { accessibleOnly: true }, + baseAsset, "room-a", "room-b", {}, ); - expect(result.route.segments.map((segment) => segment.kind)).toEqual( - expect.arrayContaining(["exit_room", "walk", "elevator", "enter_room"]), + const kinds = result.route.segments.map((s) => s.kind); + expect(kinds).toEqual(expect.arrayContaining(["exit_room", "walk", "stairs", "enter_room"])); + expect(result.route.fullyAccessible).toBe(false); + }); + + it("passes accessibleOnly=false explicitly when toggle is off", () => { + getIndoorNavigationRoute("H", "101", "201", { accessibleOnly: false }); + expect(mockedFindShortestPath).toHaveBeenCalledWith( + baseAsset, "room-a", "room-b", { accessibleOnly: false }, ); - expect(result.route.estimatedSeconds).toBe(Math.round(30 / 1.4)); }); - it("returns scaled route waypoints for a specific floor", () => { + it("populates route metadata correctly", () => { + const result = getIndoorNavigationRoute("H", "101", "201"); + expect(result.success).toBe(true); + if (!result.success) return; + + expect(result.route.origin).toEqual(originRoom); + expect(result.route.destination).toEqual(destinationRoom); + expect(result.route.floors).toEqual([1, 2]); + expect(result.route.totalDistance).toBe(30); + }); + + it("returns scaled waypoints only for the requested floor", () => { const route = { origin: originRoom, destination: destinationRoom, - totalDistance: 50, + totalDistance: 20, fullyAccessible: true, estimatedSeconds: 36, floors: [1, 2], @@ -200,4 +280,50 @@ describe("utils/indoorNavigation", () => { { x: 25, y: 30 }, ]); }); + + it("returns only floor-1 waypoints without scaling when coordinateScale defaults to 1", () => { + const route = { + origin: originRoom, + destination: destinationRoom, + totalDistance: 10, + fullyAccessible: true, + estimatedSeconds: 1, + floors: [1, 2], + segments: [], + path: { + steps: [ + { node: { id: "a", x: 10, y: 20, floor: 1, type: "hallway", label: "A", accessible: true }, cumulativeDistance: 0 }, + { node: { id: "b", x: 30, y: 40, floor: 2, type: "hallway", label: "B", accessible: true }, cumulativeDistance: 10 }, + ], + totalDistance: 10, + fullyAccessible: true, + floors: [1, 2], + }, + }; + + expect(getRouteWaypointsForFloor(route as any, 1)).toEqual([{ x: 10, y: 20 }]); + }); + + it("returns empty array when no steps exist on the requested floor", () => { + const route = { + origin: originRoom, + destination: destinationRoom, + totalDistance: 10, + fullyAccessible: true, + estimatedSeconds: 1, + floors: [1], + segments: [], + path: { + steps: [ + { node: { id: "a", x: 10, y: 20, floor: 1, type: "hallway", label: "A", accessible: true }, cumulativeDistance: 0 }, + ], + totalDistance: 10, + fullyAccessible: true, + floors: [1], + }, + }; + + expect(getRouteWaypointsForFloor(route as any, 9)).toEqual([]); + }); + }); diff --git a/__tests__/indoorPathFinding.test.tsx b/__tests__/indoorPathFinding.test.tsx index 21dbeb0..e363f9f 100644 --- a/__tests__/indoorPathFinding.test.tsx +++ b/__tests__/indoorPathFinding.test.tsx @@ -1,6 +1,6 @@ import { - findShortestPath, - resolveRoutingNodeId, + findShortestPath, + resolveRoutingNodeId, } from "../utils/indoorPathFinding"; import type { BuildingPlanAsset } from "../utils/mapAssets"; @@ -20,6 +20,33 @@ const multiPathAsset: BuildingPlanAsset = { ], }; +const multiFloorAsset: BuildingPlanAsset = { + meta: { buildingId: "H" }, + nodes: [ + { id: "room1", type: "room", buildingId: "H", floor: 1, x: 0, y: 0, label: "H-101", accessible: true }, + { id: "hall1", type: "hallway", buildingId: "H", floor: 1, x: 5, y: 0, label: "Hall F1", accessible: true }, + { id: "stair1", type: "stair_landing", buildingId: "H", floor: 1, x: 10, y: 0, label: "Stair F1", accessible: false }, + { id: "elev1", type: "elevator_door", buildingId: "H", floor: 1, x: 15, y: 0, label: "Elev F1", accessible: true }, + { id: "stair2", type: "stair_landing", buildingId: "H", floor: 2, x: 10, y: 0, label: "Stair F2", accessible: false }, + { id: "elev2", type: "elevator_door", buildingId: "H", floor: 2, x: 15, y: 0, label: "Elev F2", accessible: true }, + { id: "hall2", type: "hallway", buildingId: "H", floor: 2, x: 5, y: 0, label: "Hall F2", accessible: true }, + { id: "room2", type: "room", buildingId: "H", floor: 2, x: 0, y: 0, label: "H-201", accessible: true }, + ], + edges: [ + // Floor 1 hallway connections + { source: "room1", target: "hall1", type: "hallway", weight: 5, accessible: true }, + { source: "hall1", target: "stair1", type: "hallway", weight: 5, accessible: true }, + { source: "hall1", target: "elev1", type: "hallway", weight: 5, accessible: true }, + // Floor 2 hallway connections + { source: "stair2", target: "hall2", type: "hallway", weight: 5, accessible: true }, + { source: "elev2", target: "hall2", type: "hallway", weight: 5, accessible: true }, + { source: "hall2", target: "room2", type: "hallway", weight: 5, accessible: true }, + // Vertical connections + { source: "stair1", target: "stair2", type: "stairs", weight: 0, accessible: false }, + { source: "elev1", target: "elev2", type: "elevator", weight: 0, accessible: true }, + ], +}; + describe("utils/indoorPathFinding", () => { it("returns null when graph edges are missing", () => { const result = findShortestPath( @@ -45,6 +72,15 @@ describe("utils/indoorPathFinding", () => { expect(path?.floors).toEqual([1]); }); + it("takes the accessible detour when accessibleOnly=true and shortest path has inaccessible edge", () => { + const path = findShortestPath(multiPathAsset, "A", "D", { accessibleOnly: true }); + expect(path).not.toBeNull(); + // B->C is hard-blocked (inaccessible edge), so must go A->B->D (weight 6) + expect(path?.steps.map((s) => s.node.id)).toEqual(["A", "B", "D"]); + expect(path?.totalDistance).toBe(6); + expect(path?.fullyAccessible).toBe(true); + }); + it("honors accessibleOnly and falls back to accessible detour", () => { const path = findShortestPath(multiPathAsset, "A", "D", { accessibleOnly: true }); expect(path).not.toBeNull(); @@ -57,18 +93,147 @@ describe("utils/indoorPathFinding", () => { const blockedAsset: BuildingPlanAsset = { meta: { buildingId: "H" }, nodes: [ - { id: "A", type: "room", buildingId: "H", floor: 1, x: 0, y: 0, label: "H-101", accessible: true }, - { id: "B", type: "hallway", buildingId: "H", floor: 1, x: 1, y: 0, label: "Hall", accessible: true }, + { id: "A", type: "room", buildingId: "H", floor: 1, x: 0, y: 0, label: "H-101", accessible: true }, + { id: "B", type: "hallway", buildingId: "H", floor: 1, x: 1, y: 0, label: "Hall", accessible: true }, ], edges: [{ source: "A", target: "B", type: "walk", weight: 1, accessible: false }], }; - expect(findShortestPath(blockedAsset, "A", "B", { accessibleOnly: true })).toBeNull(); }); + it("returns a path with correct floor set", () => { + const path = findShortestPath(multiPathAsset, "A", "D"); + expect(path?.floors).toEqual([1]); + }); + it("resolves routing id by direct match, nearest-floor match, and null", () => { expect(resolveRoutingNodeId(multiPathAsset, "D", 99, 99, 1)).toBe("D"); expect(resolveRoutingNodeId(multiPathAsset, "missing", 2.2, 0.1, 1)).toBe("C"); expect(resolveRoutingNodeId(multiPathAsset, "missing", 10, 10, 9)).toBeNull(); }); + +it("uses stairs (not elevator) on standard route between floors", () => { + const path = findShortestPath(multiFloorAsset, "room1", "room2"); + expect(path).not.toBeNull(); + // Standard route: elevator edges hard-blocked → must use stairs + const ids = path!.steps.map((s) => s.node.id); + expect(ids).toContain("stair1"); + expect(ids).toContain("stair2"); + expect(ids).not.toContain("elev1"); + expect(ids).not.toContain("elev2"); + expect(path?.floors).toEqual([1, 2]); + }); + + it("uses elevator (not stairs) on accessible route between floors", () => { + const path = findShortestPath(multiFloorAsset, "room1", "room2", { accessibleOnly: true }); + expect(path).not.toBeNull(); + // Accessible route: stair edges hard-blocked → must use elevator + const ids = path!.steps.map((s) => s.node.id); + expect(ids).toContain("elev1"); + expect(ids).toContain("elev2"); + expect(ids).not.toContain("stair1"); + expect(ids).not.toContain("stair2"); + expect(path?.floors).toEqual([1, 2]); + expect(path?.fullyAccessible).toBe(true); + }); + + it("returns null for accessible route when no elevator exists", () => { + const stairsOnlyAsset: BuildingPlanAsset = { + meta: { buildingId: "H" }, + nodes: [ + { id: "room1", type: "room", buildingId: "H", floor: 1, x: 0, y: 0, label: "H-101", accessible: true }, + { id: "stair1", type: "stair_landing", buildingId: "H", floor: 1, x: 5, y: 0, label: "Stair F1", accessible: false }, + { id: "stair2", type: "stair_landing", buildingId: "H", floor: 2, x: 5, y: 0, label: "Stair F2", accessible: false }, + { id: "room2", type: "room", buildingId: "H", floor: 2, x: 0, y: 0, label: "H-201", accessible: true }, + ], + edges: [ + { source: "room1", target: "stair1", type: "hallway", weight: 5, accessible: true }, + { source: "stair1", target: "stair2", type: "stairs", weight: 0, accessible: false }, + { source: "stair2", target: "room2", type: "hallway", weight: 5, accessible: true }, + ], + }; + expect(findShortestPath(stairsOnlyAsset, "room1", "room2", { accessibleOnly: true })).toBeNull(); + }); + + it("does not detour through a third floor when direct elevator edge exists", () => { + // Reproduces the F8->F9 bug: ensure direct edge is used, not F8->F2->F9 + const threeFloorAsset: BuildingPlanAsset = { + meta: { buildingId: "H" }, + nodes: [ + { id: "room8", type: "room", buildingId: "H", floor: 8, x: 0, y: 0, label: "H-801", accessible: true }, + { id: "hall8", type: "hallway", buildingId: "H", floor: 8, x: 5, y: 0, label: "Hall F8", accessible: true }, + { id: "elev8", type: "elevator_door", buildingId: "H", floor: 8, x: 10, y: 0, label: "Elev F8", accessible: true }, + { id: "elev2", type: "elevator_door", buildingId: "H", floor: 2, x: 10, y: 0, label: "Elev F2", accessible: true }, + { id: "hall2", type: "hallway", buildingId: "H", floor: 2, x: 5, y: 0, label: "Hall F2", accessible: true }, + { id: "elev9", type: "elevator_door", buildingId: "H", floor: 9, x: 10, y: 0, label: "Elev F9", accessible: true }, + { id: "hall9", type: "hallway", buildingId: "H", floor: 9, x: 5, y: 0, label: "Hall F9", accessible: true }, + { id: "room9", type: "room", buildingId: "H", floor: 9, x: 0, y: 0, label: "H-901", accessible: true }, + ], + edges: [ + { source: "room8", target: "hall8", type: "hallway", weight: 5, accessible: true }, + { source: "hall8", target: "elev8", type: "hallway", weight: 5, accessible: true }, + // elev8 <-> elev2 (indirect hub) + { source: "elev8", target: "elev2", type: "elevator", weight: 0, accessible: true }, + { source: "elev2", target: "hall2", type: "hallway", weight: 5, accessible: true }, + // elev2 <-> elev9 + { source: "elev2", target: "elev9", type: "elevator", weight: 0, accessible: true }, + // direct elev8 <-> elev9 + { source: "elev8", target: "elev9", type: "elevator", weight: 0, accessible: true }, + { source: "elev9", target: "hall9", type: "hallway", weight: 5, accessible: true }, + { source: "hall9", target: "room9", type: "hallway", weight: 5, accessible: true }, + ], + }; + + const path = findShortestPath(threeFloorAsset, "room8", "room9", { accessibleOnly: true }); + expect(path).not.toBeNull(); + const ids = path!.steps.map((s) => s.node.id); + // Should go directly F8->F9, NOT through F2 + expect(ids).not.toContain("elev2"); + expect(ids).not.toContain("hall2"); + expect(ids).toContain("elev8"); + expect(ids).toContain("elev9"); + expect(path?.floors).toEqual([8, 9]); + }); + + // ── findShortestPath: path metadata ───────────────────────────────────────── + + it("marks fullyAccessible=true when all edges and nodes are accessible", () => { + const allAccessibleAsset: BuildingPlanAsset = { + meta: { buildingId: "H" }, + nodes: [ + { id: "A", type: "room", buildingId: "H", floor: 1, x: 0, y: 0, label: "H-101", accessible: true }, + { id: "B", type: "hallway", buildingId: "H", floor: 1, x: 1, y: 0, label: "Hall", accessible: true }, + ], + edges: [{ source: "A", target: "B", type: "walk", weight: 1, accessible: true }], + }; + const path = findShortestPath(allAccessibleAsset, "A", "B"); + expect(path?.fullyAccessible).toBe(true); + }); + + it("marks fullyAccessible=false when a stair node is in the path", () => { + const path = findShortestPath(multiFloorAsset, "room1", "room2"); + // Standard route uses stairs which are inaccessible nodes + expect(path?.fullyAccessible).toBe(false); + }); + + // ── resolveRoutingNodeId ───────────────────────────────────────────────────── + + it("resolves by direct ID match on the correct floor", () => { + expect(resolveRoutingNodeId(multiPathAsset, "D", 99, 99, 1)).toBe("D"); + }); + + it("falls back to nearest node on the same floor when ID is not found", () => { + // Point (2.2, 0.1) is closest to C (x=2, y=0) + expect(resolveRoutingNodeId(multiPathAsset, "missing", 2.2, 0.1, 1)).toBe("C"); + }); + + it("returns null when no nodes exist on the requested floor", () => { + expect(resolveRoutingNodeId(multiPathAsset, "missing", 10, 10, 9)).toBeNull(); + }); + + it("does not match a node whose ID matches but is on a different floor", () => { + // "D" exists on floor 1, requesting floor 2 — should fall back to nearest on floor 2 + // multiPathAsset has no floor-2 nodes so returns null + expect(resolveRoutingNodeId(multiPathAsset, "D", 3, 0, 2)).toBeNull(); + }); }); \ No newline at end of file From 1244965c9830590628c40bfb04799ec0202c206c Mon Sep 17 00:00:00 2001 From: tinaw1412 Date: Mon, 23 Mar 2026 18:32:06 -0400 Subject: [PATCH 08/15] remove duplicate indoor navigation test --- __tests__/indoorNavigation.test.ts | 203 ----------------------------- 1 file changed, 203 deletions(-) delete mode 100644 __tests__/indoorNavigation.test.ts diff --git a/__tests__/indoorNavigation.test.ts b/__tests__/indoorNavigation.test.ts deleted file mode 100644 index 09a4ffd..0000000 --- a/__tests__/indoorNavigation.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { - getIndoorNavigationRoute, - getRouteWaypointsForFloor, -} from "../utils/indoorNavigation"; -import type { BuildingPlanAsset } from "../utils/mapAssets"; - -jest.mock("../utils/indoorBuildingPlan", () => ({ - getNormalizedBuildingPlan: jest.fn(), -})); - -jest.mock("../utils/indoorRoomSearch", () => ({ - findIndoorRoomMatch: jest.fn(), -})); - -jest.mock("../utils/mapAssets", () => ({ - getBuildingPlanAsset: jest.fn(), -})); - -jest.mock("../utils/indoorPathFinding", () => ({ - resolveRoutingNodeId: jest.fn(), - findShortestPath: jest.fn(), -})); - -import { getNormalizedBuildingPlan } from "../utils/indoorBuildingPlan"; -import { - findShortestPath, - resolveRoutingNodeId, -} from "../utils/indoorPathFinding"; -import { findIndoorRoomMatch } from "../utils/indoorRoomSearch"; -import { getBuildingPlanAsset } from "../utils/mapAssets"; - -const mockedGetNormalizedBuildingPlan = getNormalizedBuildingPlan as jest.Mock; -const mockedFindIndoorRoomMatch = findIndoorRoomMatch as jest.Mock; -const mockedGetBuildingPlanAsset = getBuildingPlanAsset as jest.Mock; -const mockedResolveRoutingNodeId = resolveRoutingNodeId as jest.Mock; -const mockedFindShortestPath = findShortestPath as jest.Mock; - -describe("utils/indoorNavigation", () => { - const originRoom = { - id: "room-a", - buildingCode: "H", - floor: 1, - label: "H-101", - roomNumber: "101", - x: 10, - y: 10, - accessible: true, - searchTerms: ["H-101", "101"], - searchKeys: ["H101", "101"], - }; - - const destinationRoom = { - ...originRoom, - id: "room-b", - floor: 2, - label: "H-201", - roomNumber: "201", - }; - - const baseAsset: BuildingPlanAsset = { - meta: { buildingId: "H" }, - nodes: [], - edges: [{ source: "n1", target: "n2", type: "walk", weight: 1, accessible: true }], - }; - - beforeEach(() => { - jest.clearAllMocks(); - mockedGetNormalizedBuildingPlan.mockReturnValue({ rooms: [] }); - mockedFindIndoorRoomMatch.mockImplementation((_: unknown, query: string) => { - if (query === "101") { - return { room: originRoom, floor: 1, matchType: "exact_label", score: 900 }; - } - if (query === "201") { - return { room: destinationRoom, floor: 2, matchType: "exact_label", score: 900 }; - } - return null; - }); - mockedGetBuildingPlanAsset.mockReturnValue(baseAsset); - mockedResolveRoutingNodeId.mockImplementation((_: unknown, roomId: string) => { - if (roomId === "room-a") return "room-a"; - if (roomId === "room-b") return "room-b"; - return null; - }); - mockedFindShortestPath.mockReturnValue({ - steps: [ - { node: { id: "room-a", x: 10, y: 10, floor: 1, type: "room", label: "H-101", accessible: true }, cumulativeDistance: 0 }, - { node: { id: "door-1", x: 15, y: 10, floor: 1, type: "doorway", label: "Door", accessible: true }, cumulativeDistance: 5 }, - { node: { id: "hall-1", x: 40, y: 10, floor: 1, type: "elevator_door", label: "Elev Lobby", accessible: true }, cumulativeDistance: 20 }, - { node: { id: "hall-2", x: 40, y: 10, floor: 2, type: "elevator_door", label: "Elev Lobby", accessible: true }, cumulativeDistance: 20 }, - { node: { id: "door-2", x: 60, y: 10, floor: 2, type: "doorway", label: "Door", accessible: true }, cumulativeDistance: 25 }, - { node: { id: "room-b", x: 80, y: 10, floor: 2, type: "room", label: "H-201", accessible: true }, cumulativeDistance: 30 }, - ], - totalDistance: 30, - fullyAccessible: true, - floors: [1, 2], - }); - }); - - it("returns NO_GRAPH_DATA when plan is missing", () => { - mockedGetNormalizedBuildingPlan.mockReturnValue(null); - const result = getIndoorNavigationRoute("H", "101", "201"); - expect(result).toEqual( - expect.objectContaining({ success: false, error: "NO_GRAPH_DATA" }), - ); - }); - - it("returns ORIGIN_NOT_FOUND and DESTINATION_NOT_FOUND errors", () => { - const noOrigin = getIndoorNavigationRoute("H", "bad", "201"); - expect(noOrigin).toEqual( - expect.objectContaining({ success: false, error: "ORIGIN_NOT_FOUND" }), - ); - - const noDestination = getIndoorNavigationRoute("H", "101", "bad"); - expect(noDestination).toEqual( - expect.objectContaining({ success: false, error: "DESTINATION_NOT_FOUND" }), - ); - }); - - it("returns SAME_ROOM when origin and destination are identical", () => { - mockedFindIndoorRoomMatch.mockReturnValue({ - room: originRoom, - floor: 1, - matchType: "exact_label", - score: 900, - }); - - const result = getIndoorNavigationRoute("H", "101", "101"); - expect(result).toEqual( - expect.objectContaining({ success: false, error: "SAME_ROOM" }), - ); - }); - - it("returns NO_GRAPH_DATA for missing edges and NO_PATH_FOUND for unresolved node/path", () => { - mockedGetBuildingPlanAsset.mockReturnValueOnce({ meta: { buildingId: "H" }, nodes: [], edges: [] }); - const missingEdges = getIndoorNavigationRoute("H", "101", "201"); - expect(missingEdges).toEqual( - expect.objectContaining({ success: false, error: "NO_GRAPH_DATA" }), - ); - - mockedResolveRoutingNodeId.mockImplementationOnce((_: unknown, roomId: string) => { - if (roomId === "room-a") return null; - return "room-b"; - }); - const unresolvedNode = getIndoorNavigationRoute("H", "101", "201"); - expect(unresolvedNode).toEqual( - expect.objectContaining({ success: false, error: "NO_PATH_FOUND" }), - ); - - mockedFindShortestPath.mockReturnValueOnce(null); - const missingPath = getIndoorNavigationRoute("H", "101", "201"); - expect(missingPath).toEqual( - expect.objectContaining({ success: false, error: "NO_PATH_FOUND" }), - ); - }); - - it("builds an indoor route with segmented instructions", () => { - const result = getIndoorNavigationRoute("H", "101", "201", { - accessibleOnly: true, - }); - - expect(result.success).toBe(true); - if (!result.success) return; - - expect(mockedFindShortestPath).toHaveBeenCalledWith( - baseAsset, - "room-a", - "room-b", - { accessibleOnly: true }, - ); - - expect(result.route.segments.map((segment) => segment.kind)).toEqual( - expect.arrayContaining(["exit_room", "walk", "elevator", "enter_room"]), - ); - expect(result.route.estimatedSeconds).toBe(Math.round(30 / 10/ 1.4)); - }); - - it("returns scaled route waypoints for a specific floor", () => { - const route = { - origin: originRoom, - destination: destinationRoom, - totalDistance: 50, - fullyAccessible: true, - estimatedSeconds: 36, - floors: [1, 2], - segments: [], - path: { - steps: [ - { node: { id: "a", x: 10, y: 20, floor: 1, type: "hallway", label: "A", accessible: true }, cumulativeDistance: 0 }, - { node: { id: "b", x: 30, y: 40, floor: 2, type: "hallway", label: "B", accessible: true }, cumulativeDistance: 10 }, - { node: { id: "c", x: 50, y: 60, floor: 2, type: "hallway", label: "C", accessible: true }, cumulativeDistance: 20 }, - ], - totalDistance: 20, - fullyAccessible: true, - floors: [1, 2], - }, - }; - - expect(getRouteWaypointsForFloor(route as any, 2, 0.5)).toEqual([ - { x: 15, y: 20 }, - { x: 25, y: 30 }, - ]); - }); -}); From 556383e9134c99c1094c5dc6196d5857c53ab949 Mon Sep 17 00:00:00 2001 From: tinaw1412 Date: Mon, 23 Mar 2026 19:29:27 -0400 Subject: [PATCH 09/15] solve chris's comments on tests --- __tests__/CampusMapScreen.test.tsx | 13 ++ __tests__/NavigationBar.test.tsx | 226 ++++++++++++++--------------- 2 files changed, 125 insertions(+), 114 deletions(-) diff --git a/__tests__/CampusMapScreen.test.tsx b/__tests__/CampusMapScreen.test.tsx index 66a179e..fb760da 100644 --- a/__tests__/CampusMapScreen.test.tsx +++ b/__tests__/CampusMapScreen.test.tsx @@ -177,6 +177,13 @@ jest.mock("../components/NavigationBar", () => { return ( {props.visible ? "visible" : "hidden"} + props.onAccessibleOnlyChange?.(!props.accessibleOnly)} + > + Toggle Accessibility + {result ? JSON.stringify(result) : "null"} @@ -1320,8 +1327,14 @@ describe("CampusMapScreen", () => { it("passes accessibleOnly true to IndoorMapScreen when accessibility mode is on", async () => { (useLocalSearchParams as jest.Mock).mockReturnValue({}); await renderScreen(); + + fireEvent.press(screen.getByTestId("accessible-mode-toggle")); + + expect(screen.getByTestId("accessible-mode-toggle").props.value).toBe(true); + fireEvent.press(screen.getByTestId("nav-confirm-accessible")); fireEvent.press(screen.getByTestId("trigger-popup-open-indoor")); + expect(router.push).toHaveBeenCalledWith({ pathname: "/IndoorMapScreen", params: expect.objectContaining({ diff --git a/__tests__/NavigationBar.test.tsx b/__tests__/NavigationBar.test.tsx index e6a472a..4300ef1 100644 --- a/__tests__/NavigationBar.test.tsx +++ b/__tests__/NavigationBar.test.tsx @@ -496,26 +496,26 @@ describe("NavigationBar", () => { expect(getByPlaceholderText(/From/).props.value).toBe(""); }); - it("should dismiss keyboard when overlay is pressed", () => { - const dismissSpy = jest.spyOn(Keyboard, "dismiss"); + it("should dismiss keyboard when overlay is pressed", () => { + const dismissSpy = jest.spyOn(Keyboard, "dismiss"); - const { UNSAFE_getAllByType } = render( - , - ); + const { UNSAFE_getAllByType } = render( + , + ); - const touchables = UNSAFE_getAllByType( - require("react-native").TouchableWithoutFeedback, - ); + const touchables = UNSAFE_getAllByType( + require("react-native").TouchableWithoutFeedback, + ); - if (touchables.length > 0) { - fireEvent.press(touchables[0]); - expect(dismissSpy).toHaveBeenCalled(); - } - }); + if (touchables.length > 0) { + fireEvent.press(touchables[0]); + expect(dismissSpy).toHaveBeenCalled(); + } + }); it("should call onClose when overlay is pressed", () => { const { UNSAFE_getAllByType } = render( @@ -1200,16 +1200,14 @@ describe("NavigationBar", () => { }); it("should not crash when autoStartBuilding is null", () => { - const { getByPlaceholderText } = render( + expect(() => render( - ); - - expect(getByPlaceholderText(/From/).props.value).toBe(""); + )).not.toThrow(); }); }); @@ -1384,107 +1382,107 @@ describe("NavigationBar", () => { }); describe("Accessible Route Toggle", () => { - it("renders the accessible route toggle button", () => { - const { getByTestId } = render( - - ); - expect(getByTestId("accessible-mode-toggle")).toBeTruthy(); - }); + it("renders the accessible route toggle button", () => { + const { getByTestId } = render( + + ); + expect(getByTestId("accessible-mode-toggle")).toBeTruthy(); + }); - it("defaults to unchecked (false) when no accessibleOnly prop is passed", () => { - const { getByTestId } = render( - - ); - expect(getByTestId("accessible-mode-toggle").props.accessibilityState).toEqual({ checked: false }); - }); + it("defaults to unchecked (false) when no accessibleOnly prop is passed", () => { + const { getByTestId } = render( + + ); + expect(getByTestId("accessible-mode-toggle").props.accessibilityState).toEqual({ checked: false }); + }); - it("defaults to checked (true) when accessibleOnly prop is true", () => { - const { getByTestId } = render( - - ); - expect(getByTestId("accessible-mode-toggle").props.accessibilityState).toEqual({ checked: true }); - }); + it("defaults to checked (true) when accessibleOnly prop is true", () => { + const { getByTestId } = render( + + ); + expect(getByTestId("accessible-mode-toggle").props.accessibilityState).toEqual({ checked: true }); + }); - it("toggles to checked when pressed once", () => { - const { getByTestId } = render( - - ); - fireEvent.press(getByTestId("accessible-mode-toggle")); - expect(getByTestId("accessible-mode-toggle").props.accessibilityState).toEqual({ checked: true }); - }); + it("toggles to checked when pressed once", () => { + const { getByTestId } = render( + + ); + fireEvent.press(getByTestId("accessible-mode-toggle")); + expect(getByTestId("accessible-mode-toggle").props.accessibilityState).toEqual({ checked: true }); + }); - it("toggles back to unchecked when pressed twice", () => { - const { getByTestId } = render( - - ); - fireEvent.press(getByTestId("accessible-mode-toggle")); - fireEvent.press(getByTestId("accessible-mode-toggle")); - expect(getByTestId("accessible-mode-toggle").props.accessibilityState).toEqual({ checked: false }); - }); + it("toggles back to unchecked when pressed twice", () => { + const { getByTestId } = render( + + ); + fireEvent.press(getByTestId("accessible-mode-toggle")); + fireEvent.press(getByTestId("accessible-mode-toggle")); + expect(getByTestId("accessible-mode-toggle").props.accessibilityState).toEqual({ checked: false }); + }); - it("calls onAccessibleOnlyChange with true when toggled on", () => { - const mockOnChange = jest.fn(); - const { getByTestId } = render( - - ); - fireEvent.press(getByTestId("accessible-mode-toggle")); - expect(mockOnChange).toHaveBeenCalledWith(true); - }); + it("calls onAccessibleOnlyChange with true when toggled on", () => { + const mockOnChange = jest.fn(); + const { getByTestId } = render( + + ); + fireEvent.press(getByTestId("accessible-mode-toggle")); + expect(mockOnChange).toHaveBeenCalledWith(true); + }); - it("calls onAccessibleOnlyChange with false when toggled off", () => { - const mockOnChange = jest.fn(); - const { getByTestId } = render( - - ); - fireEvent.press(getByTestId("accessible-mode-toggle")); - expect(mockOnChange).toHaveBeenCalledWith(false); - }); + it("calls onAccessibleOnlyChange with false when toggled off", () => { + const mockOnChange = jest.fn(); + const { getByTestId } = render( + + ); + fireEvent.press(getByTestId("accessible-mode-toggle")); + expect(mockOnChange).toHaveBeenCalledWith(false); + }); - it("passes accessibleOnly=false to onConfirm when toggle is off", () => { - const { getByText } = render( - - ); - fireEvent.press(getByText("Get Directions")); - expect(mockOnConfirm).toHaveBeenCalledWith( - null, null, - expect.objectContaining({ mode: "walking" }), - null, null, - false - ); - }); + it("passes accessibleOnly=false to onConfirm when toggle is off", () => { + const { getByText } = render( + + ); + fireEvent.press(getByText("Get Directions")); + expect(mockOnConfirm).toHaveBeenCalledWith( + null, null, + expect.objectContaining({ mode: "walking" }), + null, null, + false + ); + }); - it("passes accessibleOnly=true to onConfirm when toggle is on", () => { - const { getByTestId, getByText } = render( - - ); - fireEvent.press(getByTestId("accessible-mode-toggle")); - fireEvent.press(getByText("Get Directions")); - expect(mockOnConfirm).toHaveBeenCalledWith( - null, null, - expect.objectContaining({ mode: "walking" }), - null, null, - true - ); - }); + it("passes accessibleOnly=true to onConfirm when toggle is on", () => { + const { getByTestId, getByText } = render( + + ); + fireEvent.press(getByTestId("accessible-mode-toggle")); + fireEvent.press(getByText("Get Directions")); + expect(mockOnConfirm).toHaveBeenCalledWith( + null, null, + expect.objectContaining({ mode: "walking" }), + null, null, + true + ); + }); - it("toggle is hidden when the suggestion list is showing", () => { - const { getByPlaceholderText, queryByTestId } = render( - - ); - fireEvent.changeText(getByPlaceholderText(/From/), "Science"); - // suggestions are showing — modeSection (including toggle) is hidden - expect(queryByTestId("accessible-mode-toggle")).toBeNull(); + it("toggle is hidden when the suggestion list is showing", () => { + const { getByPlaceholderText, queryByTestId } = render( + + ); + fireEvent.changeText(getByPlaceholderText(/From/), "Science"); + // suggestions are showing — modeSection (including toggle) is hidden + expect(queryByTestId("accessible-mode-toggle")).toBeNull(); + }); }); }); -}); From e9672dee27f6806dad062f5009a839f0169f52b9 Mon Sep 17 00:00:00 2001 From: tinaw1412 Date: Mon, 23 Mar 2026 20:00:00 -0400 Subject: [PATCH 10/15] solve nick g's comment on accessible route color not triggering in routeOverlay --- app/IndoorMapScreen.tsx | 1 + components/IndoorRouteOverlay.tsx | 12 +++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/IndoorMapScreen.tsx b/app/IndoorMapScreen.tsx index bce3ef3..3b6b024 100644 --- a/app/IndoorMapScreen.tsx +++ b/app/IndoorMapScreen.tsx @@ -562,6 +562,7 @@ export default function IndoorMapScreen() { coordinateScale={coordinateScale} stageLayout={floorStageLayout} floorBounds={floorBounds} + accessibleOnly={accessibleOnly} /> )} diff --git a/components/IndoorRouteOverlay.tsx b/components/IndoorRouteOverlay.tsx index 13dd586..012e9ca 100644 --- a/components/IndoorRouteOverlay.tsx +++ b/components/IndoorRouteOverlay.tsx @@ -27,6 +27,7 @@ interface IndoorRouteOverlayProps { coordinateScale: number; stageLayout: FloorStageLayout; floorBounds: FloorBounds; + accessibleOnly?: boolean; } const ROUTE_COLOR = "#3B82F6"; @@ -44,6 +45,7 @@ export function IndoorRouteOverlay({ coordinateScale, stageLayout, floorBounds, + accessibleOnly, }: IndoorRouteOverlayProps) { const { points, originPoint, destPoint } = useMemo(() => { const waypoints = getRouteWaypointsForFloor(route, floor, coordinateScale); @@ -68,9 +70,9 @@ export function IndoorRouteOverlay({ if (!points || !originPoint) return null; - const isAccessible = route.fullyAccessible; - const mainColor = isAccessible ? ACCESSIBLE_ROUTE_COLOR : ROUTE_COLOR; - const alphaColor = isAccessible ? ACCESSIBLE_ROUTE_COLOR_ALPHA : ROUTE_COLOR_ALPHA; + const mainColor = accessibleOnly ? ACCESSIBLE_ROUTE_COLOR : ROUTE_COLOR; + const alphaColor = accessibleOnly ? ACCESSIBLE_ROUTE_COLOR_ALPHA : ROUTE_COLOR_ALPHA; + return ( Date: Mon, 23 Mar 2026 20:10:40 -0400 Subject: [PATCH 11/15] solve clark's comment to stick with the nulish coalescing operator? --- app/CampusMapScreen.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/app/CampusMapScreen.tsx b/app/CampusMapScreen.tsx index db94ff8..fabc12f 100644 --- a/app/CampusMapScreen.tsx +++ b/app/CampusMapScreen.tsx @@ -18,13 +18,13 @@ import { useShuttleAvailability } from "../hooks/useShuttleAvailability"; import { RouteStrategy } from "../services/Routing"; import { styles } from "../styles/CampusMapScreen.styles"; import { - buildIndoorMapRouteParams, - getIndoorAccessState, + buildIndoorMapRouteParams, + getIndoorAccessState, } from "../utils/indoorAccess"; import { IndoorRoomRecord } from "../utils/indoorBuildingPlan"; import { - getNextClassFromItems, - loadCachedSchedule, + getNextClassFromItems, + loadCachedSchedule, } from "../utils/parseCourseEvents"; import { getDistanceToPolygon } from "../utils/pointInPolygon"; @@ -146,11 +146,7 @@ export default function CampusMapScreen() { ...params, ...(navOrigin ? { navOrigin } : {}), ...(navDest ? { navDest } : {}), - accessibleOnly: String( - accessibleOnlyOverride !== undefined - ? accessibleOnlyOverride - : accessibleOnly, - ), + accessibleOnly: String(accessibleOnlyOverride ?? accessibleOnly), }, }); }, From 8fefdf963e76bedb0b8b4c3748d61eb20e7e46a5 Mon Sep 17 00:00:00 2001 From: tinaw1412 Date: Mon, 23 Mar 2026 20:46:27 -0400 Subject: [PATCH 12/15] REFACTOR: dead code elimination of setSelectedBuilding useState call --- app/CampusMapScreen.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/CampusMapScreen.tsx b/app/CampusMapScreen.tsx index fabc12f..ef682b4 100644 --- a/app/CampusMapScreen.tsx +++ b/app/CampusMapScreen.tsx @@ -61,7 +61,6 @@ export default function CampusMapScreen() { const [currentCampus, setCurrentCampus] = useState( campus === "loyola" ? "loyola" : "sgw", ); - const [, setSelectedBuilding] = useState(null); const [focusTarget, setFocusTarget] = useState( campus === "loyola" ? "loyola" : "sgw", ); @@ -211,7 +210,6 @@ export default function CampusMapScreen() { const handleViewBuildingIndoorMap = useCallback( (building: Buildings) => { - setSelectedBuilding(null); openIndoorMap(building.name); }, [openIndoorMap], @@ -273,7 +271,6 @@ export default function CampusMapScreen() { setIsNavVisible(true); }} onSetAsMyLocation={(building) => setDemoCurrentBuilding(building)} - onBuildingSelected={(building) => setSelectedBuilding(building)} onViewIndoorMap={handleViewBuildingIndoorMap} /> From c7f9e4994bc0154667fa1222edfd351d2526a53a Mon Sep 17 00:00:00 2001 From: tinaw1412 Date: Mon, 23 Mar 2026 21:05:15 -0400 Subject: [PATCH 13/15] REFACTOR: dead code elimination of constant floorSummaryText --- app/IndoorMapScreen.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/app/IndoorMapScreen.tsx b/app/IndoorMapScreen.tsx index 3b6b024..11aa83f 100644 --- a/app/IndoorMapScreen.tsx +++ b/app/IndoorMapScreen.tsx @@ -401,16 +401,6 @@ export default function IndoorMapScreen() { } }, []); - const floorSummaryText = activeRoute - ? `${activeRoute.origin.label} → ${activeRoute.destination.label}` - : selectedRoomOnCurrentFloor - ? `${selectedRoomOnCurrentFloor.label}${selectedRoomOnCurrentFloor.roomName - ? ` - ${selectedRoomOnCurrentFloor.roomName}` - : "" - }` - : normalizedBuildingPlan - ? "Search a room to pin it on the floor plan." - : "Floor overview"; return ( From e4ceb345352ff9f3a19c5c4e94fe3e309401b338 Mon Sep 17 00:00:00 2001 From: tinaw1412 Date: Mon, 23 Mar 2026 21:38:39 -0400 Subject: [PATCH 14/15] REFACTOR: Extract Method to reduce the cognitive complexity of function IndoorMapScreen() --- app/IndoorMapScreen.tsx | 76 +++++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 30 deletions(-) diff --git a/app/IndoorMapScreen.tsx b/app/IndoorMapScreen.tsx index 11aa83f..641b806 100644 --- a/app/IndoorMapScreen.tsx +++ b/app/IndoorMapScreen.tsx @@ -180,6 +180,44 @@ function getFloorStageLayout( }; } +function trimParam(val: unknown): string { + return typeof val === "string" ? val.trim() : ""; +} + +function useFloorSync(availableFloors: number[], selectedFloor: number, setSelectedFloor: (f: number) => void) { + useEffect(() => { + if (!availableFloors.includes(selectedFloor)) { + setSelectedFloor(availableFloors[0] || 1); + } + }, [availableFloors, selectedFloor]); +} + +function useInitialRoomQuery( + initialRoomQuery: string, + availableFloors: number[], + setSearchQuery: (q: string) => void, + performRoomSearch: (q: string, floor: number) => void, +) { + useEffect(() => { + if (!initialRoomQuery) return; + setSearchQuery(initialRoomQuery); + performRoomSearch(initialRoomQuery, availableFloors[0] || 1); + }, [availableFloors, initialRoomQuery, performRoomSearch]); +} + +function useNavAutoTrigger( + buildingName: string | undefined, + navOrigin: string | undefined, + navDest: string | undefined, + handleNavigate: () => void, +) { + useEffect(() => { + if (buildingName && trimParam(navOrigin) && trimParam(navDest)) { + handleNavigate(); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps +} + export default function IndoorMapScreen() { const { buildingName, @@ -222,9 +260,7 @@ export default function IndoorMapScreen() { const [navError, setNavError] = useState(null); const [activeRoute, setActiveRoute] = useState(null); - const initialRoomQuery = - typeof roomQuery === "string" ? roomQuery.trim() : ""; - + const initialRoomQuery = trimParam(roomQuery); const mapKey = `${buildingName}-${selectedFloor}`; const floorImageMetadata = getFloorImageMetadata( buildingName || "", @@ -242,11 +278,8 @@ export default function IndoorMapScreen() { setSelectedRoom(null); }, [buildingName]); - useEffect(() => { - if (!availableFloors.includes(selectedFloor)) { - setSelectedFloor(availableFloors[0] || 1); - } - }, [availableFloors, selectedFloor]); + useFloorSync(availableFloors, selectedFloor, setSelectedFloor); + const currentFloorRooms = useMemo( () => normalizedBuildingPlan?.roomsByFloor[selectedFloor] ?? [], @@ -274,15 +307,12 @@ export default function IndoorMapScreen() { const effectiveViewport = useMemo( () => ({ - width: - mapViewport.width > 0 ? mapViewport.width : Math.max(windowWidth, 320), - height: - mapViewport.height > 0 - ? mapViewport.height - : Math.max(windowHeight * 0.44, DEFAULT_VIEWPORT_HEIGHT), + width: mapViewport.width || Math.max(windowWidth, 320), + height: mapViewport.height || Math.max(windowHeight * 0.44, DEFAULT_VIEWPORT_HEIGHT), }), [mapViewport, windowHeight, windowWidth], ); + const floorStageLayout = useMemo( () => getFloorStageLayout(effectiveViewport, floorImageDimensions, floorBounds), @@ -361,11 +391,7 @@ export default function IndoorMapScreen() { [buildingName, normalizedBuildingPlan], ); - useEffect(() => { - if (!initialRoomQuery) return; - setSearchQuery(initialRoomQuery); - performRoomSearch(initialRoomQuery, availableFloors[0] || 1); - }, [availableFloors, initialRoomQuery, performRoomSearch]); + useInitialRoomQuery(initialRoomQuery, availableFloors, setSearchQuery, performRoomSearch); const handleNavigate = useCallback(() => { if (!buildingName) return; @@ -389,17 +415,7 @@ export default function IndoorMapScreen() { } }, [buildingName, navOriginQuery, navDestQuery, accessibleOnly]); - useEffect(() => { - if ( - buildingName && - typeof navOrigin === "string" && - navOrigin.trim() && - typeof navDest === "string" && - navDest.trim() - ) { - handleNavigate(); - } - }, []); + useNavAutoTrigger(buildingName, navOrigin, navDest, handleNavigate); return ( From c6470de8d93bd4ca2228f2d78acf13a2e8596674 Mon Sep 17 00:00:00 2001 From: Jessicuta Date: Mon, 23 Mar 2026 22:03:00 -0400 Subject: [PATCH 15/15] add the boundingBox of VE --- constants/buildings.ts | 174 +++++++++++++++++++++++++---------------- 1 file changed, 108 insertions(+), 66 deletions(-) diff --git a/constants/buildings.ts b/constants/buildings.ts index 3e22b5e..55d586d 100644 --- a/constants/buildings.ts +++ b/constants/buildings.ts @@ -18,7 +18,8 @@ export const BUILDINGS: Buildings[] = [ address: "7141 Sherbrooke St W, Montreal, QC H4B 1R6", coordinates: { latitude: 45.4576633, longitude: -73.6413024 }, icons: ["information", "wheelchair"], - departments: ["Biology", + departments: [ + "Biology", "Centre for Biological Applications of Mass Spectrometry", "Centre for NanoScience Research", "Centre for Research in Molecular Modeling", @@ -26,11 +27,14 @@ export const BUILDINGS: Buildings[] = [ "Chemistry and Biochemistry", "Health, Kinesiology and Applied Physiology", "Physics", - "Psychology"], - services: ["Café", + "Psychology", + ], + services: [ + "Café", "Campus Safety and Prevention Services", "First Stop", - "Science College"], + "Science College", + ], boundingBox: [ { latitude: 45.4569746, longitude: -73.6408295 }, { latitude: 45.4570166, longitude: -73.6409402 }, @@ -233,8 +237,7 @@ export const BUILDINGS: Buildings[] = [ displayName: "Physical Services (PS)", address: "7141 Sherbrooke St W, Montreal, QC H4B 1R6", coordinates: { latitude: 45.4596178, longitude: -73.6397704 }, - services: ["Environmental Health and Safety", - "Facilities Management"], + services: ["Environmental Health and Safety", "Facilities Management"], boundingBox: [ { latitude: 45.4593968, longitude: -73.6395352 }, { latitude: 45.4594378, longitude: -73.6396447 }, @@ -258,8 +261,7 @@ export const BUILDINGS: Buildings[] = [ address: "7141 Sherbrooke St W, Montreal, QC H4B 1R6", coordinates: { latitude: 45.45750475, longitude: -73.6402663 }, icons: ["wheelchair"], - departments: ["Communication Studies", - "Journalism"], + departments: ["Communication Studies", "Journalism"], services: ["Book Stop"], boundingBox: [ { latitude: 45.4571693, longitude: -73.6403939 }, @@ -307,9 +309,7 @@ export const BUILDINGS: Buildings[] = [ address: "7141 Sherbrooke St W, Montreal, QC H4B 1R6", coordinates: { latitude: 45.4591611, longitude: -73.63919545 }, icons: ["wheelchair"], - services: ["Cafeteria", - "Café", - "Food Services"], + services: ["Cafeteria", "Café", "Food Services"], boundingBox: [ { latitude: 45.4592282, longitude: -73.6389487 }, { latitude: 45.4591458, longitude: -73.6390148 }, @@ -417,10 +417,12 @@ export const BUILDINGS: Buildings[] = [ address: "7200 Sherbrooke St W, Montreal, QC H4B 2A4", coordinates: { latitude: 45.4569675, longitude: -73.63732315 }, icons: ["information", "parking", "wheelchair"], - services: ["Athletic Therapy Clinic", + services: [ + "Athletic Therapy Clinic", "Nutrition Centre", "PERFORM Gym", - "School of Health"], + "School of Health", + ], boundingBox: [ { latitude: 45.4572701, longitude: -73.6376525 }, { latitude: 45.4570453, longitude: -73.6378295 }, @@ -479,8 +481,10 @@ export const BUILDINGS: Buildings[] = [ address: "7141 Sherbrooke St W, Montreal, QC H4B 1R6", coordinates: { latitude: 45.45858305, longitude: -73.6410027 }, icons: ["wheelchair"], - services: ["Conference services", - "Loyola Jesuit Hall and Conference Centre"], + services: [ + "Conference services", + "Loyola Jesuit Hall and Conference Centre", + ], boundingBox: [ { latitude: 45.4583412, longitude: -73.6409057 }, { latitude: 45.4583493, longitude: -73.6408995 }, @@ -511,9 +515,11 @@ export const BUILDINGS: Buildings[] = [ address: "7141 Sherbrooke St W, Montreal, QC H4B 1R6", coordinates: { latitude: 45.45823045, longitude: -73.64035915 }, icons: ["information", "bike", "wheelchair"], - services: ["Concordia Student Union", + services: [ + "Concordia Student Union", "Loyola College for Diversity and Sustainability and Loyola", - "Zen Den"], + "Zen Den", + ], boundingBox: [ { latitude: 45.45837846246661, longitude: -73.6407936233295 }, { latitude: 45.45838881002474, longitude: -73.64079261750112 }, @@ -540,7 +546,8 @@ export const BUILDINGS: Buildings[] = [ coordinates: { latitude: 45.45808355, longitude: -73.63977735 }, icons: ["information", "bike", "parking", "wheelchair"], departments: ["Faculty of Arts and Science"], - services: ["Access Centre for Students with Disabilities", + services: [ + "Access Centre for Students with Disabilities", "Centre for Teaching and Learning", "Counselling and Psychological Services", "Health Services", @@ -548,7 +555,8 @@ export const BUILDINGS: Buildings[] = [ "Loyola Landing", "Office of the Provost and Vice-President, Academic", "Office of Student Life and Engagement", - "Welcome Crew Office"], + "Welcome Crew Office", + ], boundingBox: [ { latitude: 45.457798967810064, longitude: -73.63982640602099 }, { latitude: 45.45782083900776, longitude: -73.63980930693852 }, @@ -661,7 +669,10 @@ export const BUILDINGS: Buildings[] = [ campusName: "Loyola", displayName: "BB Annex (BB)", address: "3502 Avenue BelmoreMontréal, QC H4B 2B9", - coordinates: { latitude: 45.459824341726055, longitude: -73.63932129841155 }, + coordinates: { + latitude: 45.459824341726055, + longitude: -73.63932129841155, + }, services: ["CPE Les P'tits Profs Daycare"], boundingBox: [ { latitude: 45.459847629799036, longitude: -73.63923764922974 }, @@ -728,8 +739,10 @@ export const BUILDINGS: Buildings[] = [ address: "7141 Sherbrooke St W, Montreal, QC H4B 1R6", coordinates: { latitude: 45.45924054914103, longitude: -73.64189015927725 }, icons: ["parking", "wheelchair"], - services: ["Concordia University Faculty Association (CUFA)", - "Student Residence"], + services: [ + "Concordia University Faculty Association (CUFA)", + "Student Residence", + ], boundingBox: [ { latitude: 45.45896902854732, longitude: -73.64180873293778 }, { latitude: 45.45899607301431, longitude: -73.64187712926766 }, @@ -754,8 +767,10 @@ export const BUILDINGS: Buildings[] = [ address: "7141 Sherbrooke St W, Montreal, QC H4B 1R6", coordinates: { latitude: 45.45900554313173, longitude: -73.64044802685575 }, icons: ["information", "wheelchair"], - departments: ["Centre for Clinical Research in Health (CCRH)", - "Psychology"], + departments: [ + "Centre for Clinical Research in Health (CCRH)", + "Psychology", + ], boundingBox: [ { latitude: 45.45884699152768, longitude: -73.64083336928228 }, { latitude: 45.45918093171645, longitude: -73.64057855942586 }, @@ -810,7 +825,15 @@ export const BUILDINGS: Buildings[] = [ icons: ["bike", "wheelchair"], departments: ["Applied Human Sciences"], services: ["Library"], - boundingBox: [], + boundingBox: [ + { latitude: 45.4590522, longitude: -73.6388647 }, + { latitude: 45.4588552, longitude: -73.639021 }, + { latitude: 45.4586289, longitude: -73.6384671 }, + { latitude: 45.4587188, longitude: -73.6383987 }, + { latitude: 45.4587051, longitude: -73.6383652 }, + { latitude: 45.4588359, longitude: -73.6382599 }, + { latitude: 45.4590522, longitude: -73.6388647 }, + ], }, { name: "B", @@ -855,7 +878,7 @@ export const BUILDINGS: Buildings[] = [ address: "1665 Rue Sainte-Catherine O, Montréal, Quebec H3H 1L9", coordinates: { latitude: 45.49422516183, - longitude: -73.579296874067 + longitude: -73.579296874067, }, icons: ["information", "printer", "wheelchair"], boundingBox: [ @@ -915,7 +938,8 @@ export const BUILDINGS: Buildings[] = [ longitude: -73.58001, }, icons: ["printer", "wheelchair"], - departments: ["Applied AI Institute", + departments: [ + "Applied AI Institute", "Centre for Engineering in Society", "Computer Science and Software Engineering", "Creative Arts Therapies", @@ -923,7 +947,8 @@ export const BUILDINGS: Buildings[] = [ "Next-Generation Cities Institute", "Simone de Beauvoir Institute", "Sustainability in the Digital Age", - "Urban Studies"], + "Urban Studies", + ], boundingBox: [ { latitude: 45.49615963345298, longitude: -73.58006721968488 }, { latitude: 45.49625880934598, longitude: -73.5803495221837 }, @@ -944,7 +969,8 @@ export const BUILDINGS: Buildings[] = [ longitude: -73.577982, }, icons: ["information", "printer", "wheelchair"], - departments: ["Art Education", + departments: [ + "Art Education", "Art History", "Building, Civil and Environmental Engineering", "Centre for Composites (CONCOM)", @@ -958,9 +984,9 @@ export const BUILDINGS: Buildings[] = [ "Gina Cody School of Engineering and Computer Science", "Mechanical, Industrial and Aerospace Engineering", "Recreation and Athletics", - "Studio Arts"], - services: ["Le Gym", - "Zen Den"], + "Studio Arts", + ], + services: ["Le Gym", "Zen Den"], boundingBox: [ { latitude: 45.496065, longitude: -73.577714 }, { latitude: 45.495757, longitude: -73.578006 }, @@ -1003,11 +1029,14 @@ export const BUILDINGS: Buildings[] = [ longitude: -73.577663, }, icons: ["parking", "wheelchair"], - departments: ["Classics, Modern Languages and Linguistics", + departments: [ + "Classics, Modern Languages and Linguistics", "Concordia Continuing Education", "Mel Hoppenheim School of Cinema", - "Theological Studies"], - services: ["Enrolment Services", + "Theological Studies", + ], + services: [ + "Enrolment Services", "Office of the Registrar", "Student Recruitment", "Environmental Health and Safety", @@ -1016,7 +1045,8 @@ export const BUILDINGS: Buildings[] = [ "Office of Sustainability", "Records Management and Archives (RMA) Office", "Senior Non-Credit Program", - "Study Hub"], + "Study Hub", + ], boundingBox: [ { latitude: 45.494893, longitude: -73.577759 }, { latitude: 45.494844, longitude: -73.577823 }, @@ -1097,10 +1127,9 @@ export const BUILDINGS: Buildings[] = [ longitude: -73.578742, }, icons: ["information", "bike", "wheelchair"], - departments: ["Contemporary Dance", - "Music", - "Theatre"], - services: ["Access Centre for Students with Disabilities", + departments: ["Contemporary Dance", "Music", "Theatre"], + services: [ + "Access Centre for Students with Disabilities", "Facilities Management", "Financial Aid and Awards Office", "Financial Services", @@ -1114,7 +1143,8 @@ export const BUILDINGS: Buildings[] = [ "Office of the Vice-President, Services and Sustainability", "Ombuds Office", "University Communications Services", - "Zen Den"], + "Zen Den", + ], boundingBox: [ { latitude: 45.496128, longitude: -73.57878 }, { latitude: 45.495781, longitude: -73.579123 }, @@ -1136,12 +1166,14 @@ export const BUILDINGS: Buildings[] = [ longitude: -73.576875, }, icons: ["wheelchair"], - departments: ["Philosophy (1175 St-Mathieu St.)]", + departments: [ + "Philosophy (1175 St-Mathieu St.)]", "Services=[Concordia Daycare (1185 St-Mathieu St.)", "Concordia University Student Parents Centre (CUSP): 1175 St-Mathieu St.", "Grey Nuns Reading Room & Group Study Rooms (1190 Guy St.)", "Residences", - "Summer accommodations"], + "Summer accommodations", + ], boundingBox: [ { latitude: 45.494399, longitude: -73.577101 }, { latitude: 45.494141, longitude: -73.577359 }, @@ -1279,13 +1311,16 @@ export const BUILDINGS: Buildings[] = [ longitude: -73.578915, }, icons: ["information", "printer", "bike", "wheelchair"], - departments: ["Economics", + departments: [ + "Economics", "Geography, Planning and Environment", "Political Science", "School of Community and Public Affairs", "School of Irish Studies", - "Sociology and Anthropology"], - services: ["Campus Safety and Prevention Services", + "Sociology and Anthropology", + ], + services: [ + "Campus Safety and Prevention Services", "Concordia Student Union (CSU)", "First Stop", "Espace Franco", @@ -1296,7 +1331,8 @@ export const BUILDINGS: Buildings[] = [ "Sexual Assault Resource Centre (SARC)", "Student Success Centre", "Welcome Crew Office", - "Zen Den"], + "Zen Den", + ], boundingBox: [ { latitude: 45.49682818364492, longitude: -73.57884845912251 }, { latitude: 45.497163503733475, longitude: -73.57954223351966 }, @@ -1334,14 +1370,17 @@ export const BUILDINGS: Buildings[] = [ longitude: -73.577936, }, icons: ["information", "printer", "bike", "parking", "wheelchair"], - departments: ["Centre for Interdisciplinary Studies in Society and Culture (CISSC)", + departments: [ + "Centre for Interdisciplinary Studies in Society and Culture (CISSC)", "Centre for the Study of Learning and Performance", "Education", "English", "Études françaises", "History", - "Mathematics and Statistics"], - services: ["Birks Student Service Centre", + "Mathematics and Statistics", + ], + services: [ + "Birks Student Service Centre", "Concordia Book Stop", "Concordia Print Services", "First Stop", @@ -1349,7 +1388,8 @@ export const BUILDINGS: Buildings[] = [ "J.A. De Sève Cinema", "R. Howard Webster Library", "SHIFT Centre for Social Transformation", - "Welcome Centre"], + "Welcome Centre", + ], boundingBox: [ { latitude: 45.497262, longitude: -73.578049 }, { latitude: 45.496987, longitude: -73.578314 }, @@ -1374,8 +1414,8 @@ export const BUILDINGS: Buildings[] = [ displayName: "LD Building (LD)", address: "1424 Bishop St, Montreal, Quebec H3G 2E6", coordinates: { - latitude: 45.496699, - longitude: -73.577267 + latitude: 45.496699, + longitude: -73.577267, }, services: ["CSU Daycare & Nursery"], boundingBox: [ @@ -1435,7 +1475,8 @@ export const BUILDINGS: Buildings[] = [ longitude: -73.579024, }, icons: ["wheelchair"], - departments: ["Accountancy", + departments: [ + "Accountancy", "Contemporary Dance", "Executive MBA Program", "Finance", @@ -1444,12 +1485,15 @@ export const BUILDINGS: Buildings[] = [ "Marketing", "Music", "Supply Chain and Business Technology Management", - "Theatre"], - services: ["Career Management Services", + "Theatre", + ], + services: [ + "Career Management Services", "First Stop", "John Molson Executive Centre", "Performing Arts Facilities", - "Zen Den"], + "Zen Den", + ], boundingBox: [ { latitude: 45.49511782033129, longitude: -73.57929809900001 }, { latitude: 45.49524379011677, longitude: -73.57916667075828 }, @@ -1468,11 +1512,13 @@ export const BUILDINGS: Buildings[] = [ latitude: 45.497785, longitude: -73.579321, }, - services: ["Association of Concordia University Management and Administrative Employees (ACUMAE)", + services: [ + "Association of Concordia University Management and Administrative Employees (ACUMAE)", "Concordia University Continuing Education Part-time Faculty Union (CUCEPTFU)", "Concordia University Professional Employee Union (CUPEU)", "Concordia University Support Staff Union (CSN)", - "The Concordia University Union of Support Staff – Technical Sector (CUUSS-TS)"], + "The Concordia University Union of Support Staff – Technical Sector (CUUSS-TS)", + ], boundingBox: [ { latitude: 45.49764635969653, longitude: -73.57940146021683 }, { latitude: 45.49762238887882, longitude: -73.57935351573069 }, @@ -1686,8 +1732,7 @@ export const BUILDINGS: Buildings[] = [ latitude: 45.497012, longitude: -73.579908, }, - services: ["Centre for Gender Advocacy", - "CUTV"], + services: ["Centre for Gender Advocacy", "CUTV"], boundingBox: [ { latitude: 45.497086, longitude: -73.579896 }, { latitude: 45.496967, longitude: -73.580014 }, @@ -1706,9 +1751,7 @@ export const BUILDINGS: Buildings[] = [ longitude: -73.57386, }, icons: ["information", "bike", "wheelchair"], - departments: ["Art Education", - "Art History", - "Studio Arts"], + departments: ["Art Education", "Art History", "Studio Arts"], services: ["VAV Gallery"], boundingBox: [ { latitude: 45.496167, longitude: -73.573787 }, @@ -1747,8 +1790,7 @@ export const BUILDINGS: Buildings[] = [ latitude: 45.496913, longitude: -73.579776, }, - services: ["Multi-Faith and Spirituality Centre", - "Sustainable Concordia"], + services: ["Multi-Faith and Spirituality Centre", "Sustainable Concordia"], boundingBox: [ { latitude: 45.496996, longitude: -73.579754 }, { latitude: 45.496901, longitude: -73.579846 },