commit f4857cf8ea9c89ac7d586c2c249f416f4e7338b9
parent 98c55ad4a7d0a29b0f22100a551db843ebac20b9
Author: Katja (ctucx) <git@ctu.cx>
Date: Thu, 10 Apr 2025 21:17:24 +0200
parent 98c55ad4a7d0a29b0f22100a551db843ebac20b9
Author: Katja (ctucx) <git@ctu.cx>
Date: Thu, 10 Apr 2025 21:17:24 +0200
completely rewrite app to use web-components via lit-element
47 files changed, 3397 insertions(+), 3245 deletions(-)
A
|
88
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
M
|
193
+++++++++++++++++++++++++++++++++----------------------------------------------
D
|
83
-------------------------------------------------------------------------------
D
|
815
-------------------------------------------------------------------------------
M
|
225
++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
M
|
492
++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
A
|
453
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
M
|
406
++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
D
|
418
-------------------------------------------------------------------------------
M
|
954
+++++++++++++++++++++++++++++++++++++++----------------------------------------
M
|
269
+++++++++++++++++++++++++++++++++++++++++--------------------------------------
A
|
151
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
81
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
86
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
95
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
88
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
164
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
diff --git a/flake.nix b/flake.nix @@ -51,7 +51,7 @@ pnpmDeps = final.pnpm.fetchDeps { inherit (finalAttrs) pname version src; - hash = "sha256-F0jSEOoosHQlKdV3gh2ZlICFxdlEN1aamnUHoqI645o="; + hash = "sha256-DS62XKpud8mQ5ZnNncLlCAkl9zHGSC9YBM7igSt06Lg="; }; env.GIT_VERSION = if (inputs.self.sourceInfo ? shortRev) then inputs.self.sourceInfo.shortRev else "dirty";
diff --git a/package.json b/package.json @@ -14,18 +14,17 @@ "buffer": "^6.0.3", "db-vendo-client": "^6.8.0", "hafas-client": "^6.3.4", + "ics": "^3.8.1", "idb": "^8.0.1", - "lit-html": "^3.2.1" + "lit": "^3.2.1", + "zustand": "^5.0.3" }, "devDependencies": { "@principalstudio/html-webpack-inject-preload": "^1.2.7", "copy-webpack-plugin": "^12.0.2", - "css-loader": "^7.1.2", - "css-minimizer-webpack-plugin": "^7.0.0", "git-revision-webpack-plugin": "^5.0.0", "html-webpack-plugin": "^5.6.3", - "mini-css-extract-plugin": "^2.9.2", - "style-loader": "^4.0.0", + "lit-css-loader": "^3.0.1", "webpack": "^5.97.1", "webpack-cli": "^6.0.1", "webpack-dev-server": "^5.2.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: hafas-client: - hash: mko7uu5feywjq6c7qe5c22nnzi + hash: jzfq2zvnydenrytq7tcgcly4hm path: patches/hafas-client.patch importers: @@ -24,16 +24,19 @@ importers: version: 6.8.0 hafas-client: specifier: ^6.3.4 - version: 6.3.4(patch_hash=mko7uu5feywjq6c7qe5c22nnzi) + version: 6.3.4(patch_hash=jzfq2zvnydenrytq7tcgcly4hm) ics: specifier: ^3.8.1 version: 3.8.1 idb: specifier: ^8.0.1 version: 8.0.2 - lit-html: + lit: specifier: ^3.2.1 version: 3.2.1 + zustand: + specifier: ^5.0.3 + version: 5.0.3 devDependencies: '@principalstudio/html-webpack-inject-preload': specifier: ^1.2.7 @@ -41,24 +44,15 @@ importers: copy-webpack-plugin: specifier: ^12.0.2 version: 12.0.2(webpack@5.98.0) - css-loader: - specifier: ^7.1.2 - version: 7.1.2(webpack@5.98.0) - css-minimizer-webpack-plugin: - specifier: ^7.0.0 - version: 7.0.2(webpack@5.98.0) git-revision-webpack-plugin: specifier: ^5.0.0 version: 5.0.0(webpack@5.98.0) html-webpack-plugin: specifier: ^5.6.3 version: 5.6.3(webpack@5.98.0) - mini-css-extract-plugin: - specifier: ^2.9.2 - version: 2.9.2(webpack@5.98.0) - style-loader: - specifier: ^4.0.0 - version: 4.0.0(webpack@5.98.0) + lit-css-loader: + specifier: ^3.0.1 + version: 3.0.1(postcss@8.5.3) webpack: specifier: ^5.97.1 version: 5.98.0(webpack-cli@6.0.1) @@ -581,14 +575,6 @@ packages: resolution: {integrity: sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==} engines: {node: '>=14.17.0'} - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/types@29.6.3': - resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jridgewell/gen-mapping@0.3.8': resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} @@ -631,6 +617,12 @@ packages: '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} + '@lit-labs/ssr-dom-shim@1.3.0': + resolution: {integrity: sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==} + + '@lit/reactive-element@2.0.4': + resolution: {integrity: sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -650,6 +642,9 @@ packages: html-webpack-plugin: ^4.0.0 || ^5.0.0 webpack: ^4.0.0 || ^5.0.0 + '@pwrs/lit-css@3.0.1': + resolution: {integrity: sha512-N3oac0XYqKEEoWMT7y02EDxoyM++805V+nXh3rEBGV2IW2Y0NjK4hepLea65HGFlu+ezSK48XAJYD4xAyvVsaQ==} + '@rollup/plugin-babel@5.3.1': resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} engines: {node: '>= 10.0.0'} @@ -699,9 +694,6 @@ packages: rollup: optional: true - '@sinclair/typebox@0.27.8': - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - '@sindresorhus/merge-streams@2.3.0': resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} @@ -752,15 +744,6 @@ packages: '@types/http-proxy@1.17.16': resolution: {integrity: sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==} - '@types/istanbul-lib-coverage@2.0.6': - resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} - - '@types/istanbul-lib-report@3.0.3': - resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} - - '@types/istanbul-reports@3.0.4': - resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -803,12 +786,6 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - - '@types/yargs@17.0.33': - resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} - '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -1060,10 +1037,6 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - cipher-base@1.0.6: resolution: {integrity: sha512-3Ek9H3X6pj5TgenXYtNWdaBon1tgYCaebd+XPg0keyjEbEfkD4KkmAxkQ/i1vYvxdcT5nscLBfq9VJRmCBcFSw==} engines: {node: '>= 0.10'} @@ -1173,43 +1146,6 @@ packages: peerDependencies: postcss: ^8.0.9 - css-loader@7.1.2: - resolution: {integrity: sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==} - engines: {node: '>= 18.12.0'} - peerDependencies: - '@rspack/core': 0.x || 1.x - webpack: ^5.27.0 - peerDependenciesMeta: - '@rspack/core': - optional: true - webpack: - optional: true - - css-minimizer-webpack-plugin@7.0.2: - resolution: {integrity: sha512-nBRWZtI77PBZQgcXMNqiIXVshiQOVLGSf2qX/WZfG8IQfMbeHUMXaBWQmiiSTmPJUflQxHjZjzAmuyO7tpL2Jg==} - engines: {node: '>= 18.12.0'} - peerDependencies: - '@parcel/css': '*' - '@swc/css': '*' - clean-css: '*' - csso: '*' - esbuild: '*' - lightningcss: '*' - webpack: ^5.0.0 - peerDependenciesMeta: - '@parcel/css': - optional: true - '@swc/css': - optional: true - clean-css: - optional: true - csso: - optional: true - esbuild: - optional: true - lightningcss: - optional: true - css-select@4.3.0: resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} @@ -1737,12 +1673,6 @@ packages: ics@3.8.1: resolution: {integrity: sha512-UqQlfkajfhrS4pUGQfGIJMYz/Jsl/ob3LqcfEhUmLbwumg+ZNkU0/6S734Vsjq3/FYNpEcZVKodLBoe+zBM69g==} - icss-utils@5.1.0: - resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} - engines: {node: ^10 || ^12 || >= 14} - peerDependencies: - postcss: ^8.1.0 - idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} @@ -1954,18 +1884,10 @@ packages: engines: {node: '>=10'} hasBin: true - jest-util@29.7.0: - resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} - jest-worker@29.7.0: - resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2018,13 +1940,26 @@ packages: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} + lit-css-loader@3.0.1: + resolution: {integrity: sha512-SiX7fe9R1nAd5SFzS0zgWovq0IjTyNOfIspWc2+U3eHfQSfLKtCLymJyidnIcm9nHp2f1uDxnvGDfjNI0WNYmA==} + + lit-element@4.1.1: + resolution: {integrity: sha512-HO9Tkkh34QkTeUmEdNYhMT8hzLid7YlMlATSi1q4q17HE5d9mrrEHJ/o8O2D0cMi182zK1F3v7x0PWFjrhXFew==} + lit-html@3.2.1: resolution: {integrity: sha512-qI/3lziaPMSKsrwlxH/xMgikhQ0EGOX2ICU73Bi/YHFvz2j/yMCIrw4+puF2IpQ4+upd3EWbvnHM9+PnJn48YA==} + lit@3.2.1: + resolution: {integrity: sha512-1BBa1E/z0O9ye5fZprPtdqnc0BFzxIxTTOO/tQFmyC/hj1O3jL4TfmLBw0WEwjAokdLwpclkvGgDJwTIh0/22w==} + loader-runner@4.3.0: resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} engines: {node: '>=6.11.5'} + loader-utils@3.3.1: + resolution: {integrity: sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==} + engines: {node: '>= 12.13.0'} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -2113,12 +2048,6 @@ packages: engines: {node: '>=4'} hasBin: true - mini-css-extract-plugin@2.9.2: - resolution: {integrity: sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w==} - engines: {node: '>= 12.13.0'} - peerDependencies: - webpack: ^5.0.0 - minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -2380,30 +2309,6 @@ packages: peerDependencies: postcss: ^8.4.31 - postcss-modules-extract-imports@3.1.0: - resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} - engines: {node: ^10 || ^12 || >= 14} - peerDependencies: - postcss: ^8.1.0 - - postcss-modules-local-by-default@4.2.0: - resolution: {integrity: sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==} - engines: {node: ^10 || ^12 || >= 14} - peerDependencies: - postcss: ^8.1.0 - - postcss-modules-scope@3.2.1: - resolution: {integrity: sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==} - engines: {node: ^10 || ^12 || >= 14} - peerDependencies: - postcss: ^8.1.0 - - postcss-modules-values@4.0.0: - resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} - engines: {node: ^10 || ^12 || >= 14} - peerDependencies: - postcss: ^8.1.0 - postcss-normalize-charset@7.0.0: resolution: {integrity: sha512-ABisNUXMeZeDNzCQxPxBCkXexvBrUHV+p7/BXOY+ulxkcjUZO0cp8ekGBwvIh2LbCwnWbyMPNJVtBSdyhM2zYQ==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} @@ -2682,11 +2587,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.1: - resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} - engines: {node: '>=10'} - hasBin: true - send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -2843,12 +2743,6 @@ packages: resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} engines: {node: '>=10'} - style-loader@4.0.0: - resolution: {integrity: sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==} - engines: {node: '>= 18.12.0'} - peerDependencies: - webpack: ^5.27.0 - stylehacks@7.0.4: resolution: {integrity: sha512-i4zfNrGMt9SB4xRK9L83rlsFCgdGANfeDAYacO1pkqcE7cRHPdWHwnKZVz7WY17Veq/FvyYsRAU++Ga+qDFIww==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} @@ -3232,6 +3126,24 @@ packages: yup@1.6.1: resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==} + zustand@5.0.3: + resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@ampproject/remapping@2.3.0': @@ -3891,19 +3803,6 @@ snapshots: '@discoveryjs/json-ext@0.6.3': {} - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.8 - - '@jest/types@29.6.3': - dependencies: - '@jest/schemas': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 22.14.0 - '@types/yargs': 17.0.33 - chalk: 4.1.2 - '@jridgewell/gen-mapping@0.3.8': dependencies: '@jridgewell/set-array': 1.2.1 @@ -3944,6 +3843,12 @@ snapshots: '@leichtgewicht/ip-codec@2.0.5': {} + '@lit-labs/ssr-dom-shim@1.3.0': {} + + '@lit/reactive-element@2.0.4': + dependencies: + '@lit-labs/ssr-dom-shim': 1.3.0 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3961,6 +3866,12 @@ snapshots: html-webpack-plugin: 5.6.3(webpack@5.98.0) webpack: 5.98.0(webpack-cli@6.0.1) + '@pwrs/lit-css@3.0.1(postcss@8.5.3)': + dependencies: + cssnano: 7.0.6(postcss@8.5.3) + transitivePeerDependencies: + - postcss + '@rollup/plugin-babel@5.3.1(@babel/core@7.26.10)(rollup@2.79.2)': dependencies: '@babel/core': 7.26.10 @@ -4009,8 +3920,6 @@ snapshots: optionalDependencies: rollup: 2.79.2 - '@sinclair/typebox@0.27.8': {} - '@sindresorhus/merge-streams@2.3.0': {} '@surma/rollup-plugin-off-main-thread@2.2.3': @@ -4076,16 +3985,6 @@ snapshots: dependencies: '@types/node': 22.14.0 - '@types/istanbul-lib-coverage@2.0.6': {} - - '@types/istanbul-lib-report@3.0.3': - dependencies: - '@types/istanbul-lib-coverage': 2.0.6 - - '@types/istanbul-reports@3.0.4': - dependencies: - '@types/istanbul-lib-report': 3.0.3 - '@types/json-schema@7.0.15': {} '@types/mime@1.3.5': {} @@ -4131,12 +4030,6 @@ snapshots: dependencies: '@types/node': 22.14.0 - '@types/yargs-parser@21.0.3': {} - - '@types/yargs@17.0.33': - dependencies: - '@types/yargs-parser': 21.0.3 - '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -4446,8 +4339,6 @@ snapshots: chrome-trace-event@1.0.4: {} - ci-info@3.9.0: {} - cipher-base@1.0.6: dependencies: inherits: 2.0.4 @@ -4557,29 +4448,6 @@ snapshots: dependencies: postcss: 8.5.3 - css-loader@7.1.2(webpack@5.98.0): - dependencies: - icss-utils: 5.1.0(postcss@8.5.3) - postcss: 8.5.3 - postcss-modules-extract-imports: 3.1.0(postcss@8.5.3) - postcss-modules-local-by-default: 4.2.0(postcss@8.5.3) - postcss-modules-scope: 3.2.1(postcss@8.5.3) - postcss-modules-values: 4.0.0(postcss@8.5.3) - postcss-value-parser: 4.2.0 - semver: 7.7.1 - optionalDependencies: - webpack: 5.98.0(webpack-cli@6.0.1) - - css-minimizer-webpack-plugin@7.0.2(webpack@5.98.0): - dependencies: - '@jridgewell/trace-mapping': 0.3.25 - cssnano: 7.0.6(postcss@8.5.3) - jest-worker: 29.7.0 - postcss: 8.5.3 - schema-utils: 4.3.0 - serialize-javascript: 6.0.2 - webpack: 5.98.0(webpack-cli@6.0.1) - css-select@4.3.0: dependencies: boolbase: 1.0.0 @@ -5114,7 +4982,7 @@ snapshots: graceful-fs@4.2.11: {} - hafas-client@6.3.4(patch_hash=mko7uu5feywjq6c7qe5c22nnzi): + hafas-client@6.3.4(patch_hash=jzfq2zvnydenrytq7tcgcly4hm): dependencies: '@derhuerst/br2nl': 1.0.0 '@derhuerst/round-robin-scheduler': 1.0.4 @@ -5259,10 +5127,6 @@ snapshots: runes2: 1.1.4 yup: 1.6.1 - icss-utils@5.1.0(postcss@8.5.3): - dependencies: - postcss: 8.5.3 - idb@7.1.1: {} idb@8.0.2: {} @@ -5457,28 +5321,12 @@ snapshots: filelist: 1.0.4 minimatch: 3.1.2 - jest-util@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 22.14.0 - chalk: 4.1.2 - ci-info: 3.9.0 - graceful-fs: 4.2.11 - picomatch: 2.3.1 - jest-worker@27.5.1: dependencies: '@types/node': 22.14.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest-worker@29.7.0: - dependencies: - '@types/node': 22.14.0 - jest-util: 29.7.0 - merge-stream: 2.0.0 - supports-color: 8.1.1 - js-tokens@4.0.0: {} jsesc@3.0.2: {} @@ -5514,12 +5362,33 @@ snapshots: lilconfig@3.1.3: {} + lit-css-loader@3.0.1(postcss@8.5.3): + dependencies: + '@pwrs/lit-css': 3.0.1(postcss@8.5.3) + loader-utils: 3.3.1 + transitivePeerDependencies: + - postcss + + lit-element@4.1.1: + dependencies: + '@lit-labs/ssr-dom-shim': 1.3.0 + '@lit/reactive-element': 2.0.4 + lit-html: 3.2.1 + lit-html@3.2.1: dependencies: '@types/trusted-types': 2.0.7 + lit@3.2.1: + dependencies: + '@lit/reactive-element': 2.0.4 + lit-element: 4.1.1 + lit-html: 3.2.1 + loader-runner@4.3.0: {} + loader-utils@3.3.1: {} + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -5592,12 +5461,6 @@ snapshots: mime@1.6.0: {} - mini-css-extract-plugin@2.9.2(webpack@5.98.0): - dependencies: - schema-utils: 4.3.0 - tapable: 2.2.1 - webpack: 5.98.0(webpack-cli@6.0.1) - minimalistic-assert@1.0.1: {} minimatch@3.1.2: @@ -5829,27 +5692,6 @@ snapshots: postcss: 8.5.3 postcss-selector-parser: 6.1.2 - postcss-modules-extract-imports@3.1.0(postcss@8.5.3): - dependencies: - postcss: 8.5.3 - - postcss-modules-local-by-default@4.2.0(postcss@8.5.3): - dependencies: - icss-utils: 5.1.0(postcss@8.5.3) - postcss: 8.5.3 - postcss-selector-parser: 7.1.0 - postcss-value-parser: 4.2.0 - - postcss-modules-scope@3.2.1(postcss@8.5.3): - dependencies: - postcss: 8.5.3 - postcss-selector-parser: 7.1.0 - - postcss-modules-values@4.0.0(postcss@8.5.3): - dependencies: - icss-utils: 5.1.0(postcss@8.5.3) - postcss: 8.5.3 - postcss-normalize-charset@7.0.0(postcss@8.5.3): dependencies: postcss: 8.5.3 @@ -6141,8 +5983,6 @@ snapshots: semver@6.3.1: {} - semver@7.7.1: {} - send@0.19.0: dependencies: debug: 2.6.9 @@ -6374,10 +6214,6 @@ snapshots: strip-comments@2.0.1: {} - style-loader@4.0.0(webpack@5.98.0): - dependencies: - webpack: 5.98.0(webpack-cli@6.0.1) - stylehacks@7.0.4(postcss@8.5.3): dependencies: browserslist: 4.24.4 @@ -6893,3 +6729,5 @@ snapshots: tiny-case: 1.0.3 toposort: 2.0.2 type-fest: 2.19.0 + + zustand@5.0.3: {}
diff --git a/src/LitOverlay.js b/src/LitOverlay.js @@ -0,0 +1,88 @@ +import { LitElement, html, nothing } from 'lit'; +import { t } from './languages.js'; + +import { flexboxStyles, overlaysStyles, buttonInputStyles, iconStyles } from './styles.js'; + +export class LitOverlay extends LitElement { + static properties = { + overlayType: { state: true }, + overlayTitle: { state: true }, + overlayVisible: { state: true }, + overlayContent: { state: true }, + }; + + static styles = [ + flexboxStyles, + overlaysStyles, + buttonInputStyles, + iconStyles + ]; + + constructor () { + super(); + this.overlayType = 'plain'; + this.overlayVisible = false; + this.overlayContent = null; + this.overlayTitle = null; + } + + showLoaderOverlay = () => this.showOverlay('loader'); + showDialogOverlay = (title, body) => this.showOverlay('dialog', body, title); + showAlertOverlay = text => this.showOverlay('alert', text); + showSelectOverlay = items => this.showOverlay('select', items); + hideOverlay = () => { this.overlayVisible = false; } + showOverlay = (type, content, title) => { + this.overlayType = type; + this.overlayContent = content; + this.overlayTitle = title; + this.overlayVisible = true; + } + + overlayHandler = event => event.target === event.currentTarget && this.overlayType !== 'loader' ? this.hideOverlay() : true; + + render () { + let overlayContent; + + if (this.overlayVisible) { + switch (this.overlayType) { + case 'loader': + overlayContent = html`<div class="spinner"></div>`; + break; + case 'dialog': + overlayContent = html` + <div class="modal dialog"> + <div class="header flex-row"> + <h4>${t(this.overlayTitle)}</h4> + <div class="icon-close" title="${t('close')}" @click=${this.hideOverlay}></div> + </div> + <div class="body">${this.overlayContent}</div> + </div> + `; + break; + case 'select': + overlayContent = html` + <div class="modal select flex-column"> + ${this.overlayContent.map(item => html`<a class="button color" @click=${item.action}>${t(item.label)}</a>`)} + <a class="button color" @click=${() => { this.hideOverlay();}}>Close</a> + </div> + `; + break; + case 'alert': + overlayContent = html` + <div class="modal alert" style="overflow:auto"> + ${this.overlayContent}<br><button class="color" style="float:right" @click=${this.hideOverlay}>OK</button> + </div> + `; + break; + default: + overlayContent = this.overlayContent; + break; + } + } + + return [ + this.renderContent(), + !this.overlayVisible ? nothing : html`<div class="overlay" @click=${this.overlayHandler}>${overlayContent}</div>` + ]; + } +}
diff --git a/src/app_functions.js b/src/app_functions.js @@ -1,113 +1,98 @@ -import { html } from 'lit-html'; import { db } from './dataStorage.js'; -import { go } from './router.js'; -import { settings, subscribeSettings } from './settings.js'; -import { showLoader, hideOverlay, showModal, showAlertModal } from './overlays.js'; -import { languages } from './languages.js'; -import { isEmptyObject, generateSlug, loyaltyCardToString, loyaltyCardFromString } from './helpers.js'; -import { formatDateTime } from './formatters.js'; +import { settingsState } from './settings.js'; +import { generateSlug, loyaltyCardToString, loyaltyCardFromString } from './helpers.js'; import { getHafasClient, client } from './hafasClient.js'; import { trainsearchToHafas, hafasToTrainsearch } from './refresh_token/index.js'; -import { default as ds100 } from './ds100.json'; - -let ds100R = {}; - const journeySettings = () => { - return { + let params = { stopovers: true, polylines: false, tickets: true, - language: settings.language, + language: settingsState.language, }; - if (settings.profile !== 'db') { - params.accessibility = settings.accessibility; - params.walkingSpeed = settings.walkingSpeed; - } else { - const card = loyaltyCardFromString(settings.loyaltyCard) - params.loyaltyCard = card; - params.ageGroup = settings.ageGroup; + if (settingsState.profile === 'db') { + params.loyaltyCard = settingsState.loyaltyCard; + params.ageGroup = settingsState.ageGroup; } return params; }; +const processJourneys = journeys => journeys.forEach(journey => processJourney(journey)); +const processJourney = journey => journey.legs.forEach(leg => processLeg(leg)); -export const getFrom = journeys => journeys[0].legs[0].origin; -export const getTo = journeys => journeys[0].legs[journeys[0].legs.length-1].destination; - -export const addJourneys = async data => { - if (!data) return false; - - const historyEntry = { - profile: data.profile, - fromPoint: getFrom(data.journeys), - viaPoint: data.params.via, - toPoint: getTo(data.journeys), - slug: data.slug - }; +export const processLeg = leg => { + const elements = [ 'plannedDeparture', 'plannedArrival', 'plannedWhen', 'departure', 'arrival', 'when' ]; - const journeyEntries = data.journeys.map(j => { - return { - ...j, - settings: data.settings, - slug: data.slug, - }; + elements.forEach(element => { + if (leg[element]) leg[element] = new Date(leg[element]); }); - if (typeof data.params.loyaltyCard === 'object') data.params.loyaltyCard = loyaltyCardToString(data.params.loyaltyCard); - - const journeysOverviewEntry = { - ...data, - journeys: data.journeys.map(j => j.refreshToken), - }; - - await db.addJourneys(journeyEntries, journeysOverviewEntry, historyEntry); + if (leg.stopovers) leg.stopovers.forEach(stopover => { + elements.forEach(element => { + if (stopover[element]) stopover[element] = new Date(stopover[element]); + }); + }); }; -const processJourneys = data => { for (const journey of data.journeys) processJourney(journey) }; -const processJourney = journey => { for (const leg of journey.legs) processLeg(leg) }; +export const getFromPoint = journeys => journeys[0].legs[0].origin; +export const getToPoint = journeys => journeys[0].legs[journeys[0].legs.length-1].destination; -export const processLeg = leg => { - if (leg.plannedDeparture) leg.plannedDeparture = new Date(leg.plannedDeparture); - if (leg.plannedArrival) leg.plannedArrival = new Date(leg.plannedArrival); - if (leg.plannedWhen) leg.plannedWhen = new Date(leg.plannedWhen); - if (leg.departure) leg.departure = new Date(leg.departure); - if (leg.arrival) leg.arrival = new Date(leg.arrival); - if (leg.when) leg.when = new Date(leg.when); - for (const stopover of (leg.stopovers || [])) { - if (stopover.plannedDeparture) stopover.plannedDeparture = new Date(stopover.plannedDeparture); - if (stopover.plannedArrival) stopover.plannedArrival = new Date(stopover.plannedArrival); - if (stopover.plannedWhen) stopover.plannedWhen = new Date(stopover.plannedWhen); - if (stopover.departure) stopover.departure = new Date(stopover.departure); - if (stopover.arrival) stopover.arrival = new Date(stopover.arrival); - if (stopover.when) stopover.when = new Date(stopover.when); - } -}; - -export const newJourneys = async (params) => { + export const newJourneys = async params => { const { from, to, ...moreOpts } = params; let data; - data = await client.journeys(from, to, { ...journeySettings(), ...moreOpts }); + const journeySettingsObj = journeySettings(); + if (typeof journeySettingsObj.loyaltyCard === 'string') journeySettingsObj.loyaltyCard = loyaltyCardFromString(journeySettingsObj.loyaltyCard); - for (const journey of data.journeys) { - journey.refreshToken = hafasToTrainsearch(journey.refreshToken); - } + data = await client.journeys(from, to, { ...moreOpts, ...journeySettingsObj }); + + data.journeys.forEach((journey, index, journeys) => { + journeys[index].refreshToken = hafasToTrainsearch(journey.refreshToken); + }); data.slug = generateSlug(); data.indexOffset = 0; data.params = params; - data.settings = journeySettings(); - data.profile = settings.profile; + data.settings = journeySettingsObj; + data.profile = settingsState.profile; await addJourneys(data); - processJourneys(data); + + processJourneys(data.journeys); return data; }; +export const addJourneys = async data => { + if (!data) return false; + + if (typeof data.settings.loyaltyCard === 'object') data.settings.loyaltyCard = loyaltyCardToString(data.settings.loyaltyCard); + + const journeyEntries = data.journeys.map(j => ({ + ...j, + settings: data.settings, + slug: data.slug, + })); + + const journeysOverviewEntry = { + ...data, + journeys: data.journeys.map(j => j.refreshToken), + }; + + const historyEntry = { + profile: data.profile, + fromPoint: getFromPoint(data.journeys), + viaPoint: data.params.via, + toPoint: getToPoint(data.journeys), + slug: data.slug + }; + + await db.addJourneys(journeyEntries, journeysOverviewEntry, historyEntry); +}; + export const getJourneys = async slug => { let data = await db.getJourneysOverview(slug); @@ -115,22 +100,22 @@ export const getJourneys = async slug => { return { ...data, - journeys: await Promise.all(data.journeys.map(x => getJourney(x, data.profile))), + journeys: await Promise.all(data.journeys.map(refreshToken => getJourney(data.profile, refreshToken))), }; }; export const getMoreJourneys = async (slug, mode) => { const saved = await db.getJourneysOverview(slug); - const params = { ...saved.params, ...journeySettings() }; + const params = { ...journeySettings(), ...saved.params }; if (typeof params.loyaltyCard === 'string') params.loyaltyCard = loyaltyCardFromString(params.loyaltyCard); params[mode+'Than'] = saved[mode+'Ref']; let { departure, arrival, from, to, ...moreOpt } = params; + const [newData, ...existingJourneys] = await Promise.all( - [ client.journeys(from, to, moreOpt) ] - .concat(saved.journeys.map(x => getJourney(x, saved.profile))) + [ client.journeys(from, to, moreOpt) ].concat(saved.journeys.map(refreshToken => getJourney(saved.profile, refreshToken))) ); const res = { @@ -156,56 +141,40 @@ export const getMoreJourneys = async (slug, mode) => { export const refreshJourneys = async (slug) => { const saved = await db.getJourneysOverview(slug); - await Promise.all(saved.journeys.map(x => refreshJourney(x, saved.profile || "db"))); + await Promise.all(saved.journeys.map(refreshToken => refreshJourney(saved.profile, refreshToken))); }; -export const getJourney = async (refreshToken, profile) => { +export const getJourney = async (profile, refreshToken) => { let journeyObject = await db.getJourney(refreshToken); - if (!journeyObject || JSON.stringify(journeyObject.settings) != JSON.stringify(journeySettings())) - journeyObject = await refreshJourney(refreshToken, profile); + if (!journeyObject || JSON.stringify(journeyObject.settings) != JSON.stringify(journeySettings())) journeyObject = await refreshJourney(profile, refreshToken); processJourney(journeyObject); return journeyObject; }; -export const refreshJourney = async (refreshToken, profile) => { - const client = await getHafasClient(profile || settings.profile || "db"); - const [saved, data] = await Promise.all([ - db.getJourney(refreshToken), - client.refreshJourney(trainsearchToHafas(refreshToken), journeySettings()) - ]); - - const {journey} = data; - - journey.settings = journeySettings(); - journey.refreshToken = hafasToTrainsearch(journey.refreshToken); - - if (saved) journey.slug = saved.slug; +export const refreshJourney = async (profile, refreshToken) => { + const client = await getHafasClient(profile); - db.updateJourney(journey); + const params = journeySettings(); + params.loyaltyCard = loyaltyCardFromString(params.loyaltyCard); - return journey; -}; + const [ saved, data ] = await Promise.all([ + db.getJourney(refreshToken), + client.refreshJourney(trainsearchToHafas(refreshToken), params) + ]); -export const ds100Name = (id) => { - if (!settings.showDS100) return null; - if (!ds100[Number(id)]) return null; + const journeyObject = data.journey; - return ds100[Number(id)]; -}; + journeyObject.refreshToken = hafasToTrainsearch(journeyObject.refreshToken); + journeyObject.settings = journeySettings(); -export const ds100Reverse = (name) => { - if (!settings.showDS100) return null; + if (saved) journeyObject.slug = saved.slug; - if (isEmptyObject(ds100R)) { - for (let [id, names] of Object.entries(ds100)) { - for (let name of names.split(", ")) ds100R[name] = id; - } - } + db.updateJourney(journeyObject); - if (!ds100R[name]) return null; + processJourney(journeyObject); - return ds100R[name]; + return journeyObject; };
diff --git a/src/assets/icons.css b/src/assets/icons.css @@ -1,83 +0,0 @@ -.icon-back { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11z" fill="white"/></svg>'); -} - -.icon-reload { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M17.65 6.35A7.96 7.96 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4z" fill="white"/></svg>'); -} - -.icon-close { - content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="30" height="30"><path d="M5.293 5.293a1 1 0 0 1 1.414 0L12 10.586l5.293-5.293a1 1 0 1 1 1.414 1.414L13.414 12l5.293 5.293a1 1 0 0 1-1.414 1.414L12 13.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L10.586 12 5.293 6.707a1 1 0 0 1 0-1.414" fill="white"/></svg>'); -} - -.icon-hint { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M5 3h14a2 2 0 0 1 2 2v14c0 .53-.21 1.04-.59 1.41-.37.38-.88.59-1.41.59H5c-.53 0-1.04-.21-1.41-.59C3.21 20.04 3 19.53 3 19V5c0-1.11.89-2 2-2m6 6h2V7h-2zm3 8v-2h-1v-4h-3v2h1v2h-1v2z"/></svg>'); -} - -.icon-status { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m8 10V7h-2v6zm0 4v-2h-2v2z"/></svg>'); -} - -.icon-warning { - content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M13 13h-2V7h2m-2 8h2v2h-2m4.73-14H8.27L3 8.27v7.46L8.27 21h7.46L21 15.73V8.27z"/></svg>'); -} - -.icon-other { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m1 17h-2v-2h2zm2.07-7.75-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25"/></svg>'); -} - -.icon-arrow1 { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>'); -} - -.icon-arrow2 { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"/></svg>'); -} - -.icon-swap { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M16 17.01V10h-2v7.01h-3L15 21l4-3.99zM9 3 5 6.99h3V14h2V6.99h3z"/></svg>'); -} - -.icon-clock { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M12 20a8 8 0 0 0 8-8 8 8 0 0 0-8-8 8 8 0 0 0-8 8 8 8 0 0 0 8 8m0-18a10 10 0 0 1 10 10 10 10 0 0 1-10 10C6.47 22 2 17.5 2 12A10 10 0 0 1 12 2m.5 5v5.25l4.5 2.67-.75 1.23L11 13V7z"/></svg>'); -} - -.icon-settings { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6"/></svg>'); -} - -.icon-walk-fast { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M13.49 5.48c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m-3.6 13.9 1-4.4 2.1 2v6h2v-7.5l-2.1-2 .6-3c1.3 1.5 3.3 2.5 5.5 2.5v-2c-1.9 0-3.5-1-4.3-2.4l-1-1.6c-.4-.6-1-1-1.7-1-.3 0-.5.1-.8.1l-5.2 2.2v4.7h2v-3.4l1.8-.7-1.6 8.1-4.9-1-.4 2z"/></svg>'); -} - -.icon-walk { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M13.5 5.5c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2M9.8 8.9 7 23h2.1l1.8-8 2.1 2v6h2v-7.5l-2.1-2 .6-3C14.8 12 16.8 13 19 13v-2c-1.9 0-3.5-1-4.3-2.4l-1-1.6c-.4-.6-1-1-1.7-1-.3 0-.5.1-.8.1L6 8.3V13h2V9.6z"/></svg>'); -} - -.icon-weelchair { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><circle cx="12" cy="4" r="2"/><path d="M19 13v-2c-1.54.02-3.09-.75-4.07-1.83l-1.29-1.43c-.17-.19-.38-.34-.61-.45-.01 0-.01-.01-.02-.01H13c-.35-.2-.75-.3-1.19-.26C10.76 7.11 10 8.04 10 9.09V15c0 1.1.9 2 2 2h5v5h2v-5.5c0-1.1-.9-2-2-2h-3v-3.45c1.29 1.07 3.25 1.94 5 1.95m-6.17 5c-.41 1.16-1.52 2-2.83 2-1.66 0-3-1.34-3-3 0-1.31.84-2.41 2-2.83V12.1a5 5 0 1 0 5.9 5.9z"/></svg>'); -} - -.icon-bike { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M5 20.5A3.5 3.5 0 0 1 1.5 17 3.5 3.5 0 0 1 5 13.5 3.5 3.5 0 0 1 8.5 17 3.5 3.5 0 0 1 5 20.5M5 12a5 5 0 0 0-5 5 5 5 0 0 0 5 5 5 5 0 0 0 5-5 5 5 0 0 0-5-5m9.8-2H19V8.2h-3.2l-1.94-3.27c-.29-.5-.86-.83-1.46-.83-.47 0-.9.19-1.2.5L7.5 8.29C7.19 8.6 7 9 7 9.5c0 .63.33 1.16.85 1.47L11.2 13v5H13v-6.5l-2.25-1.65 2.32-2.35m5.93 13a3.5 3.5 0 0 1-3.5-3.5 3.5 3.5 0 0 1 3.5-3.5 3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m0-8.5a5 5 0 0 0-5 5 5 5 0 0 0 5 5 5 5 0 0 0 5-5 5 5 0 0 0-5-5m-3-7.2c1 0 1.8-.8 1.8-1.8S17 1.2 16 1.2 14.2 2 14.2 3 15 4.8 16 4.8"/></svg>'); -} - -.icon-seat { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M9 19h6v2H9c-2.76 0-5-2.24-5-5V7h2v9c0 1.66 1.34 3 3 3m1.42-13.59c.78-.78.78-2.05 0-2.83s-2.05-.78-2.83 0-.78 2.05 0 2.83c.78.79 2.04.79 2.83 0M11.5 9c0-1.1-.9-2-2-2H9c-1.1 0-2 .9-2 2v6c0 1.66 1.34 3 3 3h5.07l3.5 3.5L20 20.07 14.93 15H11.5z"/></svg>'); -} - -.icon-table { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M3 9h14V7H3zm0 4h14v-2H3zm0 4h14v-2H3zm16 0h2v-2h-2zm0-10v2h2V7zm0 6h2v-2h-2z" fill="white" /></svg>'); -} - -.icon-canvas { - content: url('data:image/svg+xml;utf8,<svg version="1.1" viewBox="0 0 24 24" height="24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m11 5v14h2v-14zm-4-2v14h2v-14zm10 4h-2v14h2z" fill="white"/></svg>'); -} - -.icon-dots { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M12 16a2 2 0 0 1 2 2 2 2 0 0 1-2 2 2 2 0 0 1-2-2 2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2 2 2 0 0 1-2 2 2 2 0 0 1-2-2 2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2 2 2 0 0 1-2 2 2 2 0 0 1-2-2 2 2 0 0 1 2-2" fill="white"/></svg>'); -} - -.icon-share { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 0 0 3-3 3 3 0 0 0-3-3 3 3 0 0 0-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9a3 3 0 0 0-3 3 3 3 0 0 0 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66 0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91A2.92 2.92 0 0 0 18 16.08" fill="white"/></svg>'); -}
diff --git a/src/assets/index.html b/src/assets/index.html @@ -1,32 +1,24 @@ <!DOCTYPE html> <html> <head> - <title><%= htmlWebpackPlugin.options.title %></title> + <title></title> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <meta name="theme-color" content="#333"> <meta name="description" content="Plan your public transport journeys"> <link rel="apple-touch-icon" href="favicon.png"> <link rel="manifest" href="manifest.json"> - <style> - body { - background-color: #333; - } - - #overlay { - position: fixed; - display: flex; - top: 0; - left: 0; - height: 100vh; - width: 100vw; - background-color: rgba(0, 0, 0, .7); - } - </style> </head> <body> - <div id="content"></div> - <div id="overlay"> + <div style=" + position: fixed; + display: flex; + top: 0; + left: 0; + height: 100vh; + width: 100vw; + background-color: #333; + "> <noscript style="margin: auto;">JavaScript is required to use <%= htmlWebpackPlugin.options.title %></noscript> <svg style="margin: auto; width: 50vmin; height: 50vmin;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28"><rect rx="4" height="28" width="28" fill="green"/><path d="M14 5.5c-4 0-8 .5-8 4V19c0 1.93 1.57 3.5 3.5 3.5L8 24v.5h2.23l2-2H16l2 2h2V24l-1.5-1.5c1.93 0 3.5-1.57 3.5-3.5V9.5c0-3.5-3.58-4-8-4m-4.5 15c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5m3.5-7H8v-4h5zm2 0v-4h5v4zm3.5 7c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5" fill="white"/></svg> </div>
diff --git a/src/assets/style.css b/src/assets/style.css @@ -1,815 +0,0 @@ -@import url('./icons.css'); - -font-face { - font-weight: normal; - font-tyle: normal; -} - -:root { - overscroll-behavior-y: none; -} - -* { - font-family: sans-serif; - box-sizing: border-box; - border-collapse: collapse; -} - - -body { - margin: 0; - background-color: #333; -} - -a { - color: inherit; -} - -.invisible { - visibility: hidden !important; -} - -.hidden { - display: none !important; -} - -.flipped { - transform-origin: center center; - transform: rotate(180deg); -} - -.flex-row { - display: flex; - flex-direction: row; -} - -.flex-column { - display: flex; - flex-direction: column; -} - -.flex-center { - display: flex; - justify-content: center; - align-items: center; -} - -.center { - margin: auto; -} - -.spinner { - margin: calc(50vh - 60px) auto; - border: 5px solid rgba(255, 255, 255, .4); - border-top: 5px solid white; - border-radius: 50%; - width: 120px; - height: 120px; - animation: spin 2s linear infinite; -} - -.spinning { - animation: spin 2s linear infinite; -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -.container { - margin: 0 auto; - max-width: 1000px; -} - -.header-container { - position: sticky; - top: 0; - z-index: 10; - - header { - display: flex; - flex-direction: row; - justify-content: center; - color: white; - background-color: #33691E; - border-bottom: 1px solid rgba(255, 255, 255, .3); - - .container { - max-width: 1000px; - width: 80vw; - margin: 0; - } - - h3 { - margin-right: 1.5em; - } - - .icon-reload { - float: right; - } - - .icon-back, - .icon-reload, - .icon-share, - .icon-dots { - cursor: pointer; - width: 32px; - height: 32px; - margin: 12px; - user-select: none; - } - - .mode-changers { - margin-top: auto; - margin-left: auto; - height: max-content; - - a { - border-bottom: 3px solid transparent; - align-items: center; - display: flex; - padding: 0 1em; - cursor: pointer; - text-decoration: none; - width: max-content; - - span { - font-weight: bold; - margin: 1em .4em; - } - } - - a.active { - border-bottom: 3px solid white; - } - } - } - -} - -footer { - color: #ddd; - padding: 2em; - width: max-content; - - a { - text-decoration: none; - } - - a:after { - margin: 0 8px; - content: "•"; - } - - :last-child:after { - content: none; - } -} - -.card { - overflow-x: auto; - - table { - border-bottom: 1px solid rgba(0, 0, 0, 0.3); - width: 100%; - background-color: white; - min-width: 390px; - max-width: 1000px; - } - - table a { - padding: 5px 3px; - text-decoration: none; - } - - thead { - .center { - padding: 5px 3px; - } - - tr:not(:last-child) { - background-color: #eee; - } - } - - tbody { - tr { - cursor: pointer; - border-top: 1px solid #ccc; - } - - tr:hover { - background-color: #ddd; - } - - tr:hover td { - background-color: transparent; - } - } - - td, th { - text-align: center; - overflow: hidden; - } - - th { - padding: 10px 5px; - } - - th.station { - width: 60%; - } - - .train-details { - flex-wrap: wrap; - padding: 0 !important; - - div { - margin: .4em 2em; - } - } - - .cancelled { - text-decoration-line: line-through; - } - - .cancelled-text { - font-weight: bold; - color: red !important; - } -} - -.settingsView { - .flex-row, - .flex-column { - padding: 1em; - border-bottom: 1px solid rgba(0, 0, 0, .4); - } - - .flex-row { - align-items: center; - } - - .flex-row:last-child { - padding: .5em; - border-bottom: unset; - justify-content: right; - } - - label { - padding: .1em; - } - - span { - padding-bottom: .25em; - } - - select { - width: 65%; - } - - select, - .flex-row div { - margin-left: auto; - } -} - -.searchView { - .title { - padding-top: 3em; - - h1 { - color: white; - font-weight: normal; - margin: .7em .3em .5em .3em; - } - - h1:hover { - -webkit-text-fill-color: transparent; - -webkit-background-clip: text !important; - background: linear-gradient(90deg, #b4dcff 20%, pink 20%, pink 40%, white 40%, white 60%, pink 60%, pink 80%, #b4dcff 80%, #b4dcff 100%); - } - } - - .title::before { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28"><rect rx="4" height="28" width="28" fill="green"/><path d="M14 5.5c-4 0-8 .5-8 4V19c0 1.93 1.57 3.5 3.5 3.5L8 24v.5h2.23l2-2H16l2 2h2V24l-1.5-1.5c1.93 0 3.5-1.57 3.5-3.5V9.5c0-3.5-3.58-4-8-4m-4.5 15c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5m3.5-7H8v-4h5zm2 0v-4h5v4zm3.5 7c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5" fill="white"/></svg>'); - width: 50px; - height: 50px; - margin: -0.7em .3em -0.5em -0.3em; - } - - form { - color: white; - - #time, #date, #datetime { - flex-grow: 1; - } - - #from, #to, #via { - width: 100%; - } - - .button.icon-arrow1, - .button.icon-arrow2, - .button.icon-swap { - padding: .3em .5em; - } - - .button.now { - display: flex; - justify-content: center; - align-items: center; - user-select: none; - padding: 0 10px; - } - - button[type="submit"], - .button.icon-settings { - height: 32px; - } - - .filler { - flex: auto; - } - - .button.icon-settings { - width: 32px; - padding: 3px; - } - - button[type="submit"]{ - display: flex; - align-items: center; - font-size: 20px; - padding: 8px; - } - - button[type="submit"]::after { - width: 24px; - height: 24px; - margin-left: 5px; - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2 4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" fill="green"/></svg>'); - } - } - - .suggestions { - position: relative; - overflow: visible; - z-index: 100; - height: 0; - margin-left: 4px; - margin-right: 3.23rem; - - p { - font-size: 1.2em; - background-color: white; - color: black; - margin: 0; - border-top: 1px solid rgba(0, 0, 0, .2); - padding: .3em .6em; - cursor: pointer; - } - - p:first-child { - margin-top: -4px; - } - - p:hover { - background-color: #d3d3d3; - } - - #fromSelected, - #viaSelected, - #toSelected { - background-color: #bfbfbf !important; - } - } - - .history { - padding: 0 4px; - overflow: hidden; - user-select: none; - - .flex-row { - justify-content: space-between; - cursor: pointer; - padding: .3em .6em .3em .3em; - margin: 0; - background-color: white; - color: black; - font-size: 1.2em; - border-bottom: 1px solid rgba(0, 0, 0, .2); - } - - .flex-row:last-child { - border-bottom: unset; - } - - .via { - font-size: smaller; - font-weight: 200; - } - - .from, - .to { - width: 40%; - } - - .to { - text-align: right; - } - - small::after { - content: "\a"; - white-space: pre; - } - - .icon-arrow1 { - width: 25px; - } - } -} - -.journeyView { - tbody:not(:last-child) { - border-bottom: 1px solid rgba(0, 0, 0, .2); - } - - thead>tr:nth-child(2) { - border-bottom: 2px solid #ccc; - } - - p { - color: white; - width: 100%; - } - - p::before { - filter: drop-shadow( 0 0 5px rgba(0, 0, 0, .6) ); - margin-right: 4px; - vertical-align: sub; - } - - p.change, - p.walk, - p.transfer { - text-shadow: 0 0 15px rgba(0, 0, 0, .6); - text-align: center; - } - - p.change::before { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M9 3 5 6.99h3V14h2V6.99h3zm7 14.01V10h-2v7.01h-3L15 21l4-3.99z" fill="white"/></svg>'); - } - - p.walk::before { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M13.5 5.5c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2M9.8 8.9 7 23h2.1l1.8-8 2.1 2v6h2v-7.5l-2.1-2 .6-3C14.8 12 16.8 13 19 13v-2c-1.9 0-3.5-1-4.3-2.4l-1-1.6c-.4-.6-1-1-1.7-1-.3 0-.5.1-.8.1L6 8.3V13h2V9.6z" fill="white"/></svg>'); - } - - p.transfer::before { - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="m21.71 11.29-9-9a.996.996 0 0 0-1.41 0l-9 9a.996.996 0 0 0 0 1.41l9 9c.39.39 1.02.39 1.41 0l9-9a.996.996 0 0 0 0-1.41M14 14.5V12h-4v3H8v-4c0-.55.45-1 1-1h5V7.5l3.5 3.5z" fill="white"/></svg>'); - } - - .link { - vertical-align: bottom; - cursor: pointer; - max-inline-size: 26px; - margin: 0 .3em; - } -} - -.departuresView { - tbody td:nth-child(2) { - text-align: unset; - } - - tbody td { - padding: 5px 3px; - } -} - -.remarksView { - .flex-row { - align-items: center; - flex-wrap: nowrap; - padding: .5em; - border-bottom: 1px solid rgba(0, 0, 0, .4); - } - - .flex-row:last-child { - border-bottom: unset; - } - - span[class^="icon-"] { - align-self: start; - padding-right: .3em; - } -} - - -#overlay { - z-index: 100; - backdrop-filter: blur(10px); - - .modal { - z-index: 1050; - margin: auto; - background-color: white; - width: max-content; - padding: 15px; - -webkit-overflow-scrolling: touch; - } - - .modal.alert button { - float: right; - } - - .modal.select { - a { - width: 100%; - margin: 5px 0; - text-align: center; - } - - a:first-child { - margin-top: unset; - } - - a:last-child { - margin-bottom: unset; - } - } - - .modal.dialog { - padding: unset; - - .body { - max-height: 70vh; - overflow: scroll; - } - - .header { - justify-content: space-between; - background-color: #33691E; - color: white; - padding: 15px; - - h4 { - margin: 0; - } - - .icon-close { - margin: -15px; - padding: 10px; - border-left: 1px solid rgba(0, 0, 0, .4); - cursor: pointer; - } - - .icon-close:hover { - background: rgba(0, 0, 0, .4); - } - } - } -} - -input, -button, -.button, -.selector { - margin: 4px; -} - -input[type="datetime-local"], -input[type="date"], -input[type="time"], -input[type="text"] { - cursor: pointer; - box-sizing: border-box; - padding: .3em .5em; - font-size: 1.5em; - padding: 7px; - border: none; - outline: none; - background-color: white; - color: black; - border-radius: 0; -} - -input[type="checkbox"] { - transform: scale(1.5); -} - -button, -.button, -.selector label { - background-color: white; - color: black; - cursor: pointer; - user-select: none; -} - -button, .button { - width: max-content; - padding: 8px 20px; - transition: background-color 300ms; - border: none; -} - -button:hover, .button:hover { - background-color: #d3d3d3; -} - -button.color, .button.color { - background-color: #43a047; - color: white; -} - -button.color:hover, .button.color:hover { - background-color: #388e3c; -} - -.arrowButton { - cursor: pointer; - user-select: none; - height: 72px; - width: 72px; - margin: 0 auto; - transition: transform 150ms; - filter: invert(); -} - -.selector { - display: flex; - - input { - display: none; - } - - input + label { - background: #d3d3d3; - } - - input:checked + label { - background: white - } - - label { - display: flex; - justify-content: center; - align-items: center; - user-select: none; - padding: 0 10px; - } - - label:after { - font-size: .9rem; - color: black; - text-align: center; - line-height: .9rem; - margin-top: 2px; - } - - div:not(:last-child), - label:not(:last-child) { - border-right: 1px solid #bbb; - } - - .icon-ice, - .icon-ic, - .icon-icice, - .icon-dzug, - .icon-regional { - font-style: italic; - } - - .icon-tram:after, - .con-bus:after, - .icon-ferry:after, - .icon-taxi:after { - font-size: 0.6rem; - } - - .icon-ice:after { content: 'ICE'; } - .icon-ic:after { content: 'IC'; } - .icon-icice:after { content: 'IC ICE'; } - .icon-dzug:after { content: 'D'; } - .icon-regional:after { content: 'NV'; } - .icon-suburban:after { content: 'S'; } - .icon-subway:after { content: 'U'; } - .icon-tram:after { content: 'Tram'; } - .icon-bus:after { content: 'Bus'; } - .icon-ferry:after { content: 'Ferry'; } - .icon-taxi:after { content: 'Taxi'; } -} - -.selector.rectangular label { - height: 32px; - width: 32px; - padding: 3px; - font-weight: bold; - overflow: hidden; -} - -@media (max-width: 650px) { - .filler { - flex: unset !important; - } - - button[type="submit"]{ - flex-basis: 100%; - justify-content: center; - } - - header { - padding-top: 10px; - - .mode-changers { - margin: auto; - } - - h3 { - margin: 8px 0; - } - } - - #overlay { - .modal.dialog { - width: 100vw; - height: 100vh; - } - } - -} - -@media (max-width: 799px) { - header { - .icon-back { - left: 10px; - } - } - - .searchView { - padding: 10px; - } - - .flex-row { - flex-wrap: wrap; - } - - .flex-row.nowrap { - flex-wrap: unset; - } - - .journeysView { - .arrowButton { - margin: 15px auto; - } - } - - .arrowButton { - width: 48px; - height: 48px; - } -} - -@media (min-width: 800px) { - .searchView { - form, - .history { - width: 80vw; - max-width: 700px; - color: white; - } - - #date { - width: 50%; - } - - #time { - width: 30%; - } - } - - .journeysView { - .arrowButton.flipped { - margin-top: 45px; - } - - table { - margin: 15px auto; - } - } - - table { - overflow: hidden; - border: none; - margin: 50px auto; - width: 80vw; - } - - #overlay { - .modal.dialog { - width: 600px; - } - } -}
diff --git a/src/coach-sequence/index.js b/src/coach-sequence/index.js @@ -1,7 +1,7 @@ import { padZeros, sleep } from '../helpers.js'; import { mapInformation } from './DB/DBMapping.js'; -const dbCoachSequenceTimeout = 1000; +const dbCoachSequenceTimeout = 1500; export const coachSequenceCache = {}; const rawDBCoachSequence = async (category, number, evaNumber, date, retry = 2) => { @@ -17,7 +17,7 @@ const rawDBCoachSequence = async (category, number, evaNumber, date, retry = 2) return await fetch(`/db/vehicle-sequence?${searchParams}`).then(x => x.json()); } catch (e) { - sleep(dbCoachSequenceTimeout); + await sleep(dbCoachSequenceTimeout); if (retry) return rawDBCoachSequence(category, number, evaNumber, date, retry - 1); } }
diff --git a/src/dataStorage.js b/src/dataStorage.js @@ -4,11 +4,8 @@ export let db; const dbName = !isDevServer ? 'trainsearch' : 'trainsearch_dev'; -class IDBStorage { - - constructor(idb) { - this.idb = idb; - } +class DataStorage { + constructor(idb) { this.idb = idb; } static async open() { const idb = await openDB(dbName, 3, { @@ -25,6 +22,7 @@ class IDBStorage { db.createObjectStore('journeys', {keyPath: 'slug'}); db.createObjectStore('journeysHistory', {autoIncrement: true}); db.createObjectStore('settings'); + case 1: /* * in database scheme v2, there are the following data stores: @@ -102,7 +100,7 @@ class IDBStorage { } }); - return new IDBStorage(idb); + return new DataStorage(idb); } async addJourneys(journeyEntries, overviewEntry, historyEntry) { @@ -111,9 +109,7 @@ class IDBStorage { const journeysOverviewStore = tx.objectStore('journeysOverview'); const journeysHistoryStore = tx.objectStore('journeysHistory'); - let proms = journeyEntries.map(j => { - journeyStore.put(j); - }); + let proms = journeyEntries.map(j => journeyStore.put(j)); if (overviewEntry.historyEntryId === undefined) overviewEntry.historyEntryId = await journeysHistoryStore.put(historyEntry); @@ -140,30 +136,15 @@ class IDBStorage { } async addHistoryEntry(entry) { - await this.idb.put('journeysHistory', entry); + return await this.idb.put('journeysHistory', entry); } - async updateHistoryEntry(key, entry) { - await this.idb.put('journeysHistory', entry, key); - } - - async getHistoryEntry(key) { - return await this.idb.get('journeysHistory', key); - } - - - async getSettings() { - return await this.idb.get('settings', 'settings'); + async updateHistoryEntry(id, entry) { + return await this.idb.put('journeysHistory', entry, id); } - async modifySettings(prevSettings, callback) { - const tx = this.idb.transaction('settings', 'readwrite'); - const newSettings = callback(prevSettings); - await Promise.all([ - tx.store.put(newSettings, 'settings'), - tx.done - ]); - return newSettings; + async getHistoryEntry(id) { + return await this.idb.get('journeysHistory', id); } async getJourneysOverview(slug) { @@ -171,8 +152,7 @@ class IDBStorage { } async getJourney(refreshToken) { - const journeyObject = await this.idb.get('journey', refreshToken); - return journeyObject; + return await this.idb.get('journey', refreshToken); } async updateJourney(data) { @@ -183,7 +163,7 @@ class IDBStorage { export const clearDataStorage = async () => await deleteDB(dbName); export const initDataStorage = async () => { try { - db = await IDBStorage.open(); + db = await DataStorage.open(); } catch(e) { console.log("IndexedDB initialization failed: ", e); alert("IndexedDB initialization failed!");
diff --git a/src/departuresView.js b/src/departuresView.js @@ -1,114 +1,139 @@ -import { html, nothing, render } from 'lit-html'; -import { settings } from './settings.js'; +import { LitElement, html, nothing } from 'lit'; +import { LitOverlay } from './LitOverlay.js'; + +import { sleep, queryBackgroundColor, setThemeColor } from './helpers.js'; import { platformTemplate, timeTemplate } from './templates.js'; -import { ElementById, setThemeColor, queryBackgroundColor } from './helpers.js'; import { processLeg } from './app_functions.js'; -import { formatDateTime, formatDuration, formatPrice } from './formatters.js'; -import { showAlertModal, showLoader, hideOverlay, showModal } from './overlays.js'; -import { go } from './router.js'; -import { getHafasClient, client } from './hafasClient.js'; +import { getHafasClient } from './hafasClient.js'; import { t } from './languages.js'; -const departuresTemplate = (data, profile, stopId, when) => { - let changes = 0; - let lastArrival; - - return html` - <div class="header-container"> - <header> - <a id="back" class="icon-back invisible" title="${t('back')}" @click=${() => history.back()}></a> - <div class="container"> - <h3>Departures from ${data.name}</h3> - </div> - <a id="reload" class="icon-reload" title="${t("reload")}" @click=${() => refreshDeparturesView(profile, stopId, when)}></a> - </header> - </div> - <div class="container departuresView"> - <div class="card"> - <table> - <thead> - <tr> - <th>Time</th> - <th class="station"></th> - <th>${t('platform')}</th> - </tr> - </thead> - <tbody> - ${(data.departures || []).map(departure => html` - <tr @click=${() => go(`/t/${profile}/${departure.tripId}`)}> - <td class="${departure.cancelled ? 'cancelled' : nothing}"><span>${timeTemplate(departure)}</span></td> - <td class="${departure.cancelled ? 'cancelled' : nothing}"><span>${departure.line.name}${departure.direction ? html` → ${departure.direction}` : nothing}</span></td> - ${departure.cancelled ? html` - <td><span class="cancelled-text">${t('cancelled-ride')}</span></td> - ` : html` - <td>${platformTemplate(departure)}</td> - `} - </tr> - `)} - </tbody> - </table> - </div> - </div> - `; -}; - -export const departuresView = async (match, isUpdate) => { - if (!isUpdate) showLoader(); - - let profile, stopId, when, data; - - try { - profile = match[0]; - stopId = match[1]; - - if (match[2]) when = new Date(parseInt(match[2].substring(1))); - - const client = await getHafasClient(profile); - const [ {departures}, stopInfo ] = await Promise.all([ - client.departures(stopId, { when }), - client.stop(stopId), - ]); - - for (let departure of departures) { - processLeg(departure); - }; - - data = { ...stopInfo, departures }; - } catch(e) { - showAlertModal(e.toString()); - throw e; +import { baseStyles, helperStyles, flexboxStyles, iconStyles, headerStyles, cardStyles, departuresViewStyles } from './styles.js'; + +class DeparturesView extends LitOverlay { + static styles = [ + baseStyles, + helperStyles, + flexboxStyles, + iconStyles, + headerStyles, + cardStyles, + departuresViewStyles + ]; + + static properties = { + profile: { attribute: true }, + stopId: { attribute: true }, + when: { attribute: true, type: Number }, + viewState: { state: true }, + isUpdating: { state: true } + }; + + constructor(...args) { + super(...args); + + this.viewState = null; + this.isUpdating = false; } - hideOverlay(); - - render(departuresTemplate(data, profile, stopId, when), ElementById('content')); - setThemeColor(queryBackgroundColor('header')); + async connectedCallback() { + super.connectedCallback(); + await sleep(100); - if (history.length > 0) ElementById('back').classList.remove('invisible'); -}; + setThemeColor(queryBackgroundColor(this.renderRoot, 'header')); + } -const refreshDeparturesView = async (profile, stopId, when) => { - document.querySelector('.icon-reload').classList.add('spinning'); + async updated(previous) { + super.updated(previous); - let data; + if (isDevServer) console.info('updated(): ', previous); - try { - const client = await getHafasClient(profile); - const [ {departures}, stopInfo ] = await Promise.all([ - client.departures(stopId, { when }), - client.stop(stopId), - ]); + if (previous.has('stopId')) { + this.viewState = null; + await this.updateViewState(); + } + } - for (let departure of departures) { - processLeg(departure); - }; + renderContent () { + return this.viewState !== null ? html` + <div class="header-container"> + <header> + <a class="icon-back ${history.length !== 1 ? '': 'invisible'}" title="${t('back')}" @click=${() => history.back()}></a> + <div class="container"> + <h3>Departures from ${this.viewState.name}</h3> + </div> + <a class="icon-reload ${this.isUpdating ? 'spinning' : ''}" title="${t("reload")}" @click=${this.updateViewState}></a> + </header> + </div> + <div class="container"> + <div class="card"> + <table> + <thead> + <tr> + <th>Time</th> + <th class="station"></th> + <th>${t('platform')}</th> + </tr> + </thead> + <tbody> + ${(this.viewState.departures || []).map(departure => html` + <tr @click=${() => window.location = `#/t/${this.profile}/${departure.tripId}`}> + <td class="${departure.cancelled ? 'cancelled' : nothing}"> + <span>${timeTemplate(departure)}</span> + </td> + <td class="${departure.cancelled ? 'cancelled' : nothing}"> + <span>${departure.line.name}${departure.direction ? html` → ${departure.direction}` : nothing}</span> + </td> + ${departure.cancelled ? html` + <td><span class="cancelled-text">${t('cancelled-ride')}</span></td> + ` : html` + <td>${platformTemplate(departure)}</td> + `} + </tr> + `)} + </tbody> + </table> + </div> + </div> + <footer-component></footer-component> + ` : html` + <div class="header-container"> + <header> + <a class="icon-back ${history.length !== 1 ? '': 'invisible'}" title="${t('back')}" @click=${() => history.back()}></a> + <div class="container"> + <h3>Departures from ...</h3> + </div> + <a class="icon-reload ${this.isUpdating ? 'spinning' : ''}" title="${t("reload")}" @click=${this.updateViewState}></a> + </header> + </div> + <div class="spinner"></div> + `; + } - data = { ...stopInfo, departures }; - } catch(e) { - showAlertModal(e.toString()); - throw e; + updateViewState = async () => { + this.isUpdating = true; + try { + const when = this.when; + const client = await getHafasClient(this.profile); + + const [ {departures}, stopInfo ] = await Promise.all([ + client.departures(this.stopId, { when }), + client.stop(this.stopId), + ]); + + departures.forEach(departure => processLeg(departure)); + + this.viewState = { + ...stopInfo, + departures + }; + + if (isDevServer) console.info('viewState: ', this.viewState); + } catch(e) { + this.showAlertOverlay(e.toString()); + console.error(e); + } + this.isUpdating = false; } +} - render(departuresTemplate(data, profile, stopId, when), ElementById('content')); - document.querySelector('.icon-reload').classList.remove('spinning'); -}; +customElements.define('departures-view', DeparturesView);
diff --git a/src/ds100.js b/src/ds100.js @@ -0,0 +1,24 @@ +import { isEmptyObject } from './helpers.js'; +import { default as ds100 } from '../ds100.json'; + +let ds100R = {}; + +export const initDS100R = () => Object.keys(ds100).forEach( + (id) => (ds100[id].split(', ').forEach( + name => (ds100R[name] = id) + )) +); + + +export const getDS100byIBNR = id => { + if (!ds100[Number(id)]) return null; + + return ds100[Number(id)]; +}; + +export const getIBNRbyDS100 = name => { + if (isEmptyObject(ds100R)) initDS100R(); + if (!ds100R[name]) return null; + + return ds100R[name]; +};
diff --git a/src/footerComponent.js b/src/footerComponent.js @@ -0,0 +1,22 @@ +import { LitElement, html } from 'lit'; + +import { baseStyles, helperStyles, footerStyles } from './styles.js'; + +class FooterElement extends LitElement { + static styles = [ + baseStyles, + helperStyles, + footerStyles, + ]; + + render() { + return html` + <footer class="center"> + <a href="https://git.ctu.cx/trainsearch" title="commit ${COMMIT} from ${COMMITDATE}">Source-Code (${VERSION})</a> + <a href="https://ctu.cx/imprint.html">Imprint</a> + </footer> + `; + } +} + +customElements.define('footer-component', FooterElement);
diff --git a/src/formatters.js b/src/formatters.js @@ -1,17 +1,19 @@ -import { ds100Name } from './app_functions.js'; +import { getDS100byIBNR } from './ds100.js'; import { padZeros } from './helpers.js'; -import { languages } from './languages.js'; +import { settingsState } from './settings.js'; -export const formatName = (point) => { +export const formatPoint = point => { switch (point.type) { case 'stop': case 'station': - let nameHTML = point.name; + let station = point.name; - const ds100 = ds100Name(point.id); - if (ds100 !== null) nameHTML += ` (${ds100})`; + if (settingsState.showDS100) { + const ds100 = getDS100byIBNR(point.id); + if (ds100 !== null) station += ` (${ds100})`; + } - return nameHTML; + return station; case 'location': if (point.address) return point.address; @@ -22,36 +24,10 @@ export const formatName = (point) => { }; }; -export const formatDateTime = (date, format) => { - if (format != null) { - switch (format) { - case 'full': - return padZeros(date.getHours()) + ':' + padZeros(date.getMinutes()) + ', ' + date.getDate() + '.' + (date.getMonth() + 1) + '.' + date.getFullYear(); - break; +export const formatDate = date => `${date.getDate()}.${date.getMonth() + 1}.${date.getFullYear()}`; +export const formatTime = date => `${padZeros(date.getHours())}:${padZeros(date.getMinutes())}`; - case 'date': - return date.getDate() + '.' + (date.getMonth() + 1) + '.' + date.getFullYear(); - break; - - case 'time': - return `${padZeros(date.getHours())}:${padZeros(date.getMinutes())}`; - break; - - default: - return false; - break; - } - - } - - if (date.toLocaleDateString() !== new Date().toLocaleDateString()) { - return padZeros(date.getHours()) + ':' + padZeros(date.getMinutes()) + ', ' + date.getDate() + '.' + (date.getMonth() + 1) + '.'; - } else { - return padZeros(date.getHours()) + ':' + padZeros(date.getMinutes()); - } -}; - -export const formatDuration = (duration) => { +export const formatDuration = duration => { const mins = duration / 60000; const h = Math.floor(mins / 60); const m = mins % 60; @@ -97,13 +73,13 @@ export const formatPrice = price => { export const formatTrainTypes = info => { const counts = {}; - for (let group of info.sequence?.groups) { + info.sequence?.groups.forEach(group => { const name = group.baureihe?.name; - if (!name) continue; + if (!name) return; counts[name] = (counts[name] ? counts[name] : 0) + 1; - } + }); return Object.entries(counts).map(([name, count]) => { let text = ""; @@ -120,7 +96,9 @@ export const formatTrainTypes = info => { }).join(" + "); }; -export const formatLineAdditionalName = (line) => { +export const formatLineDisplayName = line => line?.name || line?.operator?.name || "???"; + +export const formatLineAdditionalName = line => { if (!line.name) return null; const splitName = line.name.split(' '); @@ -130,5 +108,3 @@ export const formatLineAdditionalName = (line) => { return null; } }; - -export const formatLineDisplayName = (line) => line?.name || line?.operator?.name || "???";
diff --git a/src/hafasClient.js b/src/hafasClient.js @@ -1,54 +1,38 @@ -import { createClient as createVendoClient } from "db-vendo-client"; -import { profile as dbnavProfile } from "db-vendo-client/p/dbnav/index.js"; - -const clients = {}; -let createHafasClient; - -export let client; - -export const initHafasClient = async profileName => { - client = await getHafasClient(profileName); -}; +import { createClient as createVendoClient } from 'db-vendo-client'; +import { profile as dbNavProfile } from 'db-vendo-client/p/dbnav/index.js'; + +let createHafasClient = null; +const clients = {}; + +export let client; +export const profiles = { + db: { name: "DB", backend: 'vendo', profile: dbNavProfile }, + bvg: { name: "BVG", backend: 'hafas', profile: (await import('hafas-client/p/bvg/index.js')).profile }, + nahsh: { name: "NAH.SH", backend: 'hafas', profile: (await import('hafas-client/p/nahsh/index.js')).profile }, + rmv: { name: "RMV", backend: 'hafas', profile: (await import('hafas-client/p/rmv/index.js')).profile }, + oebb: { name: "ÖBB", backend: 'hafas', profile: (await import('hafas-client/p/oebb/index.js')).profile }, +} +export const getDefaultProfile = () => 'db'; export const getHafasClient = async profileName => { - if (!clients[profileName]) { - let profile; - - switch(profileName) { - case "db": - clients[profileName] = createVendoClient(dbnavProfile, "trainsearch", {enrichStations: false}); - console.info("initialized vendo client"); - return clients[profileName]; - - case "bvg": - const { profile: bvgProfile } = await import('hafas-client/p/bvg/index.js'); - profile = bvgProfile; - break; + if (!profiles[profileName]) throw "Unknown profile name: " + profileName; - case "nahsh": - const { profile: nahshProfile } = await import('hafas-client/p/nahsh/index.js'); - profile = nahshProfile; - break; - - case "rmv": - const { profile: rmvProfile } = await import('hafas-client/p/rmv/index.js'); - profile = rmvProfile; - break; - - case "oebb": - const { profile: oebbProfile } = await import('hafas-client/p/oebb/index.js'); - profile = oebbProfile; - break; - - default: - throw "Unknown profile name: " + profileName; + if (!clients[profileName]) { + if (profiles[profileName].backend === 'vendo') { + clients[profileName] = createVendoClient(profiles[profileName].profile, APPNAME, {enrichStations: false}); + if (isDevServer) console.info('initialized vendo client with profile ' + profileName); } - if (!createHafasClient) createHafasClient = (await import("hafas-client")).createClient; - - clients[profileName] = createHafasClient(profile, "trainsearch"); + if (profiles[profileName].backend === 'hafas') { + if ( createHafasClient === null) createHafasClient = (await import('hafas-client')).createClient; + clients[profileName] = createHafasClient(profiles[profileName].profile, APPNAME); + if (isDevServer) console.info('initialized hafas client with profile ' + profileName); + } } - console.info("initialized hafas client with profile " + profileName); return clients[profileName]; } + +export const initHafasClient = async profileName => { + client = await getHafasClient(profileName); +};
diff --git a/src/helpers.js b/src/helpers.js @@ -19,7 +19,8 @@ const loyaltyCardsReverse = { }; export const sleep = delay => new Promise((resolve) => setTimeout(resolve, delay)); - +export const isEmptyObject = obj => Object.keys(obj).length === 0; +export const padZeros = str => (('00' + str).slice(-2)); export const ElementById = id => document.getElementById(id); export const showElement = element => element.classList.remove('hidden'); @@ -29,14 +30,8 @@ export const elementHidden = element => element.classList.contains('hidden'); export const unflipElement = element => element.classList.remove('flipped'); export const flipElement = element => element.classList.add('flipped'); -export const setThemeColor = color => document.querySelector('meta[name="theme-color"]').setAttribute('content', color); -export const queryBackgroundColor = query => window.getComputedStyle(document.querySelector(query)).getPropertyValue('background-color'); - -export const isEmptyObject = (obj) => Object.keys(obj).length === 0; - -export const padZeros = str => { - return ('00' + str).slice(-2); -}; +export const setThemeColor = color => document.querySelector('meta[name="theme-color"]').setAttribute('content', color); +export const queryBackgroundColor = (target, query) => window.getComputedStyle(target.querySelector(query)).getPropertyValue('background-color'); export const isValidDate = date => { const matches = /^(\d{4})[-\/](\d{2})[-\/](\d{2})$/.exec(date); @@ -53,7 +48,11 @@ export const isValidDate = date => { composedDate.getFullYear() == y; }; -export const loyaltyCardToString = loyaltyCard => `${loyaltyCardsReverse[loyaltyCard.type.toString()]}-${loyaltyCard.discount}-${loyaltyCard.class}`; +export const loyaltyCardToString = loyaltyCard => + loyaltyCardsReverse[loyaltyCard.type.toString()] !== 'NONE' ? + `${loyaltyCardsReverse[loyaltyCard.type.toString()]}-${loyaltyCard.discount}-${loyaltyCard.class}` + : 'NONE'; + export const loyaltyCardFromString = string => { const splitedString = string.split('-'); if (splitedString[0] === 'NONE') return { type: loyaltyCards[splitedString[0]] };
diff --git a/src/journeyView.js b/src/journeyView.js @@ -1,220 +1,352 @@ -import { html, nothing, render } from 'lit-html'; -import { cachedCoachSequence } from './coach-sequence/index.js'; +import { LitElement, html, css, nothing } from 'lit'; +import { LitOverlay } from './LitOverlay.js'; +import { createEvents } from 'ics'; + +import { sleep, queryBackgroundColor, setThemeColor } from './helpers.js'; +import { db } from './dataStorage.js'; import { settings } from './settings.js'; -import { remarksModalTemplate, platformTemplate, stopTemplate, timeTemplate, footerTemplate } from './templates.js'; -import { ElementById, setThemeColor, queryBackgroundColor } from './helpers.js'; -import { getJourney, refreshJourney } from './app_functions.js'; -import { formatName, formatDateTime, formatDuration, formatPrice, formatTrainTypes, formatLineAdditionalName, formatLineDisplayName } from './formatters.js'; -import { showAlertModal, showLoader, hideOverlay, showModal } from './overlays.js'; -import { go } from './router.js'; import { t } from './languages.js'; -import { db } from './dataStorage.js'; -import { showSelectModal } from './overlays.js'; - -const legTemplate = (leg, profile) => { - const remarks = leg.remarks || []; - const remarksStatus = remarks.some((obj) => obj.type === 'status'); - const remarksWarning = remarks.some((obj) => obj.type === 'warning'); - const remarksIcon = remarksWarning ? 'icon-warning' : (remarksStatus ? 'icon-status' : 'icon-hint'); - - return html` - ${leg.walking ? html` - <p class="walk">${t(leg.distance === null ? 'walkinfo' : 'walkinfo_meters', formatName(leg.destination), leg.distance)}</p> - ` : leg.transfer ? html` - <p class="transfer">${t('transferinfo', formatName(leg.destination))}</p> - ` : leg.change ? html` - <p class="change">${t('changeinfo', formatDuration(leg.duration))}</p> - ` : html` - <div class="card"> - <table> - <thead> - <tr> - <td colspan="4"> - <div class="flex-center"><a href="#/t/${profile}/${leg.tripId}">${formatLineDisplayName(leg.line)}${leg.direction ? html` → ${leg.direction}` : nothing}</a> - ${leg.cancelled ? html`<b class="cancelled-text">${t('cancelled-ride')}</b>` : nothing} - ${!!remarks.length ? html` - <a class="link ${remarksIcon}" @click=${() => showModal(t('remarks'), remarksModalTemplate(remarks))}></a> - ` : nothing}</div> - </td> - </tr> - <tr> - <td colspan="4"> - <div class="train-details flex-center"> - ${formatLineAdditionalName(leg.line) ? html` - <div> - Trip: ${formatLineAdditionalName(leg.line)} - </div> - ` : nothing} - ${leg.line.trainType ? html` - <div> - Train type: ${leg.line.trainType} - </div> - ` : nothing} - ${(leg.arrival && leg.departure) ? html` - <div> - ${t('duration')}: ${formatDuration(leg.arrival - leg.departure)} - </div> - ` : nothing} - ${leg.loadFactor ? html` - <div> - ${t("load-"+leg.loadFactor)} - </div> - ` : nothing} - </div> - </td> - </tr> - <tr> - <th>${t('arrival')}</th> - <th>${t('departure')}</th> - <th class="station">${t('station')}</th> - <th>${t('platform')}</th> - </tr> - </thead> - <tbody> - ${(leg.stopovers || []).map(stop => html` - <tr class="stop ${stop.cancelled ? 'cancelled' : nothing}"> - <td><span>${timeTemplate(stop, 'arrival')}</span></td> - <td><span>${timeTemplate(stop, 'departure')}</span></td> - <td>${stopTemplate(profile, stop.stop)}</td> - <td><span>${platformTemplate(stop)}</span></td> - </tr> - `)} - </tbody> - </table> - </div> - `} - `; -}; +import { getJourney, refreshJourney } from './app_functions.js'; +import { remarksModal, platformTemplate, stopTemplate, timeTemplate } from './templates.js'; +import { formatPoint, formatDate, formatDuration, formatPrice, formatTrainTypes, formatLineAdditionalName, formatLineDisplayName } from './formatters.js'; +import { cachedCoachSequence } from './coach-sequence/index.js'; -const journeyTemplate = (data, profile) => { - let duration = null; +import { baseStyles, helperStyles, flexboxStyles, headerStyles, iconStyles, cardStyles, journeyViewStyles } from './styles.js'; - if (data.legs[data.legs.length - 1].arrival && data.legs[0].departure) - duration = data.legs[data.legs.length - 1].arrival - data.legs[0].departure; +class JourneyView extends LitOverlay { + static styles = [ + super.styles, + baseStyles, + helperStyles, + flexboxStyles, + iconStyles, + headerStyles, + cardStyles, + journeyViewStyles + ]; - const legs = []; - let changes = 0; - let lastArrival; + static properties = { + profile: { attribute: true }, + refreshToken: { attribute: true, converter: (value) => decodeURIComponent(value)}, + viewState: { state: true }, + settingsState: { state: true }, + isUpdating: { state: true } + }; - for (const leg of data.legs) { - if (!leg.walking && !leg.transfer) { + constructor (...args) { + super(...args); - // add change - if (lastArrival) { - let duration = null; + this.viewState = null; + this.settingsState = settings.getState(); + this.isUpdating = false; + } - if (leg.departure && lastArrival) { - duration = leg.departure - lastArrival; - } + async connectedCallback () { + super.connectedCallback(); + await sleep(100); - legs.push({ - change: true, - duration, - }); - } - changes++; + setThemeColor(queryBackgroundColor(this.renderRoot, 'header')); + + if (this.viewState === null) await this.updateViewState(); + + this._unsubscribeSettingsState = settings.subscribe((state) => { + this.settingsState = state; + this.performUpdate(); + }); + } - lastArrival = leg.arrival; - } else if (legs.length) { + disconnectedCallback () { + super.disconnectedCallback(); + this.viewState = null; - // if this is a walking leg and it is the first one, we don't want to - // insert a 0 minutes change entry for this - lastArrival = leg.arrival; + if (typeof this._unsubscribeSettingsState === 'function') { + this._unsubscribeSettingsState(); + this._unsubscribeSettingsState = undefined; } + } + + async updated (previous) { + super.updated(previous); + + if (isDevServer) console.info('updated(): ', previous); + + if (previous.has('refreshToken')) { + this.viewState = null; + await this.updateViewState(); + } + } + + renderContent () { + return html` + <div class="header-container"> + <header> + <a id="back" class="icon-back ${history.length !== 1 ? '': 'invisible'}" title="${t('back')}" @click=${() => history.back()}></a> + <div class="container"> + <a class="icon-reload ${this.isUpdating ? 'spinning' : ''}" title="${t("reload")}" @click=${this.refreshJourney}></a> + ${this.viewState !== null ? html` + <h3>${formatPoint(this.viewState.legs[0].origin)} → ${formatPoint(this.viewState.legs[this.viewState.legs.length - 1].destination)}</h3> + <p><b>${t('duration')}: ${formatDuration(this.viewState.duration)} | ${t('changes')}: ${this.viewState.changes-1} | ${t('date')}: ${formatDate(this.viewState.legs[0].plannedDeparture)}${this.settingsState.showPrices && this.viewState.price ? html` | ${t('price')}: <td><span>${formatPrice(this.viewState.price)}</span></td>` : nothing}</b></p> + ` : html` + <h3>... → ...</h3> + <p><b>${t('duration')}: ... | ${t('changes')}: ... | ${t('date')}: ...</b></p> + `} + </div> + <a class="icon-dots" title="${t("more")}" @click=${this.moreModal}></a> + </header> + </div> - legs.push(leg); + ${this.viewState !== null ? html` + <div class="container journeyView"> + ${this.viewState.legs.map(leg => this.legTemplate(leg))} + </div> + <footer-component></footer-component> + ` : html`<div class="spinner"></div>`} + `; } - return html` - <div class="header-container"> - <header> - ${data.slug ? html`<a class="icon-back" href="#/${data.slug}/${settings.journeysViewMode}" title="${t('back')}"></a>` : nothing} - <div class="container"> - <a class="icon-reload" title="${t("reload")}" @click=${() => refreshJourneyView(profile, data.refreshToken)}></a> - <h3>${formatName(data.legs[0].origin)} → ${formatName(data.legs[data.legs.length - 1].destination)}</h3> - <p><b>${t('duration')}: ${formatDuration(duration)} | ${t('changes')}: ${changes-1} | ${t('date')}: ${formatDateTime(data.legs[0].plannedDeparture, 'date')}${settings.showPrices && settings.profile === 'db' && data.price ? html` | ${t('price')}: <td><span>${formatPrice(data.price)}</span></td>` : nothing}</b></p> + legTemplate (leg) { + if (leg.walking) { + return html`<p class="walk">${t(leg.distance === null ? 'walkinfo' : 'walkinfo_meters', formatPoint(leg.destination), leg.distance)}</p>`; + } else if (leg.transfer) { + return html`<p class="transfer">${t('transferinfo', formatPoint(leg.destination))}</p>`; + } else if (leg.change) { + return html`<p class="change">${t('changeinfo', formatDuration(leg.duration))}</p>`; + } else { + return html` + <div class="card"> + <table> + <thead> + <tr> + <td colspan="4"> + <div class="flex-center"><a href="#/t/${this.profile}/${leg.tripId}">${formatLineDisplayName(leg.line)}${leg.direction ? html` → ${leg.direction}` : nothing}</a> + ${leg.cancelled ? html`<b class="cancelled-text">${t('cancelled-ride')}</b>` : nothing} + ${leg.remarks.length !== 0 ? html` + <a class="link ${leg.remarksIcon}" @click=${() => remarksModal(this, leg.remarks)}></a> + ` : nothing}</div> + </td> + </tr> + <tr> + <td colspan="4"> + <div class="train-details flex-center"> + ${formatLineAdditionalName(leg.line) ? html`<div>Trip: ${formatLineAdditionalName(leg.line)}</div>` : nothing} + ${leg.line.trainType ? html`<div>${t('trainType')}: ${leg.line.trainType}</div>` : nothing} + ${(leg.arrival && leg.departure) ? html`<div>${t('duration')}: ${formatDuration(leg.arrival - leg.departure)}</div>` : nothing} + ${leg.loadFactor ? html`<div>${t(`load-${leg.loadFactor}`)}</div>` : nothing} + </div> + </td> + </tr> + <tr> + <th>${t('arrival')}</th> + <th>${t('departure')}</th> + <th class="station">${t('station')}</th> + <th>${t('platform')}</th> + </tr> + </thead> + <tbody> + ${(leg.stopovers || []).map(stop => html` + <tr class="stop ${stop.cancelled ? 'cancelled' : nothing}"> + <td><span>${timeTemplate(stop, 'arrival')}</span></td> + <td><span>${timeTemplate(stop, 'departure')}</span></td> + <td>${stopTemplate(this.profile, stop.stop)}</td> + <td><span>${platformTemplate(stop)}</span></td> + </tr> + `)} + </tbody> + </table> </div> - <a class="icon-dots" title="${t("more")}" @click=${() => moreModal(profile, data.refreshToken)}></a> - </header> - </div> + `; + } + } - <div class="container journeyView"> - ${legs.map(leg => legTemplate(leg, profile))} - </div> + async updateViewState (viewState ) { + this.isUpdating = true; + try { + if (viewState === undefined) viewState = await getJourney(this.profile, this.refreshToken); - ${footerTemplate} - `; -}; + if (viewState.slug) { + let overviewObject = await db.getJourneysOverview(viewState.slug); + let historyObject = await db.getHistoryEntry(overviewObject.historyEntryId); -export const journeyView = async (match, isUpdate) => { - if (!isUpdate) showLoader(); - let profile, refreshToken, journeyObject; + historyObject.lastSelectedJourneyId = viewState.refreshToken; + + await db.updateHistoryEntry(overviewObject.historyEntryId, historyObject); + } - try { - profile = match[0]; - refreshToken = decodeURIComponent(match[1]); - journeyObject = await getJourney(refreshToken, profile); - if (journeyObject.slug) { - let overviewObject = await db.getJourneysOverview(journeyObject.slug); - let historyObject = await db.getHistoryEntry(overviewObject.historyEntryId); + viewState.changes = 0; + viewState.duration = null; - historyObject.lastSelectedJourneyId = journeyObject.refreshToken; + if (viewState.legs[viewState.legs.length - 1].arrival && viewState.legs[0].departure) + viewState.duration = viewState.legs[viewState.legs.length - 1].arrival - viewState.legs[0].departure; - await db.updateHistoryEntry(overviewObject.historyEntryId, historyObject); + let lastArrival; + viewState.legs = viewState.legs.flatMap(leg => { + if (!leg.remarks) leg.remarks = []; + + let legs = []; + + const remarksStatus = leg.remarks.some((obj) => obj.type === 'status'); + const remarksWarning = leg.remarks.some((obj) => obj.type === 'warning'); + leg.remarksIcon = remarksWarning ? 'icon-warning' : (remarksStatus ? 'icon-status' : 'icon-hint'); + + if (!leg.walking && !leg.transfer) { + // add change + if (lastArrival) { + let duration = null; + + if (leg.departure && lastArrival) duration = leg.departure - lastArrival; + + legs.push({ + change: true, + duration, + }); + } + viewState.changes++; + + lastArrival = leg.arrival; + } else if (legs.length) { + // if this is a walking leg and it is the first one, we don't want to + // insert a 0 minutes change entry for this + lastArrival = leg.arrival; + } + + legs.push(leg); + + return legs + }); + + this.viewState = viewState; + + //fetch train types after setting the viewState + for (const leg of this.viewState.legs) { + if (leg.line && leg.line.name) { + const [category, number] = leg.line.name.split(' '); + const info = await cachedCoachSequence(category, leg.line.fahrtNr || number, leg.origin.id, leg.plannedDeparture); + + if (info) leg.line.trainType = formatTrainTypes(info); + this.requestUpdate(); + } + } + + if (isDevServer) console.info('viewState: ', this.viewState); + } catch(e) { + this.showAlertOverlay(e.toString()); + console.error(e); } - } catch(e) { - console.error(e); - await showAlertModal(e.toString()); - go('/'); - return; + this.isUpdating = false; } - for (const leg of journeyObject.legs) { - if (leg.line && leg.line.name) { - const [category, number] = leg.line.name.split(' '); - const info = await cachedCoachSequence(category, leg.line.fahrtNr || number, leg.origin.id, leg.plannedDeparture); + async refreshJourney () { + if (this.isUpdating !== false) return false; - if (info) leg.line.trainType = formatTrainTypes(info); + this.isUpdating = true; + try { + const data = await refreshJourney(this.profile, this.refreshToken); + await this.updateViewState(data); + } catch(e) { + this.showAlertOverlay(e.toString()); + console.error(e); } + this.isUpdating = false; } - hideOverlay(); + async moreModal () { + const options = [ + { 'label': !navigator.canShare ? 'copyURL' : 'shareURL', 'action': async () => await this.shareAction() }, + ]; + + if (isDevServer) options.push({ 'label': 'addCalendar', 'action': async () => await this.calendarAction() }); - render(journeyTemplate(journeyObject, profile), ElementById('content')); - setThemeColor(queryBackgroundColor('header')); -}; + if (this.profile === 'db') options.push({ 'label': 'tickets', 'action': async () => await this.showTicketsModal() }); -const refreshJourneyView = async (profile, refreshToken) => { - document.querySelector('.icon-reload').classList.add('spinning'); + this.showSelectOverlay(options); + }; - try { - await refreshJourney(refreshToken, profile); - } catch(e) { - showAlertModal(e.toString()); - document.querySelector('.icon-reload').classList.remove('spinning'); - throw e; + async shareAction () { + try { + await navigator.share({ url: window.location }); + } catch (error) { + await navigator.clipboard.writeText(window.location); + this.showAlertOverlay('URL has been copied to clipboard.'); + } } - journeyView([profile, refreshToken], true); - document.querySelector('.icon-reload').classList.remove('spinning'); -}; + async showTicketsModal () { + try { + this.showLoaderOverlay(); + await this.refreshJourney(); -const moreModal = (profile, refreshToken) => { - const options = [ - { 'label': !navigator.canShare ? t('copyURL') : t('shareURL'), 'action': () => { shareAction(); hideOverlay(); }}, - ]; + if (this.viewState.tickets === undefined) { + await showAlertOverlay('No ticket data available'); + return false; + } + + this.showDialogOverlay('tickets', html` + ${this.viewState.tickets.map(ticket => html` + <div class="flex-row"> + <div class="icon-ticket"></div> + <div> + <b>${ticket.name} <small>${ticket.firstClass ? `1. ${t('class')}` : nothing}</small></b><br> + <small>${ticket.addDataTicketDetails}</small> + <br> + <b>${ticket.priceObj.amount / 100}</b> + </div> + </div> + `)} + <style>${css` + .flex-row{ + flex-wrap: nowrap; + padding: .5em; + border-bottom: 1px solid rgba(0, 0, 0, .4); + } + + .flex-row:last-child { + border-bottom: unset; + } + + .icon-ticket { + align-self: start; + padding-right: .3em; + } + `}</style> + `); + } catch(e) { + this.showAlertOverlay(e.toString()); + console.error(e); + } + }; - showSelectModal(options); -}; + async calendarAction () { + let events = []; -const shareAction = async () => { - try { - await navigator.share({ - url: window.location, + this.viewState.legs.forEach(leg => { + if (!leg.line) return; + + const departureStop = leg.origin.location + + events.push({ + productId: APPNAME, + created: (new Date).getTime(), + start: leg.plannedDeparture.getTime(), + end: leg.plannedArrival.getTime(), + title: `${leg.line.name} → ${formatPoint(leg.destination)}`, + url: window.location.href, + geo: { lat: departureStop.latitude , lon: departureStop.longitude }, + description: `${leg.line.name} → ${leg.direction}\n + dep ${timeTemplate(departureStop, 'departure')} ${formatPoint(departureStop)}`, + }); }); - } catch (error) { - navigator.clipboard.writeText(window.location); - } -}; + const file = await new Promise((resolve, reject) => { + const { error, value } = createEvents(events); + + if (error) reject(error); + + resolve(new File([value], 'event.ics', { type: 'text/calendar' })); + }); + + window.location = URL.createObjectURL(file); + }; +} + +customElements.define('journey-view', JourneyView);
diff --git a/src/journeysCanvas.js b/src/journeysCanvas.js @@ -0,0 +1,453 @@ +import { LitElement, html, css } from 'lit'; +import { LitOverlay } from './LitOverlay.js'; + +import { sleep } from './helpers.js'; +import { formatTrainTypes, formatLineDisplayName, formatTime } from './formatters.js' +import { cachedCoachSequence, coachSequenceCache, coachSequenceCacheKey } from './coach-sequence/index.js'; + +export class JourneysCanvas extends LitOverlay { + static properties = { + canvasState: { state: true }, + }; + + static styles = [ + super.styles, + css` + canvas { + display: block; + } + ` + ]; + + static colors = { + fill: { + 'tram': '#cc5555', + 'subway': '#5555cc', + 'suburban': '#55aa55', + 'nationalExpress': '#fff', + 'national': '#fff', + 'regionalExpress': '#888', + 'regional': '#888', + 'bus': '#aa55aa', + default: '#888' + }, + text: { + 'nationalExpress': '#ee3333', + 'national': '#ee3333', + default: '#fff' + }, + cancelFill: { + 'tram': '#fff', + default: '#cc4444ff', + }, + icon: { + 'walk': 'directions_walk', + 'transfer': 'directions_transfer', + 'subway': 'directions_subway', + 'bus': 'directions_bus', + 'tram': 'tram', + default: 'train' + }, + loadFactor: { + 'low-to-medium': [ '#777', '#ccc', '#ccc' ], + 'high': [ '#777', '#777', '#ccc' ], + 'very-high': [ '#ee8800', '#ee8800', '#ee8800' ], + 'exceptionally-high': [ '#cc3300', '#cc3300', '#cc3300' ], + } + }; + + constructor (...args) { + super(...args); + + this.canvasElement = null; + this.canvasContext = null; + this.canvasState = { + handlersConnected: false, + textCache: {}, + animationInterval: null, + offsetX: 0, + dpr: (window.devicePixelRatio || 1), + lastAnimationUpdate: 0, + firstDeparture: 0, + scaleFactor: 0, + lastArrival: 0, + rectWidth: 0, + padding: 0, + rectWidthWithPadding: 0 + }; + + this.canvasState.offsetX = (window.innerWidth / this.canvasState.dpr) > 600 ? 140 : 80; + }; + + updated (previous) { + super.updated(previous); + if (isDevServer) console.info('canvasState: ', this.canvasState); + } + + getCanvas = () => html`<canvas @mousedown=${this.mouseDownHandler} @touchstart=${this.mouseDownHandler}></canvas>`; + + connectCanvas = () => { + if (!this.canvasState.handlersConnected) { + window.addEventListener('mouseup', this.mouseUpHandler); + window.addEventListener('touchend', this.mouseUpHandler); + window.addEventListener('mousemove', this.mouseMoveHandler); + window.addEventListener('touchmove', this.mouseMoveHandler); + window.addEventListener('resize', this.resizeHandler); + window.addEventListener('zoom', this.resizeHandler); + this.canvasState.handlersConnected = true; + } + + if (this.canvasElement === null) { + const canvasElement = this.renderRoot.querySelector('canvas'); + + if (canvasElement !== null) { + this.canvasElement = canvasElement; + this.canvasContext = this.canvasElement.getContext('2d'); + this.resizeHandler(); + } + } + } + + disconnectCanvas = () => { + window.removeEventListener('mouseup', this.mouseUpHandler); + window.removeEventListener('touchend', this.mouseUpHandler); + window.removeEventListener('mousemove', this.mouseMoveHandler); + window.removeEventListener('touchmove', this.mouseMoveHandler); + window.removeEventListener('resize', this.resizeHandler); + window.removeEventListener('zoom', this.resizeHandler); + this.canvasState.handlersConnected = false; + this.canvasElement = null; + this.canvasContext = null; + } + + resizeHandler = () => { + if (this.canvasContext === null) return true; + + this.canvasState.dpr = window.devicePixelRatio || 1; + + this.canvasState.rectWidth = (window.innerWidth / this.canvasState.dpr) > 600 ? 100 : 80; + this.canvasState.padding = (window.innerWidth / this.canvasState.dpr) > 600 ? 20 : 5; + this.canvasState.rectWidthWithPadding = this.canvasState.rectWidth + 2 * this.canvasState.padding; + + const rect = this.renderRoot.querySelector('header').getBoundingClientRect(); + this.canvasElement.width = window.innerWidth * this.canvasState.dpr; + this.canvasElement.height = (window.innerHeight - rect.height) * this.canvasState.dpr; + + this.canvasContext.restore(); + this.canvasContext.save(); + this.canvasContext.scale(this.canvasState.dpr, this.canvasState.dpr); + + this.canvasState.lastAnimationUpdate = 0; + this.renderCanvas(); + }; + + mouseUpHandler = event => { + if (this.canvasState.dragging && this.canvasState.isClick) { + const x = event.x || event.changedTouches[0].pageX; + event.preventDefault(); + + const num = Math.floor((x - this.canvasState.offsetX + 2 * this.canvasState.padding) / this.canvasState.rectWidthWithPadding) + this.viewState.indexOffset; + if (num >= 0) { + if (num < this.viewState.journeys.length) { + const j = this.viewState.journeys[num]; + window.location = `#/j/${this.viewState.profile}/${j.refreshToken}`; + } else if (this.viewState.laterRef) { + this.moreJourneys('later'); + } + } else if (this.viewState.earlierRef) { + this.moreJourneys('earlier'); + } + } + + this.canvasState.dragging = false; + this.canvasState.isClick = false; + } + + mouseDownHandler = event => { + const x = event.x || event.changedTouches[0].pageX; + this.canvasState.dragStartMouse = x; + this.canvasState.dragStartOffset = this.canvasState.offsetX; + this.canvasState.dragging = true; + this.canvasState.isClick = true; + } + + mouseMoveHandler = event => { + if (this.canvasState.dragging) { + event.preventDefault(); + const x = event.x || event.changedTouches[0].pageX; + this.canvasState.offsetX = this.canvasState.dragStartOffset - (this.canvasState.dragStartMouse - x); + if (Math.abs(this.canvasState.dragStartMouse - x) > 20) this.canvasState.isClick = false; + this.renderCanvas(); + return true; + } + } + + makeTextCache = (text, color, fixedHeight) => { + const cacheCanvas = document.createElement('canvas'); + const cacheCanvasContext = cacheCanvas.getContext('2d'); + let height, width; + + cacheCanvasContext.shadowColor = '#00000080'; + + if (fixedHeight) { + height = 15; + cacheCanvasContext.font = `${height}px sans-serif`; + width = cacheCanvasContext.measureText(text).width; + } else { + const measureAccuracy = 50; + cacheCanvasContext.font = `${measureAccuracy}px sans-serif`; + + width = this.canvasState.rectWidth - 10; + height = Math.abs(measureAccuracy * (width / (1 - cacheCanvasContext.measureText(text).width))); + } + + cacheCanvas.width = width * this.canvasState.dpr; + cacheCanvas.height = Math.ceil(height * 1.5) * this.canvasState.dpr; + cacheCanvasContext.scale(this.canvasState.dpr, this.canvasState.dpr); + + cacheCanvasContext.font = `${height}px sans-serif`; + cacheCanvasContext.fillStyle = color; + cacheCanvasContext.fillText(text, 0, height); + + return cacheCanvas; + }; + + getTextCache = (text, color, fixedHeight) => { + const index = `${text}|${color}|${this.canvasState.rectWidth}|${this.canvasState.dpr}|${fixedHeight}`; + if (!this.canvasState.textCache[index]) this.canvasState.textCache[index] = this.makeTextCache(text, color, fixedHeight); + + return this.canvasState.textCache[index]; + }; + + getColor = (type, leg, num) => { + if (type !== 'loadFactor') { + const product = leg.line?.product || 'walk'; + return JourneysCanvas.colors[type][product] || JourneysCanvas.colors[type].default; + } else { + return JourneysCanvas.colors[type][leg][num]; + } + } + + getCoachSequences = async () => { + if (this.viewState.profile !== 'db') return; + + this.isUpdating = true; + for (const journey of this.viewState.journeys) { + for (const leg of journey.legs) { + if (!leg.line) continue; + const [category, number] = leg.line.name.split(" "); + await cachedCoachSequence(category, leg.line.fahrtNr || number, leg.origin.id, leg.plannedDeparture); + this.renderCanvas(); + } + } + this.isUpdating = false; + } + + getTrainTypeTexts = leg => { + if (!leg.line || !leg.line.name) return []; + const [category, number] = leg.line.name.split(" "); + const key = coachSequenceCacheKey(category, leg.line.fahrtNr || number, leg.origin.id, leg.plannedDeparture); + if (!key) return []; + const info = coachSequenceCache[key]; + if (!info || info instanceof Promise) return []; + + return formatTrainTypes(info).split(" + "); + }; + + renderCanvas = () => { + if (this.canvasContext === null) return; + + const drawButton = mode => { + const buttonPath = new Path2D('M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z'); + + this.canvasContext.fillStyle = '#fff'; + this.canvasContext.shadowColor = '#00000080'; + this.canvasContext.save(); + this.canvasContext.scale(3, 3); + if (mode === 'earlier') this.canvasContext.translate(x / 3 - 15, this.canvasElement.height / this.canvasState.dpr / 6 - 24); + if (mode === 'later') this.canvasContext.translate(x / 3 + 5, this.canvasElement.height / this.canvasState.dpr / 6); + if (mode === 'earlier') this.canvasContext.rotate(-Math.PI*1.5); + if (mode === 'later') this.canvasContext.rotate(Math.PI*1.5); + this.canvasContext.fill(buttonPath); + this.canvasContext.restore(); + this.canvasContext.beginPath(); + if (mode === 'earlier') this.canvasContext.arc(x - 80, this.canvasElement.height / this.canvasState.dpr / 2 - 35,50,0,2*Math.PI); + if (mode === 'later') this.canvasContext.arc(x + 50, this.canvasElement.height / this.canvasState.dpr / 2 - 35,50,0,2*Math.PI); + this.canvasContext.fillStyle = '#ffffff40'; + this.canvasContext.fill(); + this.canvasContext.strokeStyle = '#00000020'; + this.canvasContext.stroke(); + } + + this.canvasContext.clearRect(0, 0, this.canvasElement.width / this.canvasState.dpr, this.canvasElement.height / this.canvasState.dpr); + let x = this.canvasState.offsetX - this.viewState.indexOffset * this.canvasState.rectWidthWithPadding, y; + + const firstVisibleJourney = Math.max(0, Math.floor((-x + this.canvasState.padding) / this.canvasState.rectWidthWithPadding)); + const numVisibleJourneys = Math.ceil(this.canvasElement.width / this.canvasState.dpr / this.canvasState.rectWidthWithPadding); + const visibleJourneys = this.viewState.journeys.slice(firstVisibleJourney, firstVisibleJourney + numVisibleJourneys); + + if (!visibleJourneys.length) return; + + const targetFirstDeparture = Number(visibleJourneys[0].legs[0].plannedDeparture); + const targetLastArrival = Math.max.apply(Math, + visibleJourneys.map(journey => journey.legs[journey.legs.length-1].plannedArrival) + .concat(visibleJourneys.map(journey => journey.legs[journey.legs.length-1].arrival) + )); + const targetScaleFactor = 1 / (targetLastArrival - targetFirstDeparture) * (this.canvasElement.height - 64 * this.canvasState.dpr) / this.canvasState.dpr; + + const now = new Date(); + const factor = Math.min(.3, (now - this.canvasState.lastAnimationUpdate) / 20); + + if (!this.canvasState.lastAnimationUpdate) { + this.canvasState.firstDeparture = Number(targetFirstDeparture); + this.canvasState.lastArrival = Number(targetLastArrival); + this.canvasState.scaleFactor = targetScaleFactor; + } else { + this.canvasState.firstDeparture = this.canvasState.firstDeparture + (targetFirstDeparture - this.canvasState.firstDeparture) * factor; + this.canvasState.lastArrival = this.canvasState.lastArrival + (targetLastArrival - this.canvasState.lastArrival) * factor; + this.canvasState.scaleFactor = this.canvasState.scaleFactor + (targetScaleFactor - this.canvasState.scaleFactor) * factor; + } + + this.canvasState.lastAnimationUpdate = now; + + if (Math.abs(this.canvasState.scaleFactor - targetScaleFactor) > 1 + || Math.abs(this.canvasState.firstDeparture - targetFirstDeparture) > 1 + || Math.abs(this.canvasState.lastArrival - targetLastArrival) > 1 + ) { + if (!this.canvasState.animationInterval) this.canvasState.animationInterval = setInterval(() => this.renderCanvas(), 16.6); + } else { + if (this.canvasState.animationInterval) { + clearInterval(this.canvasState.animationInterval); + this.canvasState.animationInterval = null; + } + } + + let time = this.viewState.journeys[0].legs[0].plannedDeparture; + + this.canvasContext.font = `${(window.innerWidth / this.canvasState.dpr) > 600 ? 20 : 15}px sans-serif`; + this.canvasContext.fillStyle = '#aaa'; + while (time < this.canvasState.lastArrival) { + const y = (time - this.canvasState.firstDeparture) * this.canvasState.scaleFactor + 32; + this.canvasContext.fillText(formatTime(time), (window.innerWidth / this.canvasState.dpr) > 600 ? 30 : 10, y); + this.canvasContext.fillRect(0, y, this.canvasElement.width / this.canvasState.dpr, 1); + time = new Date(Number(time) + 3600000);//Math.floor(120/scaleFactor)); + } + + this.canvasContext.fillStyle = '#fa5'; + y = (new Date() - this.canvasState.firstDeparture) * this.canvasState.scaleFactor + 32; + this.canvasContext.fillRect(0, y-2, this.canvasElement.width / this.canvasState.dpr, 5); + + if (this.viewState.earlierRef) drawButton('earlier'); + + this.viewState.journeys.forEach(journey => { + journey.legs.reverse(); + + journey.legs.forEach(leg => { + if (Math.abs(leg.departureDelay) > 60 || Math.abs(leg.arrivalDelay) > 60) { + const duration = (leg.plannedArrival - leg.plannedDeparture) * this.canvasState.scaleFactor; + + y = (leg.plannedDeparture - this.canvasState.firstDeparture) * this.canvasState.scaleFactor + 32; + + this.canvasContext.fillStyle = '#44444480'; + this.canvasContext.strokeStyle = '#ffffff80'; + this.canvasContext.fillRect(x - this.canvasState.padding, y, this.canvasState.rectWidth, duration); + this.canvasContext.strokeRect(x - this.canvasState.padding, y, this.canvasState.rectWidth, duration); + } + }); + + x += this.canvasState.rectWidthWithPadding; + }); + + x = this.canvasState.offsetX - this.viewState.indexOffset * this.canvasState.rectWidthWithPadding; + + this.viewState.journeys.forEach(journey => { + let xOffset = 0; + let nextLeg; + + journey.legs.forEach(leg => { + if (nextLeg && nextLeg.departure < leg.arrival) xOffset -= 5; + + x += xOffset; + const duration = ((leg.arrival || leg.plannedArrival) - (leg.departure || leg.plannedDeparture)) * this.canvasState.scaleFactor; + + y = ((leg.departure || leg.plannedDeparture) - this.canvasState.firstDeparture) * this.canvasState.scaleFactor + 32; + + this.canvasContext.shadowColor = '#00000060'; + if (!this.settingsState.disableCanvasBlur) this.canvasContext.shadowBlur = 5; + + if (leg.walking || leg.transfer) { + this.canvasContext.fillStyle = '#777'; + this.canvasContext.fillRect(x + this.canvasState.rectWidth / 2 - this.canvasState.rectWidth / 10, y, this.canvasState.rectWidth / 5, duration); + } else { + this.canvasContext.fillStyle = this.getColor('fill', leg); + this.canvasContext.fillRect(x, y, this.canvasState.rectWidth, duration); + this.canvasContext.strokeStyle = this.getColor('text', leg); + this.canvasContext.strokeRect(x, y, this.canvasState.rectWidth, duration); + } + if (!this.settingsState.disableCanvasBlur) this.canvasContext.shadowBlur = 0; + + let preRenderedText = this.getTextCache(formatLineDisplayName(leg.line), this.getColor('text', leg)); + let offset = duration / 2; + if ((offset + preRenderedText.height / this.canvasState.dpr) < duration - 5) { + this.canvasContext.scale(1 / this.canvasState.dpr, 1 / this.canvasState.dpr); + this.canvasContext.drawImage(preRenderedText, this.canvasState.dpr * (x + 5), Math.floor(this.canvasState.dpr * (y + offset) - preRenderedText.height / 2.3)); + this.canvasContext.scale(this.canvasState.dpr, this.canvasState.dpr); + offset += preRenderedText.height / this.canvasState.dpr / 1.3 + 5; + } + + this.getTrainTypeTexts(leg).forEach(typeText => { + const preRenderedTypeText = this.getTextCache(typeText, '#555'); + if ((offset + preRenderedText.height / this.canvasState.dpr) < duration - 5) { + this.canvasContext.scale(1 / this.canvasState.dpr, 1 / this.canvasState.dpr); + this.canvasContext.drawImage(preRenderedTypeText, this.canvasState.dpr * (x + 5), Math.floor(this.canvasState.dpr * (y + offset) - preRenderedTypeText.height / 2.3)); + this.canvasContext.scale(this.canvasState.dpr, this.canvasState.dpr); + offset += preRenderedText.height / this.canvasState.dpr / 2; + } + }); + + if (leg.cancelled) { + this.canvasContext.beginPath(); + this.canvasContext.moveTo(x, y); + this.canvasContext.lineTo(x + this.canvasState.rectWidth, y + duration); + this.canvasContext.strokeStyle = this.getColor('cancelFill', leg); + this.canvasContext.lineWidth = 5; + this.canvasContext.stroke(); + this.canvasContext.lineWidth = 1; + } + + /* draw journey start and end time */ + // note: leg order is reversed at this point in time + const times = []; + if (journey.legs.indexOf(leg) == journey.legs.length - 1) times.push([leg.departure || leg.plannedDeparture, y - 9.5]); + if (journey.legs.indexOf(leg) == 0) times.push([leg.arrival || leg.plannedArrival, y + duration + 7.5]); + times.forEach(([time, y]) => { + preRenderedText = this.getTextCache(formatTime(time), '#fff', 15); + this.canvasContext.scale(1 / this.canvasState.dpr, 1 / this.canvasState.dpr); + this.canvasContext.drawImage(preRenderedText, Math.ceil(this.canvasState.dpr * (x + ((this.canvasState.rectWidth - preRenderedText.width/this.canvasState.dpr)) / 2)), this.canvasState.dpr * (y - 7.5)); + this.canvasContext.scale(this.canvasState.dpr, this.canvasState.dpr); + }); + + if (leg.loadFactor && duration > 20) { + this.canvasContext.shadowColor = '#00000090'; + if (!this.settingsState.disableCanvasBlur) this.canvasContext.shadowBlur = 2; + [ "#777", "#aaa", "#aaa" ]; + for (let i = 0; i < 3; i++) { + this.canvasContext.beginPath(); + this.canvasContext.fillStyle = this.getColor('loadFactor', leg.loadFactor, i); + this.canvasContext.arc(x + (i + 3) * this.canvasState.rectWidth / 8, y + duration - 9.5, 5, 0, 2 * Math.PI, false); + this.canvasContext.fill(); + } + if (!this.settingsState.disableCanvasBlur) this.canvasContext.shadowBlur = 0; + } + + x -= xOffset; + nextLeg = leg; + }); + + journey.legs.reverse(); + x += this.canvasState.rectWidthWithPadding; + }); + + if (this.viewState.laterRef) drawButton('later'); + } +}
diff --git a/src/journeysView.js b/src/journeysView.js @@ -1,190 +1,262 @@ -import { html, nothing, render } from 'lit-html'; -import { ElementById, setThemeColor, queryBackgroundColor, padZeros } from './helpers.js'; -import { getJourneys, getMoreJourneys, refreshJourneys, getFrom, getTo } from './app_functions.js'; -import { formatName, formatDuration, formatFromTo, formatPrice } from './formatters.js'; -import { timeTemplate, footerTemplate } from './templates.js'; -import { settings, modifySettings } from './settings.js'; -import { setupCanvas } from './journeysViewCanvas.js'; -import { go } from './router.js'; -import { showAlertModal, showLoader, hideOverlay } from './overlays.js'; +import { html, css, nothing } from 'lit'; + +import { sleep, queryBackgroundColor, setThemeColor } from './helpers.js'; +import { getJourneys, getMoreJourneys, refreshJourneys, getFromPoint, getToPoint } from './app_functions.js'; +import { formatPoint, formatDuration, formatPrice } from './formatters.js'; +import { timeTemplate } from './templates.js'; +import { settings } from './settings.js'; import { t } from './languages.js'; -const journeysTemplate = (data) => html` - <div class="header-container"> - <header id="header"> - <a class="icon-back" href="#/" title="${t('back')}"></a> - <div class="container flex-row"> - <div> - <h3>${t('from')}: ${formatName(getFrom(data.journeys))}</h3> - <h3>${t('to')}: ${formatName(getTo(data.journeys))}</h3> - </div> - <div class="mode-changers flex-row"> - <a href="#/${data.slug}/table" class="${settings.journeysViewMode === 'table' ? 'active' : ''}"> - <div class="icon-table"></div> - <span>${t('table-view')}</span> - </a> - <a href="#/${data.slug}/canvas" class="${settings.journeysViewMode === 'canvas' ? 'active' : ''}"> - <div class="icon-canvas"></div> - <span>${t('canvas-view')}</span> - </a> - </div> - </div> - <a class="icon-reload" title="${t("reload")}" @click=${() => refreshJourneysView(data.slug)}></a> - </header> - </div> - - ${settings.journeysViewMode === 'canvas' ? html` - - <div id="journeysCanvas"> - <canvas id="canvas"></canvas> - </div> - - ` : nothing} - - ${settings.journeysViewMode === 'table' ? html` - - <div class="container journeysView"> - ${data.earlierRef ? html`<a class="arrowButton icon-arrow2 flipped flex-center" title="${t('label_earlier')}" @click=${() => moreJourneys(data.slug, 'earlier')}></a>` : nothing} - - <div class="card"> - <table> - <thead> - <tr> - <th>${t('departure')}</th> - <th>${t('arrival')}</th> - <th>${t('duration')}</th> - <th>${t('changes')}</th> - <th>${t('products')}</th> - ${settings.showPrices && settings.profile === 'db' ? html`<th>${t('price')}</th>` : nothing} - <th></th> - </tr> - </thead> - <tbody> - ${Object.entries(data.journeys).map(([key, value]) => journeyOverviewTemplate(data.profile || "db", value, data.slug, key - data.indexOffset))} - </tbody> - </table> - </div> - - ${data.laterRef ? html`<a class="arrowButton icon-arrow2 flex-center" title="${t('label_later')}" @click=${() => moreJourneys(data.slug, 'later')}></a>` : nothing} - </div> - - ${footerTemplate} - ` : nothing} -`; - -const journeyOverviewTemplate = (profile, entry, slug, key) => { - const firstLeg = entry.legs[0]; - const lastLeg = entry.legs[entry.legs.length - 1]; - - let changes = 0; - const products = {}; - let productsString = ''; - const changesDuration = 0; - let cancelled = false; - - const duration = Number(lastLeg.arrival || lastLeg.plannedArrival) - Number(firstLeg.departure || firstLeg.plannedDeparture); - - for (const leg of entry.legs) { - if (leg.cancelled) cancelled = true; - if (leg.walking || leg.transfer) continue; - - changes = changes+1; - - if (leg.line) { - const productName = leg.line.name.split(' ')[0]; - if (!products[productName]) products[productName] = []; - } - //if (leg.line && leg.line.trainTypeShort) products[leg.line.productName].push(leg.line.trainTypeShort); +import { baseStyles, helperStyles, flexboxStyles, buttonInputStyles, iconStyles, headerStyles, cardStyles, journeysViewStyles } from './styles.js'; + +import { JourneysCanvas } from './journeysCanvas.js'; + +export class JourneysView extends JourneysCanvas { + static styles = [ + super.styles, + baseStyles, + helperStyles, + flexboxStyles, + iconStyles, + headerStyles, + buttonInputStyles, + cardStyles, + journeysViewStyles + ]; + + static properties = { + slug: { attribute: true }, + mode: { attribute: true }, + viewState: { state: true }, + settingsState: { state: true }, + isUpdating: { state: true }, + }; + + constructor (...args) { + super(...args); + + this.viewState = null; + this.isUpdating = false; + this.settingsState = settings.getState(); } - productsString = Object.entries(products).map(([prod, types]) => { - if (types.length >= 2) { - prod += ' (' + types.join(', ') + ')'; - } else if (types.length) { - prod += ' ' + types[0]; - } - return prod; - }).join(', '); - - return html` - <tr @click=${() => go(`/j/${profile}/${entry.refreshToken}`)}> - <td class="${cancelled ? 'cancelled' : ''}">${timeTemplate(firstLeg, 'departure')}</td> - ${cancelled ? html` - <td><span class="cancelled-text">${t('cancelled-ride')}</span></td> - ` : html` - <td>${timeTemplate(lastLeg, 'arrival')}</td> - `} - <td class="${cancelled ? 'cancelled' : ''}" title="${changesDuration > 0 ? 'including '+formatDuration(changesDuration)+' transfer durations' : ''}">${formatDuration(duration)}</td> - <td>${changes-1}</td> - <td>${productsString}</td> - ${settings.showPrices && settings.profile === 'db' ? html`<td>${formatPrice(entry.price)}</td>` : nothing} - <td><a class="icon-arrow1"></a></td> - </tr>`; -}; - -export const journeysView = async (match, isUpdate) => { - const slug = match[0]; - const mode = match[1]; - - if (settings.journeysViewMode != mode) { - await modifySettings(settings => { - settings.journeysViewMode = mode; - return settings; + async connectedCallback() { + super.connectedCallback(); + await sleep(100); + + setThemeColor(queryBackgroundColor(this.renderRoot, 'header')); + + if (this.viewState === null) await this.updateViewState(); + if (this.mode === 'canvas') this.connectCanvas(); + + this._unsubscribeSettingsState = settings.subscribe((state) => { + this.settingsState = state; + this.performUpdate(); }); } - let data; + disconnectedCallback () { + super.disconnectedCallback(); - if (!isUpdate) showLoader(); + if (this.mode === 'canvas') this.disconnectCanvas(); + this.viewState = null; - try { - data = await getJourneys(slug); - } catch(e) { - await showAlertModal(e.toString()); - go('/'); - throw e; + if (typeof this._unsubscribeSettingsState === 'function') { + this._unsubscribeSettingsState(); + this._unsubscribeSettingsState = undefined; + } } - if (!data) { - await showAlertModal(html`journeys overview id invalid. <br />journeys overview links can not be shared across devices in ${APPNAME}.`); - go('/'); - return; + async updated (previous) { + if (isDevServer) console.info('updated(): ', previous); + + if (previous.has('mode') && this.settingsState.journeysViewMode !== this.mode) this.settingsState.setJourneysViewMode(this.mode); + + if (previous.has('slug')) await this.updateViewState(); + + if (previous.has('mode')) { + if (this.mode === 'canvas') this.connectCanvas(); + if (previous.get('mode') === 'canvas' && this.mode !== 'canvas') this.disconnectCanvas(); + } + + if (this.mode === 'canvas') { + if (this.canvas === null) this.connectCanvas(); + if (previous.has('viewState') && this.viewState !== null) { + this.renderCanvas(); + await this.getCoachSequences(); + } + } + + await super.updated(previous); } - hideOverlay(); + renderContent () { + return html` + <div class="header-container"> + <header> + <a id="back" class="icon-back" title="${t('back')}" href="#/"></a> + <div class="container flex-row"> + <div> + <h3>${t('from')}: ${this.viewState !== null ? formatPoint(getFromPoint(this.viewState.journeys)) : '...'}</h3> + <h3>${t('to')}: ${this.viewState !== null ? formatPoint(getToPoint(this.viewState.journeys)) : '...'}</h3> + </div> + <div class="mode-changers flex-row"> + <a href="#/${this.slug}/table" class="${this.settingsState.journeysViewMode === 'table' ? 'active' : ''}"> + <div class="icon-table"></div> + <span>${t('table-view')}</span> + </a> + <a href="#/${this.slug}/canvas" class="${this.settingsState.journeysViewMode === 'canvas' ? 'active' : ''}"> + <div class="icon-canvas"></div> + <span>${t('canvas-view')}</span> + </a> + </div> + </div> + <a class="icon-reload ${this.isUpdating ? 'spinning' : ''}" title="${t("reload")}" @click=${this.refreshJourneys}></a> + </header> + </div> - render(journeysTemplate(data), ElementById('content')); - setThemeColor(queryBackgroundColor('header')); + ${this.viewState !== null ? html` + ${this.settingsState.journeysViewMode === 'canvas' ? this.getCanvas() : nothing} + ${this.settingsState.journeysViewMode === 'table' ? html` + <div class="container journeysView"> + ${this.viewState.earlierRef ? html` + <a class="arrowButton icon-arrow2 flipped flex-center" title="${t('label_earlier')}" @click=${() => this.moreJourneys('earlier')}></a> + ` : nothing} + + <div class="card"> + <table> + <thead> + <tr> + <th>${t('departure')}</th> + <th>${t('arrival')}</th> + <th>${t('duration')}</th> + <th>${t('changes')}</th> + <th>${t('products')}</th> + ${this.settingsState.showPrices && this.viewState.profile === 'db' ? html`<th>${t('price')}</th>` : nothing} + <th></th> + </tr> + </thead> + <tbody> + ${this.viewState.journeys.map(journey => this.journeyTemplate(journey))} + </tbody> + </table> + </div> - if (settings.journeysViewMode === 'canvas') - return setupCanvas(data, isUpdate); -}; + ${this.viewState.laterRef ? html` + <a class="arrowButton icon-arrow2 flex-center" title="${t('label_later')}" @click=${() => this.moreJourneys('later')}></a> + ` : nothing} + </div> + <footer-component></footer-component> + ` : nothing} + ` : html`<div class="spinner"></div>`} + `; + } -export const moreJourneys = async (slug, mode) => { - showLoader(); + journeyTemplate (journey) { + const firstLeg = journey.legs[0]; + const lastLeg = journey.legs[journey.legs.length - 1]; + + return html` + <tr @click=${() => window.location = `#/j/${this.viewState.profile}/${journey.refreshToken}`}> + <td class="${!journey.cancelled ? nothing : 'cancelled'}">${timeTemplate(firstLeg, 'departure')}</td> + ${journey.cancelled ? html` + <td><span class="cancelled-text">${t('cancelled-ride')}</span></td> + ` : html` + <td>${timeTemplate(lastLeg, 'arrival')}</td> + `} + <td class="${!journey.cancelled ? nothing : 'cancelled'}" title="${journey.changesDuration > 0 ? 'including '+formatDuration(journey.changesDuration)+' transfer durations' : ''}">${formatDuration(journey.duration)}</td> + <td>${journey.changes-1}</td> + <td>${journey.products}</td> + ${this.settingsState.showPrices && this.viewState.profile === 'db' ? html` + <td>${formatPrice(journey.price)}</td> + ` : nothing} + <td><a class="icon-arrow1"></a></td> + </tr> + `; + } - try { - await getMoreJourneys(slug, mode); - } catch(e) { - showAlertModal(e.toString()); - throw e; + async updateViewState () { + try { + let viewState = await getJourneys(this.slug); + + if (!viewState) { + this.showAlertOverlay(html`journeys overview id invalid. <br>journeys overview links can not be shared across devices.`); + window.location = '#/'; + return; + } + + viewState.journeys.forEach((journey, index, journeys) => { + const firstLeg = journey.legs[0]; + const lastLeg = journey.legs[journey.legs.length - 1]; + const products = {}; + + journeys[index].duration = Number(lastLeg.arrival || lastLeg.plannedArrival) - Number(firstLeg.departure || firstLeg.plannedDeparture); + journeys[index].changesDuration = 0; + journeys[index].cancelled = false; + journeys[index].changes = 0; + + journey.legs.forEach(leg => { + if (leg.cancelled) journeys[index].cancelled = true; + if (leg.walking || leg.transfer) return; + + journeys[index].changes++; + + if (leg.line) { + const productName = leg.line.name.split(' ')[0]; + if (!products[productName]) products[productName] = []; + } + //if (leg.line && leg.line.trainTypeShort) products[leg.line.productName].push(leg.line.trainTypeShort); + }) + + journeys[index].products = Object.entries(products).map(([prod, types]) => { + if (types.length >= 2) { + prod += ' (' + types.join(', ') + ')'; + } else if (types.length) { + prod += ' ' + types[0]; + } + return prod; + }).join(', '); + }); + + this.viewState = viewState; + if (isDevServer) console.info('viewState: ', this.viewState); + } catch(e) { + this.showAlertOverlay(e.toString()); + console.error(e); + } } - hideOverlay(); - journeysView([slug, settings.journeysViewMode], true); -}; + async refreshJourneys () { + if (this.isUpdating !== false) return false; + + try { + this.isUpdating = true; + await refreshJourneys(this.slug); + this.isUpdating = false; -const refreshJourneysView = async (slug) => { - document.querySelector('.icon-reload').classList.add('spinning'); + await this.updateViewState(); + } catch(e) { + this.isUpdating = false; + this.showAlertOverlay(e.toString()); + console.error(e); + } + } - try { - await refreshJourneys(slug, true); - } catch(e) { - showAlertModal(e.toString()); - document.querySelector('.icon-reload').classList.remove('spinning'); - throw e; + async moreJourneys (mode) { + this.showLoaderOverlay(); + try { + this.isUpdating = true; + await getMoreJourneys(this.slug, mode); + this.isUpdating = false; + + await this.updateViewState(); + } catch(e) { + this.isUpdating = false; + this.showAlertOverlay(e.toString()); + console.error(e); + } + this.hideOverlay(); } +} - journeysView([slug, settings.journeysViewMode], true); - document.querySelector('.icon-reload').classList.remove('spinning'); -}; +customElements.define('journeys-view', JourneysView);
diff --git a/src/journeysViewCanvas.js b/src/journeysViewCanvas.js @@ -1,418 +0,0 @@ -import { moreJourneys } from './journeysView.js'; -import { go } from './router.js'; -import { padZeros } from './helpers.js'; -import { formatTrainTypes, formatLineDisplayName, formatDateTime } from './formatters.js' -import { cachedCoachSequence, coachSequenceCache, coachSequenceCacheKey } from './coach-sequence/index.js'; - - -const colorFor = (leg, type) => { - const product = leg.line?.product || 'walk'; - return colors[type][product] || colors[type].default; -}; - -const loadFactorColors = { - 'low-to-medium': [ '#777', '#ccc', '#ccc' ], - 'high': [ '#777', '#777', '#ccc' ], - 'very-high': [ '#ee8800', '#ee8800', '#ee8800' ], - 'exceptionally-high': [ '#cc3300', '#cc3300', '#cc3300' ], -}; - -const flatten = (arr) => [].concat(...arr); - -const colors = { - fill: { - 'tram': '#cc5555', - 'subway': '#5555cc', - 'suburban': '#55aa55', - 'nationalExpress': '#fff', - 'national': '#fff', - 'regionalExpress': '#888', - 'regional': '#888', - 'bus': '#aa55aa', - default: '#888' - }, - text: { - 'nationalExpress': '#ee3333', - 'national': '#ee3333', - default: '#fff' - }, - cancelFill: { - 'tram': '#fff', - default: '#cc4444ff', - }, - icon: { - 'walk': 'directions_walk', - 'transfer': 'directions_transfer', - 'subway': 'directions_subway', - 'bus': 'directions_bus', - 'tram': 'tram', - default: 'train' - } -}; - -let rectWidth, padding, rectWidthWithPadding, canvas, ctx; -let dpr = window.devicePixelRatio || 1; - -const canvasState = { - offsetX: 0, -}; - -let textCache = {}; - -const typeTextsFor = leg => { - if (!leg.line || !leg.line.name) return []; - const [category, number] = leg.line.name.split(" "); - const key = coachSequenceCacheKey(category, leg.line.fahrtNr || number, leg.origin.id, leg.plannedDeparture); - if (!key) return []; - const info = coachSequenceCache[key]; - if (!info || info instanceof Promise) { - return []; - } - return formatTrainTypes(info).split(" + "); -}; - -export const setupCanvas = (data, isUpdate) => { - if (!isUpdate) canvasState.offsetX = (window.innerWidth / dpr) > 600 ? 140 : 80; - canvas = document.getElementById('canvas'); - ctx = canvas.getContext('2d'); - if (data) { - canvasState.data = { - ...data, - journeys: Object.keys(data.journeys).sort((a, b) => Number(a) - Number(b)).map(k => data.journeys[k]) - }; - - (async () => { - for (const journey of canvasState.data.journeys) { - for (const leg of journey.legs) { - if (!leg.line) continue; - const [category, number] = leg.line.name.split(" "); - if (data.profile === "db") await cachedCoachSequence(category, leg.line.fahrtNr || number, leg.origin.id, leg.plannedDeparture); - setupCanvas(null, true); - } - } - }) (); - } - - canvas.addEventListener('mousedown', mouseDownHandler, {passive: true}); - canvas.addEventListener('touchstart', mouseDownHandler, {passive: true}); - window.addEventListener('mouseup', mouseUpHandler); - window.addEventListener('touchend', mouseUpHandler); - window.addEventListener('mousemove', mouseMoveHandler); - window.addEventListener('touchmove', mouseMoveHandler); - window.addEventListener('resize', resizeHandler); - window.addEventListener('zoom', resizeHandler); - resizeHandler(); - - return { - unload: () => { - canvas.removeEventListener('mousedown', mouseDownHandler); - canvas.removeEventListener('touchstart', mouseDownHandler); - window.removeEventListener('mouseup', mouseUpHandler); - window.removeEventListener('touchend', mouseUpHandler); - window.removeEventListener('mousemove', mouseMoveHandler); - window.removeEventListener('touchmove', mouseMoveHandler); - window.removeEventListener('resize', resizeHandler); - window.removeEventListener('zoom', resizeHandler); - }, - }; -}; - -const getTextCache = (text, color, fixedHeight) => { - const index = `${text}|${color}|${rectWidth}|${dpr}|${fixedHeight}`; - if (!textCache[index]) { - textCache[index] = makeTextCache(text, color, fixedHeight); - } - return textCache[index]; -}; - -const makeTextCache = (text, color, fixedHeight) => { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - ctx.shadowColor = '#00000080'; - - let height, width; - if (fixedHeight) { - height = 15; - ctx.font = `${height}px sans-serif`; - width = ctx.measureText(text).width; - } else { - const measureAccuracy = 50; - ctx.font = `${measureAccuracy}px sans-serif`; - width = rectWidth - 10; - height = Math.abs(measureAccuracy * (width / (1 - ctx.measureText(text).width))); - } - - canvas.width = width * dpr; - canvas.height = Math.ceil(height * 1.5) * dpr; - ctx.scale(dpr, dpr); - - ctx.font = `${height}px sans-serif`; - ctx.fillStyle = color; - ctx.fillText(text, 0, height); - - return canvas; -}; - -let lastAnimationUpdate = 0, firstDeparture = 0, scaleFactor = 0, lastArrival = 0; -let animationInterval; -const renderJourneys = () => { - ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr); - let x = canvasState.offsetX - canvasState.data.indexOffset * rectWidthWithPadding, y; - - const firstVisibleJourney = Math.max(0, Math.floor((-x + padding) / rectWidthWithPadding)); - const numVisibleJourneys = Math.ceil(canvas.width / dpr / rectWidthWithPadding); - const visibleJourneys = canvasState.data.journeys.slice(firstVisibleJourney, firstVisibleJourney + numVisibleJourneys); - - if (!visibleJourneys.length) return; - - const targetFirstDeparture = Number(visibleJourneys[0].legs[0].plannedDeparture); - const targetLastArrival = Math.max.apply(Math, - visibleJourneys.map(journey => journey.legs[journey.legs.length-1].plannedArrival) - .concat(visibleJourneys.map(journey => journey.legs[journey.legs.length-1].arrival) - )); - const targetScaleFactor = 1/(targetLastArrival - targetFirstDeparture) * (canvas.height - 64 * dpr) / dpr; - - const now = new Date(); - const factor = Math.min(.3, (now - lastAnimationUpdate) / 20); - if (!lastAnimationUpdate) { - firstDeparture = Number(targetFirstDeparture); - lastArrival = Number(targetLastArrival); - scaleFactor = targetScaleFactor; - } else { - firstDeparture = firstDeparture + (targetFirstDeparture - firstDeparture) * factor; - lastArrival = lastArrival + (targetLastArrival - lastArrival) * factor; - scaleFactor = scaleFactor + (targetScaleFactor - scaleFactor) * factor; - } - lastAnimationUpdate = now; - - if (Math.abs(scaleFactor - targetScaleFactor) > 1 - || Math.abs(firstDeparture - targetFirstDeparture) > 1 - || Math.abs(lastArrival - targetLastArrival) > 1 - ) { - if (!animationInterval) animationInterval = setInterval(() => renderJourneys(), 16.6); - } else { - if (animationInterval) { - clearInterval(animationInterval); - animationInterval = null; - } - } - - let time = canvasState.data.journeys[0].legs[0].plannedDeparture; - - ctx.font = `${(window.innerWidth / dpr) > 600 ? 20 : 15}px sans-serif`; - ctx.fillStyle = '#aaa'; - while (time < lastArrival) { - const y = (time - firstDeparture) * scaleFactor + 32; - ctx.fillText(formatDateTime(time, 'time'), (window.innerWidth / dpr) > 600 ? 30 : 10, y); - ctx.fillRect(0, y, canvas.width / dpr, 1); - time = new Date(Number(time) + 3600000);//Math.floor(120/scaleFactor)); - } - ctx.fillStyle = '#fa5'; - y = (new Date() - firstDeparture) * scaleFactor + 32; - ctx.fillRect(0, y-2, canvas.width / dpr, 5); - - const p = new Path2D('M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z'); - if (canvasState.data.earlierRef) { - ctx.fillStyle = '#fff'; - ctx.shadowColor = '#00000080'; - ctx.save(); - ctx.scale(3, 3); - ctx.translate(x / 3 - 15, canvas.height / dpr / 6 - 24); - ctx.rotate(-Math.PI*1.5); - ctx.fill(p); - ctx.restore(); - ctx.beginPath(); - ctx.arc(x - 80,canvas.height / dpr / 2 - 35,50,0,2*Math.PI); - ctx.fillStyle = '#ffffff40'; - ctx.fill(); - ctx.strokeStyle = '#00000020'; - ctx.stroke(); - } - - for (const journey of canvasState.data.journeys) { - journey.legs.reverse(); - for (const leg of journey.legs) { - if (Math.abs(leg.departureDelay) > 60 || Math.abs(leg.arrivalDelay) > 60) { - const duration = (leg.plannedArrival - leg.plannedDeparture) * scaleFactor; - - y = (leg.plannedDeparture - firstDeparture) * scaleFactor + 32; - - ctx.fillStyle = '#44444480'; - ctx.strokeStyle = '#ffffff80'; - ctx.fillRect(x-padding, y, rectWidth, duration); - ctx.strokeRect(x-padding, y, rectWidth, duration); - } - } - x += rectWidthWithPadding; - } - - x = canvasState.offsetX - canvasState.data.indexOffset * rectWidthWithPadding; - - for (const journey of canvasState.data.journeys) { - let xOffset = 0; - let nextLeg; - for (const leg of journey.legs) { - if (nextLeg && nextLeg.departure < leg.arrival) { - xOffset -= 5; - } - - x += xOffset; - const duration = ((leg.arrival || leg.plannedArrival) - (leg.departure || leg.plannedDeparture)) * scaleFactor; - - y = ((leg.departure || leg.plannedDeparture) - firstDeparture) * scaleFactor + 32; - - ctx.shadowColor = '#00000060'; - //ctx.shadowBlur = 5; - - if (leg.walking || leg.transfer) { - ctx.fillStyle = '#777'; - ctx.fillRect(x + rectWidth / 2 - rectWidth / 10, y, rectWidth / 5, duration); - } else { - ctx.fillStyle = colorFor(leg, 'fill'); - ctx.fillRect(x, y, rectWidth, duration); - ctx.strokeStyle = colorFor(leg, 'text'); - ctx.strokeRect(x, y, rectWidth, duration); - } - //ctx.shadowBlur = 0; - - let preRenderedText = getTextCache(formatLineDisplayName(leg.line), colorFor(leg, 'text')); - let offset = duration / 2; - if ((offset + preRenderedText.height / dpr) < duration - 5) { - ctx.scale(1 / dpr, 1 / dpr); - ctx.drawImage(preRenderedText, dpr * (x + 5), Math.floor(dpr * (y + offset) - preRenderedText.height / 2.3)); - ctx.scale(dpr, dpr); - offset += preRenderedText.height / dpr / 1.3 + 5; - } - const typeTexts = typeTextsFor(leg); - for (const typeText of typeTexts) { - const preRenderedTypeText = getTextCache(typeText, '#555'); - if ((offset + preRenderedText.height / dpr) < duration - 5) { - ctx.scale(1 / dpr, 1 / dpr); - ctx.drawImage(preRenderedTypeText, dpr * (x + 5), Math.floor(dpr * (y + offset) - preRenderedTypeText.height / 2.3)); - ctx.scale(dpr, dpr); - offset += preRenderedText.height / dpr / 1.3; - } - } - - if (leg.cancelled) { - ctx.beginPath(); - ctx.moveTo(x, y); - ctx.lineTo(x + rectWidth, y + duration); - ctx.strokeStyle = colorFor(leg, 'cancelFill'); - ctx.lineWidth = 5; - ctx.stroke(); - ctx.lineWidth = 1; - } - - /* draw journey start and end time */ - let time; - // note: leg order is reversed at this point in time - const times = []; - if (journey.legs.indexOf(leg) == journey.legs.length - 1) times.push([leg.departure || leg.plannedDeparture, y - 9.5]); - if (journey.legs.indexOf(leg) == 0) times.push([leg.arrival || leg.plannedArrival, y + duration + 7.5]); - for (const [time, y] of times) { - preRenderedText = getTextCache(formatDateTime(time, 'time'), '#fff', 15); - ctx.scale(1 / dpr, 1 / dpr); - ctx.drawImage(preRenderedText, Math.ceil(dpr * (x + ((rectWidth - preRenderedText.width/dpr)) / 2)), dpr * (y - 7.5)); - ctx.scale(dpr, dpr); - } - - if (leg.loadFactor && duration > 20) { - ctx.shadowColor = '#00000090'; - //ctx.shadowBlur = 2; - [ "#777", "#aaa", "#aaa" ]; - for (let i = 0; i < 3; i++) { - ctx.beginPath(); - ctx.fillStyle = loadFactorColors[leg.loadFactor][i]; - ctx.arc(x + (i + 3) * rectWidth / 8, y + duration - 9.5, 5, 0, 2 * Math.PI, false); - ctx.fill(); - } - //ctx.shadowBlur = 0; - } - x -= xOffset; - nextLeg = leg; - } - - journey.legs.reverse(); - - x += rectWidthWithPadding; - } - if (canvasState.data.laterRef) { - ctx.fillStyle = '#fff'; - ctx.shadowColor = '#00000080'; - ctx.save(); - ctx.scale(3, 3); - ctx.translate(x / 3 + 5, canvas.height / dpr / 6); - ctx.rotate(Math.PI*1.5); - ctx.fill(p); - ctx.restore(); - ctx.beginPath(); - ctx.arc(x + 50,canvas.height / dpr / 2 - 35,50,0,2*Math.PI); - ctx.fillStyle = '#ffffff40'; - ctx.fill(); - ctx.strokeStyle = '#00000020'; - ctx.stroke(); - } -}; - -const resizeHandler = () => { - dpr = window.devicePixelRatio || 1; - if (!document.getElementById('canvas')) return; - - rectWidth = (window.innerWidth / dpr) > 600 ? 100 : 80; - padding = (window.innerWidth / dpr) > 600 ? 20 : 5; - rectWidthWithPadding = rectWidth + 2 * padding; - - const rect = document.getElementById('header').getBoundingClientRect(); - canvas.width = window.innerWidth * dpr; - canvas.height = (window.innerHeight - rect.height) * dpr; - canvas.style.width = `${window.innerWidth}px`; - canvas.style.height = `${window.innerHeight - rect.height - 4}px`; - - ctx.restore(); - ctx.save(); - ctx.scale(dpr, dpr); - - lastAnimationUpdate = 0; - renderJourneys(); -}; - -const mouseUpHandler = (evt) => { - const x = evt.x || evt.changedTouches[0].pageX; - if (canvasState.dragging && canvasState.isClick) { - evt.preventDefault(); - const num = Math.floor((x - canvasState.offsetX + 2 * padding) / rectWidthWithPadding) + canvasState.data.indexOffset; - if (num >= 0) { - if (num < canvasState.data.journeys.length) { - const j = canvasState.data.journeys[num]; - go(`/j/${canvasState.data.profile || "db"}/${j.refreshToken}`); - } else if (canvasState.data.laterRef) { - moreJourneys(canvasState.data.slug, 'later'); - } - } else if (canvasState.data.earlierRef) { - moreJourneys(canvasState.data.slug, 'earlier'); - } - } - - canvasState.dragging = false; - canvasState.isClick = false; -}; - -const mouseDownHandler = (evt) => { - const x = evt.x || evt.changedTouches[0].pageX; - canvasState.dragStartMouse = x; - canvasState.dragStartOffset = canvasState.offsetX; - canvasState.dragging = true; - canvasState.isClick = true; -}; - -const mouseMoveHandler = (evt) => { - if (canvasState.dragging) { - evt.preventDefault(); - const x = evt.x || evt.changedTouches[0].pageX; - canvasState.offsetX = canvasState.dragStartOffset - (canvasState.dragStartMouse - x); - if (Math.abs(canvasState.dragStartMouse - x) > 20) canvasState.isClick = false; - renderJourneys(); - return true; - } -};
diff --git a/src/languages.js b/src/languages.js @@ -1,4 +1,4 @@ -import { settings } from './settings.js'; +import { settingsState } from './settings.js'; export const getDefaultLanguage = () => { const userLang = navigator.language || navigator.userLanguage; @@ -11,7 +11,7 @@ export const getDefaultLanguage = () => { export const getLanguages = () => Object.keys(languages); export const t = (key, ...params) => { - let translation = languages[settings.language][key]; + let translation = languages[settingsState.language][key]; if (!translation) translation = languages['en'][key] if (!translation) return key; @@ -105,7 +105,10 @@ const languages = { 'ageGroupYoung': 'Jung', 'ageGroupAdult': 'Erwachsen', 'ageGroupSenior': 'Senior', + 'tickets': 'Tickets', 'now': 'Jetzt', + 'minTransferTime': 'Umstiegszeit (Minuten)', + 'trainType': 'Zugtyp', }, 'nl': { @@ -243,11 +246,11 @@ const languages = { 'combineDateTime': 'Use combined DateTime-input', 'titleSetDateTimeNow': 'Set Date & Time to now', 'titleBikeFriendly': 'Bicycle transport possible', - 'loyaltyCard': 'Discount Card', + 'loyaltyCard': 'Discount card', 'loyaltyCardNone': 'No discount card', 'class': 'Class', 'titleNoTransfers': 'only direct connections', - 'lastSelectedJourney': 'Last selected Journey', + 'lastSelectedJourney': 'Last selected journey', 'walkingSpeed': 'Walking speed', 'walkingSpeedSlow': 'slow', 'walkingSpeedNormal': 'normal', @@ -259,6 +262,9 @@ const languages = { 'ageGroupYoung': 'Young', 'ageGroupAdult': 'Adult', 'ageGroupSenior': 'Senior', + 'tickets': 'Tickets', 'now': 'Now', + 'minTransferTime': 'Transfer time (Minutes)', + 'trainType': 'Train type', } };
diff --git a/src/main.js b/src/main.js @@ -1,39 +1,93 @@ -import { route, go, start } from './router.js'; -import { ElementById } from './helpers.js'; -import { showAlertModal, hideOverlay } from './overlays.js'; -import { initSettings, settings } from './settings.js'; -import { initDataStorage } from './dataStorage.js'; -import { initHafasClient } from './hafasClient.js'; -import { searchView } from './searchView.js'; -import { journeysView } from './journeysView.js'; -import { journeyView } from './journeyView.js'; -import { tripView } from './tripView.js'; -import { departuresView } from './departuresView.js'; +import { LitElement, html, render } from 'lit'; +import { cache } from 'lit/directives/cache.js'; -import './assets/style.css'; +import { initDataStorage } from './dataStorage.js'; +import { initHafasClient } from './hafasClient.js'; +import { initSettingsState, settingsState } from './settings.js' +import { baseStyles, helperStyles } from './styles.js'; + +import './searchView.js'; +import './journeysView.js'; +import './journeyView.js'; +import './tripView.js'; +import './departuresView.js'; +import './settingsView.js'; +import './footerComponent.js'; + +class Oeffisearch extends LitElement { + static properties = { + outlet: { state: true }, + }; + + routes = [ + { + pattern: /^\/$/, + render: () => html`<search-view></search-view>` + }, + { + pattern: /^\/([a-zA-Z0-9]+)\/([a-z]+)$/, + render: param => html`<journeys-view slug="${param[0]}" mode="${param[1]}"></journeys-view>` + }, + { + pattern: /^\/j\/([a-z]+)\/(.+)$/, + render: param => html`<journey-view profile="${param[0]}" refreshToken="${param[1]}"></journey-view>` + }, + { + pattern: /^\/t\/([a-z]+)\/(.+)$/, + render: param => html`<trip-view profile="${param[0]}" refreshToken="${param[1]}"></trip-view>` + }, + { + pattern: /^\/d\/([a-z]+)\/([^/]+)(\/[0-9]+)?$/, + render: param => html`<departures-view profile="${param[0]}" stopId="${param[1]}" when="${param[2]}"></departures-view>` + } + ]; + + constructor (...args) { + super(...args); + this.outlet = html``; + if (!window.location.hash.length) window.location = '#/'; + } + + routeHandler = () => { + const dest = window.location.hash.slice(1); + this.routes.forEach(route => { + const match = route.pattern.exec(dest); + if (match !== null) this.outlet = route.render(match.slice(1)); + }); + } + + connectedCallback () { + super.connectedCallback(); + window.addEventListener('hashchange', this.routeHandler); + this.routeHandler(); + } + + disconnectedCallback () { + super.disconnectedCallback(); + window.removeEventListener('hashchange', this.routeHandler); + } + + render = () => cache(this.outlet); +} + +customElements.define('oeffi-search', Oeffisearch); (async () => { + await initSettingsState(); await initDataStorage(); - await initSettings(); - await initHafasClient(settings.profile || "db"); - - hideOverlay(); - ElementById('overlay').innerHTML = ''; - - route(/^\/$/, searchView); - route(/^\/([a-zA-Z0-9]+)\/([a-z]+)$/, journeysView); - route(/^\/j\/([a-z]+)\/(.+)$/, journeyView); - route(/^\/t\/([a-z]+)\/(.+)$/, tripView); - route(/^\/d\/([a-z]+)\/([^/]+)(\/[0-9]+)?$/, departuresView); - route(/^.*$/, async () => { - await showAlertModal('Route not found'); - go('/'); - }); + await initHafasClient(settingsState.profile); + + const style = document.createElement('style'); + style.type = 'text/css'; + style.appendChild(document.createTextNode([ + baseStyles, + helperStyles + ].join(''))); - if (!window.location.hash.length) go('/'); - start(); -}) (); + document.head.appendChild(style); + document.body.innerHTML = '<oeffi-search></oeffi-search>'; +})(); if (!isDevServer) { window.addEventListener('load', () => {
diff --git a/src/overlays.js b/src/overlays.js @@ -1,53 +0,0 @@ -import { html, render } from 'lit-html'; -import { ElementById, showElement, hideElement } from './helpers.js'; - -const overlayElement = ElementById('overlay'); - -export const showAlertModal = (text) => { - showElement(overlayElement); - return new Promise(resolve => { - render(html` - <div class="modal alert"> - ${text} - <br><button class="color" id="alertButton" @click=${() => { hideOverlay(); resolve(); }}>OK</button> - </div> - `, overlayElement); - ElementById('alertButton').focus(); - }); -}; - -export const showSelectModal = (items) => { - showElement(overlayElement); - return new Promise(resolve => { - render(html` - <div class="modal select flex-column"> - ${items.map( - (item) => html`<a class="button color" @click=${item.action}>${item.label}</a>` - )} - <a class="button color" @click=${() => { hideOverlay(); resolve(); }}>Close</a> - </div> - `, overlayElement); - }); -}; - -export const showModal = (title, content) => { - showElement(overlayElement); - return new Promise(resolve => { - render(html` - <div class="modal dialog"> - <div class="header flex-row"> - <h4>${title}</h4> - <div class="icon-close" title="Close" @click=${() => { hideOverlay(); resolve(); }}></div> - </div> - <div class="body">${content}</div> - </div> - `, overlayElement); - }); -}; - -export const showLoader = () => { - showElement(overlayElement); - render(html`<div class="spinner"></div>`, overlayElement); -}; - -export const hideOverlay = () => hideElement(overlayElement);
diff --git a/src/router.js b/src/router.js @@ -1,31 +0,0 @@ -const routes = []; -let currentRoute; - -export const route = (pattern, handler) => { - routes.push({ - pattern: pattern, - handler: handler - }); -}; - -export const go = (dest) => { - window.location.hash = '#' + dest; -}; - -export const start = async () => { - const dest = window.location.hash.slice(1); - - if (currentRoute && currentRoute.unload) currentRoute.unload(); - - for (const route of routes) { - const match = route.pattern.exec(dest); - - if (!match) continue; - - currentRoute = await route.handler(match.slice(1)); - - return; - } -}; - -window.addEventListener('hashchange', start);
diff --git a/src/searchView.js b/src/searchView.js @@ -1,574 +1,560 @@ -import { html, nothing, render } from 'lit-html'; -import { ElementById, hideElement, showElement, elementHidden, flipElement, unflipElement, setThemeColor, queryBackgroundColor, padZeros, isValidDate, loyaltyCardFromString } from './helpers.js'; +import { LitElement, html, nothing } from 'lit'; +import { LitOverlay } from './LitOverlay.js'; + import { db } from './dataStorage.js'; -import { getJourneys, newJourneys, ds100Reverse } from './app_functions.js'; -import { formatName, formatFromTo } from './formatters.js'; -import { modifySettings, settings } from './settings.js'; -import { go } from './router.js'; -import { showAlertModal, showSelectModal, showLoader, hideOverlay} from './overlays.js'; -import { footerTemplate } from './templates.js'; import { t } from './languages.js'; -import { showSettings } from './settingsView.js'; import { client } from './hafasClient.js'; +import { getIBNRbyDS100 } from './ds100.js'; +import { formatPoint } from './formatters.js'; +import { newJourneys } from './app_functions.js'; +import { padZeros, sleep, queryBackgroundColor, setThemeColor } from './helpers.js'; + +import { baseStyles, helperStyles, flexboxStyles, buttonInputStyles, iconStyles, searchViewStyles } from './styles.js'; +import { settings } from './settings.js'; + +class SearchView extends LitOverlay { + static properties = { + settingsState: { state: true }, + date: { state: true }, + numEnter: { state: true }, + noTransfers: { state: true }, + isArrival: { state: true }, + showHistory: { state: true }, + history: { state: true }, + location: { state: true }, + }; -const viewState = { - currDate: new Date(), - numEnter: 0, - noTransfers: false, - isArrival: false, - dateValue: '', - timeValue: '', - dateTimeValue: '', - fromValue: '', - viaValue: '', - toValue: '', - suggestions: { - from: {}, - via: {}, - to: {}, - }, -}; - -viewState.dateValue = `${viewState.currDate.getFullYear()}-${padZeros(viewState.currDate.getMonth()+1)}-${padZeros(viewState.currDate.getDate())}`; -viewState.timeValue = `${padZeros(viewState.currDate.getHours())}:${padZeros(viewState.currDate.getMinutes())}`; -viewState.dateTimeValue = `${viewState.dateValue}T${viewState.timeValue}`; - -const productIcons = { - // DB - "nationalExpress": "icon-ice", - "national": "icon-ic", - "regionalExpress": "icon-dzug", - - // vbb, bvg - "express": "icon-icice", - - // nahsh - "interregional": "icon-dzug", - "onCall": "icon-taxi", - - // SNCB - "intercity-p": "icon-ic", - "s-train": "icon-suburban", - - // RMV - "express-train": "icon-ice", - "long-distance-train": "icon-ic", - "regiona-train": "icon-regional", - "s-bahn": "icon-suburban", - "u-bahn": "icon-subway", - "watercraft": "icon-ferry", - "ast": "icon-taxi", - - // VRN - "regional-train": "icon-regional", - "urban-train": "icon-suburban", - "dial-a-ride": "icon-taxi", -// "long-distance-train": "icon-icice", -}; - -const iconFor = id => { - return productIcons[id] || `icon-${id}`; -}; - -const searchTemplate = (journeysHistory) => html` - <div class="container searchView"> - <div class="title flex-center"> - <h1>${APPNAME}</h1> - </div> - - <form class="center" id="form" @submit=${submitHandler}> - <div class="flex-row nowrap"> - <input type="text" name="from" id="from" title="${t('from')}" placeholder="${t('from')}" value="${viewState.fromValue}" autocomplete="off" - @focus=${focusHandler} @blur=${blurHandler} @keydown=${keydownHandler} @keyup=${keyupHandler} @input=${loadSuggestions} required> - <div class="button icon-arrow2 ${!settings.showVia ? '' : 'flipped'}" id="viaButton" title="${t('via')}" @click=${toggleVia}></div> - </div> - <div class="suggestions" id="fromSuggestions"></div> + static styles = [ + super.styles, + baseStyles, + helperStyles, + flexboxStyles, + buttonInputStyles, + iconStyles, + searchViewStyles + ]; - <div class="flex-row nowrap ${!settings.showVia ? 'hidden' : ''}" id="viaRow"> - <input type="text" name="via" id="via" title="${t('via')}" placeholder="${t('via')}" value="${viewState.viaValue}" autocomplete="off" - @focus=${focusHandler} @blur=${blurHandler} @keydown=${keydownHandler} @keyup=${keyupHandler} @input=${loadSuggestions}> - <div class="button icon-arrow2 invisible"></div> - </div> - <div class="suggestions" id="viaSuggestions"></div> + constructor (...args) { + super(...args); + + this.settingsState = settings.getState(); + this.date = new CustomDate(); + this.numEnter = 0; + this.noTransfers = false, + this.isArrival = false, + this.showHistory = false; + this.history = []; + this.location = { + from: { + value: '', + suggestionsVisible: false, + suggestionSelected: null, + suggestion: null, + suggestions: [], + }, + to: { + value: '', + suggestionsVisible: false, + suggestionSelected: null, + suggestion: null, + suggestions: [], + }, + via: { + value: '', + suggestionsVisible: false, + suggestionSelected: null, + suggestion: null, + suggestions: [], + }, + }; + } + async connectedCallback() { + super.connectedCallback(); + await sleep(200); - <div class="flex-row nowrap"> - <input type="text" name="to" id="to" title="${t('to')}" placeholder="${t('to')}" value="${viewState.toValue}" autocomplete="off" - @focus=${focusHandler} @blur=${blurHandler} @keydown=${keydownHandler} @keyup=${keyupHandler} @input=${loadSuggestions} required> - <div class="button icon-swap" title="${t('swap')}" @click=${swapFromTo}></div> - </div> - <div class="suggestions" id="toSuggestions"></div> - - <div class="flex-row"> - <div class="selector"> - <input type="radio" id="departure" name="deparr" ?checked=${!viewState.isArrival}> - <label for="departure">${t('departure')}</label> - <input type="radio" id="arrival" name="deparr" ?checked=${viewState.isArrival}> - <label for="arrival">${t('arrival')}</label> - </div> + setThemeColor(queryBackgroundColor(document, 'body')); - <div class="button now" title="${t('titleSetDateTimeNow')}" @click=${setDateTimeNow}>${t('now')}</div> - <input type="datetime-local" name="datetime" id="datetime" title="${t('date')} & ${t('time')}" value="${viewState.dateTimeValue}" class="${!settings.combineDateTime ? 'hidden' : nothing}" required> - <input type="time" name="time" id="time" title="${t('time')}" value="${viewState.timeValue}" class="${!settings.combineDateTime ? nothing : 'hidden'}" required> - <input type="date" name="date" id="date" title="${t('date')}" value="${viewState.dateValue}" class="${!settings.combineDateTime ? nothing : 'hidden'}" required> - </div> + await this.updateHistoryState(); - <div class="flex-row"> - <div class="selector rectangular"> - ${client.profile.products.map(prod => html` - <input type="checkbox" id="${prod.id}" name="${prod.id}" checked> - <label class="${iconFor(prod.id)}" for="${prod.id}" title="${t('product')}: ${prod.name}"></label> - `)} - </div> + this._unsubscribeSettingsState = settings.subscribe((state) => { + this.settingsState = state; + this.performUpdate(); + }); + } + + disconnectedCallback () { + super.disconnectedCallback(); - <div class="selector rectangular ${settings.profile === 'db' ? 'hidden' : ''}"> - <input type="radio" id="accessibilityNone" name="accessibility" value="none" ?checked=${settings.accessibility === 'none'}> - <label class="icon-walk-fast" for="accessibilityNone" title="${t('accessibility')}: ${t('access_none')}"></label> + if (typeof this._unsubscribeSettingsState === 'function') { + this._unsubscribeSettingsState(); + this._unsubscribeSettingsState = undefined; + } + } - <input type="radio" id="accessibilityPartial" name="accessibility" value="partial" ?checked=${settings.accessibility === 'partial'}> - <label class="icon-walk" for="accessibilityPartial" title="${t('accessibility')}: ${t('access_partial')}"></label> + async updated (previous) { + super.updated(previous); - <input type="radio" id="accessibilityComplete" name="accessibility" value="complete" ?checked=${settings.accessibility === 'complete'}> - <label class="icon-weelchair" for="accessibilityComplete" title="${t('accessibility')}: ${t('access_full')}"></label> - </div> + if (isDevServer) console.info('updated(): ', previous); - <div class="selector rectangular"> - <input type="checkbox" id="bikeFriendly" name="bikeFriendly" ?checked=${settings.bikeFriendly}> - <label class="icon-bike" for="bikeFriendly" title="${t('titleBikeFriendly')}"></label> - </div> + if (previous.has('history') && previous.get('history') === undefined) await this.updateHistoryState() - <div class="selector rectangular"> - <input type="checkbox" id="noTransfers" name="noTransfers" ?checked=${viewState.noTransfers}> - <label class="icon-seat" for="noTransfers" title="${t('titleNoTransfers')}"></label> - </div> + if ( + previous.has('settingsState') && + previous.get('settingsState') !== undefined && + previous.get('settingsState').profile !== this.settingsState.profile + ) await this.profileChangeHandler(); - <div class="filler"></div> + if (isDevServer) console.info('settingsState: ', this.settingsState); + } - <div class="button icon-settings" title="${t('settings')}" @click=${showSettings}></div> - <button type="submit" tabindex="0" id="submit">${t('search')}</button> - </div> + async firstUpdated () { + await sleep(500); + this.renderRoot.querySelector('input[name=from]').focus(); + } + + renderContent () { + return html` + <div class="container"> + <div class="title flex-center"><h1>${APPNAME}</h1></div> + + <form class="center" id="form" @submit=${this.submitHandler}> + ${['from', 'via', 'to'].map(name => html` + <div class="flex-row nowrap ${name === 'via' && !this.settingsState.showVia ? 'hidden' : ''}"> + <input type="text" class="flex-grow" name="${name}" title="${t(name)}" placeholder="${t(name)}" .value=${this.location[name].value} + @focus=${this.focusHandler} + @blur=${this.blurHandler} + @keydown=${this.keydownHandler} + @keyup=${this.keyupHandler} + @input=${this.inputHandler} + autocomplete="off" ?required=${name !== 'via'}> + + ${name !== 'from' ? nothing : html` + <div class="button icon-arrow2 ${this.settingsState.showVia ? 'flipped' : ''}" title="${t('via')}" + @click=${this.settingsState.toggleShowVia}></div> + `} + ${name !== 'via' ? nothing : html`<div class="button invisible"></div>`} + ${name !== 'to' ? nothing : html`<div class="button icon-swap" title="${t('swap')}" @click=${this.swapFromTo}></div>`} + </div> + + <div class="suggestions ${this.location[name].suggestionsVisible ? '' : 'hidden'}"> + ${this.location[name].suggestions.map((suggestion, index) => html` + <p class="${index !== this.location[name].suggestion ? nothing : 'selected'}" + @click=${(event) => this.setSuggestion(name, index, event.pointerType)} + @mouseover=${(event) => this.mouseOverHandler(event, name)} + @mouseout=${(event) => this.mouseOutHandler(event, name)}>${formatPoint(suggestion)}</p> + `)} + </div> + `)} - ${journeysHistory.length ? html` - <div id="historyButton" class="arrowButton icon-arrow2" title="History" @click=${toggleHistory}></div> - ` : nothing} - </form> - - <div id="history" class="history center hidden"> - ${journeysHistory.map(element => html` - <div class="flex-row" @click="${() => {journeysHistoryAction(journeysHistory, element);}}"> - <div class="from"> - <small>${t('from')}:</small> - ${formatName(element.fromPoint)} - ${element.viaPoint ? html` - <div class="via">${t('via')} ${formatName(element.viaPoint)}</div> + <div class="flex-row"> + <div class="selector"> + <input type="radio" id="departure" name="isArrival" value="0" @change=${this.changeHandler} .checked=${!this.isArrival}> + <label for="departure">${t('departure')}</label> + <input type="radio" id="arrival" name="isArrival" value="1" @change=${this.changeHandler} .checked=${this.isArrival}> + <label for="arrival">${t('arrival')}</label> + </div> + + <div class="button now" title="${t('titleSetDateTimeNow')}" @click=${this.resetDate}>${t('now')}</div> + ${!this.settingsState.combineDateTime ? html` + <input type="time" name="time" title="${t('time')}" class="flex-grow" @change=${this.changeHandler} .value=${this.date.formatTime()} required> + <input type="date" name="date" title="${t('date')}" class="flex-grow" @change=${this.changeHandler} .value=${this.date.formatDate()} required> + ` : html` + <input type="datetime-local" name="dateTime" title="${t('date')} & ${t('time')}" class="flex-grow" + @change=${this.changeHandler} .value=${this.date.formatDateTime()} required> + `} + </div> + + <div class="flex-row"> + <div class="selector rectangular"> + ${client.profile.products.map(product => html` + <input type="checkbox" name="${product.id}" id="${product.id}" + @change=${(event) => this.settingsState.toggleProduct(event.target.name)} .checked=${this.settingsState.products[product.id] ?? true}> + <label class="${this.iconForProduct(product.id)}" for="${product.id}" title="${t('product')}: ${product.name}"></label> + `)} + </div> + + <div class="selector rectangular ${settings.profile === 'db' ? 'hidden' : ''}"> + <input type="radio" id="accessibilityNone" name="accessibility" value="none" + @change=${this.changeHandler} .checked=${this.settingsState.accessibility === 'none'}> + <label class="icon-walk-fast" for="accessibilityNone" title="${t('accessibility')}: ${t('access_none')}"></label> + + <input type="radio" id="accessibilityPartial" name="accessibility" value="partial" + @change=${this.changeHandler} .checked=${this.settingsState.accessibility === 'partial'}> + <label class="icon-walk" for="accessibilityPartial" title="${t('accessibility')}: ${t('access_partial')}"></label> + + <input type="radio" id="accessibilityComplete" name="accessibility" value="complete" + @change=${this.changeHandler} .checked=${this.settingsState.accessibility === 'complete'}> + <label class="icon-weelchair" for="accessibilityComplete" title="${t('accessibility')}: ${t('access_full')}"></label> + </div> + + <div class="selector rectangular"> + <input type="checkbox" id="bikeFriendly" name="bikeFriendly" @change=${this.settingsState.toggleBikeFriendly} .checked=${this.settingsState.bikeFriendly}> + <label class="icon-bike" for="bikeFriendly" title="${t('titleBikeFriendly')}"></label> + </div> + + <div class="selector rectangular"> + <input type="checkbox" id="noTransfers" name="noTransfers" @change=${this.changeHandler} .checked=${this.noTransfers}> + <label class="icon-seat" for="noTransfers" title="${t('titleNoTransfers')}"></label> + </div> + + <div class="filler"></div> + + <div class="button icon-settings" title="${t('settings')}" @click=${this.showSettings}></div> + <button type="submit" tabindex="0" id="submit">${t('search')}</button> + </div> + + ${this.history.length !== 0 ? html` + <div id="historyButton" class="arrowButton icon-arrow2 ${!this.showHistory ? '' : 'flipped'}" title="History" @click=${this.toggleHistory}></div> ` : nothing} + </form> + + ${!this.showHistory ? nothing : html` + <div id="history" class="history center"> + ${this.history.map((element, index) => html` + <div class="flex-row" @click="${() => this.journeysHistoryAction(index)}"> + <div class="from"> + <small>${t('from')}:</small> + ${formatPoint(element.fromPoint)} + ${element.viaPoint ? html`<div class="via">${t('via')} ${formatPoint(element.viaPoint)}</div>` : nothing} + </div> + <div class="icon-arrow1"></div> + <div class="to"> + <small>${t('to')}:</small> + ${formatPoint(element.toPoint)} + </div> + </div> + `)} </div> - <div class="icon-arrow1"></div> - <div class="to"> - <small>${t('to')}:</small> - ${formatName(element.toPoint)} - </div> + `} + <footer-component></footer-component> </div> - `)} - </div> - </div> - - ${footerTemplate} -`; - -const journeysHistoryAction = (journeysHistory, element) => { - const options = [ - {'label': t('setfromto'), 'action': () => { setFromHistory(element.key); hideOverlay(); }}, - {'label': t('journeyoverview'), 'action': () => go(`/${element.slug}/${settings.journeysViewMode}`)} - ]; - - if (element.lastSelectedJourneyId !== undefined) - options.push({'label': t('lastSelectedJourney'), 'action': () => go(`/j/${settings.profile}/${element.lastSelectedJourneyId}`)}) - - showSelectModal(options); -}; - -export const searchView = async (clearInputs) => { - const journeysHistory = (await db.getHistory(settings.profile)).slice().reverse(); - - render(searchTemplate(journeysHistory), ElementById('content')); - setThemeColor(); - - if (clearInputs === true) { - viewState.fromValue = ''; - viewState.viaValue = ''; - viewState.toValue = ''; - viewState.suggestions.from = {}; - viewState.suggestions.via = {}; - viewState.suggestions.to = {}; - ElementById('from').value = ''; - ElementById('via').value = ''; - ElementById('to').value = ''; - ElementById('fromSuggestions').textContent = ''; - ElementById('viaSuggestions').textContent = ''; - ElementById('toSuggestions').textContent = ''; + `; } - for (const [product, enabled] of Object.entries(settings.products)) { - const element = ElementById(product); - if (!element) continue; - - element.checked = enabled; + swapFromTo = () => { + this.location.from = [this.location.to, this.location.to = this.location.from][0]; + this.requestUpdate(); } - ElementById('from').focus(); -}; + resetDate = () => { + this.date = new CustomDate(); + this.requestUpdate(); + } -const submitHandler = async (event) => { - event.preventDefault(); + showSettings = () => this.showDialogOverlay('settings', html`<settings-view></settings-view>`); - await modifySettings(settings => { - settings.products = readProductSelection(settings); - settings.accessibility = document.querySelector('input[name="accessibility"]:checked').value; - settings.bikeFriendly = ElementById('bikeFriendly').checked - return settings; - }); + updateHistoryState = async () => this.history = (await db.getHistory(this.settingsState.profile)).slice().reverse(); + toggleHistory = () => this.showHistory = !this.showHistory; + journeysHistoryAction = num => { + const element = this.history[num]; - viewState.fromValue = ElementById('from').value.trim(); - viewState.viaValue = ElementById('via').value.trim(); - viewState.toValue = ElementById('to').value.trim(); - viewState.dateValue = ElementById('date').value.trim(); - viewState.timeValue = ElementById('time').value.trim(); - viewState.dateTimeValue = ElementById('datetime').value.trim(); - viewState.isArrival = ElementById('arrival').checked; - viewState.noTransfers = ElementById('noTransfers').checked; + const options = [ + {'label': t('setfromto'), 'action': () => { this.setFromHistory(element.key); this.hideOverlay(); }}, + {'label': t('journeyoverview'), 'action': () => { window.location = `#/${element.slug}/${this.settingsState.journeysViewMode}`; this.hideOverlay(); }} + ]; + if (element.lastSelectedJourneyId !== undefined) { + options.push({ + 'label': t('lastSelectedJourney'), + 'action': () => { window.location = `#/j/${this.settingsState.profile}/${element.lastSelectedJourneyId}`; this.hideOverlay(); } + }); + } - // check if From or To empty - if (viewState.fromValue === '' || viewState.toValue === '') { - showAlertModal('At least From and To need to be filled!'); - return false; + this.showSelectOverlay(options); } + setFromHistory = async id => { + const entry = await db.getHistoryEntry(id); + if (!entry) return; - // date and time - if (!settings.combineDateTime) { - viewState.dateTimeValue = `${viewState.dateValue}T${viewState.timeValue}`; - } else { - const splitedDateTimeValue = viewState.dateTimeValue.split('T'); - viewState.dateValue = splitedDateTimeValue[0]; - viewState.timeValue = splitedDateTimeValue[1]; - }; + [ 'from', 'via', 'to' ].forEach(mode => { + if ( entry[`${mode}Point`] === null) return false; + if (mode === 'via') this.settingsState.setShowVia(true); - if (viewState.dateValue !== '') { - if (!isValidDate(viewState.dateValue)) { - showAlertModal('Invalid date'); - return false; - } - } else { - viewState.dateValue = `${viewState.currDate.getFullYear()}-${padZeros(viewState.currDate.getMonth()+1)}-${padZeros(viewState.currDate.getDate())}`; + this.location[mode].value = formatPoint(entry[`${mode}Point`]); + this.location[mode].suggestionSelected = entry[`${mode}Point`]; + }); + + this.requestUpdate(); } - if (viewState['timeValue'] !== '') { - if (!new RegExp('([0-1][0-9]|2[0-3]):([0-5][0-9])').test(viewState.timeValue)) { - showAlertModal('Invalid time'); - return false; - } - } else { - viewState['timeValue'] = `${padZeros(viewState.currDate.getHours())}:${padZeros(viewState.currDate.getMinutes())}`; + iconForProduct = id => { + const productIcons = { + // DB + "nationalExpress": "icon-ice", + "national": "icon-ic", + "regionalExpress": "icon-dzug", + + // BVG + "express": "icon-icice", + + // nahsh + "interregional": "icon-dzug", + "onCall": "icon-taxi", + + // SNCB + "intercity-p": "icon-ic", + "s-train": "icon-suburban", + + // RMV + "express-train": "icon-ice", + "long-distance-train": "icon-ic", + "regiona-train": "icon-regional", + "s-bahn": "icon-suburban", + "u-bahn": "icon-subway", + "watercraft": "icon-ferry", + "ast": "icon-taxi", + }; + + return productIcons[id] || `icon-${id}`; } - const splitedDate = viewState.dateValue.split('-'); - const splitedTime = viewState.timeValue.split(':'); - const timestamp = Math.round(new Date(splitedDate[0], splitedDate[1]-1, splitedDate[2], splitedTime[0], splitedTime[1]).getTime()/1000); + profileChangeHandler = async () => { + [ 'from', 'via','to' ].forEach(name => { + this.location[name] = { + value: '', + suggestionsVisible: false, + suggestionSelected: null, + suggestion: null, + suggestions: [], + }; + }); + + await this.updateHistoryState(); + } + submitHandler = async event => { + event.preventDefault(); - // from - let from = ''; + const params = { + from: null, + to: null, + via: null, + bike: this.settingsState.bikeFriendly, + transferTime: this.settingsState.transferTime, + results: 6, + products: {}, + }; + + await Promise.all([ 'from', 'via','to' ].map(async mode => { + if (this.location[mode].value !== '') { + if (mode === 'via' && this.settingsState.showVia === false) return false; + if (this.location[mode].suggestionSelected !== null) { + params[mode] = this.location[mode].suggestionSelected; + } else { + if (this.location[mode].suggestions.length !== 0) { + params[mode] = this.location[mode].suggestions[0] + } else { + const data = await client.locations(this.location[mode].value, {'results': 1}); + if (!data[0]) return false; + params[mode] = data[0]; + } + } + } + })); - if (Object.entries(viewState.suggestions.from).length !== 0) { - from = viewState.suggestions.from; - } else { - const data = await client.locations(viewState.fromValue, {'results': 1}); + if (params.from === null || params.to === null) return false; - if (!data[0]) { - showAlertModal('Invalid From'); + if (formatPoint(params.from) === formatPoint(params.to) && params.via === null) { + this.showAlertOverlay('From and To are the same place.'); return false; - } - - from = data[0]; - } + }; + client.profile.products.forEach(product => { + params.products[product.id] = this.settingsState.products[product.id] ?? true; + }); - // via - let via = ''; + if (this.noTransfers) params.transfers = 0; - if (viewState.viaValue == '') { - via = null; - } else if (Object.entries(viewState.suggestions.via).length !== 0) { - via = viewState.suggestions.via; - } else { - const data = await client.locations(viewState.viaValue, {'results': 1}); + if (!this.isArrival) params.departure = this.date.getTime(); + else params.arrival = this.date.getTime(); - if (!data[0]) { - showAlertModal('Invalid Via'); - return false; + if (this.settingsState.profile !== 'db') { + params.accessibility = this.settingsState.accessibility; + params.walkingSpeed = this.settingsState.walkingSpeed; } - via = data[0]; - } - - - // to - let to = ''; + if (isDevServer) console.info('submitHandler(): ',params); - if (Object.entries(viewState.suggestions.to).length !== 0) { - to = viewState.suggestions.to; - } else { - const data = await client.locations(viewState.toValue, {'results': 1}); + this.showLoaderOverlay(); - if (!data[0]) { - showAlertModal('Invalid To'); - return false; + let responseData; + try { + responseData = await newJourneys(params); + } catch(e) { + this.showAlertOverlay(e.toString()); + console.error(e); } - to = data[0]; - } - - if (formatName(to) === formatName(from) && via === null) { - showAlertModal('From and To are the same place.'); - return false; - }; - + this.hideOverlay(); - const params = { - from, - to, - results: 6, - bike: settings.bikeFriendly, - products: settings.products, - }; + window.location = `#/${responseData.slug}/${this.settingsState.journeysViewMode}`; + } - if (via) params.via = via; + mouseOverHandler (event, name) { this.location[name].suggestionsFocused = true; } + mouseOutHandler (event, name) { this.location[name].suggestionsFocused = false; } - if (viewState.noTransfers) params.transfers = 0; + focusHandler = event => { + const name = event.target.name; - if (!viewState.isArrival) params.departure = timestamp * 1000; - else params.arrival = timestamp * 1000; + this.location[name].suggestionsVisible = true; + this.requestUpdate(); + } - showLoader(); + blurHandler = event => { + const name = event.target.name; - let responseData; - try { - responseData = await newJourneys(params); - } catch(e) { - showAlertModal(e.toString()); - throw e; + if (!this.location[name].suggestionsFocused) { + this.location[name].suggestionsVisible = false; + this.requestUpdate(); + } } - hideOverlay(); + focusNextElement = currentElementId => { + switch (currentElementId) { + case 'from': + this.renderRoot.querySelector('input[name=to]').focus(); - go(`/${responseData.slug}/${settings.journeysViewMode}`); -}; + if (this.settingsState.showVia) this.renderRoot.querySelector('input[name=via]').focus(); + break; -const toggleHistory = () => { - const historyElement = ElementById('history'); - const buttonElement = ElementById('historyButton'); + case 'via': + this.renderRoot.querySelector('input[name=to]').focus(); + break; - if (!elementHidden(historyElement)) { - unflipElement(buttonElement); - hideElement(historyElement); - } else { - flipElement(buttonElement); - showElement(historyElement); + case 'to': + this.renderRoot.querySelector('[type=submit]').focus(); + break; + } } -} -const toggleVia = async mode => { - const rowElement = ElementById('viaRow'); - const buttonElement = ElementById('viaButton'); - - if (mode === 'show' || elementHidden(rowElement)) { - flipElement(buttonElement); - showElement(rowElement); - } else { - unflipElement(buttonElement); - hideElement(rowElement); - ElementById('via').value = ''; + setSuggestion = (name, num, pointerType) => { + this.location[name].value = formatPoint(this.location[name].suggestions[num]); + this.location[name].suggestionSelected = this.location[name].suggestions[num]; + this.location[name].suggestionsVisible = false; + this.requestUpdate(); + if (pointerType !== '') this.focusNextElement(name); } - await modifySettings(settings => { - settings.showVia = !elementHidden(rowElement); - return settings; - }); -} - -const swapFromTo = () => { - viewState.suggestions.from = [viewState.suggestions.to, viewState.suggestions.to = viewState.suggestions.from][0]; - - const from = ElementById('from'); - const to = ElementById('to'); - - from.value = [to.value, to.value = from.value][0]; -}; - -const setFromHistory = async id => { - const entry = (await db.getHistoryEntry(id)); + keyupHandler = event => { + const name = event.target.name; + const value = event.target.value; - if (!entry) return; + if (event.key !== 'Enter') return true; - setSuggestion(entry.fromPoint, 'from'); - - if (entry.viaPoint !== undefined) { - setSuggestion(entry.viaPoint, 'via'); - toggleVia('show'); + if (this.numEnter === 2 && value === formatPoint(this.location[name].suggestionSelected)) { + this.numEnter = 0; + this.focusNextElement(name); + } }; - setSuggestion(entry.toPoint, 'to'); -}; - -const setDateTimeNow = () => { - viewState.currDate = new Date(); - viewState.dateValue = `${viewState.currDate.getFullYear()}-${padZeros(viewState.currDate.getMonth()+1)}-${padZeros(viewState.currDate.getDate())}`; - viewState.timeValue = `${padZeros(viewState.currDate.getHours())}:${padZeros(viewState.currDate.getMinutes())}`; - viewState.dateTimeValue = `${viewState.dateValue}T${viewState.timeValue}`; - ElementById('date').value = viewState.dateValue; - ElementById('time').value = viewState.timeValue; - ElementById('datetime').value = viewState.dateTimeValue; -}; - -const readProductSelection = settings => { - const productsMap = {}; - - for (const prod of client.profile.products) { - productsMap[prod.id] = ElementById(prod.id).checked; - } - - return productsMap; -}; - -const showSuggestions = (id) => { - viewState.numEnter = 0; - showElement(ElementById(`${id}Suggestions`)); -}; - -const hideSuggestions = (id) => { - const suggestionsElement = ElementById(`${id}Suggestions`); - - hideElement(suggestionsElement); - suggestionsElement.classList.remove('mouseover'); -}; - -const suggestionsTemplate = (data, inputId) => data.map((element, index) => html` - <p id="${index == 0 ? inputId+'Selected' : ''}" class="suggestion" - @mouseover=${mouseOverHandler} @mouseout=${mouseOutHandler} @click=${(event) => setSuggestion(encodeURI(JSON.stringify(element)), inputId, event.pointerType)}>${formatName(element)}</p> -`); - -const loadSuggestions = async (event) => { - const elementId = event.target.id; - const elementValue = event.target.value.trim(); - - viewState.suggestions[elementId] = {}; + keydownHandler = event => { + const name = event.target.name; - if (elementValue === '') return; - - let results; - const ds100Result = ds100Reverse(elementValue); - - if (ds100Result !== null) { - results = await client.locations(ds100Result, {'results': 1}); - } else { - results = await client.locations(elementValue, {'results': 10}) - } + if (this.location[name].suggestions.length == 0) return true; + + if (event.key === 'Enter') { + event.preventDefault(); + this.numEnter++; + this.setSuggestion(name, this.location[name].suggestion); + return true; + }; + + if (!this.location[name].suggestionsVisible) { + this.numEnter = 0; + this.location[name].suggestionsVisible = true; + this.requestUpdate(); + return true; + } - render(suggestionsTemplate(results, elementId), ElementById(`${elementId}Suggestions`)); -}; + if (['Escape', 'Tab'].includes(event.key)) { + this.location[name].suggestionsVisible = false; + } -const focusNextElement = (currentElementId) => { - switch (currentElementId) { - case 'from': - ElementById('to').focus(); + if (['ArrowUp', 'ArrowDown'].includes(event.key) && !event.shiftKey) { + event.preventDefault(); - if (!elementHidden(ElementById('via'))) { - ElementById('via').focus(); + const numSuggesttions = this.location[name].suggestions.length-1; + + if (event.key === 'ArrowUp') { + if (this.location[name].suggestion === 0) { + this.location[name].suggestion = numSuggesttions; + } else { + this.location[name].suggestion--; + } + } else { + if (this.location[name].suggestion === numSuggesttions) { + this.location[name].suggestion = 0; + } else { + this.location[name].suggestion++; + } } - break; - - case 'via': - ElementById('to').focus(); - break; - - case 'to': - hideSuggestions(currentElementId); - ElementById('submit').focus(); - break; + } + this.requestUpdate(); } -}; -const setSuggestion = (data, inputId, pointerType) => { - if (typeof data === 'string') { - data = JSON.parse(decodeURI(data)); + inputHandler = async event => { + const name = event.target.name; + const value = event.target.value.trim(); + + if (['from', 'via', 'to'].includes(name)) { + if (value === '') return; + + this.location[name].value = value; + + let suggestions; + const ds100Result = getIBNRbyDS100(value); + + if (ds100Result !== null) { suggestions = await client.locations(ds100Result, {'results': 1}); } + else { suggestions = await client.locations(value, {'results': 10}); } + + this.location[name].suggestionSelected = null; + this.location[name].suggestion = 0; + this.location[name].suggestions = suggestions; + this.requestUpdate(); + }; } - ElementById(inputId).value = formatName(data); - viewState.suggestions[inputId] = data; - - hideSuggestions(inputId); - if (pointerType !== '') focusNextElement(inputId); -}; - -const mouseOverHandler = (event) => event.target.parentElement.classList.add('mouseover'); -const mouseOutHandler = (event) => event.target.parentElement.classList.remove('mouseover'); - -const focusHandler = (event) => showSuggestions(event.target.id); -const blurHandler = (event) => { - if (!ElementById(`${event.target.id}Suggestions`).classList.contains('mouseover')) hideSuggestions(event.target.id); -}; + changeHandler = event => { + const name = event.target.name; + const value = event.target.value; -const keyupHandler = (event) => { - const eventElement = event.target; + if (name === 'accessibility') this.settingsState.setAccessibility(value); + if (name === 'noTransfers') this.noTransfers = !this.noTransfers; + if (name === 'isArrival') this.isArrival = !this.isArrival; + if (name === 'dateTime') this.date.setDateTime(value); + if (name === 'date') this.date.setDate(value); + if (name === 'time') this.date.setTime(value); - if (event.key !== 'Enter') return true; - - if (viewState.numEnter === 2 && eventElement.value === formatName(viewState.suggestions[eventElement.id])) { - viewState.numEnter = 0; - focusNextElement(eventElement.id); + this.requestUpdate(); } -}; - -const keydownHandler = (event) => { - const eventElement = event.target; - const firstSuggestionElement = document.querySelector(`#${eventElement.id}Suggestions>p:first-child`); - const lastSuggestionElement = document.querySelector(`#${eventElement.id}Suggestions>p:last-child`); - const selectedElement = ElementById(`${eventElement.id}Selected`); +} - if (selectedElement === null) return true; +class CustomDate extends Date { + formatDate() { return `${this.getFullYear()}-${padZeros(this.getMonth()+1)}-${padZeros(this.getDate())}`; } + formatTime() { return `${padZeros(this.getHours())}:${padZeros(this.getMinutes())}`; } + formatDateTime() { return `${this.formatDate()}T${this.formatTime()}`; } - if (event.key === 'Enter') { - event.preventDefault(); - selectedElement.click(); - viewState.numEnter++; - return true; - }; - - if (elementHidden(ElementById(`${eventElement.id}Suggestions`))) { - showSuggestions(eventElement.id); - return true; + setDate(value) { + const splitValue = value.split('-'); + if (splitValue.length !== 3) return false; + this.setFullYear(splitValue[0], splitValue[1]-1, splitValue[2]); } - switch (event.key) { - case 'Escape': - case 'Tab': - hideSuggestions(eventElement.id); - break; - - case 'ArrowUp': - case 'ArrowDown': - event.preventDefault(); - - if (event.shiftKey) break; - - let nextElement = selectedElement.nextElementSibling ?? firstSuggestionElement; - if (event.key === 'ArrowUp') nextElement = selectedElement.previousElementSibling ?? lastSuggestionElement; + setTime(value) { + const splitValue = value.split(':'); + if (splitValue.length !== 2) return false; + this.setHours(splitValue[0], splitValue[1]); + } - selectedElement.id = ''; - nextElement.id = `${eventElement.id}Selected`; - break; + setDateTime(value) { + const splitValue = value.split('T'); + if (splitValue.length !== 2) return false; + this.setDate(splitValue[0]); + this.setTime(splitValue[1]); } -}; +} + +customElements.define('search-view', SearchView);
diff --git a/src/settings.js b/src/settings.js @@ -1,47 +1,59 @@ -import { db } from './dataStorage.js'; -import { getDefaultLanguage } from './languages.js'; +import { createStore } from 'zustand/vanilla'; +import { persist, createJSONStorage } from 'zustand/middleware' -const subscribers = []; -const defaultSettings = { - language: getDefaultLanguage(), - profile: "db", - products: { - 'nationalExpress': true, - 'national': true, - 'regionalExpress': true, - 'regional': true, - 'suburban': true, - 'bus': true, - 'ferry': true, - 'subway': true, - 'tram': true, - 'taxi': true - }, - accessibility: 'none', - walkingSpeed: 'normal', - bikeFriendly: false, - loyaltyCard: 'NONE', - ageGroup: 'E', - journeysViewMode: 'canvas', - combineDateTime: false, - showPrices: true, - showDS100: true, - showVia: false, -}; +import { db, initDataStorage } from './dataStorage.js'; +import { getDefaultLanguage } from './languages.js'; +import { getDefaultProfile } from './hafasClient.js'; -export let settings; +export let settingsState; +export const settings = createStore()( + persist( + (set, get) => ({ + language: getDefaultLanguage(), + profile: getDefaultProfile(), + products: {}, + accessibility: 'none', + walkingSpeed: 'normal', + transferTime: 0, + loyaltyCard: 'NONE', + ageGroup: 'E', + journeysViewMode: 'canvas', + bikeFriendly: false, + combineDateTime: false, + showVia: false, + showPrices: true, + showDS100: true, + disableCanvasBlur: false, -export const subscribeSettings = cb => subscribers.push(cb); + setJourneysViewMode: (journeysViewMode) => set({ journeysViewMode }), + setLanguage: (language) => set({ language }), + setProfile: (profile) => set({ profile }), + setTransferTime: (transferTime) => set({ transferTime }), + setAgeGroup: (ageGroup) => set({ ageGroup }), + setLoyaltyCard: (loyaltyCard) => set({ loyaltyCard }), + setAccessibility: (accessibility) => set({ accessibility }), + setShowVia: (showVia) => set({ showVia }), -export const initSettings = async () => { - settings = (await db.getSettings()) || defaultSettings; + toggleCombineDateTime: () => set((state) => ({ combineDateTime: !state.combineDateTime })), + toggleShowPrices: () => set((state) => ({ showPrices: !state.showPrices })), + toggleShowDS100: () => set((state) => ({ showDS100: !state.showDS100 })), + toggleShowVia: () => set((state) => ({ showVia: !state.showVia })), + toggleBikeFriendly: () => set((state) => ({ bikeFriendly: !state.bikeFriendly })), + toggleProduct: (key) => set((state) => { + state.products[key] = !state.products[key]; + return { products: state.products }; + }) + }), + { + name: 'settings' + } + ) +) - for (const cb of subscribers) await cb(); -}; +export const initSettingsState = async () => { + settingsState = settings.getState(); -export const modifySettings = async callback => { - const newSettings = await db.modifySettings(JSON.parse(JSON.stringify(settings)), callback); - Object.freeze(newSettings); - settings = newSettings; - for (const cb of subscribers) await cb(); + settings.subscribe((state) => { + settingsState = state; + }); };
diff --git a/src/settingsView.js b/src/settingsView.js @@ -1,133 +1,144 @@ -import { html, render } from 'lit-html'; -import { db, clearDataStorage } from './dataStorage.js'; -import { modifySettings, settings } from './settings.js'; -import { showModal, hideOverlay } from './overlays.js'; -import { ElementById, hideElement, showElement } from './helpers.js'; -import { t, getLanguages } from './languages.js'; -import { searchView } from './searchView.js'; -import { initHafasClient } from './hafasClient.js'; - -export const showSettings = async () => { - showModal(t('settings'), settingsTemplate()); - profileChangeHandler(settings.profile); -}; - -const settingsTemplate = () => html` - <div class="settingsView"> - <div class="flex-row"> - <label for="language">${t('language')}:</label> - <select id="language"> - ${getLanguages().map(lang => html` - <option value="${lang}" ?selected=${settings.language === lang}>${t(lang)}</option> - `)} - </select> - </div> - - <div class="flex-row"> - <label for="profile">${t('datasource')}:</label> - <select id="profile" @change="${(event) => {profileChangeHandler(event.target.value)}}"> - <option value="db" ?selected=${settings.profile === 'db'}>DB</option> - <option value="nahsh" ?selected=${settings.profile === 'nahsh'}>NAH.SH</option> - <option value="rmv" ?selected=${settings.profile === 'rmv'}>RMV</option> - <option value="bvg" ?selected=${settings.profile === 'bvg'}>BVG</option> - <option value="oebb" ?selected=${settings.profile === 'oebb'}>ÖBB</option> - </select> - </div> - - <div class="flex-row" id="walkingSpeedElement"> - <label for="walkingSpeed">${t('walkingSpeed')}:</label> - <select id="walkingSpeed"> - <option value="slow" ?selected=${settings.walkingSpeed === 'slow'}>${t('walkingSpeedSlow')}</option> - <option value="normal" ?selected=${settings.walkingSpeed === 'normal'}>${t('walkingSpeedNormal')}</option> - <option value="fast" ?selected=${settings.walkingSpeed === 'fast'}>${t('walkingSpeedFast')}</option> - </select> - </div> - - <div class="flex-row" id="ageGroupElement"> - <label for="ageGroup">${t('ageGroup')}:</label> - <select id="ageGroup"> - <option value="K" ?selected=${settings.ageGroup === 'K'}>${t('ageGroupChild')} (7-14)</option> - <option value="Y" ?selected=${settings.ageGroup === 'Y'}>${t('ageGroupYoung')} (15-26)</option> - <option value="E" ?selected=${settings.ageGroup === 'E'}>${t('ageGroupAdult')} (27-64)</option> - <option value="S" ?selected=${settings.ageGroup === 'S'}>${t('ageGroupSenior')} (65+)</option> - </select> - </div> - - <div class="flex-row" id="loyaltyCardElement"> - <label for="loyaltyCard">${t('loyaltyCard')}:</label> - <select id="loyaltyCard"> - <option value="NONE" ?selected=${settings.loyaltyCard === 'NONE'}>${t('loyaltyCardNone')}</option> - <option value="BAHNCARD-25-2" ?selected=${settings.loyaltyCard === 'BAHNCARD-25-2'}>BahnCard 25, 2. ${t("class")}</option> - <option value="BAHNCARD-25-1" ?selected=${settings.loyaltyCard === 'BAHNCARD-25-1'}>BahnCard 25, 1. ${t("class")}</option> - <option value="BAHNCARD-50-2" ?selected=${settings.loyaltyCard === 'BAHNCARD-50-2'}>BahnCard 50, 2. ${t("class")}</option> - <option value="BAHNCARD-50-1" ?selected=${settings.loyaltyCard === 'BAHNCARD-50-1'}>BahnCard 50, 1. ${t("class")}</option> - <option value="BAHNCARD-100-2" ?selected=${settings.loyaltyCard === 'BAHNCARD-100-2'}>BahnCard 100, 2. ${t("class")}</option> - <option value="BAHNCARD-100-1" ?selected=${settings.loyaltyCard === 'BAHNCARD-100-1'}>BahnCard 100, 1. ${t("class")}</option> - </select> - </div> - - <div class="flex-column"> - <span>${t('options')}:</span> - <label id="showPricesElement"><input type="checkbox" id="showPrices" ?checked=${settings.showPrices}> ${t('show-prices')}<br></label> - <label id="showDS100Element"><input type="checkbox" id="showDS100" ?checked=${settings.showDS100}> ${t('showds100')}<br></label> - <label><input type="checkbox" id="combineDateTime" ?checked=${settings.combineDateTime}> ${t('combineDateTime')}</label> - </div> - - <div class="flex-row"> - <button class="color" id="clear" @click=${clearStorage}>${t('clearstorage')}</button> - <button class="color" id="save" @click=${saveSettings}>${t('save')}</button> - </div> - </div> -`; - -const profileChangeHandler = (profile) => { - switch (profile) { - case 'db': - showElement(ElementById('showPricesElement')); - showElement(ElementById('loyaltyCardElement')); - showElement(ElementById('ageGroupElement')); - hideElement(ElementById('walkingSpeedElement')); - break; - - default: - showElement(ElementById('walkingSpeedElement')); - hideElement(ElementById('showPricesElement')); - hideElement(ElementById('loyaltyCardElement')); - hideElement(ElementById('ageGroupElement')); - break; +import { LitElement, html, nothing } from 'lit'; + +import { sleep, queryBackgroundColor, setThemeColor } from './helpers.js'; +import { getLanguages, t } from './languages.js'; +import { settings } from './settings.js'; +import { initHafasClient, profiles } from './hafasClient.js'; +import { clearDataStorage } from './dataStorage.js'; + +import { baseStyles, helperStyles, flexboxStyles, buttonInputStyles, settingsViewStyles } from './styles.js'; + +class SettingsView extends LitElement { + static styles = [ + baseStyles, + helperStyles, + flexboxStyles, + buttonInputStyles, + settingsViewStyles + ]; + + static properties = { + viewState: { state: true }, }; -}; -const clearStorage = async () => { - clearDataStorage(); - - caches.keys().then(names => { - for (let name of names) caches.delete(name); - }); - - location.reload(); -}; - -const saveSettings = async () => { - const profile = ElementById('profile').value; - let clearInputs = false; - - if (profile !== settings.profile) clearInputs = true; - - await modifySettings(settings => { - settings.combineDateTime = ElementById('combineDateTime').checked; - settings.showDS100 = ElementById('showDS100').checked; - settings.showPrices = ElementById('showPrices').checked; - settings.language = ElementById('language').value; - settings.profile = profile; - settings.loyaltyCard = ElementById('loyaltyCard').value; - settings.walkingSpeed = ElementById('walkingSpeed').value; - settings.ageGroup = ElementById('ageGroup').value; + constructor (...args) { + super(...args); + this.viewState = settings.getState(); + } + + connectedCallback () { + super.connectedCallback(); + + this._unsubscribeViewState = settings.subscribe((state, prev) => { + this.viewState = state; + this.performUpdate(); + }); + } + + disconnectedCallback () { + super.disconnectedCallback(); + + if (typeof this._unsubscribeViewState === 'function') { + this._unsubscribeViewState(); + this._unsubscribeViewState = undefined; + } + } + + + render () { + return html` + <div class="flex-row"> + <label for="language">${t('language')}:</label> + <select id="language" @change=${this.changeHandler}> + ${getLanguages().map(lang => html`<option value="${lang}" ?selected=${this.viewState.language === lang}>${t(lang)}</option>`)} + </select> + </div> + + <div class="flex-row"> + <label for="profile">${t('datasource')}:</label> + <select id="profile" @change=${this.changeHandler}> + ${Object.keys(profiles).map(profile => html`<option value="${profile}" ?selected=${this.viewState.profile === profile}>${profiles[profile].name}</option>`)} + </select> + </div> + + <div class="flex-row ${this.viewState.profile !== 'db' ? '' : 'hidden'}"> + <label for="walkingSpeed">${t('walkingSpeed')}:</label> + <select id="walkingSpeed" @change=${this.changeHandler}> + <option value="slow" ?selected=${this.viewState.walkingSpeed === 'slow'}>${t('walkingSpeedSlow')}</option> + <option value="normal" ?selected=${this.viewState.walkingSpeed === 'normal'}>${t('walkingSpeedNormal')}</option> + <option value="fast" ?selected=${this.viewState.walkingSpeed === 'fast'}>${t('walkingSpeedFast')}</option> + </select> + </div> + + <div class="flex-row ${this.viewState.profile !== 'db' ? 'hidden' : ''}"> + <label for="ageGroup">${t('ageGroup')}:</label> + <select id="ageGroup" @change=${this.changeHandler}> + <option value="K" ?selected=${this.viewState.ageGroup === 'K'}>${t('ageGroupChild')} (7-14)</option> + <option value="Y" ?selected=${this.viewState.ageGroup === 'Y'}>${t('ageGroupYoung')} (15-26)</option> + <option value="E" ?selected=${this.viewState.ageGroup === 'E'}>${t('ageGroupAdult')} (27-64)</option> + <option value="S" ?selected=${this.viewState.ageGroup === 'S'}>${t('ageGroupSenior')} (65+)</option> + </select> + </div> + + <div class="flex-row ${this.viewState.profile !== 'db' ? 'hidden' : ''}"> + <label for="loyaltyCard">${t('loyaltyCard')}:</label> + <select id="loyaltyCard" @change=${this.changeHandler}> + <option value="NONE" ?selected=${this.viewState.loyaltyCard === 'NONE'}>${t('loyaltyCardNone')}</option> + <option value="BAHNCARD-25-2" ?selected=${this.viewState.loyaltyCard === 'BAHNCARD-25-2'}>BahnCard 25, 2. ${t("class")}</option> + <option value="BAHNCARD-25-1" ?selected=${this.viewState.loyaltyCard === 'BAHNCARD-25-1'}>BahnCard 25, 1. ${t("class")}</option> + <option value="BAHNCARD-50-2" ?selected=${this.viewState.loyaltyCard === 'BAHNCARD-50-2'}>BahnCard 50, 2. ${t("class")}</option> + <option value="BAHNCARD-50-1" ?selected=${this.viewState.loyaltyCard === 'BAHNCARD-50-1'}>BahnCard 50, 1. ${t("class")}</option> + <option value="BAHNCARD-100-2" ?selected=${this.viewState.loyaltyCard === 'BAHNCARD-100-2'}>BahnCard 100, 2. ${t("class")}</option> + <option value="BAHNCARD-100-1" ?selected=${this.viewState.loyaltyCard === 'BAHNCARD-100-1'}>BahnCard 100, 1. ${t("class")}</option> + </select> + </div> + + <div class="flex-row"> + <label for="transferTime">${t('minTransferTime')}:</label> + <input type="number" id="transferTime" min="0" max="99" @change=${this.changeHandler} .value="${this.viewState.transferTime}"> + </div> + + <div class="flex-column"> + <span>${t('options')}:</span><br> + <label><input type="checkbox" id="showDS100" @change=${this.changeHandler} ?checked=${this.viewState.showDS100}> ${t('showds100')}<br></label> + <label><input type="checkbox" id="combineDateTime" @change=${this.changeHandler} ?checked=${this.viewState.combineDateTime}> ${t('combineDateTime')}<br></label> + <label class="${this.viewState.profile !== 'db' ? 'hidden' : nothing}"> + <input type="checkbox" id="showPrices" @change=${this.changeHandler} ?checked=${this.viewState.showPrices}> ${t('show-prices')} + </label> + </div> + + <div class="flex-row"> + <div class="button color icon-trashcan" title="${t('clearstorage')}" @click=${this.clearStorage}></div> + </div> + `; + } + + changeHandler = async event => { + const id = event.target.id; + const value = event.target.value; + + if (id === 'language') this.viewState.setLanguage(value); + if (id === 'transferTime') this.viewState.setTransferTime(parseInt(value)); + if (id === 'ageGroup') this.viewState.setAgeGroup(value); + if (id === 'loyaltyCard') this.viewState.setLoyaltyCard(value); + if (id === 'showDS100') this.viewState.toggleShowDS100(); + if (id === 'showPrices') this.viewState.toggleShowPrices(); + if (id === 'combineDateTime') this.viewState.toggleCombineDateTime(); + if (id === 'profile') { + this.viewState.setProfile(value); + await initHafasClient(this.viewState.profile); + } + } + + clearStorage = () => { + clearDataStorage(); + localStorage.clear(); + + caches.keys().then(names => names.forEach(name => caches.delete(name))); + + location.reload(); + }; - return settings; - }); +} - await initHafasClient(settings.profile); - searchView(clearInputs); - hideOverlay(); -}; +customElements.define('settings-view', SettingsView);
diff --git a/src/shim/cross-fetch.js b/src/shim/cross-fetch.js @@ -5,6 +5,7 @@ export const fetch = (resource, options) => { resource = new Request(resource); } + if (isDevServer) console.log('fetch(): ', resource, options); const replacement = { 'https://app.vendo.noncd.db.de/mob/location/search': '/db/vendo/locations',
diff --git a/src/styles.js b/src/styles.js @@ -0,0 +1,17 @@ +import baseStyles from './styles/base.css' assert { type: 'css' }; +import helperStyles from './styles/helpers.css' assert { type: 'css' }; +import flexboxStyles from './styles/flexbox.css' assert { type: 'css' }; +import buttonInputStyles from './styles/buttonInput.css' assert { type: 'css' }; +import headerStyles from './styles/header.css' assert { type: 'css' }; +import cardStyles from './styles/card.css' assert { type: 'css' }; +import iconStyles from './styles/icons.css' assert { type: 'css' }; +import overlaysStyles from './styles/overlays.css' assert { type: 'css' }; +import searchViewStyles from './styles/searchView.css' assert { type: 'css' }; +import journeysViewStyles from './styles/journeysView.css' assert { type: 'css' }; +import journeyViewStyles from './styles/journeyView.css' assert { type: 'css' }; +import departuresViewStyles from './styles/departuresView.css' assert { type: 'css' }; +import settingsViewStyles from './styles/settingsView.css' assert { type: 'css' }; +import footerStyles from './styles/footer.css' assert { type: 'css' }; + +export { baseStyles, helperStyles, flexboxStyles, buttonInputStyles, headerStyles, cardStyles, iconStyles, overlaysStyles, footerStyles }; +export { searchViewStyles, journeysViewStyles, journeyViewStyles, departuresViewStyles, settingsViewStyles };
diff --git a/src/styles/base.css b/src/styles/base.css @@ -0,0 +1,47 @@ +font-face { + font-weight: normal; + font-tyle: normal; +} + +:root { + overscroll-behavior-y: none; +} + +body { + margin: 0; + background-color: #333; +} + +* { + font-family: sans-serif; + box-sizing: border-box; + border-collapse: collapse; +} + +a { + color: inherit; +} + +.container { + margin: 0 auto; + max-width: 1000px; +} + +.spinner { + margin: calc(50vh - 120px) auto; + border: 5px solid rgba(255, 255, 255, .4); + border-top: 5px solid white; + border-radius: 50%; + width: 120px; + height: 120px; + animation: spin 2s linear infinite; +} + +.spinning { + animation: spin 2s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +}
diff --git a/src/styles/buttonInput.css b/src/styles/buttonInput.css @@ -0,0 +1,151 @@ +input, +button, +.button, +.selector { + margin: 4px; +} + +input[type="datetime-local"], +input[type="date"], +input[type="time"], +input[type="text"] { + cursor: pointer; + box-sizing: border-box; + padding: .3em .5em; + font-size: 1.5em; + padding: 7px; + border: none; + outline: none; + background-color: white; + color: black; + border-radius: 0; +} + +input[type="checkbox"] { + transform: scale(1.5); +} + +button, +.button, +.selector label { + background-color: white; + color: black; + cursor: pointer; + user-select: none; +} + +button, .button { + width: max-content; + padding: 8px 20px; + transition: background-color 300ms; + border: none; +} + +button:hover, .button:hover { + background-color: #d3d3d3; +} + +button.color, .button.color { + background-color: #43a047; + color: white; +} + +button.color:hover, .button.color:hover { + background-color: #388e3c; +} + +.arrowButton { + cursor: pointer; + user-select: none; + height: 72px; + width: 72px; + margin: 0 auto; + transition: transform 150ms; + filter: invert(); +} + +.selector { + display: flex; + + input { + display: none; + } + + input + label { + background: #d3d3d3; + } + + input:checked + label { + background: white + } + + label { + display: flex; + justify-content: center; + align-items: center; + user-select: none; + padding: 0 10px; + } + + label:after { + font-size: .9rem; + color: black; + text-align: center; + line-height: .9rem; + margin-top: 2px; + } + + div:not(:last-child), + label:not(:last-child) { + border-right: 1px solid #bbb; + } + + .icon-ice, + .icon-ic, + .icon-icice, + .icon-dzug, + .icon-regional { + font-style: italic; + } + + .icon-tram:after, + .con-bus:after, + .icon-ferry:after, + .icon-taxi:after { + font-size: 0.6rem; + } + + .icon-ice:after { content: 'ICE'; } + .icon-ic:after { content: 'IC'; } + .icon-icice:after { content: 'IC ICE'; } + .icon-dzug:after { content: 'D'; } + .icon-regional:after { content: 'NV'; } + .icon-suburban:after { content: 'S'; } + .icon-subway:after { content: 'U'; } + .icon-tram:after { content: 'Tram'; } + .icon-bus:after { content: 'Bus'; } + .icon-ferry:after { content: 'Ferry'; } + .icon-taxi:after { content: 'Taxi'; } +} + +.selector.rectangular label { + height: 32px; + width: 32px; + padding: 3px; + font-weight: bold; + overflow: hidden; +} + +@media (max-width: 650px) { + button[type="submit"]{ + flex-basis: 100%; + justify-content: center; + } +} + +@media (max-width: 799px) { + .arrowButton { + width: 48px; + height: 48px; + } +}
diff --git a/src/styles/card.css b/src/styles/card.css @@ -0,0 +1,81 @@ +.card { + overflow-x: auto; + + table { + border-bottom: 1px solid rgba(0, 0, 0, 0.3); + width: 100%; + background-color: white; + min-width: 390px; + max-width: 1000px; + } + + table a { + padding: 5px 3px; + text-decoration: none; + } + + thead { + .center { + padding: 5px 3px; + } + + tr:not(:last-child) { + background-color: #eee; + } + } + + tbody { + tr { + cursor: pointer; + border-top: 1px solid #ccc; + } + + tr:hover { + background-color: #ddd; + } + + tr:hover td { + background-color: transparent; + } + } + + td, th { + text-align: center; + overflow: hidden; + } + + th { + padding: 10px 5px; + } + + th.station { + width: 60%; + } + + .train-details { + flex-wrap: wrap; + padding: 0 !important; + + div { + margin: .4em 2em; + } + } + + .cancelled { + text-decoration-line: line-through; + } + + .cancelled-text { + font-weight: bold; + color: red !important; + } +} + +@media (min-width: 800px) { + table { + overflow: hidden; + border: none; + margin: 50px auto; + width: 80vw; + } +}
diff --git a/src/styles/departuresView.css b/src/styles/departuresView.css @@ -0,0 +1,7 @@ +tbody td:nth-child(2) { + text-align: unset; +} + +tbody td { + padding: 5px 3px; +}
diff --git a/src/styles/flexbox.css b/src/styles/flexbox.css @@ -0,0 +1,29 @@ +.flex-row { + display: flex; + flex-direction: row; +} + +.flex-column { + display: flex; + flex-direction: column; +} + +.flex-center { + display: flex; + justify-content: center; + align-items: center; +} + +.flex-grow { + flex-grow: 1; +} + +@media (max-width: 799px) { + .flex-row { + flex-wrap: wrap; + } + + .flex-row.nowrap { + flex-wrap: unset; + } +}
diff --git a/src/styles/footer.css b/src/styles/footer.css @@ -0,0 +1,18 @@ +footer { + color: #ddd; + padding: 2em; + width: max-content; + + a { + text-decoration: none; + } + + a:after { + margin: 0 8px; + content: "•"; + } + + :last-child:after { + content: none; + } +}
diff --git a/src/styles/header.css b/src/styles/header.css @@ -0,0 +1,86 @@ +.header-container { + position: sticky; + top: 0; + z-index: 10; + + header { + display: flex; + flex-direction: row; + justify-content: center; + color: white; + background-color: #33691E; + border-bottom: 1px solid rgba(255, 255, 255, .3); + + .container { + max-width: 1000px; + width: 80vw; + margin: 0; + } + + h3 { + margin-right: 1.5em; + } + + .icon-reload { + float: right; + } + + .icon-back, + .icon-reload, + .icon-share, + .icon-dots { + cursor: pointer; + width: 32px; + height: 32px; + margin: 12px; + user-select: none; + } + + .mode-changers { + margin-top: auto; + margin-left: auto; + height: max-content; + + a { + border-bottom: 3px solid transparent; + align-items: center; + display: flex; + padding: 0 1em; + cursor: pointer; + text-decoration: none; + width: max-content; + + span { + font-weight: bold; + margin: 1em .4em; + } + } + + a.active { + border-bottom: 3px solid white; + } + } + } +} + +@media (max-width: 650px) { + header { + padding-top: 10px; + + .mode-changers { + margin: auto; + } + + h3 { + margin: 8px 0; + } + } +} + +@media (max-width: 799px) { + header { + .icon-back { + left: 10px; + } + } +}
diff --git a/src/styles/helpers.css b/src/styles/helpers.css @@ -0,0 +1,16 @@ +.invisible { + visibility: hidden !important; +} + +.hidden { + display: none !important; +} + +.flipped { + transform-origin: center center; + transform: rotate(180deg); +} + +.center { + margin: auto; +}
diff --git a/src/styles/icons.css b/src/styles/icons.css @@ -0,0 +1,95 @@ +.icon-back { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11z" fill="white"/></svg>'); +} + +.icon-reload { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M17.65 6.35A7.96 7.96 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4z" fill="white"/></svg>'); +} + +.icon-close { + content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="30" height="30"><path d="M5.293 5.293a1 1 0 0 1 1.414 0L12 10.586l5.293-5.293a1 1 0 1 1 1.414 1.414L13.414 12l5.293 5.293a1 1 0 0 1-1.414 1.414L12 13.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L10.586 12 5.293 6.707a1 1 0 0 1 0-1.414" fill="white"/></svg>'); +} + +.icon-hint { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M5 3h14a2 2 0 0 1 2 2v14c0 .53-.21 1.04-.59 1.41-.37.38-.88.59-1.41.59H5c-.53 0-1.04-.21-1.41-.59C3.21 20.04 3 19.53 3 19V5c0-1.11.89-2 2-2m6 6h2V7h-2zm3 8v-2h-1v-4h-3v2h1v2h-1v2z"/></svg>'); +} + +.icon-status { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m8 10V7h-2v6zm0 4v-2h-2v2z"/></svg>'); +} + +.icon-warning { + content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M13 13h-2V7h2m-2 8h2v2h-2m4.73-14H8.27L3 8.27v7.46L8.27 21h7.46L21 15.73V8.27z"/></svg>'); +} + +.icon-other { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m1 17h-2v-2h2zm2.07-7.75-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25"/></svg>'); +} + +.icon-arrow1 { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>'); +} + +.icon-arrow2 { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"/></svg>'); +} + +.icon-swap { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M16 17.01V10h-2v7.01h-3L15 21l4-3.99zM9 3 5 6.99h3V14h2V6.99h3z"/></svg>'); +} + +.icon-clock { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M12 20a8 8 0 0 0 8-8 8 8 0 0 0-8-8 8 8 0 0 0-8 8 8 8 0 0 0 8 8m0-18a10 10 0 0 1 10 10 10 10 0 0 1-10 10C6.47 22 2 17.5 2 12A10 10 0 0 1 12 2m.5 5v5.25l4.5 2.67-.75 1.23L11 13V7z"/></svg>'); +} + +.icon-settings { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6"/></svg>'); +} + +.icon-walk-fast { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M13.49 5.48c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m-3.6 13.9 1-4.4 2.1 2v6h2v-7.5l-2.1-2 .6-3c1.3 1.5 3.3 2.5 5.5 2.5v-2c-1.9 0-3.5-1-4.3-2.4l-1-1.6c-.4-.6-1-1-1.7-1-.3 0-.5.1-.8.1l-5.2 2.2v4.7h2v-3.4l1.8-.7-1.6 8.1-4.9-1-.4 2z"/></svg>'); +} + +.icon-walk { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M13.5 5.5c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2M9.8 8.9 7 23h2.1l1.8-8 2.1 2v6h2v-7.5l-2.1-2 .6-3C14.8 12 16.8 13 19 13v-2c-1.9 0-3.5-1-4.3-2.4l-1-1.6c-.4-.6-1-1-1.7-1-.3 0-.5.1-.8.1L6 8.3V13h2V9.6z"/></svg>'); +} + +.icon-weelchair { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><circle cx="12" cy="4" r="2"/><path d="M19 13v-2c-1.54.02-3.09-.75-4.07-1.83l-1.29-1.43c-.17-.19-.38-.34-.61-.45-.01 0-.01-.01-.02-.01H13c-.35-.2-.75-.3-1.19-.26C10.76 7.11 10 8.04 10 9.09V15c0 1.1.9 2 2 2h5v5h2v-5.5c0-1.1-.9-2-2-2h-3v-3.45c1.29 1.07 3.25 1.94 5 1.95m-6.17 5c-.41 1.16-1.52 2-2.83 2-1.66 0-3-1.34-3-3 0-1.31.84-2.41 2-2.83V12.1a5 5 0 1 0 5.9 5.9z"/></svg>'); +} + +.icon-bike { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M5 20.5A3.5 3.5 0 0 1 1.5 17 3.5 3.5 0 0 1 5 13.5 3.5 3.5 0 0 1 8.5 17 3.5 3.5 0 0 1 5 20.5M5 12a5 5 0 0 0-5 5 5 5 0 0 0 5 5 5 5 0 0 0 5-5 5 5 0 0 0-5-5m9.8-2H19V8.2h-3.2l-1.94-3.27c-.29-.5-.86-.83-1.46-.83-.47 0-.9.19-1.2.5L7.5 8.29C7.19 8.6 7 9 7 9.5c0 .63.33 1.16.85 1.47L11.2 13v5H13v-6.5l-2.25-1.65 2.32-2.35m5.93 13a3.5 3.5 0 0 1-3.5-3.5 3.5 3.5 0 0 1 3.5-3.5 3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m0-8.5a5 5 0 0 0-5 5 5 5 0 0 0 5 5 5 5 0 0 0 5-5 5 5 0 0 0-5-5m-3-7.2c1 0 1.8-.8 1.8-1.8S17 1.2 16 1.2 14.2 2 14.2 3 15 4.8 16 4.8"/></svg>'); +} + +.icon-seat { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M9 19h6v2H9c-2.76 0-5-2.24-5-5V7h2v9c0 1.66 1.34 3 3 3m1.42-13.59c.78-.78.78-2.05 0-2.83s-2.05-.78-2.83 0-.78 2.05 0 2.83c.78.79 2.04.79 2.83 0M11.5 9c0-1.1-.9-2-2-2H9c-1.1 0-2 .9-2 2v6c0 1.66 1.34 3 3 3h5.07l3.5 3.5L20 20.07 14.93 15H11.5z"/></svg>'); +} + +.icon-table { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M3 9h14V7H3zm0 4h14v-2H3zm0 4h14v-2H3zm16 0h2v-2h-2zm0-10v2h2V7zm0 6h2v-2h-2z" fill="white" /></svg>'); +} + +.icon-canvas { + content: url('data:image/svg+xml;utf8,<svg version="1.1" viewBox="0 0 24 24" height="24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m11 5v14h2v-14zm-4-2v14h2v-14zm10 4h-2v14h2z" fill="white"/></svg>'); +} + +.icon-dots { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M12 16a2 2 0 0 1 2 2 2 2 0 0 1-2 2 2 2 0 0 1-2-2 2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2 2 2 0 0 1-2 2 2 2 0 0 1-2-2 2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2 2 2 0 0 1-2 2 2 2 0 0 1-2-2 2 2 0 0 1 2-2" fill="white"/></svg>'); +} + +.icon-ticket { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M13 8.5h-2v-2h2zm0 4.5h-2v-2h2zm0 4.5h-2v-2h2zm9-7.5V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v4a2 2 0 0 1 2 2 2 2 0 0 1-2 2v4a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-4a2 2 0 0 1-2-2 2 2 0 0 1 2-2"/></svg>'); +} + +.icon-plus { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z"/></svg>'); +} + +.icon-minus { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M19 13H5v-2h14z"/></svg>'); +} + +.icon-trashcan { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M9 3v1H4v2h1v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6h1V4h-5V3zM7 6h10v13H7zm2 2v9h2V8zm4 0v9h2V8z" fill="white"/></svg>'); +}
diff --git a/src/styles/journeyView.css b/src/styles/journeyView.css @@ -0,0 +1,44 @@ +tbody:not(:last-child) { + border-bottom: 1px solid rgba(0, 0, 0, .2); +} + +thead>tr:nth-child(2) { + border-bottom: 2px solid #ccc; +} + +p { + color: white; + width: 100%; +} + +p::before { + filter: drop-shadow( 0 0 5px rgba(0, 0, 0, .6) ); + margin-right: 4px; + vertical-align: sub; +} + +p.change, +p.walk, +p.transfer { + text-shadow: 0 0 15px rgba(0, 0, 0, .6); + text-align: center; +} + +p.change::before { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M9 3 5 6.99h3V14h2V6.99h3zm7 14.01V10h-2v7.01h-3L15 21l4-3.99z" fill="white"/></svg>'); +} + +p.walk::before { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M13.5 5.5c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2M9.8 8.9 7 23h2.1l1.8-8 2.1 2v6h2v-7.5l-2.1-2 .6-3C14.8 12 16.8 13 19 13v-2c-1.9 0-3.5-1-4.3-2.4l-1-1.6c-.4-.6-1-1-1.7-1-.3 0-.5.1-.8.1L6 8.3V13h2V9.6z" fill="white"/></svg>'); +} + +p.transfer::before { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="m21.71 11.29-9-9a.996.996 0 0 0-1.41 0l-9 9a.996.996 0 0 0 0 1.41l9 9c.39.39 1.02.39 1.41 0l9-9a.996.996 0 0 0 0-1.41M14 14.5V12h-4v3H8v-4c0-.55.45-1 1-1h5V7.5l3.5 3.5z" fill="white"/></svg>'); +} + +.link { + vertical-align: bottom; + cursor: pointer; + max-inline-size: 26px; + margin: 0 .3em; +}+ \ No newline at end of file
diff --git a/src/styles/journeysView.css b/src/styles/journeysView.css @@ -0,0 +1,15 @@ +@media (max-width: 799px) { + .arrowButton { + margin: 15px auto; + } +} + +@media (min-width: 800px) { + .arrowButton.flipped { + margin-top: 45px; + } + + table { + margin: 15px auto; + } +}
diff --git a/src/styles/overlays.css b/src/styles/overlays.css @@ -0,0 +1,87 @@ +.overlay { + position: fixed; + display: flex; + top: 0; + left: 0; + height: 100vh; + width: 100vw; + background-color: rgba(0, 0, 0, .7); + z-index: 100; + backdrop-filter: blur(10px); + + .content { + z-index: 1050; + margin: auto; + width: max-content; + -webkit-overflow-scrolling: touch; + } +} + +.modal { + z-index: 1050; + margin: auto; + background-color: white; + width: max-content; + padding: 15px; + -webkit-overflow-scrolling: touch; +} + +.modal.select { + a { + width: 100%; + margin: 5px 0; + text-align: center; + } + + a:first-child { + margin-top: unset; + } + + a:last-child { + margin-bottom: unset; + } +} + +.modal.dialog { + padding: unset; + + .body { + max-height: 70vh; + overflow: scroll; + } + + .header { + justify-content: space-between; + background-color: #33691E; + color: white; + padding: 15px; + + h4 { + margin: 0; + } + + .icon-close { + margin: -15px; + padding: 10px; + border-left: 1px solid rgba(0, 0, 0, .4); + cursor: pointer; + } + + .icon-close:hover { + background: rgba(0, 0, 0, .4); + } + } +} + +@media (max-width: 650px) { + .modal.dialog { + width: 100vw; + height: 100vh; + } +} + +@media (min-width: 800px) { + .modal.dialog { + width: 600px; + } +}+ \ No newline at end of file
diff --git a/src/styles/searchView.css b/src/styles/searchView.css @@ -0,0 +1,164 @@ +.title { + padding-top: 3em; + + h1 { + color: white; + font-weight: normal; + margin: .7em .3em .5em .3em; + } + + h1:hover { + -webkit-text-fill-color: transparent; + -webkit-background-clip: text !important; + background: linear-gradient(90deg, #b4dcff 20%, pink 20%, pink 40%, white 40%, white 60%, pink 60%, pink 80%, #b4dcff 80%, #b4dcff 100%); + } +} + +.title::before { + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28"><rect rx="4" height="28" width="28" fill="green"/><path d="M14 5.5c-4 0-8 .5-8 4V19c0 1.93 1.57 3.5 3.5 3.5L8 24v.5h2.23l2-2H16l2 2h2V24l-1.5-1.5c1.93 0 3.5-1.57 3.5-3.5V9.5c0-3.5-3.58-4-8-4m-4.5 15c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5m3.5-7H8v-4h5zm2 0v-4h5v4zm3.5 7c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5" fill="white"/></svg>'); + width: 50px; + height: 50px; + margin: -0.7em .3em -0.5em -0.3em; +} + +form { + color: white; + + .button.icon-arrow1, + .button.icon-arrow2, + .button.icon-plus, + .button.icon-minus, + .button.icon-swap { + padding: .3em .5em; + } + + .button.now { + display: flex; + justify-content: center; + align-items: center; + user-select: none; + padding: 0 10px; + } + + button[type="submit"], + .button.icon-settings { + height: 32px; + } + + .filler { + flex: auto; + } + + .button.icon-settings { + width: 32px; + padding: 3px; + } + + button[type="submit"]{ + display: flex; + align-items: center; + font-size: 20px; + padding: 8px; + } + + button[type="submit"]::after { + width: 24px; + height: 24px; + margin-left: 5px; + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2 4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" fill="green"/></svg>'); + } +} + +.suggestions { + position: relative; + overflow: visible; + z-index: 100; + height: 0; + margin-left: 4px; + margin-right: 3.23rem; + + p { + font-size: 1.2em; + background-color: white; + color: black; + margin: 0; + border-top: 1px solid rgba(0, 0, 0, .2); + padding: .3em .6em; + cursor: pointer; + } + + p:first-child { + margin-top: -4px; + } + + p:hover { + background-color: #d3d3d3; + } + + p.selected { + background-color: #bfbfbf !important; + } +} + +.history { + padding: 0 4px; + overflow: hidden; + user-select: none; + + .flex-row { + justify-content: space-between; + cursor: pointer; + padding: .3em .6em .3em .3em; + margin: 0; + background-color: white; + color: black; + font-size: 1.2em; + border-bottom: 1px solid rgba(0, 0, 0, .2); + } + + .flex-row:last-child { + border-bottom: unset; + } + + .via { + font-size: smaller; + font-weight: 200; + } + + .from, + .to { + width: 40%; + } + + .to { + text-align: right; + } + + small { + display: block; + } + + .icon-arrow1 { + width: 25px; + } +} + +@media (max-width: 650px) { + .filler { + flex: unset !important; + } +} + +@media (max-width: 799px) { + .container { + padding: 10px; + } +} + +@media (min-width: 800px) { + form, .history { + width: 80vw; + max-width: 700px; + color: white; + } +}
diff --git a/src/styles/settingsView.css b/src/styles/settingsView.css @@ -0,0 +1,25 @@ +.flex-row, +.flex-column { + padding: 1em; + border-bottom: 1px solid rgba(0, 0, 0, .4); +} + +.flex-row:last-child { + padding: .5em; + border-bottom: unset; + justify-content: right; +} + +label { + padding: .1em; +} + +span { + padding-bottom: .25em; +} + +select, input[type=number]{ + margin: 0; + margin-left: auto; + width: 65%; +}
diff --git a/src/templates.js b/src/templates.js @@ -1,25 +1,45 @@ -import { html, nothing } from 'lit-html'; -import { unsafeHTML } from 'lit-html/directives/unsafe-html.js'; -import { formatDateTime, lineAdditionalName } from './formatters.js'; -import { showModal } from './overlays.js'; -import { settings } from './settings.js'; -import { ds100Name } from './app_functions.js'; -import { t } from './languages.js'; +import { html, css, nothing } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; -export const remarksModalTemplate = (remarks) => html` - <div class="remarksView"> - ${remarks.map(remark => html` - <div class="flex-row"> - <span class="icon-${remark.type}"></span> - <span>${unsafeHTML(remark.text)}</span> - </div> - `)} - </div> -`; +import { formatTime } from './formatters.js'; +import { settingsState } from './settings.js'; +import { getDS100byIBNR } from './ds100.js'; + + +export const remarksModal = (element, remarks) => element.showDialogOverlay('remarks', [].concat( + remarks.map(remark => html` + <div class="flex-row"> + <span class="icon-${remark.type}"></span> + <span>${unsafeHTML(remark.text)}</span> + </div> + `), + [ html`<style>${css` + .flex-row { + align-items: center; + flex-wrap: nowrap; + padding: .5em; + border-bottom: 1px solid rgba(0, 0, 0, .4); + } + + .flex-row:last-child { + border-bottom: unset; + } + + span[class^="icon-"] { + align-self: start; + padding-right: .3em; + } + `}</style>`] +)); export const stopTemplate = (profile, stop) => { - const ds100 = ds100Name(stop.id); - return html`<a class="flex-center" href="#/d/${profile}/${stop.id}">${stop.name} ${ds100 !== null ? ` (${ds100})` : nothing}</a>`; + let stopName = stop.name; + if (settingsState.showDS100) { + const ds100 = getDS100byIBNR(stop.id); + if (ds100 !== null) stopName += ` (${ds100})`; + } + + return html`<a class="flex-center" href="#/d/${profile}/${stop.id}">${stopName}</a>`; } export const platformTemplate = (data) => { @@ -61,26 +81,20 @@ export const timeTemplate = (data, mode) => { arrival: 'arrivalDelay', }, }; + const getField = fieldName => data[fieldsMap[fieldName][mode] || fieldName]; - const time = getField('when') || getField('plannedWhen'); - if (!time) return '-'; + let timeStr; + const dateObj = getField('when') || getField('plannedWhen'); + if (!dateObj) return '-'; + + if (dateObj.toLocaleDateString() !== new Date().toLocaleDateString()) { + timeStr = `${formatTime(dateObj)}, ${dateObj.getDate()}.${dateObj.getMonth() + 1}.`; + } else { + timeStr = formatTime(dateObj); + } const delayMinutes = Math.round(getField('delay') / 60); - return html` - ${delayMinutes != 0 ? html` - ${formatDateTime(time)} <b>(${delayMinutes > 0 ? '+' : ''}${delayMinutes})</b> - ` : html` - ${formatDateTime(time)} - `} - `; + return html`${timeStr}${delayMinutes != 0 ? html` <b>(${delayMinutes > 0 ? '+' : ''}${delayMinutes})</b>` : nothing}`; }; - - -export const footerTemplate = html` - <footer class="center"> - <a href="https://git.ctu.cx/trainsearch" title="commit ${COMMIT} from ${COMMITDATE}">Source-Code (${VERSION})</a> - <a href="https://ctu.cx/imprint.html">Imprint</a> - </footer> -`;- \ No newline at end of file
diff --git a/src/tripView.js b/src/tripView.js @@ -1,143 +1,176 @@ -import { html, nothing, render } from 'lit-html'; -import { settings } from './settings.js'; -import { remarksModalTemplate, platformTemplate, stopTemplate, timeTemplate } from './templates.js'; -import { ElementById, showElement, setThemeColor, queryBackgroundColor } from './helpers.js'; +import { LitElement, html, nothing } from 'lit'; +import { LitOverlay } from './LitOverlay.js'; + +import { sleep, queryBackgroundColor, setThemeColor } from './helpers.js'; +import { cachedCoachSequence } from './coach-sequence/index.js'; +import { remarksModal, platformTemplate, stopTemplate, timeTemplate } from './templates.js'; import { processLeg } from './app_functions.js'; -import { formatDateTime, formatDuration, formatPrice, formatLineAdditionalName, formatLineDisplayName } from './formatters.js'; -import { showAlertModal, showLoader, hideOverlay, showModal } from './overlays.js'; -import { go } from './router.js'; -import { getHafasClient, client } from './hafasClient.js'; +import { formatDuration, formatLineAdditionalName, formatLineDisplayName, formatTrainTypes } from './formatters.js'; +import { getHafasClient } from './hafasClient.js'; import { t } from './languages.js'; -const tripTemplate = (data, profile, refreshToken) => { - let changes = 0; - let lastArrival; +import { baseStyles, helperStyles, flexboxStyles, iconStyles, headerStyles, cardStyles, journeyViewStyles } from './styles.js'; + +class TripView extends LitOverlay { + static styles = [ + super.styles, + baseStyles, + helperStyles, + flexboxStyles, + iconStyles, + headerStyles, + cardStyles, + journeyViewStyles + ]; + + static properties = { + profile: { attribute: true }, + refreshToken: { attribute: true, converter: (value) => decodeURIComponent(value)}, + viewState: { state: true }, + isUpdating: { state: true } + }; + + constructor (...args) { + super(...args); + + this.viewState = null; + this.isUpdating = false; + } + + async connectedCallback () { + super.connectedCallback(); + await sleep(100); + + setThemeColor(queryBackgroundColor(this.renderRoot, 'header')); + } + + async updated (previous) { + super.updated(previous); - const remarks = data.remarks || []; - const remarksStatus = remarks.some((obj) => obj.type === 'status'); - const remarksWarning = remarks.some((obj) => obj.type === 'warning'); - const remarksIcon = remarksWarning ? 'icon-warning' : (remarksStatus ? 'icon-status' : 'icon-hint'); + if (isDevServer) console.info('updated(): ', previous); - let bahnExpertUrl = null; - if (data.line && (data.line.product == 'nationalExpress' || data.line.product == 'national' || data.line.product == 'regionalExpress' || data.line.product == 'regional')) { - const trainName = formatLineAdditionalName(data.line) || data.line?.name; - if (trainName) { - bahnExpertUrl = 'https://bahn.expert/details/' + encodeURIComponent(trainName) + '/' + Number(data.plannedDeparture); + if (previous.has('refreshToken')) { + this.viewState = null; + await this.updateViewState(); } } - return html` - <div class="header-container"> - <header> - <a id="back" class="icon-back hidden" title="${t('back')}" @click=${() => history.back()}>$</a> - <div class="container"> - <h3>Trip of ${formatLineDisplayName(data.line)} to ${data.direction}</h3> - </div> - <a class="icon-reload" title="${t('title')}" @click=${() => refreshTripView(profile, refreshToken)}></a> - </header> - </div> - - <div class="container journeyView"> - <div class="card"> - <table> - <thead> - <tr> - <td colspan="4"> - <div class="center">${bahnExpertUrl ? html` - <a href="${bahnExpertUrl}">${formatLineDisplayName(data.line)}${data.direction ? html` → ${data.direction}` : ''}</a> - ` : html ` - ${formatLineDisplayName(data.line)}${data.direction ? html` → ${data.direction}` : ''} - `} - ${data.cancelled ? html`<b class="cancelled-text">${t('cancelled-ride')}</b>` : ''} - ${!!remarks.length ? html`<a class="link ${remarksIcon}" @click=${() => showModal(t('remarks'), remarksModalTemplate(remarks))}></a>` : nothing}</div> - </td> - </tr> - <tr> - <td colspan="4"> - <div class="train-details center"> - ${formatLineAdditionalName(data.line) ? html` - <div> - Trip: ${formatLineAdditionalName(data.line)} - </div> - ` : nothing} - ${data.line.trainType ? html` - <div> - Train type: ${data.line.trainType} - </div> - ` : nothing} - <div ${data.cancelled ? 'cancelled' : ''}"> - ${t('duration')}: ${formatDuration(data.arrival - (data.departure ? data.departure : data.plannedDeparture))} ${data.departure ? '' : ('(' + t('planned') + ')')} + renderContent () { + return this.viewState !== null ? html` + <div class="header-container"> + <header> + <a id="back" class="icon-back ${history.length !== 1 ? '': 'invisible'}" title="${t('back')}" @click=${() => history.back()}></a> + <div class="container"> + <h3>Trip of ${formatLineDisplayName(this.viewState.trip.line)} to ${this.viewState.trip.direction}</h3> + </div> + <a class="icon-reload ${this.isUpdating ? 'spinning' : ''}" title="${t("reload")}" @click=${this.updateViewState}></a> + </header> + </div> + + <div class="container"> + <div class="card"> + <table> + <thead> + <tr> + <td colspan="4"> + <div class="center"> + ${this.viewState.trip.bahnExpertUrl ? html` + <a href="${this.viewState.trip.bahnExpertUrl}">${formatLineDisplayName(this.viewState.trip.line)}${this.viewState.trip.direction ? html` → ${this.viewState.trip.direction}` : ''}</a> + ` : html ` + ${formatLineDisplayName(this.viewState.trip.line)}${this.viewState.trip.direction ? html` → ${this.viewState.trip.direction}` : ''} + `} + ${this.viewState.trip.cancelled ? html`<b class="cancelled-text">${t('cancelled-ride')}</b>` : ''} + ${this.viewState.trip.remarks.length !== 0 ? html` + <a class="link ${this.viewState.trip.remarksIcon}" @click=${() => remarksModal(this, this.viewState.trip.remarks)}></a> + ` : nothing} </div> - ${data.loadFactor ? html` - <div - ${t("load-"+data.loadFactor)} + </td> + </tr> + <tr> + <td colspan="4"> + <div class="train-details flex-center"> + ${formatLineAdditionalName(this.viewState.trip.line) ? html`<div>Trip: ${formatLineAdditionalName(this.viewState.trip.line)}</div>` : nothing} + ${this.viewState.trip.line.trainType ? html`<div>Train type: ${this.viewState.trip.line.trainType}</div>` : nothing} + <div class="${!this.viewState.trip.cancelled ? nothing : 'cancelled'}"> + ${t('duration')}: ${formatDuration(this.viewState.trip.arrival - (this.viewState.trip.departure ? this.viewState.trip.departure : this.viewState.trip.plannedDeparture))} ${this.viewState.trip.departure ? '' : ('(' + t('planned') + ')')} </div> - ` : nothing} - </div> - </td> - </tr> - <tr> - <th>${t('arrival')}</th> - <th>${t('departure')}</th> - <th class="station">${t('station')}</th> - <th>${t('platform')}</th> - </tr> - </thead> - <tbody> - ${(data.stopovers || []).map(stop => html` - <tr class="${stop.cancelled ? 'cancelled' : ''}"> - <td>${timeTemplate(stop, 'arrival')}</td> - <td>${timeTemplate(stop, 'departure')}</td> - <td>${stopTemplate(profile, stop.stop)}</td> - <td>${platformTemplate(stop)}</td> + ${this.viewState.trip.loadFactor ? html`<div>${t("load-"+this.viewState.trip.loadFactor)}</div>` : nothing} + </div> + </td> + </tr> + <tr> + <th>${t('arrival')}</th> + <th>${t('departure')}</th> + <th class="station">${t('station')}</th> + <th>${t('platform')}</th> </tr> - `)} - </tbody> - </table> + </thead> + <tbody> + ${(this.viewState.trip.stopovers || []).map(stop => html` + <tr class="${stop.cancelled ? 'cancelled' : ''}"> + <td>${timeTemplate(stop, 'arrival')}</td> + <td>${timeTemplate(stop, 'departure')}</td> + <td>${stopTemplate(this.profile, stop.stop)}</td> + <td>${platformTemplate(stop)}</td> + </tr> + `)} + </tbody> + </table> + </div> + </div> + <footer-component></footer-component> + ` : html` + <div class="header-container"> + <header> + <a class="icon-back ${history.length !== 1 ? '': 'invisible'}" title="${t('back')}" @click=${() => history.back()}></a> + <div class="container"> + <h3>Trip of ... to ...</h3> + </div> + <a class="icon-reload ${this.isUpdating ? 'spinning' : ''}" title="${t("reload")}" @click=${this.updateViewState}></a> + </header> </div> - </div> - `; -}; - -export const tripView = async (match, isUpdate) => { - if (!isUpdate) showLoader(); - let profile, refreshToken, data; - - try { - profile = match[0]; - refreshToken = decodeURIComponent(match[1]); - const client = await getHafasClient(profile); - data = await client.trip(refreshToken, {stopovers: true}); - processLeg(data.trip); - } catch(e) { - showAlertModal(e.toString()); - throw e; + <div class="spinner"></div> + `; } - hideOverlay(); + updateViewState = async () => { + this.isUpdating = true; + try { + const client = await getHafasClient(this.profile); + + let viewState = await client.trip(this.refreshToken, {stopovers: true}); - render(tripTemplate(data.trip, profile, refreshToken), ElementById('content')); - setThemeColor(queryBackgroundColor('header')); + processLeg(viewState.trip); - console.log(data) + if (!viewState.trip.remarks) viewState.trip.remarks = []; - if (history.length > 0) showElement(ElementById('back')); -}; + const remarksStatus = viewState.trip.remarks.some((obj) => obj.type === 'status'); + const remarksWarning = viewState.trip.remarks.some((obj) => obj.type === 'warning'); + viewState.trip.remarksIcon = remarksWarning ? 'icon-warning' : (remarksStatus ? 'icon-status' : 'icon-hint'); -const refreshTripView = async (profile, refreshToken) => { - document.querySelector('.icon-reload').classList.add('spinning'); + viewState.trip.bahnExpertUrl = null; + if (viewState.trip.line && [ 'nationalExpress', 'national', 'regionalExpress', 'regional' ].includes(viewState.trip.line.product)) { + const trainName = formatLineAdditionalName(viewState.trip.line) || viewState.trip.line?.name; + if (trainName) viewState.trip.bahnExpertUrl = 'https://bahn.expert/details/' + encodeURIComponent(trainName) + '/' + Number(viewState.trip.plannedDeparture); + } - let data; + this.viewState = viewState; - try { - const client = await getHafasClient(profile); - data = await client.trip(refreshToken, {stopovers: true}); - processLeg(data.trip); - } catch(e) { - showAlertModal(e.toString()); - throw e; + if (this.viewState.trip.line && this.viewState.trip.line.name) { + const [category, number] = this.viewState.trip.line.name.split(' '); + const info = await cachedCoachSequence(category, this.viewState.trip.line.fahrtNr || number, this.viewState.trip.origin.id, this.viewState.trip.plannedDeparture); + + if (info) this.viewState.trip.line.trainType = formatTrainTypes(info); + this.requestUpdate(); + } + + if (isDevServer) console.info('viewState: ',this.viewState); + } catch(e) { + this.showAlertOverlay(e.toString()); + console.error(e); + } + this.isUpdating = false; } +} - render(tripTemplate(data.trip, profile, refreshToken), ElementById('content')); - document.querySelector('.icon-reload').classList.remove('spinning'); -}; +customElements.define('trip-view', TripView);
diff --git a/webpack.config.js b/webpack.config.js @@ -5,8 +5,6 @@ import CopyPlugin from 'copy-webpack-plugin'; import WorkboxPlugin from 'workbox-webpack-plugin'; import HtmlWebpackPlugin from 'html-webpack-plugin'; import HtmlWebpackInjectPreload from '@principalstudio/html-webpack-inject-preload'; -import MiniCssExtractPlugin from 'mini-css-extract-plugin'; -import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; import { GitRevisionPlugin } from 'git-revision-webpack-plugin'; const __filename = fileURLToPath(import.meta.url); @@ -20,40 +18,21 @@ const appName = "Öffisearch"; export default { mode: 'production', entry: './src/main.js', - output: { - path: dist, - filename: "[name].js" - }, - module: { - rules: [{ - test: /\.css$/, - use: [ - isDevServer ? 'style-loader' : MiniCssExtractPlugin.loader, 'css-loader', - ], - }], - }, performance: { hints: false, }, optimization: { minimize: true, - minimizer: [ - `...`, - new CssMinimizerPlugin(), - ], - }, - devServer: { - static: dist, - client: { - progress: true, - }, - proxy: [{ - context: [ '/hafas', '/db' ], - target: 'https://oeffi.katja.wtf', - changeOrigin: true, - }], }, plugins: [ + new webpack.DefinePlugin({ + 'process.env': '{}', + 'isDevServer': isDevServer, + 'APPNAME': JSON.stringify(appName), + 'VERSION': JSON.stringify(process.env.GIT_VERSION ? process.env.GIT_VERSION : gitRevisionPlugin.version()), + 'COMMIT': JSON.stringify(process.env.GIT_COMMIT ? process.env.GIT_COMMIT : gitRevisionPlugin.commithash()), + 'COMMITDATE': JSON.stringify(process.env.GIT_COMMITDATE ? process.env.GIT_COMMITDATE : gitRevisionPlugin.lastcommitdatetime()), + }), new webpack.NormalModuleReplacementPlugin(/(node:|https-proxy-agent|cross-fetch|db-hafas-stations)/, (resource) => { const newRequest = { 'node:buffer': 'buffer', @@ -66,14 +45,6 @@ export default { resource.request = newRequest; } }), - new webpack.DefinePlugin({ - 'process.env': '{}', - 'isDevServer': isDevServer, - 'APPNAME': JSON.stringify(appName), - 'VERSION': JSON.stringify(process.env.GIT_VERSION ? process.env.GIT_VERSION : gitRevisionPlugin.version()), - 'COMMIT': JSON.stringify(process.env.GIT_COMMIT ? process.env.GIT_COMMIT : gitRevisionPlugin.commithash()), - 'COMMITDATE': JSON.stringify(process.env.GIT_COMMITDATE ? process.env.GIT_COMMITDATE : gitRevisionPlugin.lastcommitdatetime()), - }), new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'], }), @@ -108,7 +79,6 @@ export default { }], }), ].concat(isDevServer ? [] : [ - new MiniCssExtractPlugin(), new WorkboxPlugin.GenerateSW({ clientsClaim: true, skipWaiting: true, @@ -130,5 +100,34 @@ export default { 'tls': false, 'net': false, } - } + }, + module: { + rules: [{ + test: /\.css$/, + loader: 'lit-css-loader', + options: { + cssnano: { + autoprefixer: true, + zindex: true, + mergeIdents: true, + reduceIdents: true + } + } + }], + }, + output: { + path: dist, + filename: "[name].js" + }, + devServer: { + static: dist, + client: { + progress: true, + }, + proxy: [{ + context: [ '/hafas', '/db' ], + target: 'https://oeffi.katja.wtf', + changeOrigin: true, + }], + }, };