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..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"} @@ -198,12 +205,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 +231,7 @@ jest.mock("../components/NavigationBar", () => { mode: "walking", label: "Walk", icon: "walk", - }) + }, null, null, false) } > Confirm Null Start @@ -917,6 +937,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 +955,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 +1162,7 @@ describe("CampusMapScreen", () => { buildingName: "MB", floors: JSON.stringify([1, -2]), roomQuery: "MB-1.210", + accessibleOnly: "false", }, }); }); @@ -1156,6 +1179,7 @@ describe("CampusMapScreen", () => { params: { buildingName: "H", floors: JSON.stringify([1, 2, 8]), + accessibleOnly: "false", }, }); }); @@ -1295,10 +1319,30 @@ 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("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({ + 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 +1354,7 @@ describe("CampusMapScreen", () => { params: { buildingName: "H", floors: JSON.stringify([1, 2, 8]), + accessibleOnly: "false", }, }); }); 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 7517953..4300ef1 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,16 +478,28 @@ describe("NavigationBar", () => { null, expect.objectContaining({ mode: "walking", label: "Walk", icon: "walk" }), null, - null + null, + false ); }); - }); - describe("Overlay Interaction", () => { + it("should not crash when autoStartBuilding is null", () => { + const { getByPlaceholderText } = render( + , + ); + + expect(getByPlaceholderText(/From/).props.value).toBe(""); + }); + it("should dismiss keyboard when overlay is pressed", () => { const dismissSpy = jest.spyOn(Keyboard, "dismiss"); - const { getByTestId, UNSAFE_getAllByType } = render( + const { UNSAFE_getAllByType } = render( { /> ); + // Use the correct accessibility label for the building picker button const listButton = getAllByLabelText("Pick from list")[0]; fireEvent.press(listButton); @@ -981,6 +996,7 @@ describe("NavigationBar", () => { expect.anything(), expect.objectContaining({ useNativeDriver: true, + damping: 20, bounciness: 4, }), ); @@ -1157,21 +1173,41 @@ 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 ); }); it("should not crash when autoStartBuilding is null", () => { - const { getByPlaceholderText } = render( + expect(() => render( - ); - - expect(getByPlaceholderText(/From/).props.value).toBe(""); + )).not.toThrow(); }); }); @@ -1240,6 +1276,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 +1299,7 @@ describe("NavigationBar", () => { /> ); + // Use the correct accessibility label for the building picker button const listButton = getAllByLabelText("Pick from list")[1]; fireEvent.press(listButton); @@ -1342,4 +1380,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__/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/__tests__/indoorNavigation.test.ts b/__tests__/indoorNavigation.test.ts deleted file mode 100644 index 18ad18a..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 / 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 }, - ]); - }); -}); 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 diff --git a/app/CampusMapScreen.tsx b/app/CampusMapScreen.tsx index b9c8a58..ef682b4 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) => { @@ -59,9 +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", ); @@ -136,6 +135,7 @@ export default function CampusMapScreen() { roomQuery?: string, navOrigin?: string, navDest?: string, + accessibleOnlyOverride?: boolean, ) => { const params = buildIndoorMapRouteParams(buildingCode, roomQuery); if (!params) return; @@ -145,10 +145,11 @@ export default function CampusMapScreen() { ...params, ...(navOrigin ? { navOrigin } : {}), ...(navDest ? { navDest } : {}), + accessibleOnly: String(accessibleOnlyOverride ?? accessibleOnly), }, }); }, - [], + [accessibleOnly], ); const handleConfirmRoute = useCallback( @@ -158,16 +159,37 @@ 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) { + 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; } @@ -188,7 +210,6 @@ export default function CampusMapScreen() { const handleViewBuildingIndoorMap = useCallback( (building: Buildings) => { - setSelectedBuilding(null); openIndoorMap(building.name); }, [openIndoorMap], @@ -250,7 +271,6 @@ export default function CampusMapScreen() { setIsNavVisible(true); }} onSetAsMyLocation={(building) => setDemoCurrentBuilding(building)} - onBuildingSelected={(building) => setSelectedBuilding(building)} onViewIndoorMap={handleViewBuildingIndoorMap} /> @@ -410,6 +430,8 @@ export default function CampusMapScreen() { onInitialDestinationApplied={() => setInitialDestination(null)} currentCampus={currentCampus} onUseMyLocation={() => demoCurrentBuilding ?? autoStartBuilding ?? null} + accessibleOnly={accessibleOnly} + onAccessibleOnlyChange={setAccessibleOnly} /> 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, ); @@ -179,15 +180,64 @@ 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, floors, roomQuery, navOrigin, navDest } = - useLocalSearchParams<{ - buildingName: string; - floors: string; - roomQuery?: string; - navOrigin?: string; - navDest?: 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", + ); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const availableFloors = useMemo(() => parseFloors(floors), [floors]); const [selectedFloor, setSelectedFloor] = useState(availableFloors[0] || 1); @@ -210,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 || "", @@ -230,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] ?? [], @@ -262,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), @@ -295,12 +337,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, }; }, [ @@ -349,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; @@ -364,7 +402,7 @@ export default function IndoorMapScreen() { navOriginQuery, navDestQuery, { - accessibleOnly: false, + accessibleOnly: accessibleOnly, }, ); @@ -375,47 +413,56 @@ export default function IndoorMapScreen() { setNavError(result.message); setActiveRoute(null); } - }, [buildingName, navOriginQuery, navDestQuery]); + }, [buildingName, navOriginQuery, navDestQuery, accessibleOnly]); + + useNavAutoTrigger(buildingName, navOrigin, navDest, handleNavigate); - useEffect(() => { - if ( - buildingName && - typeof navOrigin === "string" && - navOrigin.trim() && - typeof navDest === "string" && - navDest.trim() - ) { - handleNavigate(); - } - }, []); - - 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 ( - {buildingName} Building + + {buildingName} Building + setAccessibleOnly((prev) => !prev)} + style={[ + styles.accessibleToggle, + accessibleOnly && styles.accessibleToggleActive, + ]} + > + + + Accessible + + + + + + {navError && ( {navError} @@ -519,6 +568,7 @@ export default function IndoorMapScreen() { coordinateScale={coordinateScale} stageLayout={floorStageLayout} floorBounds={floorBounds} + accessibleOnly={accessibleOnly} /> )} diff --git a/assets/maps/buildingsPlan/hall.json b/assets/maps/buildingsPlan/hall.json index b348d0b..2869831 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", @@ -11064,75 +11064,89 @@ "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", "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/IndoorRouteOverlay.tsx b/components/IndoorRouteOverlay.tsx index 9f19fb9..012e9ca 100644 --- a/components/IndoorRouteOverlay.tsx +++ b/components/IndoorRouteOverlay.tsx @@ -27,10 +27,13 @@ interface IndoorRouteOverlayProps { coordinateScale: number; stageLayout: FloorStageLayout; floorBounds: FloorBounds; + accessibleOnly?: boolean; } 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; @@ -42,6 +45,7 @@ export function IndoorRouteOverlay({ coordinateScale, stageLayout, floorBounds, + accessibleOnly, }: IndoorRouteOverlayProps) { const { points, originPoint, destPoint } = useMemo(() => { const waypoints = getRouteWaypointsForFloor(route, floor, coordinateScale); @@ -66,11 +70,14 @@ export function IndoorRouteOverlay({ if (!points || !originPoint) return null; + const mainColor = accessibleOnly ? ACCESSIBLE_ROUTE_COLOR : ROUTE_COLOR; + const alphaColor = accessibleOnly ? 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,8 @@ 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 +254,7 @@ export default function NavigationBar({ selectedStrategy, startRoom, endRoom, + localAccessibleOnly, ); onClose(); }; @@ -425,7 +433,10 @@ export default function NavigationBar({ Room {startRoom.label} - setStartRoom(null)}> + setStartRoom(null)} + > Room {endRoom.label} - setEndRoom(null)}> + setEndRoom(null)} + > + + { + 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 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 }, 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, +}, }); diff --git a/utils/indoorNavigation.ts b/utils/indoorNavigation.ts index b69c00c..196f397 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,7 @@ export function getIndoorNavigationRoute( destQuery: string, options: PathfindingOptions = {}, ): NavigationResult { - // 1. Resolve rooms via existing search infrastructure + console.log("findShortestPath options:", options); const plan = getNormalizedBuildingPlan(buildingCode); if (!plan) { return { @@ -238,7 +210,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 +219,6 @@ export function getIndoorNavigationRoute( }; } - // 3. Map rooms to graph node ids const originNodeId = resolveRoutingNodeId( asset, originMatch.room.id, @@ -272,18 +242,18 @@ export function getIndoorNavigationRoute( }; } - // 4. Run Dijkstra const path = findShortestPath(asset, originNodeId, destNodeId, options); if (!path) { return { 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.", }; } - // 5. Build segments and return const segments = buildSegments(path); return { @@ -296,19 +266,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..12fa3c1 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; @@ -20,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; @@ -83,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; @@ -96,18 +82,73 @@ class MinHeap { } } -// --------------------------------------------------------------------------- -// Graph builder -// --------------------------------------------------------------------------- +//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; accessible: boolean; + edgeType: string; } function buildAdjacencyList( - edges: BuildingPlanEdge[], + asset: BuildingPlanAsset, + nodeMap: Map, + accessibleOnly: boolean, ): Map { const adj = new Map(); @@ -116,35 +157,73 @@ 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); + 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, + edge.weight, + edge.accessible, + edge.type ?? "", + ); + add( + edge.target, + edge.source, + edge.weight, + edge.accessible, + edge.type ?? "", + ); } return adj; } + export function findShortestPath( asset: BuildingPlanAsset, originId: string, destinationId: string, options: PathfindingOptions = {}, ): IndoorPath | null { - const { accessibleOnly = false } = options; + const accessibleOnly = + options.accessibleOnly === true || + String(options.accessibleOnly) === "true"; 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); @@ -154,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(); @@ -171,14 +250,10 @@ 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) { - if (accessibleOnly && !accessible) continue; + for (const { targetId, weight } of adj.get(currentId) ?? []) { const newCost = currentCost + weight; if (newCost < (dist.get(targetId) ?? Infinity)) { dist.set(targetId, newCost); @@ -188,11 +263,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(); @@ -202,19 +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); + } - // 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}`; - 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)!; @@ -223,12 +293,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; - - // Recompute actual distance for this segment from adjacency list + 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 (!isNodeAccessible(node) || isStairNode(node)) fullyAccessible = false; } return { @@ -247,7 +319,7 @@ export function findShortestPath( return { steps, - totalDistance, + totalDistance: cumulative, fullyAccessible, floors: [...floorSet].sort((a, b) => a - b), }; @@ -260,10 +332,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; + 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;