From 090dca29c2909711b25c1ba0d6220539305ce90b Mon Sep 17 00:00:00 2001 From: zwitschi Date: Sat, 11 Oct 2025 19:28:35 +0200 Subject: [PATCH] feat: add route selection functionality and improve station handling - Added `vitest` for testing and created initial tests for route utilities. - Implemented route selection logic in the App component, allowing users to select start and end stations. - Updated the NetworkMap component to reflect focused and selected stations, including visual indicators for start and end stations. - Enhanced the route panel UI to display selected route information and estimated lengths. - Introduced utility functions for building track adjacency and computing routes based on selected stations. - Improved styling for route selection and station list items to enhance user experience. --- TODO.md | 6 +- frontend/package-lock.json | 711 ++++++++++++++++++++- frontend/package.json | 4 +- frontend/src/App.tsx | 228 ++++++- frontend/src/components/map/NetworkMap.tsx | 93 ++- frontend/src/styles/global.css | 115 ++++ frontend/src/utils/route.test.ts | 141 ++++ frontend/src/utils/route.ts | 185 ++++++ frontend/tsconfig.json | 2 +- frontend/vitest.config.ts | 8 + 10 files changed, 1441 insertions(+), 52 deletions(-) create mode 100644 frontend/src/utils/route.test.ts create mode 100644 frontend/src/utils/route.ts create mode 100644 frontend/vitest.config.ts diff --git a/TODO.md b/TODO.md index 61febf9..840351c 100644 --- a/TODO.md +++ b/TODO.md @@ -16,12 +16,12 @@ - [x] Define geographic bounding boxes and filtering rules for importing real-world stations from OpenStreetMap. - [x] Implement an import script/CLI that pulls OSM station data and normalizes it to the PostGIS schema. - [x] Expose backend CRUD endpoints for stations (create, update, archive) with validation and geometry handling. -- [ ] Build React map tooling for selecting a station. -- [ ] Build tools for station editing, including form validation. +- [x] Build React map tooling for selecting a station. +- [ ] Enhance map UI to support selecting two stations and previewing the rail corridor between them. - [ ] Define track selection criteria and tagging rules for harvesting OSM rail segments within target regions. - [ ] Extend the importer to load track geometries and associate them with existing stations. - [ ] Implement backend track-management APIs with length/speed validation and topology checks. -- [ ] Create a frontend track-drawing workflow (polyline editor, snapping to stations, undo/redo). +- [ ] Implement track path mapping along existing OSM rail segments between chosen stations. - [ ] Design train connection manager requirements (link trains to operating tracks, manage consist data). - [ ] Implement backend services and APIs to attach trains to routes and update assignments. - [ ] Add UI flows for managing train connections, including visual feedback on the map. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 66ab24c..5297f45 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,7 +30,8 @@ "eslint-plugin-react-hooks": "^4.6.2", "prettier": "^3.3.3", "typescript": "^5.5.3", - "vite": "^5.4.0" + "vite": "^5.4.0", + "vitest": "^1.6.0" } }, "node_modules/@babel/code-frame": { @@ -885,6 +886,19 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1322,6 +1336,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1699,6 +1720,109 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", + "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1722,6 +1846,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1942,6 +2079,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2069,6 +2216,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2150,6 +2307,25 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2167,6 +2343,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2194,6 +2383,13 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2302,6 +2498,19 @@ } } }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2345,6 +2554,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -3120,6 +3339,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3130,6 +3359,30 @@ "node": ">=0.10.0" } }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3355,6 +3608,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3394,6 +3657,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -3618,6 +3894,16 @@ "node": ">= 0.4" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -3994,6 +4280,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -4255,6 +4554,23 @@ "node": ">= 0.8.0" } }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4290,6 +4606,16 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4300,6 +4626,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4310,6 +4646,13 @@ "node": ">= 0.4" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4334,6 +4677,19 @@ "node": ">=8.6" } }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -4360,6 +4716,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4400,6 +4776,35 @@ "dev": true, "license": "MIT" }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4533,6 +4938,22 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4651,6 +5072,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4671,6 +5109,25 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/playwright": { "version": "1.56.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz", @@ -4783,6 +5240,41 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5276,6 +5768,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5286,6 +5798,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -5436,6 +5962,19 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -5449,6 +5988,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5482,6 +6041,33 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5547,6 +6133,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -5652,6 +6248,13 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "dev": true, + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -5779,6 +6382,95 @@ } } }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5884,6 +6576,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index fba9d8c..e2e4818 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "lint": "eslint \"src/**/*.{ts,tsx}\"", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", + "test": "vitest run", "test:e2e": "playwright test" }, "dependencies": { @@ -34,6 +35,7 @@ "eslint-plugin-react-hooks": "^4.6.2", "prettier": "^3.3.3", "typescript": "^5.5.3", - "vite": "^5.4.0" + "vite": "^5.4.0", + "vitest": "^1.6.0" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 257b2ba..a0af01c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,38 +1,114 @@ import './styles/global.css'; +import type { LatLngExpression } from 'leaflet'; import { useEffect, useMemo, useState } from 'react'; import { LoginForm } from './components/auth/LoginForm'; import { NetworkMap } from './components/map/NetworkMap'; import { useNetworkSnapshot } from './hooks/useNetworkSnapshot'; import { useAuth } from './state/AuthContext'; +import type { Station } from './types/domain'; +import { buildTrackAdjacency, computeRoute } from './utils/route'; function App(): JSX.Element { const { token, user, status: authStatus, logout } = useAuth(); const isAuthenticated = authStatus === 'authenticated' && token !== null; const { data, status, error } = useNetworkSnapshot(isAuthenticated ? token : null); - const [selectedStationId, setSelectedStationId] = useState(null); + const [focusedStationId, setFocusedStationId] = useState(null); + const [routeSelection, setRouteSelection] = useState<{ + startId: string | null; + endId: string | null; + }>({ startId: null, endId: null }); useEffect(() => { if (status !== 'success' || !data?.stations.length) { - setSelectedStationId(null); + setFocusedStationId(null); + setRouteSelection({ startId: null, endId: null }); return; } - if ( - !selectedStationId || - !data.stations.some((station) => station.id === selectedStationId) - ) { - setSelectedStationId(data.stations[0].id); + if (!focusedStationId || !hasStation(data.stations, focusedStationId)) { + setFocusedStationId(data.stations[0].id); } - }, [status, data, selectedStationId]); + }, [status, data, focusedStationId]); - const selectedStation = useMemo(() => { - if (!data || !selectedStationId) { + useEffect(() => { + if (status !== 'success' || !data) { + return; + } + + setRouteSelection((current) => { + const startExists = current.startId + ? hasStation(data.stations, current.startId) + : false; + const endExists = current.endId + ? hasStation(data.stations, current.endId) + : false; + + return { + startId: startExists ? current.startId : null, + endId: endExists ? current.endId : null, + }; + }); + }, [status, data]); + + const stationById = useMemo(() => { + if (!data) { + return new Map(); + } + const lookup = new Map(); + for (const station of data.stations) { + lookup.set(station.id, station); + } + return lookup; + }, [data]); + + const trackAdjacency = useMemo( + () => buildTrackAdjacency(data ? data.tracks : []), + [data] + ); + + const routeComputation = useMemo(() => { + const core = computeRoute({ + startId: routeSelection.startId, + endId: routeSelection.endId, + stationById, + adjacency: trackAdjacency, + }); + + const segments = core.stations ? buildSegmentsFromStations(core.stations) : []; + + return { + ...core, + segments, + }; + }, [routeSelection, stationById, trackAdjacency]); + + const focusedStation = useMemo(() => { + if (!data || !focusedStationId) { return null; } - return data.stations.find((station) => station.id === selectedStationId) ?? null; - }, [data, selectedStationId]); + return stationById.get(focusedStationId) ?? null; + }, [data, focusedStationId, stationById]); + + const handleStationSelection = (stationId: string) => { + setFocusedStationId(stationId); + setRouteSelection((current) => { + if (!current.startId || (current.startId && current.endId)) { + return { startId: stationId, endId: null }; + } + + if (current.startId === stationId) { + return { startId: stationId, endId: null }; + } + + return { startId: current.startId, endId: stationId }; + }); + }; + + const clearRouteSelection = () => { + setRouteSelection({ startId: null, endId: null }); + }; return (
@@ -65,10 +141,71 @@ function App(): JSX.Element {
setSelectedStationId(id)} + focusedStationId={focusedStationId} + startStationId={routeSelection.startId} + endStationId={routeSelection.endId} + routeSegments={routeComputation.segments} + onStationClick={handleStationSelection} />
+
+
+

Route Selection

+ +
+

+ Click a station to set the origin, then click another station to + preview the rail corridor between them. +

+
+
+
Origin
+
+ {routeSelection.startId + ? (stationById.get(routeSelection.startId)?.name ?? + 'Unknown station') + : 'Choose a station'} +
+
+
+
Destination
+
+ {routeSelection.endId + ? (stationById.get(routeSelection.endId)?.name ?? + 'Unknown station') + : 'Choose a station'} +
+
+
+
Estimated Length
+
+ {routeComputation.totalLength !== null + ? `${(routeComputation.totalLength / 1000).toFixed(2)} km` + : 'N/A'} +
+
+
+ {routeComputation.error && ( +

{routeComputation.error}

+ )} + {!routeComputation.error && routeComputation.stations && ( +
+ Path: +
    + {routeComputation.stations.map((station) => ( +
  1. {station.name}
  2. + ))} +
+
+ )} +

Stations

@@ -78,12 +215,20 @@ function App(): JSX.Element { ))} @@ -120,43 +273,43 @@ function App(): JSX.Element {
- {selectedStation && ( + {focusedStation && (
-

Selected Station

+

Focused Station

Name
-
{selectedStation.name}
+
{focusedStation.name}
Coordinates
- {selectedStation.latitude.toFixed(5)},{' '} - {selectedStation.longitude.toFixed(5)} + {focusedStation.latitude.toFixed(5)},{' '} + {focusedStation.longitude.toFixed(5)}
- {selectedStation.code && ( + {focusedStation.code && (
Code
-
{selectedStation.code}
+
{focusedStation.code}
)} - {typeof selectedStation.elevationM === 'number' && ( + {typeof focusedStation.elevationM === 'number' && (
Elevation
-
{selectedStation.elevationM.toFixed(1)} m
+
{focusedStation.elevationM.toFixed(1)} m
)} - {selectedStation.osmId && ( + {focusedStation.osmId && (
OSM ID
-
{selectedStation.osmId}
+
{focusedStation.osmId}
)}
Status
- {(selectedStation.isActive ?? true) ? 'Active' : 'Inactive'} + {(focusedStation.isActive ?? true) ? 'Active' : 'Inactive'}
@@ -172,3 +325,20 @@ function App(): JSX.Element { } export default App; + +function hasStation(stations: Station[], id: string): boolean { + return stations.some((station) => station.id === id); +} + +function buildSegmentsFromStations(stations: Station[]): LatLngExpression[][] { + const segments: LatLngExpression[][] = []; + for (let index = 0; index < stations.length - 1; index += 1) { + const current = stations[index]; + const next = stations[index + 1]; + segments.push([ + [current.latitude, current.longitude], + [next.latitude, next.longitude], + ]); + } + return segments; +} diff --git a/frontend/src/components/map/NetworkMap.tsx b/frontend/src/components/map/NetworkMap.tsx index 3f93e2c..593ad9a 100644 --- a/frontend/src/components/map/NetworkMap.tsx +++ b/frontend/src/components/map/NetworkMap.tsx @@ -15,8 +15,11 @@ import 'leaflet/dist/leaflet.css'; interface NetworkMapProps { readonly snapshot: NetworkSnapshot; - readonly selectedStationId?: string | null; - readonly onSelectStation?: (stationId: string) => void; + readonly focusedStationId?: string | null; + readonly startStationId?: string | null; + readonly endStationId?: string | null; + readonly routeSegments?: LatLngExpression[][]; + readonly onStationClick?: (stationId: string) => void; } interface StationPosition { @@ -29,8 +32,11 @@ const DEFAULT_CENTER: LatLngExpression = [51.505, -0.09]; export function NetworkMap({ snapshot, - selectedStationId, - onSelectStation, + focusedStationId, + startStationId, + endStationId, + routeSegments = [], + onStationClick, }: NetworkMapProps): JSX.Element { const stationPositions = useMemo(() => { return snapshot.stations.map((station) => ({ @@ -86,12 +92,12 @@ export function NetworkMap({ ] as LatLngBoundsExpression; }, [stationPositions]); - const selectedPosition = useMemo(() => { - if (!selectedStationId) { + const focusedPosition = useMemo(() => { + if (!focusedStationId) { return null; } - return stationLookup.get(selectedStationId) ?? null; - }, [selectedStationId, stationLookup]); + return stationLookup.get(focusedStationId) ?? null; + }, [focusedStationId, stationLookup]); return ( - {selectedPosition ? : null} + {focusedPosition ? : null} {trackSegments.map((segment, index) => ( + ))} + {routeSegments.map((segment, index) => ( + ))} {stationPositions.map((station) => ( { - onSelectStation?.(station.id); + onStationClick?.(station.id); }, }} > {station.name} @@ -152,3 +175,39 @@ function StationFocus({ position }: { position: LatLngExpression }): null { return null; } + +function resolveMarkerStroke( + stationId: string, + startStationId?: string | null, + endStationId?: string | null, + focusedStationId?: string | null +): string { + if (stationId === startStationId) { + return '#38bdf8'; + } + if (stationId === endStationId) { + return '#fb923c'; + } + if (stationId === focusedStationId) { + return '#22c55e'; + } + return '#f97316'; +} + +function resolveMarkerFill( + stationId: string, + startStationId?: string | null, + endStationId?: string | null, + focusedStationId?: string | null +): string { + if (stationId === startStationId) { + return '#bae6fd'; + } + if (stationId === endStationId) { + return '#fed7aa'; + } + if (stationId === focusedStationId) { + return '#bbf7d0'; + } + return '#ffe4c7'; +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index a21d52b..a30d070 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -102,6 +102,7 @@ body { background-color 0.18s ease, border-color 0.18s ease, transform 0.18s ease; + flex-wrap: wrap; } .station-list-item:hover, @@ -118,6 +119,16 @@ body { box-shadow: 0 8px 18px -10px rgba(45, 212, 191, 0.65); } +.station-list-item--start { + border-color: rgba(56, 189, 248, 0.8); + background: rgba(14, 165, 233, 0.2); +} + +.station-list-item--end { + border-color: rgba(249, 115, 22, 0.8); + background: rgba(234, 88, 12, 0.18); +} + .station-list-item__name { font-weight: 600; } @@ -128,6 +139,27 @@ body { font-family: 'Fira Code', 'Source Code Pro', monospace; } +.station-list-item__badge { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.1rem 0.45rem; + border-radius: 999px; + background: rgba(148, 163, 184, 0.18); + color: rgba(226, 232, 240, 0.85); +} + +.station-list-item--start .station-list-item__badge { + background: rgba(56, 189, 248, 0.35); + color: #0ea5e9; +} + +.station-list-item--end .station-list-item__badge { + background: rgba(249, 115, 22, 0.35); + color: #f97316; +} + .grid h3 { margin-bottom: 0.5rem; font-size: 1.1rem; @@ -151,6 +183,89 @@ body { width: 100%; } +.route-panel { + display: grid; + gap: 0.85rem; + padding: 1.1rem 1.35rem; + border-radius: 12px; + border: 1px solid rgba(250, 204, 21, 0.3); + background: rgba(161, 98, 7, 0.16); +} + +.route-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.route-panel__hint { + font-size: 0.9rem; + color: rgba(226, 232, 240, 0.78); +} + +.route-panel__meta { + display: grid; + gap: 0.45rem; +} + +.route-panel__meta > div { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.75rem; +} + +.route-panel__meta dt { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(226, 232, 240, 0.65); +} + +.route-panel__meta dd { + font-size: 0.95rem; + color: rgba(226, 232, 240, 0.92); +} + +.route-panel__error { + color: #f87171; + font-weight: 600; +} + +.route-panel__path { + display: flex; + gap: 0.6rem; + align-items: baseline; +} + +.route-panel__path span { + font-size: 0.85rem; + color: rgba(226, 232, 240, 0.7); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.route-panel__path ol { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + list-style: none; + padding: 0; + margin: 0; +} + +.route-panel__path li::after { + content: '→'; + margin-left: 0.35rem; + color: rgba(250, 204, 21, 0.75); +} + +.route-panel__path li:last-child::after { + content: ''; + margin: 0; +} + .selected-station { margin-top: 1rem; padding: 1rem 1.25rem; diff --git a/frontend/src/utils/route.test.ts b/frontend/src/utils/route.test.ts new file mode 100644 index 0000000..5ff5b40 --- /dev/null +++ b/frontend/src/utils/route.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTrackAdjacency, computeRoute } from './route'; +import type { Station, Track } from '../types/domain'; + +const baseTimestamps = { + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', +}; + +describe('route utilities', () => { + it('finds a multi-hop path across connected tracks', () => { + const stations: Station[] = [ + { + id: 'station-a', + name: 'Alpha', + latitude: 51.5, + longitude: -0.1, + ...baseTimestamps, + }, + { + id: 'station-b', + name: 'Bravo', + latitude: 51.52, + longitude: -0.11, + ...baseTimestamps, + }, + { + id: 'station-c', + name: 'Charlie', + latitude: 51.54, + longitude: -0.12, + ...baseTimestamps, + }, + { + id: 'station-d', + name: 'Delta', + latitude: 51.55, + longitude: -0.15, + ...baseTimestamps, + }, + ]; + + const tracks: Track[] = [ + { + id: 'track-ab', + startStationId: 'station-a', + endStationId: 'station-b', + lengthMeters: 1200, + maxSpeedKph: 120, + ...baseTimestamps, + }, + { + id: 'track-bc', + startStationId: 'station-b', + endStationId: 'station-c', + lengthMeters: 1500, + maxSpeedKph: 110, + ...baseTimestamps, + }, + { + id: 'track-cd', + startStationId: 'station-c', + endStationId: 'station-d', + lengthMeters: 900, + maxSpeedKph: 115, + ...baseTimestamps, + }, + ]; + + const stationById = new Map(stations.map((station) => [station.id, station])); + const adjacency = buildTrackAdjacency(tracks); + + const result = computeRoute({ + startId: 'station-a', + endId: 'station-d', + stationById, + adjacency, + }); + + expect(result.error).toBeNull(); + expect(result.stations?.map((station) => station.id)).toEqual([ + 'station-a', + 'station-b', + 'station-c', + 'station-d', + ]); + expect(result.tracks.map((track) => track.id)).toEqual([ + 'track-ab', + 'track-bc', + 'track-cd', + ]); + expect(result.totalLength).toBe(1200 + 1500 + 900); + }); + + it('returns an error when no path exists', () => { + const stations: Station[] = [ + { + id: 'station-a', + name: 'Alpha', + latitude: 51.5, + longitude: -0.1, + ...baseTimestamps, + }, + { + id: 'station-b', + name: 'Bravo', + latitude: 51.6, + longitude: -0.2, + ...baseTimestamps, + }, + ]; + + const tracks: Track[] = [ + { + id: 'track-self', + startStationId: 'station-a', + endStationId: 'station-a', + lengthMeters: 0, + maxSpeedKph: 80, + ...baseTimestamps, + }, + ]; + + const stationById = new Map(stations.map((station) => [station.id, station])); + const adjacency = buildTrackAdjacency(tracks); + + const result = computeRoute({ + startId: 'station-a', + endId: 'station-b', + stationById, + adjacency, + }); + + expect(result.stations).toBeNull(); + expect(result.tracks).toHaveLength(0); + expect(result.error).toBe( + 'No rail connection found between the selected stations.' + ); + }); +}); diff --git a/frontend/src/utils/route.ts b/frontend/src/utils/route.ts new file mode 100644 index 0000000..f9592af --- /dev/null +++ b/frontend/src/utils/route.ts @@ -0,0 +1,185 @@ +import type { Station, Track } from '../types/domain'; + +export interface NeighborEdge { + readonly neighborId: string; + readonly track: Track; +} + +export type TrackAdjacency = Map; + +export interface ComputeRouteParams { + readonly startId?: string | null; + readonly endId?: string | null; + readonly stationById: Map; + readonly adjacency: TrackAdjacency; +} + +export interface RouteComputation { + readonly stations: Station[] | null; + readonly tracks: Track[]; + readonly totalLength: number | null; + readonly error: string | null; +} + +export function buildTrackAdjacency(tracks: readonly Track[]): TrackAdjacency { + const adjacency: TrackAdjacency = new Map(); + + const register = (fromId: string, toId: string, track: Track) => { + if (!adjacency.has(fromId)) { + adjacency.set(fromId, []); + } + adjacency.get(fromId)!.push({ neighborId: toId, track }); + }; + + for (const track of tracks) { + register(track.startStationId, track.endStationId, track); + register(track.endStationId, track.startStationId, track); + } + + return adjacency; +} + +export function computeRoute({ + startId, + endId, + stationById, + adjacency, +}: ComputeRouteParams): RouteComputation { + if (!startId || !endId) { + return emptyResult(); + } + + if (!stationById.has(startId) || !stationById.has(endId)) { + return { + stations: null, + tracks: [], + totalLength: null, + error: 'Selected stations are no longer available.', + }; + } + + if (startId === endId) { + const station = stationById.get(startId); + return { + stations: station ? [station] : null, + tracks: [], + totalLength: 0, + error: null, + }; + } + + const visited = new Set(); + const queue: string[] = []; + const parent = new Map(); + + queue.push(startId); + visited.add(startId); + parent.set(startId, { prev: null, via: null }); + + while (queue.length > 0) { + const current = queue.shift()!; + if (current === endId) { + break; + } + + const neighbors = adjacency.get(current) ?? []; + for (const { neighborId, track } of neighbors) { + if (visited.has(neighborId)) { + continue; + } + visited.add(neighborId); + parent.set(neighborId, { prev: current, via: track }); + queue.push(neighborId); + } + } + + if (!parent.has(endId)) { + return { + stations: null, + tracks: [], + totalLength: null, + error: 'No rail connection found between the selected stations.', + }; + } + + const stationPath: string[] = []; + const trackSequence: Track[] = []; + let cursor: string | null = endId; + + while (cursor) { + const details = parent.get(cursor); + if (!details) { + break; + } + stationPath.push(cursor); + if (details.via) { + trackSequence.push(details.via); + } + cursor = details.prev; + } + + stationPath.reverse(); + trackSequence.reverse(); + + const stations = stationPath + .map((id) => stationById.get(id)) + .filter((station): station is Station => Boolean(station)); + + const totalLength = computeTotalLength(trackSequence, stations); + + return { + stations, + tracks: trackSequence, + totalLength, + error: null, + }; +} + +function computeTotalLength(tracks: Track[], stations: Station[]): number | null { + if (tracks.length === 0 && stations.length <= 1) { + return 0; + } + + const hasTrackLengths = tracks.every( + (track) => + typeof track.lengthMeters === 'number' && Number.isFinite(track.lengthMeters) + ); + + if (hasTrackLengths) { + return tracks.reduce((total, track) => total + (track.lengthMeters ?? 0), 0); + } + + if (stations.length < 2) { + return null; + } + + let total = 0; + for (let index = 0; index < stations.length - 1; index += 1) { + total += haversineDistance(stations[index], stations[index + 1]); + } + return total; +} + +function haversineDistance(a: Station, b: Station): number { + const R = 6371_000; + const toRad = (value: number) => (value * Math.PI) / 180; + const dLat = toRad(b.latitude - a.latitude); + const dLon = toRad(b.longitude - a.longitude); + const lat1 = toRad(a.latitude); + const lat2 = toRad(b.latitude); + + const sinDLat = Math.sin(dLat / 2); + const sinDLon = Math.sin(dLon / 2); + const root = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon; + const c = 2 * Math.atan2(Math.sqrt(root), Math.sqrt(1 - root)); + return R * c; +} + +function emptyResult(): RouteComputation { + return { + stations: null, + tracks: [], + totalLength: null, + error: null, + }; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index afc7141..b1a8a97 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -15,7 +15,7 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - "types": ["vite/client"] + "types": ["vite/client", "vitest"] }, "include": ["src"] } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..96eb6ab --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + }, +});