Partially working api and web
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
dist/
|
||||
315
bun.lock
Normal file
315
bun.lock
Normal 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
347
bus-tracker-json.ts
Normal 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
103
config.ts
Normal 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
110
find-stops-routes.ts
Normal 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
4
gtfs/agency.txt
Normal 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
5
gtfs/calendar.txt
Normal 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
9
gtfs/calendar_dates.txt
Normal 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
1
gtfs/frequencies.txt
Normal file
@@ -0,0 +1 @@
|
||||
trip_id,start_time,end_time,headway_secs,exact_times
|
||||
111
gtfs/routes.txt
Normal file
111
gtfs/routes.txt
Normal 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
4726
gtfs/shapes.txt
Normal file
File diff suppressed because it is too large
Load Diff
110091
gtfs/stop_times.txt
Normal file
110091
gtfs/stop_times.txt
Normal file
File diff suppressed because it is too large
Load Diff
1514
gtfs/stops.txt
Normal file
1514
gtfs/stops.txt
Normal file
File diff suppressed because it is too large
Load Diff
4347
gtfs/trips.txt
Normal file
4347
gtfs/trips.txt
Normal file
File diff suppressed because it is too large
Load Diff
88
lib/gtfs.ts
Normal file
88
lib/gtfs.ts
Normal 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
2058
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal 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
623
public/index.html
Normal 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: '© <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
260
server.ts
Normal 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
86
setup-gtfs.ts
Normal 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
21
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user