From 9f19c6ca23e257415669d8ff891a1d34283165c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Dec 2025 18:28:21 +0000 Subject: [PATCH] Add Time-Distance Graph tab with Recharts visualization - Add new tab showing stop times as a graph with stop sequence on X-axis and seconds since departure on Y-axis - Support date and route selection similar to Timetables tab - Group trips by direction with click-to-toggle multi-selection - Use hash-based color generation from trip short name - Include optional real-time data display (dotted lines) for today - Install Recharts library for chart rendering --- package-lock.json | 393 +++++++++++++++++- package.json | 3 +- src/App.tsx | 15 +- src/tabs/TimeDistanceGraphTab.tsx | 666 ++++++++++++++++++++++++++++++ 4 files changed, 1073 insertions(+), 4 deletions(-) create mode 100644 src/tabs/TimeDistanceGraphTab.tsx diff --git a/package-lock.json b/package-lock.json index 3a7de3f..b74d9d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "leaflet": "^1.9.4", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-leaflet": "^4.2.1" + "react-leaflet": "^4.2.1", + "recharts": "^3.5.1" }, "devDependencies": { "@types/react": "^18.3.12", @@ -1617,6 +1618,42 @@ "react-dom": "^18.0.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.0.tgz", + "integrity": "sha512-hBjYg0aaRL1O2Z0IqWhnTLytnjDIxekmRxm1snsHjHaKVmIF1HiImWqsq+PuEbn6zdMlkIj9WofK1vR8jjx+Xw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz", + "integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1932,6 +1969,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "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", @@ -1977,6 +2026,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2056,6 +2168,12 @@ "@types/react": "*" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.46.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz", @@ -2775,6 +2893,127 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2792,6 +3031,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2853,6 +3098,16 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-toolkit": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz", + "integrity": "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -3154,6 +3409,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3477,6 +3738,16 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3509,6 +3780,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -4440,6 +4720,29 @@ "react-dom": "^18.0.0" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4504,6 +4807,57 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.5.1.tgz", + "integrity": "sha512-+v+HJojK7gnEgG6h+b2u7k8HH7FhyFUzAc4+cPrsjL4Otdgqr/ecXzAnHciqlzV1ko064eNcsdzrYOM78kankA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -4956,6 +5310,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5111,12 +5471,43 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", diff --git a/package.json b/package.json index 431c8c3..4a852ab 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "leaflet": "^1.9.4", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-leaflet": "^4.2.1" + "react-leaflet": "^4.2.1", + "recharts": "^3.5.1" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/src/App.tsx b/src/App.tsx index 4788392..ed24f28 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,7 +19,8 @@ import { Map as MapIcon, Warning as WarningIcon, DirectionsBus as BusIcon, - Update as UpdateIcon + Update as UpdateIcon, + ShowChart as ShowChartIcon } from '@mui/icons-material' import { Agency, @@ -43,6 +44,7 @@ import AlertsTab from './tabs/AlertsTab' import DeparturesTab from './tabs/DeparturesTab' import DeparturesV2Tab from './tabs/DeparturesV2Tab' import RealtimeDataTab from './tabs/RealtimeDataTab' +import TimeDistanceGraphTab from './tabs/TimeDistanceGraphTab' const PROXY_BASE = 'https://gtfs-proxy.sys-dev-run.re/proxy/' @@ -499,6 +501,7 @@ function App() { } label="Departures at Stop" disabled={!gtfsLoaded} /> } label="Departures v2" disabled={!gtfsLoaded} /> } label="GTFS-RT Data" disabled={!gtfsLoaded} /> + } label="Time-Distance Graph" disabled={!gtfsLoaded} /> } label="Configuration" /> @@ -578,7 +581,15 @@ function App() { /> )} - {currentTab === 7 && ( + {currentTab === 7 && gtfsLoaded && ( + + )} + + {currentTab === 8 && ( | null + agencies: Agency[] +} + +interface TripWithTimes { + trip: Trip + stopTimes: StopTimeWithRealtime[] +} + +interface DirectionGroup { + directionId: number + headsign: string + trips: TripWithTimes[] +} + +interface ChartDataPoint { + stopSequence: number + stopName: string + [key: string]: number | string | undefined // For trip data: tripId_theoretical, tripId_realtime +} + +// Hash-based color generation from string +const stringToColor = (str: string): string => { + let hash = 0 + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash) + } + + // Generate HSL color with good saturation and lightness for visibility + const h = Math.abs(hash) % 360 + const s = 65 + (Math.abs(hash >> 8) % 20) // 65-85% + const l = 45 + (Math.abs(hash >> 16) % 15) // 45-60% + + return `hsl(${h}, ${s}%, ${l}%)` +} + +// Format seconds as duration (e.g., "1h 23m" or "45m 30s") +const formatDuration = (seconds: number): string => { + if (seconds < 0) seconds = 0 + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = Math.floor(seconds % 60) + + if (hours > 0) { + return `${hours}h ${minutes.toString().padStart(2, '0')}m` + } + return `${minutes}m ${secs.toString().padStart(2, '0')}s` +} + +export default function TimeDistanceGraphTab({ routes, workerApi, agencies }: TimeDistanceGraphTabProps) { + const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]) + const [selectedRoute, setSelectedRoute] = useState(null) + const [directions, setDirections] = useState([]) + const [selectedTripIds, setSelectedTripIds] = useState>(new Set()) + const [showRealtime, setShowRealtime] = useState(true) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const [stopsMap, setStopsMap] = useState>(new Map()) + + const directionsRef = useRef(null) + const graphRef = useRef(null) + + const isToday = selectedDate === new Date().toISOString().split('T')[0] + + // Scroll to directions when a route is selected + useEffect(() => { + if (selectedRoute && directions.length > 0 && directionsRef.current) { + setTimeout(() => { + directionsRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }) + }, 100) + } + }, [selectedRoute, directions.length]) + + // Scroll to graph when trips are selected + useEffect(() => { + if (selectedTripIds.size > 0 && graphRef.current) { + setTimeout(() => { + graphRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }) + }, 100) + } + }, [selectedTripIds.size]) + + // Load trips for selected route and date + useEffect(() => { + if (!workerApi || !selectedRoute) { + setDirections([]) + setSelectedTripIds(new Set()) + return + } + + setLoading(true) + setError(null) + + const dateString = selectedDate.replace(/-/g, '') + + const loadTripsAndStopTimes = async () => { + try { + const trips = await workerApi.getTrips({ + routeId: selectedRoute.route_id, + date: dateString, + includeRealtime: true + }) + + // Load stop times for each trip and collect stop IDs + const tripsWithTimes: TripWithTimes[] = [] + const stopIds = new Set() + for (const trip of trips) { + const stopTimes = await workerApi.getStopTimes({ + tripId: trip.trip_id, + includeRealtime: true + }) + tripsWithTimes.push({ trip, stopTimes }) + stopTimes.forEach(st => stopIds.add(st.stop_id)) + } + + // Fetch stops and build a map + const allStops = await workerApi.getStops() + const newStopsMap = new Map() + allStops.forEach(stop => { + if (stopIds.has(stop.stop_id)) { + newStopsMap.set(stop.stop_id, stop) + } + }) + setStopsMap(newStopsMap) + + // Sort trips by first departure time + tripsWithTimes.sort((a, b) => { + const aTime = a.stopTimes[0]?.departure_time || '' + const bTime = b.stopTimes[0]?.departure_time || '' + return aTime.localeCompare(bTime) + }) + + // Group by direction_id + const groupsMap = new Map() + + tripsWithTimes.forEach((tripWithTimes) => { + const dirId = tripWithTimes.trip.direction_id ?? 0 + if (!groupsMap.has(dirId)) { + groupsMap.set(dirId, { + directionId: dirId, + headsign: tripWithTimes.trip.trip_headsign || `Direction ${dirId}`, + trips: [] + }) + } + groupsMap.get(dirId)!.trips.push(tripWithTimes) + }) + + const directionsList = Array.from(groupsMap.values()) + setDirections(directionsList) + setSelectedTripIds(new Set()) + setLoading(false) + } catch (err) { + console.error('Error loading trips:', err) + setError('Failed to load trips') + setLoading(false) + } + } + + loadTripsAndStopTimes() + }, [workerApi, selectedRoute, selectedDate]) + + // Toggle trip selection + const toggleTripSelection = (tripId: string) => { + setSelectedTripIds(prev => { + const newSet = new Set(prev) + if (newSet.has(tripId)) { + newSet.delete(tripId) + } else { + newSet.add(tripId) + } + return newSet + }) + } + + // Get selected trips data + const getSelectedTrips = (): TripWithTimes[] => { + const allTrips: TripWithTimes[] = [] + directions.forEach(dir => { + dir.trips.forEach(tripWithTimes => { + if (selectedTripIds.has(tripWithTimes.trip.trip_id)) { + allTrips.push(tripWithTimes) + } + }) + }) + return allTrips + } + + // Build chart data + const buildChartData = (): { data: ChartDataPoint[], trips: TripWithTimes[] } => { + const selectedTrips = getSelectedTrips() + if (selectedTrips.length === 0) return { data: [], trips: [] } + + // Find the maximum stop sequence across all selected trips + let maxStopSequence = 0 + selectedTrips.forEach(({ stopTimes }) => { + stopTimes.forEach(st => { + if (st.stop_sequence > maxStopSequence) { + maxStopSequence = st.stop_sequence + } + }) + }) + + // Build data points for each stop sequence + const dataPoints: ChartDataPoint[] = [] + + // Create a map of stop_sequence to stop_name (using first trip that has it) + const stopNameMap = new Map() + selectedTrips.forEach(({ stopTimes }) => { + stopTimes.forEach(st => { + if (!stopNameMap.has(st.stop_sequence)) { + const stop = stopsMap.get(st.stop_id) + stopNameMap.set(st.stop_sequence, stop?.stop_name || `Stop ${st.stop_sequence}`) + } + }) + }) + + // Always start at (0, 0) - virtual first point + const firstPoint: ChartDataPoint = { + stopSequence: 0, + stopName: 'Departure' + } + selectedTrips.forEach(({ trip }) => { + const tripKey = trip.trip_short_name || trip.trip_id + firstPoint[`${tripKey}_theoretical`] = 0 + if (showRealtime && isToday) { + firstPoint[`${tripKey}_realtime`] = 0 + } + }) + dataPoints.push(firstPoint) + + // Get unique stop sequences sorted + const stopSequences = Array.from(new Set( + selectedTrips.flatMap(({ stopTimes }) => stopTimes.map(st => st.stop_sequence)) + )).sort((a, b) => a - b) + + // Build data for each stop sequence + stopSequences.forEach(seq => { + const point: ChartDataPoint = { + stopSequence: seq, + stopName: stopNameMap.get(seq) || `Stop ${seq}` + } + + selectedTrips.forEach(({ trip, stopTimes }) => { + const tripKey = trip.trip_short_name || trip.trip_id + const stopTime = stopTimes.find(st => st.stop_sequence === seq) + + if (stopTime) { + // Get first departure time for this trip + const firstStopTime = stopTimes[0] + const firstDepartureSeconds = timeToSeconds(firstStopTime.departure_time) + + // Theoretical time (seconds since departure) + const arrivalSeconds = timeToSeconds(stopTime.arrival_time || stopTime.departure_time) + const theoreticalSeconds = Math.max(0, arrivalSeconds - firstDepartureSeconds) + point[`${tripKey}_theoretical`] = theoreticalSeconds + + // Realtime (if available and showing today) + if (showRealtime && isToday && stopTime.realtime) { + let realtimeArrivalSeconds: number | null = null + + // Try to get realtime arrival time + if (stopTime.realtime.arrival_time) { + // arrival_time is a Unix timestamp + const agencyTimezone = agencies.length > 0 && agencies[0].agency_timezone + ? agencies[0].agency_timezone + : Intl.DateTimeFormat().resolvedOptions().timeZone + + const date = new Date(stopTime.realtime.arrival_time * 1000) + const timeString = date.toLocaleString('en-US', { + timeZone: agencyTimezone, + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + realtimeArrivalSeconds = timeToSeconds(timeString) + } else if (stopTime.realtime.arrival_delay !== undefined) { + // Apply delay to theoretical arrival + const theoreticalArrival = timeToSeconds(stopTime.arrival_time || stopTime.departure_time) + realtimeArrivalSeconds = theoreticalArrival + stopTime.realtime.arrival_delay + } + + if (realtimeArrivalSeconds !== null) { + // Get first stop realtime departure + let firstRealtimeDeparture = firstDepartureSeconds + if (firstStopTime.realtime?.departure_time) { + const agencyTimezone = agencies.length > 0 && agencies[0].agency_timezone + ? agencies[0].agency_timezone + : Intl.DateTimeFormat().resolvedOptions().timeZone + const date = new Date(firstStopTime.realtime.departure_time * 1000) + const timeString = date.toLocaleString('en-US', { + timeZone: agencyTimezone, + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + firstRealtimeDeparture = timeToSeconds(timeString) + } else if (firstStopTime.realtime?.departure_delay !== undefined) { + firstRealtimeDeparture = firstDepartureSeconds + firstStopTime.realtime.departure_delay + } + + // Ensure never negative + const realtimeSeconds = Math.max(0, realtimeArrivalSeconds - firstRealtimeDeparture) + point[`${tripKey}_realtime`] = realtimeSeconds + } + } + } + }) + + dataPoints.push(point) + }) + + return { data: dataPoints, trips: selectedTrips } + } + + const { data: chartData, trips: chartTrips } = buildChartData() + + // Custom tooltip + const CustomTooltip = ({ active, payload, label }: { active?: boolean, payload?: Array<{ name: string, value: number, color: string }>, label?: number }) => { + if (!active || !payload || payload.length === 0) return null + + const point = chartData.find(p => p.stopSequence === label) + + return ( + + + {point?.stopName || `Stop ${label}`} + + + Stop sequence: {label} + + {payload.map((entry, index) => { + const isRealtime = entry.name.includes('(RT)') + return ( + + + + {entry.name}: {formatDuration(entry.value)} + + + ) + })} + + ) + } + + // Custom dot component for the chart + const renderDot = (props: { cx?: number, cy?: number, fill?: string }) => { + const { cx, cy, fill } = props + if (cx === undefined || cy === undefined) return null + return + } + + return ( + + {/* Top controls (Route + Date) */} + + {/* Route selection */} + + + Routes + + + {routes.map((route) => { + const bgColor = route.route_color ? `#${route.route_color}` : '#CCCCCC' + const textColor = route.route_text_color ? `#${route.route_text_color}` : '#000000' + + return ( + + setSelectedRoute(route)} + > + + {route.route_short_name || route.route_long_name?.substring(0, 3)} + + + + + ) + })} + + + + {/* Date selection */} + + + Date + + setSelectedDate(e.target.value)} + fullWidth + InputLabelProps={{ shrink: true }} + /> + + + setShowRealtime(e.target.checked)} + disabled={!isToday} + /> + } + label={ + + Show real-time data + {!isToday && ( + + (only available for today) + + )} + + } + /> + + + + + {/* Direction groups and trip selection */} + {selectedRoute && directions.length > 0 && ( + + {directions.map((dir) => { + const bgColor = selectedRoute.route_color ? `#${selectedRoute.route_color}` : '#CCCCCC' + const textColor = selectedRoute.route_text_color ? `#${selectedRoute.route_text_color}` : '#000000' + + return ( + + + + {selectedRoute.route_short_name || selectedRoute.route_long_name?.substring(0, 3)} + + + + {dir.headsign} + + + {dir.trips.length} trips - Click to select/deselect + + + + + + {dir.trips.map(({ trip, stopTimes }) => { + const tripKey = trip.trip_short_name || trip.trip_id + const isSelected = selectedTripIds.has(trip.trip_id) + const color = stringToColor(tripKey) + const firstDeparture = stopTimes[0]?.departure_time?.substring(0, 5) || '' + + return ( + + + {tripKey} + + + ({firstDeparture}) + + + } + onClick={() => toggleTripSelection(trip.trip_id)} + sx={{ + borderColor: color, + borderWidth: 2, + borderStyle: 'solid', + backgroundColor: isSelected ? `${color}20` : 'transparent', + '&:hover': { + backgroundColor: isSelected ? `${color}30` : `${color}10` + } + }} + variant={isSelected ? 'filled' : 'outlined'} + /> + ) + })} + + + ) + })} + + )} + + {/* Loading state */} + {loading && ( + Loading trips... + )} + + {/* Error state */} + {error && ( + + {error} + + )} + + {/* Graph */} + {chartData.length > 0 && chartTrips.length > 0 && ( + + + Time-Distance Graph + + + X-axis: Stop sequence | Y-axis: Time since departure + {showRealtime && isToday && ' | Dashed lines: Real-time data'} + + + + + + + + formatDuration(value)} + label={{ value: 'Time Since Departure', angle: -90, position: 'insideLeft', offset: 10 }} + tick={{ fontSize: 11 }} + /> + } /> + { + const isRealtime = value.includes('(RT)') + return ( + + {value} + + ) + }} + /> + + {chartTrips.map(({ trip, stopTimes }) => { + const tripKey = trip.trip_short_name || trip.trip_id + const color = stringToColor(tripKey) + const hasRealtime = showRealtime && isToday && stopTimes.some(st => st.realtime) + + return ( + + {/* Theoretical line */} + + + {/* Realtime line (if available) */} + {hasRealtime && ( + + )} + + ) + })} + + + + + )} + + {/* Empty state */} + {selectedRoute && directions.length > 0 && selectedTripIds.size === 0 && !loading && ( + + Select one or more trips above to display the time-distance graph. + + )} + + {selectedRoute && directions.length === 0 && !loading && ( + + No trips found for this route on the selected date. + + )} + + ) +} + +// Need to import React for JSX.Element in Legend formatter +import React from 'react'