Partially working api and web

This commit is contained in:
2026-02-07 11:19:36 +01:00
parent a52482256a
commit 699c826bf0
21 changed files with 124861 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
.env
dist/

315
bun.lock Normal file
View File

@@ -0,0 +1,315 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "skopje-bus-api-client",
"dependencies": {
"express": "^4.18.2",
"protobufjs": "^7.5.4",
},
"optionalDependencies": {
"gtfs-realtime-bindings": "^1.1.1",
},
},
},
"packages": {
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@jsdoc/salty": ["@jsdoc/salty@0.2.9", "", { "dependencies": { "lodash": "^4.17.21" } }, "sha512-yYxMVH7Dqw6nO0d5NIV8OQWnitU8k6vXH8NtgqAfIa/IUqRMxRv/NUJJ08VEKbAakwxlgBl5PJdrU0dMPStsnw=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
"@types/node": ["@types/node@20.19.32", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
"body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="],
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"catharsis": ["catharsis@0.9.0", "", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
"escodegen": ["escodegen@1.14.3", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^4.2.0", "esutils": "^2.0.2", "optionator": "^0.8.1" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "escodegen": "bin/escodegen.js", "esgenerate": "bin/esgenerate.js" } }, "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw=="],
"eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"gtfs-realtime-bindings": ["gtfs-realtime-bindings@1.1.1", "", { "dependencies": { "protobufjs": "^7.1.2", "protobufjs-cli": "^1.0.2" } }, "sha512-+k8+/MmiBmUUWlASs4CeTkV+Qyz/FgbZxXdg9rDU62XRfJOpRaRe+nKWCGKse965jffVZ0tIu1K+R7hRvjSLfQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"js2xmlparser": ["js2xmlparser@4.0.2", "", { "dependencies": { "xmlcreate": "^2.0.4" } }, "sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA=="],
"jsdoc": ["jsdoc@4.0.5", "", { "dependencies": { "@babel/parser": "^7.20.15", "@jsdoc/salty": "^0.2.1", "@types/markdown-it": "^14.1.1", "bluebird": "^3.7.2", "catharsis": "^0.9.0", "escape-string-regexp": "^2.0.0", "js2xmlparser": "^4.0.2", "klaw": "^3.0.0", "markdown-it": "^14.1.0", "markdown-it-anchor": "^8.6.7", "marked": "^4.0.10", "mkdirp": "^1.0.4", "requizzle": "^0.2.3", "strip-json-comments": "^3.1.0", "underscore": "~1.13.2" }, "bin": "jsdoc.js" }, "sha512-P4C6MWP9yIlMiK8nwoZvxN84vb6MsnXcHuy7XzVOvQoCizWX5JFCBsWIIWKXBltpoRZXddUOVQmCTOZt9yDj9g=="],
"klaw": ["klaw@3.0.0", "", { "dependencies": { "graceful-fs": "^4.1.9" } }, "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g=="],
"levn": ["levn@0.3.0", "", { "dependencies": { "prelude-ls": "~1.1.2", "type-check": "~0.3.2" } }, "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA=="],
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": "bin/markdown-it.mjs" }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="],
"markdown-it-anchor": ["markdown-it-anchor@8.6.7", "", { "peerDependencies": { "@types/markdown-it": "*", "markdown-it": "*" } }, "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA=="],
"marked": ["marked@4.3.0", "", { "bin": "bin/marked.js" }, "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mkdirp": ["mkdirp@1.0.4", "", { "bin": "bin/cmd.js" }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
"ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"optionator": ["optionator@0.8.3", "", { "dependencies": { "deep-is": "~0.1.3", "fast-levenshtein": "~2.0.6", "levn": "~0.3.0", "prelude-ls": "~1.1.2", "type-check": "~0.3.2", "word-wrap": "~1.2.3" } }, "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
"prelude-ls": ["prelude-ls@1.1.2", "", {}, "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w=="],
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
"protobufjs-cli": ["protobufjs-cli@1.2.0", "", { "dependencies": { "chalk": "^4.0.0", "escodegen": "^1.13.0", "espree": "^9.0.0", "estraverse": "^5.1.0", "glob": "^8.0.0", "jsdoc": "^4.0.0", "minimist": "^1.2.0", "semver": "^7.1.2", "tmp": "^0.2.1", "uglify-js": "^3.7.7" }, "peerDependencies": { "protobufjs": "^7.0.0" }, "bin": { "pbjs": "bin/pbjs", "pbts": "bin/pbts" } }, "sha512-+YvqJEmsmZHGzE5j0tvEzFeHm0sX7pzRFpyj7+GazhkS4Y0r+jgbioVvFxxSWIlPzUel/lxeOnLChBmV8NmyHA=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
"qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
"requizzle": ["requizzle@0.2.4", "", { "dependencies": { "lodash": "^4.17.21" } }, "sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
"serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"type-check": ["type-check@0.3.2", "", { "dependencies": { "prelude-ls": "~1.1.2" } }, "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
"underscore": ["underscore@1.13.7", "", {}, "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"xmlcreate": ["xmlcreate@2.0.4", "", {}, "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg=="],
"escodegen/estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
}
}

347
bus-tracker-json.ts Normal file
View File

@@ -0,0 +1,347 @@
import { loadGtfsStops, loadGtfsRoutes, GtfsStop, GtfsRoute } from './lib/gtfs';
import { config } from './config';
// ============================================================================
// CLI Arguments
// ============================================================================
function parseArgs() {
const args = process.argv.slice(2);
let stopId = config.defaultStop.stopId;
let routeId = config.defaultRoute.routeId;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--stop' && args[i + 1]) {
stopId = args[i + 1];
i++;
} else if (args[i] === '--route' && args[i + 1]) {
routeId = args[i + 1];
i++;
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
Usage: npm run tracker [options]
Options:
--stop <stopId> Stop ID to track (default: ${config.defaultStop.stopId})
--route <routeId> Route ID to track (default: ${config.defaultRoute.routeId})
--help, -h Show this help message
Examples:
npm run tracker
npm run tracker -- --stop 1571 --route 125
`);
process.exit(0);
}
}
return { stopId, routeId };
}
const { stopId: TARGET_STOP_ID, routeId: TARGET_ROUTE_ID } = parseArgs();
// ============================================================================
// Configuration
// ============================================================================
// ============================================================================
// Additional Types
// ============================================================================
interface StopTime {
scheduledArrival: number;
scheduledDeparture: number;
realtimeArrival: number;
realtimeDeparture: number;
arrivalDelay: number;
departureDelay: number;
timepoint: boolean;
realtime: boolean;
realtimeState: string;
serviceDay: number;
headsign: string;
}
interface Pattern {
routeId: number;
index: number;
stopTimes: StopTime[];
}
interface StopArrivalData {
id: number;
patterns: Pattern[];
distance: number;
}
interface BusArrival {
tripId: string;
routeName: string;
stopName: string;
headsign: string;
arrivalTime: Date;
scheduledTime: Date;
delaySeconds: number;
minutesUntilArrival: number;
isApproaching: boolean;
realtimeState: string;
isRealtime: boolean;
}
// ============================================================================
// Real-time Data Fetching
// ============================================================================
async function getNextBuses(
stops: Map<string, GtfsStop>,
routes: Map<string, GtfsRoute>
): Promise<BusArrival[]> {
const targetStop = stops.get(TARGET_STOP_ID);
if (!targetStop) {
throw new Error(`Stop ${TARGET_STOP_ID} not found in GTFS data`);
}
// Fetch nearby arrivals using JSON API
const radius = 50; // 50 meters
const nearbyUrl = `${config.baseUrl}/transport/planner/stops/nearbyTimes?latitude=${targetStop.stop_lat}&longitude=${targetStop.stop_lon}&radius=${radius}`;
const response = await fetch(nearbyUrl);
const nearbyData = await response.json() as StopArrivalData[];
const now = new Date();
const maxTime = new Date(now.getTime() + config.tracking.minutesAhead * 60000);
const arrivals: BusArrival[] = [];
// Get route info
const targetRoute = routes.get(TARGET_ROUTE_ID);
// Process the nearby data
for (const stopData of nearbyData) {
if (stopData.id.toString() !== TARGET_STOP_ID) continue;
for (const pattern of stopData.patterns) {
// Filter by target route
if (pattern.routeId.toString() !== TARGET_ROUTE_ID) continue;
const routeInfo = routes.get(pattern.routeId.toString());
if (!routeInfo) continue;
for (const stopTime of pattern.stopTimes) {
// Convert service day + seconds to actual timestamp
const serviceDay = new Date(stopTime.serviceDay * 1000);
const arrivalTime = new Date(serviceDay.getTime() + stopTime.realtimeArrival * 1000);
const scheduledTime = new Date(serviceDay.getTime() + stopTime.scheduledArrival * 1000);
// Only include buses arriving within our time window
if (arrivalTime > maxTime || arrivalTime < now) continue;
const minutesUntil = Math.floor((arrivalTime.getTime() - now.getTime()) / 60000);
arrivals.push({
tripId: `${pattern.routeId}.${pattern.index}`,
routeName: `${routeInfo.route_short_name} - ${routeInfo.route_long_name}`,
stopName: targetStop.stop_name,
headsign: stopTime.headsign,
arrivalTime: arrivalTime,
scheduledTime: scheduledTime,
delaySeconds: stopTime.arrivalDelay,
minutesUntilArrival: minutesUntil,
isApproaching: minutesUntil <= 5 && minutesUntil >= 0,
realtimeState: stopTime.realtimeState,
isRealtime: stopTime.realtime,
});
}
}
}
// Sort by arrival time
arrivals.sort((a, b) => a.arrivalTime.getTime() - b.arrivalTime.getTime());
return arrivals;
}
// ============================================================================
// Display Functions
// ============================================================================
function formatArrival(arrival: BusArrival, index: number): string {
const delayMin = Math.floor(arrival.delaySeconds / 60);
let delayText = "";
if (delayMin > 0) {
delayText = ` [${delayMin}min LATE]`;
} else if (delayMin < -3) {
delayText = ` [${Math.abs(delayMin)}min EARLY]`;
} else {
delayText = " [ON TIME]";
}
const timeStr = arrival.arrivalTime.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
let status = "";
if (arrival.minutesUntilArrival <= 0) {
status = " >> ARRIVING NOW!";
} else if (arrival.isApproaching) {
status = " >> APPROACHING";
}
const minutesText = arrival.minutesUntilArrival === 0
? "NOW"
: `${arrival.minutesUntilArrival} min`;
const realtimeIndicator = arrival.isRealtime ? "[LIVE]" : "[SCHED]";
return ` ${index + 1}. ${timeStr} (in ${minutesText}) ${delayText} ${realtimeIndicator}${status}`;
}
async function displayBusSchedule(
stops: Map<string, GtfsStop>,
routes: Map<string, GtfsRoute>
) {
console.clear();
const targetStop = stops.get(TARGET_STOP_ID);
const targetRoute = routes.get(TARGET_ROUTE_ID);
console.log("=".repeat(75));
console.log(` BUS TRACKER - SKOPJE PUBLIC TRANSPORT`);
console.log("=".repeat(75));
console.log(` Route: ${targetRoute?.route_short_name} - ${targetRoute?.route_long_name}`);
console.log(` Stop: ${targetStop?.stop_name} (Code: ${targetStop?.stop_code})`);
console.log(` Location: ${targetStop?.stop_lat.toFixed(5)}, ${targetStop?.stop_lon.toFixed(5)}`);
console.log("=".repeat(75));
console.log(` Updated: ${new Date().toLocaleString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})}\n`);
try {
const arrivals = await getNextBuses(stops, routes);
if (arrivals.length === 0) {
console.log(` No buses scheduled in the next ${config.tracking.minutesAhead} minutes.\n`);
console.log(" This could mean:");
console.log(" - No buses are currently running on this route");
console.log(" - The next bus is more than 90 minutes away");
console.log(" - Service has ended for the day\n");
return;
}
console.log(` Found ${arrivals.length} upcoming bus${arrivals.length > 1 ? 'es' : ''}:\n`);
// Show all buses (or first 10 if too many)
const showCount = Math.min(arrivals.length, 10);
arrivals.slice(0, showCount).forEach((arrival, index) => {
console.log(formatArrival(arrival, index));
if (arrival.headsign && index < 5) {
console.log(` Direction: ${arrival.headsign}`);
}
});
if (arrivals.length > showCount) {
console.log(`\n ... and ${arrivals.length - showCount} more later`);
}
// Highlight the next bus
const nextBus = arrivals[0];
if (nextBus) {
console.log("\n" + "-".repeat(75));
console.log(" NEXT BUS:");
console.log("-".repeat(75));
if (nextBus.minutesUntilArrival <= 0) {
console.log(" >> BUS IS ARRIVING NOW! HEAD TO THE STOP! <<");
} else if (nextBus.minutesUntilArrival <= 2) {
console.log(` >> Bus arriving in ${nextBus.minutesUntilArrival} minute${nextBus.minutesUntilArrival > 1 ? 's' : ''}! Run! <<`);
} else if (nextBus.minutesUntilArrival <= 5) {
console.log(` >> Bus arriving in ${nextBus.minutesUntilArrival} minutes - Time to head to the stop <<`);
} else if (nextBus.minutesUntilArrival <= 15) {
console.log(` >> ${nextBus.minutesUntilArrival} minutes - Start getting ready <<`);
} else {
console.log(` >> ${nextBus.minutesUntilArrival} minutes - You have time to relax <<`);
}
console.log(` Direction: ${nextBus.headsign}`);
console.log(` Data source: ${nextBus.isRealtime ? 'Real-time tracking' : 'Scheduled times'}`);
const delayMin = Math.floor(nextBus.delaySeconds / 60);
if (delayMin > 5) {
console.log(` WARNING: Bus is running ${delayMin} minutes LATE`);
} else if (delayMin < -3) {
console.log(` NOTE: Bus is ${Math.abs(delayMin)} minutes EARLY`);
}
}
} catch (error) {
console.log(`\n ERROR: ${error}\n`);
console.log(" Please check:");
console.log(" - Your internet connection");
console.log(" - The API is accessible");
console.log(" - The GTFS files are present in ./gtfs/ directory\n");
}
console.log("\n" + "=".repeat(75));
console.log(` Auto-refresh: Every ${config.tracking.refreshInterval.terminal / 1000} seconds`);
console.log(" Press Ctrl+C to stop");
console.log("=".repeat(75));
}
// ============================================================================
// Main Program
// ============================================================================
async function startMonitoring() {
console.log("Loading GTFS data...\n");
try {
const stops = loadGtfsStops();
const routes = loadGtfsRoutes();
console.log(`Loaded ${stops.size} stops`);
console.log(`Loaded ${routes.size} routes\n`);
// Verify target stop and route exist
const targetStop = stops.get(TARGET_STOP_ID);
const targetRoute = routes.get(TARGET_ROUTE_ID);
if (!targetStop) {
console.error(`ERROR: Stop ${TARGET_STOP_ID} not found in GTFS data`);
process.exit(1);
}
if (!targetRoute) {
console.error(`ERROR: Route ${TARGET_ROUTE_ID} not found in GTFS data`);
process.exit(1);
}
console.log("Configuration validated");
console.log("Starting bus monitor...\n");
// Initial display
await displayBusSchedule(stops, routes);
// Set up periodic refresh
setInterval(async () => {
await displayBusSchedule(stops, routes);
}, config.tracking.refreshInterval.terminal);
} catch (error) {
console.error(`Failed to start: ${error}`);
console.error("\nPlease ensure:");
console.error(" 1. GTFS files exist in ./gtfs/ directory");
console.error(" 2. Files include: stops.txt and routes.txt");
console.error(" 3. Node.js has permission to read the files");
process.exit(1);
}
}
// Start the application
startMonitoring();

103
config.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Configuration for bus tracking
* Add or modify stops and routes to track different buses
*/
export interface StopConfig {
stopId: string;
stopCode?: string;
name?: string;
lat: number;
lon: number;
}
export interface RouteConfig {
routeId: string;
shortName?: string;
name?: string;
}
export interface AppConfig {
baseUrl: string;
apiEndpoints: {
gtfsRtTripUpdates: string;
vehiclesJson: string;
nearbyTimes: string;
};
defaultStop: StopConfig;
defaultRoute: RouteConfig;
server: {
port: number;
};
tracking: {
refreshInterval: {
web: number; // milliseconds
terminal: number; // milliseconds
};
minutesAhead: number; // Show buses arriving in next X minutes
};
}
// Base configuration
const BASE_URL = 'https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e';
export const config: AppConfig = {
baseUrl: BASE_URL,
apiEndpoints: {
gtfsRtTripUpdates: `${BASE_URL}/transport/gtfsrt/tripupdates.pb`,
vehiclesJson: `${BASE_URL}/transport/public/vehicles`,
nearbyTimes: `${BASE_URL}/transport/planner/stops/nearbyTimes`,
},
// Default stop: АМЕРИКАН КОЛЕЏ-КОН ЦЕНТАР
defaultStop: {
stopId: '1571',
stopCode: '59',
name: 'АМЕРИКАН КОЛЕЏ-КОН ЦЕНТАР',
lat: 41.98057556152344,
lon: 21.457794189453125,
},
// Default route: Line 7
defaultRoute: {
routeId: '125',
shortName: '7',
name: 'ЛИНИЈА 7',
},
server: {
port: 3000,
},
tracking: {
refreshInterval: {
web: 5000, // 5 seconds
terminal: 10000, // 10 seconds
},
minutesAhead: 90,
},
};
/**
* Example: Adding more stops to track
* Uncomment and modify as needed
*/
// export const additionalStops: StopConfig[] = [
// {
// stopId: 'YOUR_STOP_ID',
// stopCode: 'STOP_CODE',
// name: 'Stop Name',
// lat: 42.0,
// lon: 21.5,
// },
// ];
// export const additionalRoutes: RouteConfig[] = [
// {
// routeId: 'YOUR_ROUTE_ID',
// shortName: '10',
// name: 'ЛИНИЈА 10',
// },
// ];

110
find-stops-routes.ts Normal file
View File

@@ -0,0 +1,110 @@
#!/usr/bin/env ts-node
/**
* Helper script to find Stop IDs and Route IDs
* Usage:
* npm run find -- --stop "american"
* npm run find -- --route "7"
*/
import { loadGtfsStops, loadGtfsRoutes } from './lib/gtfs';
function parseArgs() {
const args = process.argv.slice(2);
let searchStop = '';
let searchRoute = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--stop' && args[i + 1]) {
searchStop = args[i + 1].toLowerCase();
i++;
} else if (args[i] === '--route' && args[i + 1]) {
searchRoute = args[i + 1].toLowerCase();
i++;
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
Usage: npm run find -- [options]
Options:
--stop <keyword> Search for stops by name (case-insensitive)
--route <keyword> Search for routes by name or number
--help, -h Show this help message
Examples:
npm run find -- --stop "american"
npm run find -- --route "7"
npm run find -- --stop "center"
npm run find -- --route "linija"
`);
process.exit(0);
}
}
return { searchStop, searchRoute };
}
async function main() {
const { searchStop, searchRoute } = parseArgs();
if (!searchStop && !searchRoute) {
console.log('Please specify --stop or --route. Use --help for usage information.');
process.exit(1);
}
console.log('Loading GTFS data...\n');
const stops = loadGtfsStops();
const routes = loadGtfsRoutes();
if (searchStop) {
console.log(`=== Searching for stops matching "${searchStop}" ===\n`);
const matches = Array.from(stops.values())
.filter(stop => stop.stop_name.toLowerCase().includes(searchStop))
.slice(0, 20); // Limit to 20 results
if (matches.length === 0) {
console.log('No stops found.');
} else {
console.log(`Found ${matches.length} stop(s):\n`);
matches.forEach(stop => {
console.log(`Stop ID: ${stop.stop_id}`);
console.log(` Name: ${stop.stop_name}`);
console.log(` Code: ${stop.stop_code}`);
console.log(` Location: ${stop.stop_lat}, ${stop.stop_lon}`);
console.log('');
});
if (matches.length === 20) {
console.log('(Showing first 20 results, refine your search for more specific results)\n');
}
}
}
if (searchRoute) {
console.log(`=== Searching for routes matching "${searchRoute}" ===\n`);
const matches = Array.from(routes.values())
.filter(route =>
route.route_short_name?.toLowerCase().includes(searchRoute) ||
route.route_long_name?.toLowerCase().includes(searchRoute)
)
.slice(0, 20); // Limit to 20 results
if (matches.length === 0) {
console.log('No routes found.');
} else {
console.log(`Found ${matches.length} route(s):\n`);
matches.forEach(route => {
console.log(`Route ID: ${route.route_id}`);
console.log(` Number: ${route.route_short_name}`);
console.log(` Name: ${route.route_long_name}`);
console.log('');
});
if (matches.length === 20) {
console.log('(Showing first 20 results, refine your search for more specific results)\n');
}
}
}
console.log('Use these IDs in the web interface or terminal tracker.');
}
main();

4
gtfs/agency.txt Normal file
View File

@@ -0,0 +1,4 @@
agency_id,agency_name,agency_url,agency_timezone,agency_lang,agency_phone
1,ICOM,none,Europe/Skopje,mk,
2,ICOM,none,Europe/Skopje,mk,
0,agency,https://agency.telelink.city,Europe/Skopje,ro,

5
gtfs/calendar.txt Normal file
View File

@@ -0,0 +1,5 @@
service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date
7,1,1,1,1,1,0,0,20260206,20260206
8,0,0,0,0,0,0,1,20260208,20260208
9,0,0,0,0,0,1,0,20260207,20260207
DEFAULT_CALENDAR,1,1,1,1,1,1,1,20220101,20300101

9
gtfs/calendar_dates.txt Normal file
View File

@@ -0,0 +1,9 @@
service_id,date,exception_type
7,20260206,1
7,20260209,1
7,20260210,1
7,20260211,1
7,20260212,1
7,20260213,1
8,20260208,1
9,20260207,1

1
gtfs/frequencies.txt Normal file
View File

@@ -0,0 +1 @@
trip_id,start_time,end_time,headway_secs,exact_times

111
gtfs/routes.txt Normal file
View File

@@ -0,0 +1,111 @@
route_id,agency_id,route_short_name,route_long_name,route_desc,route_type,route_color,route_text_color
118,2,2А,ЛИНИЈА 2А,2А ЛИНИЈА 2А,700,,
119,2,3,ЛИНИЈА 3,3 ЛИНИЈА 3,700,,
120,2,3Б,ЛИНИЈА 3Б,3Б ЛИНИЈА 3Б,700,,
121,2,4,ЛИНИЈА 4,4 ЛИНИЈА 4,700,,
122,2,4А,ЛИНИЈА 4А,4А ЛИНИЈА 4А,700,,
123,2,5,ЛИНИЈА 5,5 ЛИНИЈА 5,700,,
124,2,5А,ЛИНИЈА 5А,5А ЛИНИЈА 5А,700,,
125,2,7,ЛИНИЈА 7,7 ЛИНИЈА 7,700,,
126,2,8,ЛИНИЈА 8,8 ЛИНИЈА 8,700,,
127,2,9,ЛИНИЈА 9,9 ЛИНИЈА 9,700,,
128,2,13,ЛИНИЈА 13,13 ЛИНИЈА 13,700,,
129,2,15,ЛИНИЈА 15,15 ЛИНИЈА 15,700,,
130,2,15А,ЛИНИЈА 15А,15А ЛИНИЈА 15А,700,,
131,2,16,ЛИНИЈА 16,16 ЛИНИЈА 16,700,,
132,2,17,ЛИНИЈА 17,17 ЛИНИЈА 17,700,,
133,2,19,ЛИНИЈА 19,19 ЛИНИЈА 19,700,,
134,2,21,ЛИНИЈА 21,21 ЛИНИЈА 21,700,,
135,2,21A,ЛИНИЈА 21A,21A ЛИНИЈА 21A,700,,
136,2,22,ЛИНИЈА 22,22 ЛИНИЈА 22,700,,
137,2,22А,ЛИНИЈА 22А,22А ЛИНИЈА 22А,700,,
138,2,24,ЛИНИЈА 24,24 ЛИНИЈА 24,700,,
139,2,25,ЛИНИЈА 25,25 ЛИНИЈА 25,700,,
140,2,26,ЛИНИЈА 26,26 ЛИНИЈА 26,700,,
141,2,35,"ЛИНИЈА 35 ","35 ЛИНИЈА 35 ",700,,
142,2,41,ЛИНИЈА 41,41 ЛИНИЈА 41,700,,
143,2,42,ЛИНИЈА 42,42 ЛИНИЈА 42,700,,
144,2,43,"ЛИНИЈА 43 ","43 ЛИНИЈА 43 ",700,,
145,2,50,ЛИНИЈА 50,50 ЛИНИЈА 50,700,,
146,2,57,ЛИНИЈА 57,57 ЛИНИЈА 57,700,,
147,2,57А,ЛИНИЈА 57А,57А ЛИНИЈА 57А,700,,
148,2,59,ЛИНИЈА 59,59 ЛИНИЈА 59,700,,
149,2,65В,ЛИНИЈА 65В,65В ЛИНИЈА 65В,700,,
150,2,33,ЛИНИЈА 33,33 ЛИНИЈА 33,700,,
151,2,44,ЛИНИЈА 44,44 ЛИНИЈА 44,700,,
152,2,18,ЛИНИЈА 18,18 ЛИНИЈА 18,700,,
153,2,31,ЛИНИЈА 31,31 ЛИНИЈА 31,700,,
154,2,47,ЛИНИЈА 47,47 ЛИНИЈА 47,700,,
155,2,51,ЛИНИЈА 51,51 ЛИНИЈА 51,700,,
156,2,51а,ЛИНИЈА 51a,51а ЛИНИЈА 51a,700,,
157,2,52,ЛИНИЈА 52,52 ЛИНИЈА 52,700,,
158,2,53,ЛИНИЈА 53,53 ЛИНИЈА 53,700,,
159,2,55а,ЛИНИЈА 55a,55а ЛИНИЈА 55a,700,,
160,2,56,ЛИНИЈА 56,56 ЛИНИЈА 56,700,,
161,2,58,ЛИНИЈА 58,58 ЛИНИЈА 58,700,,
162,2,58а,ЛИНИЈА 58а,58а ЛИНИЈА 58а,700,,
163,2,58б,ЛИНИЈА 58б,58б ЛИНИЈА 58б,700,,
164,2,60,ЛИНИЈА 60,60 ЛИНИЈА 60,700,,
165,2,61,ЛИНИЈА 61,61 ЛИНИЈА 61,700,,
166,2,62,ЛИНИЈА 62,62 ЛИНИЈА 62,700,,
167,2,63,ЛИНИЈА 63,63 ЛИНИЈА 63,700,,
168,2,63А,ЛИНИЈА 63А,63А ЛИНИЈА 63А,700,,
170,2,64А,ЛИНИЈА 64А,64А ЛИНИЈА 64А,700,,
171,2,65,ЛИНИЈА 65,65 ЛИНИЈА 65,700,,
172,2,65А,ЛИНИЈА 65А,65А ЛИНИЈА 65А,700,,
173,2," 65Б",ЛИНИЈА 65Б," 65Б ЛИНИЈА 65Б",700,,
174,2,66,ЛИНИЈА 66,66 ЛИНИЈА 66,700,,
175,2,66a,ЛИНИЈА 66a,66a ЛИНИЈА 66a,700,,
176,2,67,ЛИНИЈА 67,67 ЛИНИЈА 67,700,,
177,2,68,ЛИНИЈА 68,68 ЛИНИЈА 68,700,,
178,2,70,ЛИНИЈА 70,70 ЛИНИЈА 70,700,,
179,2,71,ЛИНИЈА 71,71 ЛИНИЈА 71,700,,
180,2,74,ЛИНИЈА 74,74 ЛИНИЈА 74,700,,
181,2,180,ЛИНИЈА 180,180 ЛИНИЈА 180,700,,
182,2,81,ЛИНИЈА 81,81 ЛИНИЈА 81,700,,
183,2,118,ЛИНИЈА 118,118 ЛИНИЈА 118,700,,
184,2,163,ЛИНИЈА 163,163 ЛИНИЈА 163,700,,
185,2,55в,ЛИНИЈА 55в,55в ЛИНИЈА 55в,700,,
187,2,б07,Ф-КА ЖЕЛЕЗАРА,б07 Ф-КА ЖЕЛЕЗАРА,700,,
188,2,41А,ЛИНИЈА 41А,41А ЛИНИЈА 41А,700,,
189,2,б03,Ф-КА ДРИСЛА,б03 Ф-КА ДРИСЛА,700,,
190,2,80a,ЛИНИЈА 80a,80a ЛИНИЈА 80a,700,,
191,2,б05,ПРЕВОЗ ЗА ЛИЦА СО ПОСЕБНИ ПОТРЕБИ,б05 ПРЕВОЗ ЗА ЛИЦА СО ПОСЕБНИ ПОТРЕБИ,700,,
192,2,27,ЛИНИЈА 27,27 ЛИНИЈА 27,700,,
193,2,21Б,ЛИНИЈА 21Б,21Б ЛИНИЈА 21Б,700,,
194,2,160,ЛИНИЈА 160,160 ЛИНИЈА 160,700,,
195,2,164,ЛИНИЈА 164,164 ЛИНИЈА 164,700,,
196,2,165,ЛИНИЈА 165,165 ЛИНИЈА 165,700,,
197,2,166,ЛИНИЈА 166,166 ЛИНИЈА 166,700,,
198,2,167,ЛИНИЈА 167,167 ЛИНИЈА 167,700,,
199,2,45 П,ЛИНИЈА 45 П,45 П ЛИНИЈА 45 П,700,,
200,2,11a,ЛИНИЈА 11а П,11a ЛИНИЈА 11а П,700,,
201,2,28,ЛИНИЈА 28,28 ЛИНИЈА 28,700,,
202,2,165б,ЛИНИЈА 165б,165б ЛИНИЈА 165б,700,,
203,2,22 П,ЛИНИЈА 22 П,22 П ЛИНИЈА 22 П,700,,
204,2,12 П,ЛИНИЈА 12 П,12 П ЛИНИЈА 12 П,700,,
205,2,19 П,ЛИНИЈА 19 П,19 П ЛИНИЈА 19 П,700,,
206,2,9 П,ЛИНИЈА 9 П,9 П ЛИНИЈА 9 П,700,,
207,2,61 П,ЛИНИЈА 61 П,61 П ЛИНИЈА 61 П,700,,
208,2,54 П,ЛИНИЈА 54 П,54 П ЛИНИЈА 54 П,700,,
209,2,52 П,ЛИНИЈА 52 П,52 П ЛИНИЈА 52 П,700,,
210,2,55 П,ЛИНИЈА 55 П,55 П ЛИНИЈА 55 П,700,,
211,2,20 П,ЛИНИЈА 20 П,20 П ЛИНИЈА 20 П,700,,
212,2,63 П,ЛИНИЈА 63 П,63 П ЛИНИЈА 63 П,700,,
213,2,23 П,ЛИНИЈА 23 П,23 П ЛИНИЈА 23 П,700,,
214,2,73 П,ЛИНИЈА 73 П,73 П ЛИНИЈА 73 П,700,,
215,2,47а П,ЛИНИЈА 47а П,47а П ЛИНИЈА 47а П,700,,
216,2,11 П,ЛИНИЈА 11 П,11 П ЛИНИЈА 11 П,700,,
217,2,24Н,ЛИНИЈА 24Н,24Н ЛИНИЈА 24Н,700,,
219,2,5Н,ЛИНИЈА 5Н,5Н ЛИНИЈА 5Н,700,,
220,2,7Н,ЛИНИЈА 7Н,7Н ЛИНИЈА 7Н,700,,
221,2,22Н,ЛИНИЈА 22Н,22Н ЛИНИЈА 22Н,700,,
222,2,41Н,ЛИНИЈА 41Н,41Н ЛИНИЈА 41Н,700,,
223,2,50Н,ЛИНИЈА 50Н,50Н ЛИНИЈА 50Н,700,,
224,2,57Н,ЛИНИЈА 57Н,57Н ЛИНИЈА 57Н,700,,
225,2,65ВН,ЛИНИЈА 65ВН,65ВН ЛИНИЈА 65ВН,700,,
228,2,50А,ЛИНИЈА 50А,50А ЛИНИЈА 50А,700,,
229,2,111,ЛИНИЈА 111,111 ЛИНИЈА 111,700,,
239,2,3А,ЛИНИЈА 3А,3А ЛИНИЈА 3А,700,,
240,2,б04,ОСНОВНИ УЧИЛИШТА,б04 ОСНОВНИ УЧИЛИШТА,700,,
245,2,2,ЛИНИЈА 2,2 ЛИНИЈА 2,700,,

4726
gtfs/shapes.txt Normal file

File diff suppressed because it is too large Load Diff

110091
gtfs/stop_times.txt Normal file

File diff suppressed because it is too large Load Diff

1514
gtfs/stops.txt Normal file

File diff suppressed because it is too large Load Diff

4347
gtfs/trips.txt Normal file

File diff suppressed because it is too large Load Diff

88
lib/gtfs.ts Normal file
View File

@@ -0,0 +1,88 @@
import * as fs from 'fs';
import * as path from 'path';
// Types
export interface GtfsStop {
stop_id: string;
stop_code: string;
stop_name: string;
stop_lat: number;
stop_lon: number;
}
export interface GtfsRoute {
route_id: string;
route_short_name: string;
route_long_name: string;
route_type?: string;
}
// CSV Parser
function parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
}
// Load GTFS Stops
export function loadGtfsStops(gtfsDir: string = 'gtfs'): Map<string, GtfsStop> {
const stopsFile = path.join(process.cwd(), gtfsDir, 'stops.txt');
const content = fs.readFileSync(stopsFile, 'utf-8');
const lines = content.split('\n').filter(line => line.trim());
const stops = new Map<string, GtfsStop>();
for (let i = 1; i < lines.length; i++) {
const values = parseCSVLine(lines[i]);
if (values.length < 6) continue;
const stop: GtfsStop = {
stop_id: values[0],
stop_code: values[1],
stop_name: values[2],
stop_lat: parseFloat(values[4]),
stop_lon: parseFloat(values[5]),
};
stops.set(stop.stop_id, stop);
}
return stops;
}
// Load GTFS Routes
export function loadGtfsRoutes(gtfsDir: string = 'gtfs'): Map<string, GtfsRoute> {
const routesFile = path.join(process.cwd(), gtfsDir, 'routes.txt');
const content = fs.readFileSync(routesFile, 'utf-8');
const lines = content.split('\n').filter(line => line.trim());
const routes = new Map<string, GtfsRoute>();
for (let i = 1; i < lines.length; i++) {
const values = parseCSVLine(lines[i]);
if (values.length < 5) continue;
const route: GtfsRoute = {
route_id: values[0],
route_short_name: values[2],
route_long_name: values[3],
route_type: values[5] || undefined,
};
routes.set(route.route_id, route);
}
return routes;
}

2058
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "skopje-bus-tracker",
"version": "1.0.0",
"description": "Real-time bus tracking for Skopje public transport",
"main": "server.ts",
"scripts": {
"setup-gtfs": "npx ts-node setup-gtfs.ts",
"find": "npx ts-node find-stops-routes.ts",
"web": "npx ts-node server.ts",
"tracker": "npx ts-node bus-tracker-json.ts",
"build": "tsc",
"start": "npm run web"
},
"keywords": [
"skopje",
"bus",
"api",
"modeshift",
"realtime",
"gtfs",
"protobuf"
],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"protobufjs": "^7.5.4"
},
"optionalDependencies": {
"gtfs-realtime-bindings": "^1.1.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"protobufjs-cli": "^2.0.0",
"ts-node": "^10.9.1",
"typescript": "^5.9.3"
}
}

623
public/index.html Normal file
View File

@@ -0,0 +1,623 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Skopje Bus Tracker</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 28px;
margin-bottom: 5px;
}
.header .subtitle {
opacity: 0.9;
font-size: 14px;
}
.controls {
background: white;
padding: 15px 20px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.control-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.control-group label {
font-size: 12px;
color: #666;
font-weight: 600;
}
.control-group input,
.control-group select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 250px;
}
.control-group input:focus,
.control-group select:focus {
outline: none;
border-color: #667eea;
}
.btn {
padding: 8px 20px;
background: #667eea;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: background 0.2s;
margin-top: 20px;
}
.btn:hover {
background: #5568d3;
}
.container {
display: flex;
height: calc(100vh - 180px);
}
.sidebar {
width: 350px;
background: white;
padding: 20px;
overflow-y: auto;
box-shadow: 2px 0 10px rgba(0,0,0,0.1);
}
.map-container {
flex: 1;
position: relative;
}
#map {
width: 100%;
height: 100%;
}
.info-card {
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
}
.info-card h3 {
color: #333;
margin-bottom: 10px;
font-size: 16px;
}
.info-card p {
color: #666;
font-size: 14px;
margin: 5px 0;
}
.arrivals-section {
margin-top: 20px;
}
.arrivals-section h2 {
color: #333;
font-size: 18px;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #eee;
}
.arrival-item {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 15px;
margin-bottom: 10px;
transition: all 0.3s;
}
.arrival-item:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.arrival-item.approaching {
border-left: 4px solid #ff6b6b;
background: #fff5f5;
}
.arrival-item.soon {
border-left: 4px solid #ffa500;
background: #fff8e1;
}
.arrival-time {
font-size: 24px;
font-weight: bold;
color: #333;
margin-bottom: 5px;
}
.arrival-minutes {
color: #667eea;
font-weight: 600;
font-size: 16px;
}
.arrival-details {
font-size: 13px;
color: #666;
margin-top: 8px;
}
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
margin-right: 5px;
}
.badge-live {
background: #4caf50;
color: white;
}
.badge-scheduled {
background: #9e9e9e;
color: white;
}
.badge-late {
background: #ff6b6b;
color: white;
}
.badge-early {
background: #2196f3;
color: white;
}
.badge-ontime {
background: #4caf50;
color: white;
}
.no-buses {
text-align: center;
padding: 30px;
color: #999;
}
.loading {
text-align: center;
padding: 20px;
color: #667eea;
}
.last-update {
text-align: center;
color: #999;
font-size: 12px;
padding: 10px;
border-top: 1px solid #eee;
margin-top: 10px;
}
.stats {
display: flex;
justify-content: space-around;
margin-bottom: 20px;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #667eea;
}
.stat-label {
font-size: 12px;
color: #999;
margin-top: 5px;
}
</style>
</head>
<body>
<div class="header">
<h1>Skopje Bus Tracker</h1>
<div class="subtitle" id="routeInfo">Select stop and route to track</div>
</div>
<div class="controls">
<div class="control-group">
<label for="stopId">Bus Stop</label>
<select id="stopId">
<option value="">Loading stops...</option>
</select>
</div>
<div class="control-group">
<label for="routeId">Bus Route</label>
<select id="routeId">
<option value="">Loading routes...</option>
</select>
</div>
<button class="btn" onclick="loadTracker()">Track Bus</button>
</div>
<div class="container">
<div class="sidebar">
<div class="info-card">
<h3>Target Stop</h3>
<p id="stopName">Loading...</p>
<p id="stopCode"></p>
</div>
<div class="stats">
<div class="stat-item">
<div class="stat-value" id="busCount">-</div>
<div class="stat-label">Active Buses</div>
</div>
<div class="stat-item">
<div class="stat-value" id="arrivalCount">-</div>
<div class="stat-label">Upcoming</div>
</div>
</div>
<div class="arrivals-section">
<h2>Upcoming Arrivals</h2>
<div id="arrivalsList" class="loading">
Loading arrivals...
</div>
</div>
<div class="last-update" id="lastUpdate">
Last updated: -
</div>
</div>
<div class="map-container">
<div id="map"></div>
</div>
</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
let map;
let stopMarker;
let vehicleMarkers = [];
let config = null;
let currentStopId = null;
let currentRouteId = null;
let refreshInterval = null;
// Get URL parameters
function getUrlParams() {
const params = new URLSearchParams(window.location.search);
return {
stopId: params.get('stopId'),
routeId: params.get('routeId')
};
}
// Update URL without reload
function updateUrl(stopId, routeId) {
const url = new URL(window.location);
url.searchParams.set('stopId', stopId);
url.searchParams.set('routeId', routeId);
window.history.pushState({}, '', url);
}
// Initialize map
async function initMap() {
if (map) {
map.remove();
}
// Get configuration with selected stop and route
const queryParams = currentStopId && currentRouteId
? `?stopId=${currentStopId}&routeId=${currentRouteId}`
: '';
const response = await fetch('/api/config' + queryParams);
config = await response.json();
// Update header
document.getElementById('routeInfo').textContent =
`${config.route.shortName} - ${config.route.longName} at ${config.stop.name}`;
// Update sidebar info
document.getElementById('stopName').textContent = config.stop.name;
document.getElementById('stopCode').textContent = `Stop Code: ${config.stop.code}`;
// Initialize map centered on stop
map = L.map('map').setView([config.stop.lat, config.stop.lon], 14);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(map);
// Add stop marker
const stopIcon = L.divIcon({
html: '<div style="background: #667eea; width: 30px; height: 30px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.3);"></div>',
className: '',
iconSize: [30, 30],
iconAnchor: [15, 15]
});
stopMarker = L.marker([config.stop.lat, config.stop.lon], { icon: stopIcon })
.addTo(map)
.bindPopup(`<b>${config.stop.name}</b><br>Stop Code: ${config.stop.code}`);
}
// Update vehicle positions
async function updateVehicles() {
if (!currentRouteId) return;
try {
const response = await fetch(`/api/vehicles?routeId=${currentRouteId}`);
const vehicles = await response.json();
document.getElementById('busCount').textContent = vehicles.length;
// Remove old markers
vehicleMarkers.forEach(marker => map.removeLayer(marker));
vehicleMarkers = [];
// Add new markers
vehicles.forEach(vehicle => {
const label = vehicle.label || vehicle.vehicleId || 'Bus';
const speedKmh = vehicle.speed ? (vehicle.speed * 3.6).toFixed(1) : '0.0'; // Convert m/s to km/h
// Create arrow icon rotated by bearing
const busIcon = L.divIcon({
html: `<div style="transform: rotate(${vehicle.bearing}deg); width: 30px; height: 30px;">
<svg width="30" height="30" viewBox="0 0 30 30">
<path d="M15 5 L25 25 L15 20 L5 25 Z" fill="#ff6b6b" stroke="white" stroke-width="2"/>
</svg>
</div>`,
className: '',
iconSize: [30, 30],
iconAnchor: [15, 15]
});
const marker = L.marker([vehicle.lat, vehicle.lon], { icon: busIcon })
.addTo(map)
.bindPopup(`
<b>Bus ${label}</b><br>
Vehicle ID: ${vehicle.vehicleId}<br>
Speed: ${speedKmh} km/h<br>
Bearing: ${vehicle.bearing}°<br>
Status: ${vehicle.currentStatus}<br>
Updated: ${new Date(vehicle.timestamp).toLocaleTimeString()}
`);
vehicleMarkers.push(marker);
});
} catch (error) {
console.error('Failed to update vehicles:', error);
}
}
// Update arrivals
async function updateArrivals() {
if (!currentStopId || !currentRouteId) return;
try {
const response = await fetch(`/api/arrivals?stopId=${currentStopId}&routeId=${currentRouteId}`);
const arrivals = await response.json();
const arrivalsList = document.getElementById('arrivalsList');
document.getElementById('arrivalCount').textContent = arrivals.length;
if (arrivals.length === 0) {
arrivalsList.innerHTML = '<div class="no-buses">No buses scheduled in the next 90 minutes</div>';
return;
}
arrivalsList.innerHTML = arrivals.slice(0, 10).map((arrival, index) => {
const arrivalTime = new Date(arrival.arrivalTime);
const delayMin = Math.floor(arrival.delaySeconds / 60);
let delayBadge = '';
if (delayMin > 2) {
delayBadge = `<span class="badge badge-late">${delayMin}min late</span>`;
} else if (delayMin < -2) {
delayBadge = `<span class="badge badge-early">${Math.abs(delayMin)}min early</span>`;
} else {
delayBadge = `<span class="badge badge-ontime">On time</span>`;
}
const realtimeBadge = arrival.isRealtime
? '<span class="badge badge-live">LIVE</span>'
: '<span class="badge badge-scheduled">SCHEDULED</span>';
let cssClass = 'arrival-item';
if (arrival.minutesUntil <= 2) {
cssClass += ' approaching';
} else if (arrival.minutesUntil <= 10) {
cssClass += ' soon';
}
const minutesText = arrival.minutesUntil <= 0
? 'ARRIVING NOW'
: `in ${arrival.minutesUntil} min`;
return `
<div class="${cssClass}">
<div class="arrival-time">${arrivalTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })}</div>
<div class="arrival-minutes">${minutesText}</div>
<div class="arrival-details">
${realtimeBadge}
${delayBadge}
<br>
Direction: ${arrival.headsign}
</div>
</div>
`;
}).join('');
document.getElementById('lastUpdate').textContent =
`Last updated: ${new Date().toLocaleTimeString()}`;
} catch (error) {
console.error('Failed to update arrivals:', error);
document.getElementById('arrivalsList').innerHTML =
'<div class="no-buses">Failed to load arrivals</div>';
}
}
// Load tracker for selected stop and route
async function loadTracker() {
const stopId = document.getElementById('stopId').value.trim();
const routeId = document.getElementById('routeId').value.trim();
if (!stopId || !routeId) {
alert('Please select both a stop and a route');
return;
}
// Clear existing interval
if (refreshInterval) {
clearInterval(refreshInterval);
}
// Update current selection
currentStopId = stopId;
currentRouteId = routeId;
// Update URL
updateUrl(stopId, routeId);
// Initialize tracker
document.getElementById('arrivalsList').innerHTML = '<div class="loading">Loading arrivals...</div>';
try {
await initMap();
await updateVehicles();
await updateArrivals();
// Refresh data every 5 seconds
refreshInterval = setInterval(async () => {
await updateVehicles();
await updateArrivals();
}, 5000);
} catch (error) {
console.error('Failed to load tracker:', error);
alert('Failed to load tracker. Please try another stop or route.');
}
}
// Load stops and routes
async function loadStopsAndRoutes() {
try {
// Load stops
const stopsResponse = await fetch('/api/stops');
const stops = await stopsResponse.json();
const stopSelect = document.getElementById('stopId');
stops.sort((a, b) => a.name.localeCompare(b.name));
stopSelect.innerHTML = '<option value="">Select a stop...</option>' +
stops.map(stop =>
`<option value="${stop.id}">${stop.name} (${stop.code})</option>`
).join('');
// Load routes
const routesResponse = await fetch('/api/routes');
const routes = await routesResponse.json();
const routeSelect = document.getElementById('routeId');
routes.sort((a, b) => {
const aNum = parseInt(a.shortName) || 9999;
const bNum = parseInt(b.shortName) || 9999;
return aNum - bNum;
});
routeSelect.innerHTML = '<option value="">Select a route...</option>' +
routes.map(route =>
`<option value="${route.id}">${route.shortName} - ${route.longName}</option>`
).join('');
} catch (error) {
console.error('Failed to load stops and routes:', error);
}
}
// Start application
async function start() {
// Load stops and routes first
await loadStopsAndRoutes();
// Check URL parameters
const params = getUrlParams();
if (params.stopId && params.routeId) {
// Load from URL
currentStopId = params.stopId;
currentRouteId = params.routeId;
document.getElementById('stopId').value = params.stopId;
document.getElementById('routeId').value = params.routeId;
await loadTracker();
} else {
// Load defaults
const response = await fetch('/api/config');
const defaultConfig = await response.json();
document.getElementById('stopId').value = defaultConfig.defaults.stopId;
document.getElementById('routeId').value = defaultConfig.defaults.routeId;
// Auto-load defaults
await loadTracker();
}
}
start();
</script>
</body>
</html>

260
server.ts Normal file
View File

@@ -0,0 +1,260 @@
import express, { Request, Response } from 'express';
import GtfsRealtimeBindings from 'gtfs-realtime-bindings';
import { loadGtfsStops, loadGtfsRoutes } from './lib/gtfs';
import { config, StopConfig, RouteConfig } from './config';
const app = express();
// Load GTFS data
const stops = loadGtfsStops();
const routes = loadGtfsRoutes();
// Serve static files
import * as path from 'path';
app.use(express.static(path.join(__dirname, 'public')));
// API Endpoints
app.get('/api/config', (req: Request, res: Response) => {
const stopId = (req.query.stopId as string) || config.defaultStop.stopId;
const routeId = (req.query.routeId as string) || config.defaultRoute.routeId;
const stop = stops.get(stopId);
const route = routes.get(routeId);
res.json({
stop: {
id: stopId,
code: stop?.stop_code,
name: stop?.stop_name,
lat: stop?.stop_lat,
lon: stop?.stop_lon,
},
route: {
id: routeId,
shortName: route?.route_short_name,
longName: route?.route_long_name,
},
defaults: {
stopId: config.defaultStop.stopId,
routeId: config.defaultRoute.routeId,
}
});
});
app.get('/api/stops', (req: Request, res: Response) => {
const stopsList = Array.from(stops.values()).map(stop => ({
id: stop.stop_id,
code: stop.stop_code,
name: stop.stop_name,
lat: stop.stop_lat,
lon: stop.stop_lon,
}));
res.json(stopsList);
});
app.get('/api/routes', (req: Request, res: Response) => {
const routesList = Array.from(routes.values()).map(route => ({
id: route.route_id,
shortName: route.route_short_name,
longName: route.route_long_name,
}));
res.json(routesList);
});
app.get('/api/arrivals', async (req: Request, res: Response) => {
try {
const stopId = (req.query.stopId as string) || config.defaultStop.stopId;
const routeId = (req.query.routeId as string) || config.defaultRoute.routeId;
const stop = stops.get(stopId);
if (!stop) {
return res.status(404).json({ error: `Stop ${stopId} not found` });
}
const radius = 50;
const nearbyUrl = `${config.baseUrl}/transport/planner/stops/nearbyTimes?latitude=${stop.stop_lat}&longitude=${stop.stop_lon}&radius=${radius}`;
const response = await fetch(nearbyUrl);
const nearbyData = await response.json() as any[];
const now = new Date();
const arrivals: any[] = [];
for (const stopData of nearbyData) {
if (stopData.id.toString() !== stopId) continue;
for (const pattern of stopData.patterns) {
if (pattern.routeId.toString() !== routeId) continue;
const routeInfo = routes.get(pattern.routeId.toString());
for (const stopTime of pattern.stopTimes) {
const serviceDay = new Date(stopTime.serviceDay * 1000);
const arrivalTime = new Date(serviceDay.getTime() + stopTime.realtimeArrival * 1000);
const scheduledTime = new Date(serviceDay.getTime() + stopTime.scheduledArrival * 1000);
const minutesUntil = Math.floor((arrivalTime.getTime() - now.getTime()) / 60000);
if (minutesUntil >= -2 && minutesUntil <= config.tracking.minutesAhead) {
arrivals.push({
arrivalTime: arrivalTime.toISOString(),
scheduledTime: scheduledTime.toISOString(),
minutesUntil: minutesUntil,
delaySeconds: stopTime.arrivalDelay,
headsign: stopTime.headsign,
isRealtime: stopTime.realtime,
realtimeState: stopTime.realtimeState,
});
}
}
}
}
arrivals.sort((a, b) => new Date(a.arrivalTime).getTime() - new Date(b.arrivalTime).getTime());
res.json(arrivals);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch arrivals' });
}
});
app.get('/api/vehicles', async (req: Request, res: Response) => {
try {
const routeId = (req.query.routeId as string) || config.defaultRoute.routeId;
// Fetch all vehicles from JSON API
const vehiclesResponse = await fetch(config.apiEndpoints.vehiclesJson);
if (!vehiclesResponse.ok) {
throw new Error(`HTTP error! status: ${vehiclesResponse.status}`);
}
const allVehicles = await vehiclesResponse.json() as any[];
// Fetch trip updates to find which vehicles are on our route
const tripUpdatesResponse = await fetch(config.apiEndpoints.gtfsRtTripUpdates);
if (!tripUpdatesResponse.ok) {
console.warn('Could not fetch trip updates, returning all vehicles');
// Return all vehicles with basic info
res.json(allVehicles.slice(0, 20).map((v: any) => ({
id: v.id,
vehicleId: v.identificationNumber,
label: v.inventoryNumber,
lat: v.positionLatitude,
lon: v.positionLongitude,
bearing: v.positionBearing,
speed: v.positionSpeed,
timestamp: v.positionModifiedAt,
tripId: '',
currentStopSequence: 0,
currentStatus: 'UNKNOWN',
})));
return;
}
const buffer = await tripUpdatesResponse.arrayBuffer();
if (buffer.byteLength === 0) {
console.warn('Empty trip updates feed');
res.json([]);
return;
}
let feed;
try {
feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(
new Uint8Array(buffer)
);
} catch (decodeError) {
console.error('Failed to decode GTFS-RT feed:', decodeError);
res.json([]);
return;
}
// Find trip IDs and vehicle IDs for our route
const routeVehicleIds = new Set<string>();
const vehicleTripMap = new Map<string, any>();
for (const entity of feed.entity) {
if (!entity.tripUpdate) continue;
const tripUpdate = entity.tripUpdate;
// Filter by target route
if (tripUpdate.trip?.routeId !== routeId) continue;
// Get vehicle ID if available
if (tripUpdate.vehicle?.id) {
routeVehicleIds.add(tripUpdate.vehicle.id);
vehicleTripMap.set(tripUpdate.vehicle.id, {
tripId: tripUpdate.trip.tripId,
routeId: tripUpdate.trip.routeId,
});
}
// Also try vehicle label
if (tripUpdate.vehicle?.label) {
routeVehicleIds.add(tripUpdate.vehicle.label);
vehicleTripMap.set(tripUpdate.vehicle.label, {
tripId: tripUpdate.trip.tripId,
routeId: tripUpdate.trip.routeId,
});
}
}
// Filter vehicles by route
const activeVehicles: any[] = [];
for (const vehicle of allVehicles) {
// Try to match by identification number or inventory number
const vehicleId = vehicle.identificationNumber || vehicle.inventoryNumber?.toString();
if (vehicleId && (routeVehicleIds.has(vehicleId) || routeVehicleIds.has(vehicle.inventoryNumber?.toString()))) {
const tripInfo = vehicleTripMap.get(vehicleId) || vehicleTripMap.get(vehicle.inventoryNumber?.toString());
activeVehicles.push({
id: vehicle.id,
vehicleId: vehicle.identificationNumber,
label: vehicle.inventoryNumber,
lat: vehicle.positionLatitude,
lon: vehicle.positionLongitude,
bearing: vehicle.positionBearing,
speed: vehicle.positionSpeed,
timestamp: vehicle.positionModifiedAt,
tripId: tripInfo?.tripId || '',
currentStopSequence: 0,
currentStatus: vehicle.status === 2 ? 'IN_TRANSIT_TO' : 'UNKNOWN',
});
}
}
console.log(`Found ${routeVehicleIds.size} vehicle IDs for route ${routeId}`);
console.log(`Matched ${activeVehicles.length} vehicles from JSON API`);
res.json(activeVehicles);
} catch (error) {
console.error('Error fetching vehicles:', error);
res.status(500).json({ error: 'Failed to fetch vehicles', details: String(error) });
}
});
// Start server
app.listen(config.server.port, () => {
console.log(`
===========================================================================
BUS TRACKER WEB APP
===========================================================================
Server running at: http://localhost:${config.server.port}
Open your browser and navigate to the URL above to view the map.
Features:
- Real-time bus arrivals
- Live vehicle locations on map
- Interactive map interface
Press Ctrl+C to stop the server
===========================================================================
`);
});

86
setup-gtfs.ts Normal file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env ts-node
/**
* Downloads and extracts GTFS static data
* Run: npm run setup-gtfs
*/
import * as fs from 'fs';
import * as path from 'path';
import * as https from 'https';
import { execSync } from 'child_process';
const GTFS_ZIP_URL = 'https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/gtfs/gtfs.zip';
const GTFS_DIR = path.join(__dirname, 'gtfs');
const ZIP_FILE = path.join(__dirname, 'gtfs.zip');
async function downloadFile(url: string, dest: string): Promise<void> {
return new Promise((resolve, reject) => {
console.log(`Downloading GTFS data from ${url}...`);
const file = fs.createWriteStream(dest);
https.get(url, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirect
if (response.headers.location) {
https.get(response.headers.location, (redirectResponse) => {
redirectResponse.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}).on('error', reject);
}
} else {
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}
}).on('error', (err) => {
fs.unlink(dest, () => reject(err));
});
});
}
async function extractZip(zipPath: string, destDir: string): Promise<void> {
console.log(`Extracting GTFS data to ${destDir}...`);
// Create directory if it doesn't exist
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
// Use unzip command (available on most Linux/Mac systems)
try {
execSync(`unzip -o "${zipPath}" -d "${destDir}"`, { stdio: 'inherit' });
} catch (error) {
throw new Error('Failed to extract zip. Make sure unzip is installed.');
}
}
async function main() {
try {
// Download GTFS zip
await downloadFile(GTFS_ZIP_URL, ZIP_FILE);
console.log('✓ Download complete');
// Extract zip
await extractZip(ZIP_FILE, GTFS_DIR);
console.log('✓ Extraction complete');
// Clean up zip file
fs.unlinkSync(ZIP_FILE);
console.log('✓ Cleanup complete');
// List extracted files
const files = fs.readdirSync(GTFS_DIR);
console.log(`\n✓ GTFS data ready! Files extracted:\n${files.map(f => ` - ${f}`).join('\n')}`);
} catch (error) {
console.error('Error setting up GTFS data:', error);
process.exit(1);
}
}
main();

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noImplicitAny": true,
"strictNullChecks": true
},
"include": ["*.ts"],
"exclude": ["node_modules", "dist"]
}