This commit is contained in:
Nicholas Orlowsky 2026-02-16 21:36:36 -05:00
parent b7ec6a292f
commit 9297006ab3
No known key found for this signature in database
GPG key ID: A9F3BA4C0AA7A70B
58 changed files with 2032 additions and 2074 deletions

View file

@ -1 +1 @@
/nix/store/jyg5pzxlxkbvzy1wb808kc5idmbij4r6-env-env /nix/store/vvk9w0m05l0yr8ahyib95sg4dmzm354c-env-env

View file

@ -14,6 +14,8 @@ CONFIG_SHELL='/nix/store/rlq03x4cwf8zn73hxaxnx0zn5q9kifls-bash-5.3p3/bin/bash'
export CONFIG_SHELL export CONFIG_SHELL
CXX='g++' CXX='g++'
export CXX export CXX
DETERMINISTIC_BUILD='1'
export DETERMINISTIC_BUILD
HOSTTYPE='x86_64' HOSTTYPE='x86_64'
HOST_PATH='/nix/store/y2ngdv5xdfy5m6jrdgjn1r81rkqy41rh-lvm2-2.03.35-bin/bin:/nix/store/k0gl1zc7f5hk87lylxwbipb0b870bcmk-openssl-3.6.0-bin/bin:/nix/store/rf0crwiz4z45li0n31pqwczi49jg2kwj-util-linux-minimal-2.41.2-bin/bin:/nix/store/f03spphbp2fyq35pcbwxkivxr3av6874-libargon2-20190702/bin:/nix/store/1h477y97ws0m7qdyqdc6s2gj6flba3ha-cryptsetup-2.8.1-bin/bin:/nix/store/vpjk1z71ga010lyzn7f5abbjm4pnr7qn-protobuf-32.1/bin:/nix/store/imad8dvhp77h0pjbckp6wvmnyhp8dpgg-coreutils-9.8/bin:/nix/store/av4xw9f56xlx5pgv862wabfif6m1yc0a-findutils-4.10.0/bin:/nix/store/20axvl7mgj15m23jgmnq97hx37fgz7bk-diffutils-3.12/bin:/nix/store/drc7kang929jaza6cy9zdx10s4gw1z5p-gnused-4.9/bin:/nix/store/x3zjxxz8m4ki88axp0gn8q8m6bldybba-gnugrep-3.12/bin:/nix/store/y2wdhdcrffp9hnkzk06d178hq3g98jay-gawk-5.3.2/bin:/nix/store/yi3c5karhx764ham5rfwk7iynr8mjf6q-gnutar-1.35/bin:/nix/store/d471xb7sfbah076s8rx02i68zpxc2r5n-gzip-1.14/bin:/nix/store/qm9rxn2sc1vrz91i443rr6f0vxm0zd82-bzip2-1.0.8-bin/bin:/nix/store/3fmzbq9y4m9nk235il7scmvwn8j9zy3p-gnumake-4.4.1/bin:/nix/store/rlq03x4cwf8zn73hxaxnx0zn5q9kifls-bash-5.3p3/bin:/nix/store/qrwznp1ikdf0qw05wia2haiwi32ik5n0-patch-2.8/bin:/nix/store/v0rfdwhg6w6i0yb6dbry4srk6pnj3xp0-xz-5.8.1-bin/bin:/nix/store/paj6a1lpzp57hz1djm5bs86b7ci221r0-file-5.45/bin' HOST_PATH='/nix/store/y2ngdv5xdfy5m6jrdgjn1r81rkqy41rh-lvm2-2.03.35-bin/bin:/nix/store/k0gl1zc7f5hk87lylxwbipb0b870bcmk-openssl-3.6.0-bin/bin:/nix/store/rf0crwiz4z45li0n31pqwczi49jg2kwj-util-linux-minimal-2.41.2-bin/bin:/nix/store/f03spphbp2fyq35pcbwxkivxr3av6874-libargon2-20190702/bin:/nix/store/1h477y97ws0m7qdyqdc6s2gj6flba3ha-cryptsetup-2.8.1-bin/bin:/nix/store/vpjk1z71ga010lyzn7f5abbjm4pnr7qn-protobuf-32.1/bin:/nix/store/imad8dvhp77h0pjbckp6wvmnyhp8dpgg-coreutils-9.8/bin:/nix/store/av4xw9f56xlx5pgv862wabfif6m1yc0a-findutils-4.10.0/bin:/nix/store/20axvl7mgj15m23jgmnq97hx37fgz7bk-diffutils-3.12/bin:/nix/store/drc7kang929jaza6cy9zdx10s4gw1z5p-gnused-4.9/bin:/nix/store/x3zjxxz8m4ki88axp0gn8q8m6bldybba-gnugrep-3.12/bin:/nix/store/y2wdhdcrffp9hnkzk06d178hq3g98jay-gawk-5.3.2/bin:/nix/store/yi3c5karhx764ham5rfwk7iynr8mjf6q-gnutar-1.35/bin:/nix/store/d471xb7sfbah076s8rx02i68zpxc2r5n-gzip-1.14/bin:/nix/store/qm9rxn2sc1vrz91i443rr6f0vxm0zd82-bzip2-1.0.8-bin/bin:/nix/store/3fmzbq9y4m9nk235il7scmvwn8j9zy3p-gnumake-4.4.1/bin:/nix/store/rlq03x4cwf8zn73hxaxnx0zn5q9kifls-bash-5.3p3/bin:/nix/store/qrwznp1ikdf0qw05wia2haiwi32ik5n0-patch-2.8/bin:/nix/store/v0rfdwhg6w6i0yb6dbry4srk6pnj3xp0-xz-5.8.1-bin/bin:/nix/store/paj6a1lpzp57hz1djm5bs86b7ci221r0-file-5.45/bin'
export HOST_PATH export HOST_PATH
@ -35,13 +37,13 @@ NIX_CC='/nix/store/vr15iyyykg9zai6fpgvhcgyw7gckl78w-gcc-wrapper-14.3.0'
export NIX_CC export NIX_CC
NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1' NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
export NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu export NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu
NIX_CFLAGS_COMPILE=' -frandom-seed=jyg5pzxlxk -isystem /nix/store/f9qvdl02ckaiawxaqmb3w738g2j395p4-postgresql-14.20-dev/include -isystem /nix/store/9071ak7icm08rqjgw7raybxmhhp086wc-cryptsetup-2.8.1-dev/include -isystem /nix/store/fxxaan0lgr5yqs299sjfw0klwwd53313-lvm2-2.03.35-dev/include -isystem /nix/store/pzxlqc84603x27hibv6zq6giyar0rz0m-json-c-0.18-dev/include -isystem /nix/store/ydrckgnllgg8nmhdwni81h7xhcpnrlhd-openssl-3.6.0-dev/include -isystem /nix/store/57nxr75jbgqnxgbjsp4f6r9shyqzylm2-util-linux-minimal-2.41.2-dev/include -isystem /nix/store/g3prh2g2vsnfwrqp19cf1wnpchn5p00b-popt-1.19/include -isystem /nix/store/f03spphbp2fyq35pcbwxkivxr3av6874-libargon2-20190702/include -isystem /nix/store/vpjk1z71ga010lyzn7f5abbjm4pnr7qn-protobuf-32.1/include -isystem /nix/store/7qfvcajvjs89fxqk4379zhbdmlmxjaxb-abseil-cpp-20250814.1/include -isystem /nix/store/f9qvdl02ckaiawxaqmb3w738g2j395p4-postgresql-14.20-dev/include -isystem /nix/store/9071ak7icm08rqjgw7raybxmhhp086wc-cryptsetup-2.8.1-dev/include -isystem /nix/store/fxxaan0lgr5yqs299sjfw0klwwd53313-lvm2-2.03.35-dev/include -isystem /nix/store/pzxlqc84603x27hibv6zq6giyar0rz0m-json-c-0.18-dev/include -isystem /nix/store/ydrckgnllgg8nmhdwni81h7xhcpnrlhd-openssl-3.6.0-dev/include -isystem /nix/store/57nxr75jbgqnxgbjsp4f6r9shyqzylm2-util-linux-minimal-2.41.2-dev/include -isystem /nix/store/g3prh2g2vsnfwrqp19cf1wnpchn5p00b-popt-1.19/include -isystem /nix/store/f03spphbp2fyq35pcbwxkivxr3av6874-libargon2-20190702/include -isystem /nix/store/vpjk1z71ga010lyzn7f5abbjm4pnr7qn-protobuf-32.1/include -isystem /nix/store/7qfvcajvjs89fxqk4379zhbdmlmxjaxb-abseil-cpp-20250814.1/include' NIX_CFLAGS_COMPILE=' -frandom-seed=vvk9w0m05l -isystem /nix/store/f9qvdl02ckaiawxaqmb3w738g2j395p4-postgresql-14.20-dev/include -isystem /nix/store/jj6jldlw37r8yy9kc1smrax9dhnjm2x4-python3-3.13.9/include -isystem /nix/store/9071ak7icm08rqjgw7raybxmhhp086wc-cryptsetup-2.8.1-dev/include -isystem /nix/store/fxxaan0lgr5yqs299sjfw0klwwd53313-lvm2-2.03.35-dev/include -isystem /nix/store/pzxlqc84603x27hibv6zq6giyar0rz0m-json-c-0.18-dev/include -isystem /nix/store/ydrckgnllgg8nmhdwni81h7xhcpnrlhd-openssl-3.6.0-dev/include -isystem /nix/store/57nxr75jbgqnxgbjsp4f6r9shyqzylm2-util-linux-minimal-2.41.2-dev/include -isystem /nix/store/g3prh2g2vsnfwrqp19cf1wnpchn5p00b-popt-1.19/include -isystem /nix/store/f03spphbp2fyq35pcbwxkivxr3av6874-libargon2-20190702/include -isystem /nix/store/vpjk1z71ga010lyzn7f5abbjm4pnr7qn-protobuf-32.1/include -isystem /nix/store/7qfvcajvjs89fxqk4379zhbdmlmxjaxb-abseil-cpp-20250814.1/include -isystem /nix/store/f9qvdl02ckaiawxaqmb3w738g2j395p4-postgresql-14.20-dev/include -isystem /nix/store/jj6jldlw37r8yy9kc1smrax9dhnjm2x4-python3-3.13.9/include -isystem /nix/store/9071ak7icm08rqjgw7raybxmhhp086wc-cryptsetup-2.8.1-dev/include -isystem /nix/store/fxxaan0lgr5yqs299sjfw0klwwd53313-lvm2-2.03.35-dev/include -isystem /nix/store/pzxlqc84603x27hibv6zq6giyar0rz0m-json-c-0.18-dev/include -isystem /nix/store/ydrckgnllgg8nmhdwni81h7xhcpnrlhd-openssl-3.6.0-dev/include -isystem /nix/store/57nxr75jbgqnxgbjsp4f6r9shyqzylm2-util-linux-minimal-2.41.2-dev/include -isystem /nix/store/g3prh2g2vsnfwrqp19cf1wnpchn5p00b-popt-1.19/include -isystem /nix/store/f03spphbp2fyq35pcbwxkivxr3av6874-libargon2-20190702/include -isystem /nix/store/vpjk1z71ga010lyzn7f5abbjm4pnr7qn-protobuf-32.1/include -isystem /nix/store/7qfvcajvjs89fxqk4379zhbdmlmxjaxb-abseil-cpp-20250814.1/include'
export NIX_CFLAGS_COMPILE export NIX_CFLAGS_COMPILE
NIX_ENFORCE_NO_NATIVE='1' NIX_ENFORCE_NO_NATIVE='1'
export NIX_ENFORCE_NO_NATIVE export NIX_ENFORCE_NO_NATIVE
NIX_HARDENING_ENABLE='bindnow format fortify fortify3 libcxxhardeningextensive libcxxhardeningfast pic relro stackclashprotection stackprotector strictoverflow zerocallusedregs' NIX_HARDENING_ENABLE='bindnow format fortify fortify3 libcxxhardeningextensive libcxxhardeningfast pic relro stackclashprotection stackprotector strictoverflow zerocallusedregs'
export NIX_HARDENING_ENABLE export NIX_HARDENING_ENABLE
NIX_LDFLAGS='-rpath /home/nickorlow/programming/septastic/api/outputs/out/lib -L/nix/store/f9qvdl02ckaiawxaqmb3w738g2j395p4-postgresql-14.20-dev/lib -L/nix/store/9l82bx0yp6q4rk2sdkjr3r8zvyghz0mh-postgresql-14.20-lib/lib -L/nix/store/p48k53gf4mskvxrfahajaczk6m3isfrm-postgresql-14.20/lib -L/nix/store/0s06zawwhmqq3vdf39bi94lxwdyvg705-lvm2-2.03.35-lib/lib -L/nix/store/64q3424klqaq5bq409nrmjmiyrs04k2a-json-c-0.18/lib -L/nix/store/61i74yjkj9p1qphfl7018ja4sdwkipx0-openssl-3.6.0/lib -L/nix/store/z4wx1a8n24fxfl4rjpf0jg8cmp5b76b5-util-linux-minimal-2.41.2-lib/lib -L/nix/store/g3prh2g2vsnfwrqp19cf1wnpchn5p00b-popt-1.19/lib -L/nix/store/f03spphbp2fyq35pcbwxkivxr3av6874-libargon2-20190702/lib -L/nix/store/c7rsnyvdb0zwhj8fb9hnxpfw7nfshfxb-cryptsetup-2.8.1/lib -L/nix/store/vpjk1z71ga010lyzn7f5abbjm4pnr7qn-protobuf-32.1/lib -L/nix/store/7qfvcajvjs89fxqk4379zhbdmlmxjaxb-abseil-cpp-20250814.1/lib -L/nix/store/f9qvdl02ckaiawxaqmb3w738g2j395p4-postgresql-14.20-dev/lib -L/nix/store/9l82bx0yp6q4rk2sdkjr3r8zvyghz0mh-postgresql-14.20-lib/lib -L/nix/store/p48k53gf4mskvxrfahajaczk6m3isfrm-postgresql-14.20/lib -L/nix/store/0s06zawwhmqq3vdf39bi94lxwdyvg705-lvm2-2.03.35-lib/lib -L/nix/store/64q3424klqaq5bq409nrmjmiyrs04k2a-json-c-0.18/lib -L/nix/store/61i74yjkj9p1qphfl7018ja4sdwkipx0-openssl-3.6.0/lib -L/nix/store/z4wx1a8n24fxfl4rjpf0jg8cmp5b76b5-util-linux-minimal-2.41.2-lib/lib -L/nix/store/g3prh2g2vsnfwrqp19cf1wnpchn5p00b-popt-1.19/lib -L/nix/store/f03spphbp2fyq35pcbwxkivxr3av6874-libargon2-20190702/lib -L/nix/store/c7rsnyvdb0zwhj8fb9hnxpfw7nfshfxb-cryptsetup-2.8.1/lib -L/nix/store/vpjk1z71ga010lyzn7f5abbjm4pnr7qn-protobuf-32.1/lib -L/nix/store/7qfvcajvjs89fxqk4379zhbdmlmxjaxb-abseil-cpp-20250814.1/lib' NIX_LDFLAGS='-rpath /home/nickorlow/programming/septastic/api/outputs/out/lib -L/nix/store/f9qvdl02ckaiawxaqmb3w738g2j395p4-postgresql-14.20-dev/lib -L/nix/store/9l82bx0yp6q4rk2sdkjr3r8zvyghz0mh-postgresql-14.20-lib/lib -L/nix/store/p48k53gf4mskvxrfahajaczk6m3isfrm-postgresql-14.20/lib -L/nix/store/jj6jldlw37r8yy9kc1smrax9dhnjm2x4-python3-3.13.9/lib -L/nix/store/0s06zawwhmqq3vdf39bi94lxwdyvg705-lvm2-2.03.35-lib/lib -L/nix/store/64q3424klqaq5bq409nrmjmiyrs04k2a-json-c-0.18/lib -L/nix/store/61i74yjkj9p1qphfl7018ja4sdwkipx0-openssl-3.6.0/lib -L/nix/store/z4wx1a8n24fxfl4rjpf0jg8cmp5b76b5-util-linux-minimal-2.41.2-lib/lib -L/nix/store/g3prh2g2vsnfwrqp19cf1wnpchn5p00b-popt-1.19/lib -L/nix/store/f03spphbp2fyq35pcbwxkivxr3av6874-libargon2-20190702/lib -L/nix/store/c7rsnyvdb0zwhj8fb9hnxpfw7nfshfxb-cryptsetup-2.8.1/lib -L/nix/store/vpjk1z71ga010lyzn7f5abbjm4pnr7qn-protobuf-32.1/lib -L/nix/store/7qfvcajvjs89fxqk4379zhbdmlmxjaxb-abseil-cpp-20250814.1/lib -L/nix/store/f9qvdl02ckaiawxaqmb3w738g2j395p4-postgresql-14.20-dev/lib -L/nix/store/9l82bx0yp6q4rk2sdkjr3r8zvyghz0mh-postgresql-14.20-lib/lib -L/nix/store/p48k53gf4mskvxrfahajaczk6m3isfrm-postgresql-14.20/lib -L/nix/store/jj6jldlw37r8yy9kc1smrax9dhnjm2x4-python3-3.13.9/lib -L/nix/store/0s06zawwhmqq3vdf39bi94lxwdyvg705-lvm2-2.03.35-lib/lib -L/nix/store/64q3424klqaq5bq409nrmjmiyrs04k2a-json-c-0.18/lib -L/nix/store/61i74yjkj9p1qphfl7018ja4sdwkipx0-openssl-3.6.0/lib -L/nix/store/z4wx1a8n24fxfl4rjpf0jg8cmp5b76b5-util-linux-minimal-2.41.2-lib/lib -L/nix/store/g3prh2g2vsnfwrqp19cf1wnpchn5p00b-popt-1.19/lib -L/nix/store/f03spphbp2fyq35pcbwxkivxr3av6874-libargon2-20190702/lib -L/nix/store/c7rsnyvdb0zwhj8fb9hnxpfw7nfshfxb-cryptsetup-2.8.1/lib -L/nix/store/vpjk1z71ga010lyzn7f5abbjm4pnr7qn-protobuf-32.1/lib -L/nix/store/7qfvcajvjs89fxqk4379zhbdmlmxjaxb-abseil-cpp-20250814.1/lib'
export NIX_LDFLAGS export NIX_LDFLAGS
NIX_NO_SELF_RPATH='1' NIX_NO_SELF_RPATH='1'
NIX_PKG_CONFIG_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1' NIX_PKG_CONFIG_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
@ -58,13 +60,19 @@ OLDPWD=''
export OLDPWD export OLDPWD
OPTERR='1' OPTERR='1'
OSTYPE='linux-gnu' OSTYPE='linux-gnu'
PATH='/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2/bin:/nix/store/f9qvdl02ckaiawxaqmb3w738g2j395p4-postgresql-14.20-dev/bin:/nix/store/p48k53gf4mskvxrfahajaczk6m3isfrm-postgresql-14.20/bin:/nix/store/8q2582rd22xp8jlcg1xn1w219q5lx5xa-patchelf-0.15.2/bin:/nix/store/vr15iyyykg9zai6fpgvhcgyw7gckl78w-gcc-wrapper-14.3.0/bin:/nix/store/kzq78n13l8w24jn8bx4djj79k5j717f1-gcc-14.3.0/bin:/nix/store/q6wgv06q39bfhx2xl8ysc05wi6m2zdss-glibc-2.40-66-bin/bin:/nix/store/imad8dvhp77h0pjbckp6wvmnyhp8dpgg-coreutils-9.8/bin:/nix/store/xwydcyvlsa3cvssk0y5llgdhlhjvmqdm-binutils-wrapper-2.44/bin:/nix/store/dc9vaz50jg7mibk9xvqw5dqv89cxzla3-binutils-2.44/bin:/nix/store/y2ngdv5xdfy5m6jrdgjn1r81rkqy41rh-lvm2-2.03.35-bin/bin:/nix/store/k0gl1zc7f5hk87lylxwbipb0b870bcmk-openssl-3.6.0-bin/bin:/nix/store/rf0crwiz4z45li0n31pqwczi49jg2kwj-util-linux-minimal-2.41.2-bin/bin:/nix/store/f03spphbp2fyq35pcbwxkivxr3av6874-libargon2-20190702/bin:/nix/store/1h477y97ws0m7qdyqdc6s2gj6flba3ha-cryptsetup-2.8.1-bin/bin:/nix/store/vpjk1z71ga010lyzn7f5abbjm4pnr7qn-protobuf-32.1/bin:/nix/store/imad8dvhp77h0pjbckp6wvmnyhp8dpgg-coreutils-9.8/bin:/nix/store/av4xw9f56xlx5pgv862wabfif6m1yc0a-findutils-4.10.0/bin:/nix/store/20axvl7mgj15m23jgmnq97hx37fgz7bk-diffutils-3.12/bin:/nix/store/drc7kang929jaza6cy9zdx10s4gw1z5p-gnused-4.9/bin:/nix/store/x3zjxxz8m4ki88axp0gn8q8m6bldybba-gnugrep-3.12/bin:/nix/store/y2wdhdcrffp9hnkzk06d178hq3g98jay-gawk-5.3.2/bin:/nix/store/yi3c5karhx764ham5rfwk7iynr8mjf6q-gnutar-1.35/bin:/nix/store/d471xb7sfbah076s8rx02i68zpxc2r5n-gzip-1.14/bin:/nix/store/qm9rxn2sc1vrz91i443rr6f0vxm0zd82-bzip2-1.0.8-bin/bin:/nix/store/3fmzbq9y4m9nk235il7scmvwn8j9zy3p-gnumake-4.4.1/bin:/nix/store/rlq03x4cwf8zn73hxaxnx0zn5q9kifls-bash-5.3p3/bin:/nix/store/qrwznp1ikdf0qw05wia2haiwi32ik5n0-patch-2.8/bin:/nix/store/v0rfdwhg6w6i0yb6dbry4srk6pnj3xp0-xz-5.8.1-bin/bin:/nix/store/paj6a1lpzp57hz1djm5bs86b7ci221r0-file-5.45/bin' PATH='/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2/bin:/nix/store/f9qvdl02ckaiawxaqmb3w738g2j395p4-postgresql-14.20-dev/bin:/nix/store/p48k53gf4mskvxrfahajaczk6m3isfrm-postgresql-14.20/bin:/nix/store/ah9sslqrl111k74wxd5mdjswza9zvwv6-rustfmt-1.91.1/bin:/nix/store/l914ycms28s9if296ygwj7aaflpa1psy-cargo-1.91.1/bin:/nix/store/kqmpsqbrf6r0g9kq1sqa0pd5r8lr12m1-djlint-1.36.4/bin:/nix/store/jj6jldlw37r8yy9kc1smrax9dhnjm2x4-python3-3.13.9/bin:/nix/store/zdg5imhib6a1kll2mdzpxsnjk6ifv3r2-python3.13-cssbeautifier-1.15.4/bin:/nix/store/b9ldwlzwk6jmsmgvh0rfrwg1rxmirzrn-python3.13-editorconfig-0.17.1/bin:/nix/store/czax2jpa46yr9dkbj5cng270c89sqzz4-python3.13-jsbeautifier-1.15.4/bin:/nix/store/6xvl7wswdiq57lsq26y8dal6hpvmsbsj-python3.13-json5-0.12.0/bin:/nix/store/in46mljcw89s7jp8wycfap7zgzsybjya-python3.13-tqdm-4.67.1/bin:/nix/store/8q2582rd22xp8jlcg1xn1w219q5lx5xa-patchelf-0.15.2/bin:/nix/store/vr15iyyykg9zai6fpgvhcgyw7gckl78w-gcc-wrapper-14.3.0/bin:/nix/store/kzq78n13l8w24jn8bx4djj79k5j717f1-gcc-14.3.0/bin:/nix/store/q6wgv06q39bfhx2xl8ysc05wi6m2zdss-glibc-2.40-66-bin/bin:/nix/store/imad8dvhp77h0pjbckp6wvmnyhp8dpgg-coreutils-9.8/bin:/nix/store/xwydcyvlsa3cvssk0y5llgdhlhjvmqdm-binutils-wrapper-2.44/bin:/nix/store/dc9vaz50jg7mibk9xvqw5dqv89cxzla3-binutils-2.44/bin:/nix/store/y2ngdv5xdfy5m6jrdgjn1r81rkqy41rh-lvm2-2.03.35-bin/bin:/nix/store/k0gl1zc7f5hk87lylxwbipb0b870bcmk-openssl-3.6.0-bin/bin:/nix/store/rf0crwiz4z45li0n31pqwczi49jg2kwj-util-linux-minimal-2.41.2-bin/bin:/nix/store/f03spphbp2fyq35pcbwxkivxr3av6874-libargon2-20190702/bin:/nix/store/1h477y97ws0m7qdyqdc6s2gj6flba3ha-cryptsetup-2.8.1-bin/bin:/nix/store/vpjk1z71ga010lyzn7f5abbjm4pnr7qn-protobuf-32.1/bin:/nix/store/imad8dvhp77h0pjbckp6wvmnyhp8dpgg-coreutils-9.8/bin:/nix/store/av4xw9f56xlx5pgv862wabfif6m1yc0a-findutils-4.10.0/bin:/nix/store/20axvl7mgj15m23jgmnq97hx37fgz7bk-diffutils-3.12/bin:/nix/store/drc7kang929jaza6cy9zdx10s4gw1z5p-gnused-4.9/bin:/nix/store/x3zjxxz8m4ki88axp0gn8q8m6bldybba-gnugrep-3.12/bin:/nix/store/y2wdhdcrffp9hnkzk06d178hq3g98jay-gawk-5.3.2/bin:/nix/store/yi3c5karhx764ham5rfwk7iynr8mjf6q-gnutar-1.35/bin:/nix/store/d471xb7sfbah076s8rx02i68zpxc2r5n-gzip-1.14/bin:/nix/store/qm9rxn2sc1vrz91i443rr6f0vxm0zd82-bzip2-1.0.8-bin/bin:/nix/store/3fmzbq9y4m9nk235il7scmvwn8j9zy3p-gnumake-4.4.1/bin:/nix/store/rlq03x4cwf8zn73hxaxnx0zn5q9kifls-bash-5.3p3/bin:/nix/store/qrwznp1ikdf0qw05wia2haiwi32ik5n0-patch-2.8/bin:/nix/store/v0rfdwhg6w6i0yb6dbry4srk6pnj3xp0-xz-5.8.1-bin/bin:/nix/store/paj6a1lpzp57hz1djm5bs86b7ci221r0-file-5.45/bin'
export PATH export PATH
PKG_CONFIG='pkg-config' PKG_CONFIG='pkg-config'
export PKG_CONFIG export PKG_CONFIG
PKG_CONFIG_PATH='/nix/store/f9qvdl02ckaiawxaqmb3w738g2j395p4-postgresql-14.20-dev/lib/pkgconfig:/nix/store/9071ak7icm08rqjgw7raybxmhhp086wc-cryptsetup-2.8.1-dev/lib/pkgconfig:/nix/store/fxxaan0lgr5yqs299sjfw0klwwd53313-lvm2-2.03.35-dev/lib/pkgconfig:/nix/store/pzxlqc84603x27hibv6zq6giyar0rz0m-json-c-0.18-dev/lib/pkgconfig:/nix/store/ydrckgnllgg8nmhdwni81h7xhcpnrlhd-openssl-3.6.0-dev/lib/pkgconfig:/nix/store/57nxr75jbgqnxgbjsp4f6r9shyqzylm2-util-linux-minimal-2.41.2-dev/lib/pkgconfig:/nix/store/g3prh2g2vsnfwrqp19cf1wnpchn5p00b-popt-1.19/lib/pkgconfig:/nix/store/f03spphbp2fyq35pcbwxkivxr3av6874-libargon2-20190702/lib/pkgconfig:/nix/store/vpjk1z71ga010lyzn7f5abbjm4pnr7qn-protobuf-32.1/lib/pkgconfig:/nix/store/7qfvcajvjs89fxqk4379zhbdmlmxjaxb-abseil-cpp-20250814.1/lib/pkgconfig' PKG_CONFIG_PATH='/nix/store/f9qvdl02ckaiawxaqmb3w738g2j395p4-postgresql-14.20-dev/lib/pkgconfig:/nix/store/jj6jldlw37r8yy9kc1smrax9dhnjm2x4-python3-3.13.9/lib/pkgconfig:/nix/store/9071ak7icm08rqjgw7raybxmhhp086wc-cryptsetup-2.8.1-dev/lib/pkgconfig:/nix/store/fxxaan0lgr5yqs299sjfw0klwwd53313-lvm2-2.03.35-dev/lib/pkgconfig:/nix/store/pzxlqc84603x27hibv6zq6giyar0rz0m-json-c-0.18-dev/lib/pkgconfig:/nix/store/ydrckgnllgg8nmhdwni81h7xhcpnrlhd-openssl-3.6.0-dev/lib/pkgconfig:/nix/store/57nxr75jbgqnxgbjsp4f6r9shyqzylm2-util-linux-minimal-2.41.2-dev/lib/pkgconfig:/nix/store/g3prh2g2vsnfwrqp19cf1wnpchn5p00b-popt-1.19/lib/pkgconfig:/nix/store/f03spphbp2fyq35pcbwxkivxr3av6874-libargon2-20190702/lib/pkgconfig:/nix/store/vpjk1z71ga010lyzn7f5abbjm4pnr7qn-protobuf-32.1/lib/pkgconfig:/nix/store/7qfvcajvjs89fxqk4379zhbdmlmxjaxb-abseil-cpp-20250814.1/lib/pkgconfig'
export PKG_CONFIG_PATH export PKG_CONFIG_PATH
PS4='+ ' PS4='+ '
PYTHONHASHSEED='0'
export PYTHONHASHSEED
PYTHONNOUSERSITE='1'
export PYTHONNOUSERSITE
PYTHONPATH='/nix/store/kqmpsqbrf6r0g9kq1sqa0pd5r8lr12m1-djlint-1.36.4/lib/python3.13/site-packages:/nix/store/p352iawg0q3q18ajklyrih6k46nhfz11-python3.13-click-8.2.1/lib/python3.13/site-packages:/nix/store/jj6jldlw37r8yy9kc1smrax9dhnjm2x4-python3-3.13.9/lib/python3.13/site-packages:/nix/store/3bws72fgyh893p03y7s4gyg1wyyfj2gn-python3.13-colorama-0.4.6/lib/python3.13/site-packages:/nix/store/zdg5imhib6a1kll2mdzpxsnjk6ifv3r2-python3.13-cssbeautifier-1.15.4/lib/python3.13/site-packages:/nix/store/b9ldwlzwk6jmsmgvh0rfrwg1rxmirzrn-python3.13-editorconfig-0.17.1/lib/python3.13/site-packages:/nix/store/czax2jpa46yr9dkbj5cng270c89sqzz4-python3.13-jsbeautifier-1.15.4/lib/python3.13/site-packages:/nix/store/32fdhi7k6nvrbj2blzhkq8ga5zq22wch-python3.13-six-1.17.0/lib/python3.13/site-packages:/nix/store/6xvl7wswdiq57lsq26y8dal6hpvmsbsj-python3.13-json5-0.12.0/lib/python3.13/site-packages:/nix/store/sxbna9s12fb3cds258n95bs8zdjlp0iv-python3.13-pathspec-0.12.1/lib/python3.13/site-packages:/nix/store/h7a9751dg6hj9zczx3zf7xb3wa522pzl-python3.13-pyyaml-6.0.3/lib/python3.13/site-packages:/nix/store/206sd795rz2b42p2vhkszv4zziy77087-python3.13-regex-2025.9.18/lib/python3.13/site-packages:/nix/store/31fval0xlhyd16jz8j6svb1wb8vvq4di-python3.13-tomli-2.2.1/lib/python3.13/site-packages:/nix/store/in46mljcw89s7jp8wycfap7zgzsybjya-python3.13-tqdm-4.67.1/lib/python3.13/site-packages'
export PYTHONPATH
RANLIB='ranlib' RANLIB='ranlib'
export RANLIB export RANLIB
READELF='readelf' READELF='readelf'
@ -79,8 +87,12 @@ STRINGS='strings'
export STRINGS export STRINGS
STRIP='strip' STRIP='strip'
export STRIP export STRIP
XDG_DATA_DIRS='/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2/share:/nix/store/p48k53gf4mskvxrfahajaczk6m3isfrm-postgresql-14.20/share:/nix/store/8q2582rd22xp8jlcg1xn1w219q5lx5xa-patchelf-0.15.2/share' XDG_DATA_DIRS='/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2/share:/nix/store/p48k53gf4mskvxrfahajaczk6m3isfrm-postgresql-14.20/share:/nix/store/l914ycms28s9if296ygwj7aaflpa1psy-cargo-1.91.1/share:/nix/store/jj6jldlw37r8yy9kc1smrax9dhnjm2x4-python3-3.13.9/share:/nix/store/8q2582rd22xp8jlcg1xn1w219q5lx5xa-patchelf-0.15.2/share'
export XDG_DATA_DIRS export XDG_DATA_DIRS
_PYTHON_HOST_PLATFORM='linux-x86_64'
export _PYTHON_HOST_PLATFORM
_PYTHON_SYSCONFIGDATA_NAME='_sysconfigdata__linux_x86_64-linux-gnu'
export _PYTHON_SYSCONFIGDATA_NAME
__structuredAttrs='' __structuredAttrs=''
export __structuredAttrs export __structuredAttrs
_substituteStream_has_warned_replace_deprecation='false' _substituteStream_has_warned_replace_deprecation='false'
@ -116,9 +128,9 @@ doInstallCheck=''
export doInstallCheck export doInstallCheck
dontAddDisableDepTrack='1' dontAddDisableDepTrack='1'
export dontAddDisableDepTrack export dontAddDisableDepTrack
declare -a envBuildBuildHooks=() declare -a envBuildBuildHooks=('addPythonPath' 'sysconfigdataHook' )
declare -a envBuildHostHooks=() declare -a envBuildHostHooks=('addPythonPath' 'sysconfigdataHook' )
declare -a envBuildTargetHooks=() declare -a envBuildTargetHooks=('addPythonPath' 'sysconfigdataHook' )
declare -a envHostHostHooks=('pkgConfigWrapper_addPkgConfigPath' 'ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' ) declare -a envHostHostHooks=('pkgConfigWrapper_addPkgConfigPath' 'ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' )
declare -a envHostTargetHooks=('pkgConfigWrapper_addPkgConfigPath' 'ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' ) declare -a envHostTargetHooks=('pkgConfigWrapper_addPkgConfigPath' 'ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' )
declare -a envTargetTargetHooks=() declare -a envTargetTargetHooks=()
@ -128,7 +140,7 @@ mesonFlags=''
export mesonFlags export mesonFlags
name='env-env' name='env-env'
export name export name
nativeBuildInputs='/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2 /nix/store/f9qvdl02ckaiawxaqmb3w738g2j395p4-postgresql-14.20-dev' nativeBuildInputs='/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2 /nix/store/f9qvdl02ckaiawxaqmb3w738g2j395p4-postgresql-14.20-dev /nix/store/ah9sslqrl111k74wxd5mdjswza9zvwv6-rustfmt-1.91.1 /nix/store/l914ycms28s9if296ygwj7aaflpa1psy-cargo-1.91.1 /nix/store/kqmpsqbrf6r0g9kq1sqa0pd5r8lr12m1-djlint-1.36.4'
export nativeBuildInputs export nativeBuildInputs
out='/home/nickorlow/programming/septastic/api/outputs/out' out='/home/nickorlow/programming/septastic/api/outputs/out'
export out export out
@ -147,7 +159,7 @@ patches=''
export patches export patches
pkg='/nix/store/vr15iyyykg9zai6fpgvhcgyw7gckl78w-gcc-wrapper-14.3.0' pkg='/nix/store/vr15iyyykg9zai6fpgvhcgyw7gckl78w-gcc-wrapper-14.3.0'
declare -a pkgsBuildBuild=() declare -a pkgsBuildBuild=()
declare -a pkgsBuildHost=('/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2' '/nix/store/f9qvdl02ckaiawxaqmb3w738g2j395p4-postgresql-14.20-dev' '/nix/store/9l82bx0yp6q4rk2sdkjr3r8zvyghz0mh-postgresql-14.20-lib' '/nix/store/p48k53gf4mskvxrfahajaczk6m3isfrm-postgresql-14.20' '/nix/store/8q2582rd22xp8jlcg1xn1w219q5lx5xa-patchelf-0.15.2' '/nix/store/l2xk4ac1wx9c95kpp8vymv9r9yn57fvh-update-autotools-gnu-config-scripts-hook' '/nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh' '/nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh' '/nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh' '/nix/store/wgrbkkaldkrlrni33ccvm3b6vbxzb656-make-symlinks-relative.sh' '/nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh' '/nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh' '/nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh' '/nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh' '/nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh' '/nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh' '/nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh' '/nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh' '/nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh' '/nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh' '/nix/store/vr15iyyykg9zai6fpgvhcgyw7gckl78w-gcc-wrapper-14.3.0' '/nix/store/xwydcyvlsa3cvssk0y5llgdhlhjvmqdm-binutils-wrapper-2.44' ) declare -a pkgsBuildHost=('/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2' '/nix/store/f9qvdl02ckaiawxaqmb3w738g2j395p4-postgresql-14.20-dev' '/nix/store/9l82bx0yp6q4rk2sdkjr3r8zvyghz0mh-postgresql-14.20-lib' '/nix/store/p48k53gf4mskvxrfahajaczk6m3isfrm-postgresql-14.20' '/nix/store/ah9sslqrl111k74wxd5mdjswza9zvwv6-rustfmt-1.91.1' '/nix/store/l914ycms28s9if296ygwj7aaflpa1psy-cargo-1.91.1' '/nix/store/kqmpsqbrf6r0g9kq1sqa0pd5r8lr12m1-djlint-1.36.4' '/nix/store/p352iawg0q3q18ajklyrih6k46nhfz11-python3.13-click-8.2.1' '/nix/store/jj6jldlw37r8yy9kc1smrax9dhnjm2x4-python3-3.13.9' '/nix/store/3bws72fgyh893p03y7s4gyg1wyyfj2gn-python3.13-colorama-0.4.6' '/nix/store/zdg5imhib6a1kll2mdzpxsnjk6ifv3r2-python3.13-cssbeautifier-1.15.4' '/nix/store/b9ldwlzwk6jmsmgvh0rfrwg1rxmirzrn-python3.13-editorconfig-0.17.1' '/nix/store/czax2jpa46yr9dkbj5cng270c89sqzz4-python3.13-jsbeautifier-1.15.4' '/nix/store/32fdhi7k6nvrbj2blzhkq8ga5zq22wch-python3.13-six-1.17.0' '/nix/store/6xvl7wswdiq57lsq26y8dal6hpvmsbsj-python3.13-json5-0.12.0' '/nix/store/sxbna9s12fb3cds258n95bs8zdjlp0iv-python3.13-pathspec-0.12.1' '/nix/store/h7a9751dg6hj9zczx3zf7xb3wa522pzl-python3.13-pyyaml-6.0.3' '/nix/store/206sd795rz2b42p2vhkszv4zziy77087-python3.13-regex-2025.9.18' '/nix/store/31fval0xlhyd16jz8j6svb1wb8vvq4di-python3.13-tomli-2.2.1' '/nix/store/in46mljcw89s7jp8wycfap7zgzsybjya-python3.13-tqdm-4.67.1' '/nix/store/8q2582rd22xp8jlcg1xn1w219q5lx5xa-patchelf-0.15.2' '/nix/store/l2xk4ac1wx9c95kpp8vymv9r9yn57fvh-update-autotools-gnu-config-scripts-hook' '/nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh' '/nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh' '/nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh' '/nix/store/wgrbkkaldkrlrni33ccvm3b6vbxzb656-make-symlinks-relative.sh' '/nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh' '/nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh' '/nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh' '/nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh' '/nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh' '/nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh' '/nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh' '/nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh' '/nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh' '/nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh' '/nix/store/vr15iyyykg9zai6fpgvhcgyw7gckl78w-gcc-wrapper-14.3.0' '/nix/store/xwydcyvlsa3cvssk0y5llgdhlhjvmqdm-binutils-wrapper-2.44' )
declare -a pkgsBuildTarget=() declare -a pkgsBuildTarget=()
declare -a pkgsHostHost=() declare -a pkgsHostHost=()
declare -a pkgsHostTarget=('/nix/store/9071ak7icm08rqjgw7raybxmhhp086wc-cryptsetup-2.8.1-dev' '/nix/store/fxxaan0lgr5yqs299sjfw0klwwd53313-lvm2-2.03.35-dev' '/nix/store/y2ngdv5xdfy5m6jrdgjn1r81rkqy41rh-lvm2-2.03.35-bin' '/nix/store/0s06zawwhmqq3vdf39bi94lxwdyvg705-lvm2-2.03.35-lib' '/nix/store/pzxlqc84603x27hibv6zq6giyar0rz0m-json-c-0.18-dev' '/nix/store/64q3424klqaq5bq409nrmjmiyrs04k2a-json-c-0.18' '/nix/store/ydrckgnllgg8nmhdwni81h7xhcpnrlhd-openssl-3.6.0-dev' '/nix/store/k0gl1zc7f5hk87lylxwbipb0b870bcmk-openssl-3.6.0-bin' '/nix/store/61i74yjkj9p1qphfl7018ja4sdwkipx0-openssl-3.6.0' '/nix/store/57nxr75jbgqnxgbjsp4f6r9shyqzylm2-util-linux-minimal-2.41.2-dev' '/nix/store/rf0crwiz4z45li0n31pqwczi49jg2kwj-util-linux-minimal-2.41.2-bin' '/nix/store/z4wx1a8n24fxfl4rjpf0jg8cmp5b76b5-util-linux-minimal-2.41.2-lib' '/nix/store/g3prh2g2vsnfwrqp19cf1wnpchn5p00b-popt-1.19' '/nix/store/f03spphbp2fyq35pcbwxkivxr3av6874-libargon2-20190702' '/nix/store/1h477y97ws0m7qdyqdc6s2gj6flba3ha-cryptsetup-2.8.1-bin' '/nix/store/c7rsnyvdb0zwhj8fb9hnxpfw7nfshfxb-cryptsetup-2.8.1' '/nix/store/vpjk1z71ga010lyzn7f5abbjm4pnr7qn-protobuf-32.1' '/nix/store/7qfvcajvjs89fxqk4379zhbdmlmxjaxb-abseil-cpp-20250814.1' ) declare -a pkgsHostTarget=('/nix/store/9071ak7icm08rqjgw7raybxmhhp086wc-cryptsetup-2.8.1-dev' '/nix/store/fxxaan0lgr5yqs299sjfw0klwwd53313-lvm2-2.03.35-dev' '/nix/store/y2ngdv5xdfy5m6jrdgjn1r81rkqy41rh-lvm2-2.03.35-bin' '/nix/store/0s06zawwhmqq3vdf39bi94lxwdyvg705-lvm2-2.03.35-lib' '/nix/store/pzxlqc84603x27hibv6zq6giyar0rz0m-json-c-0.18-dev' '/nix/store/64q3424klqaq5bq409nrmjmiyrs04k2a-json-c-0.18' '/nix/store/ydrckgnllgg8nmhdwni81h7xhcpnrlhd-openssl-3.6.0-dev' '/nix/store/k0gl1zc7f5hk87lylxwbipb0b870bcmk-openssl-3.6.0-bin' '/nix/store/61i74yjkj9p1qphfl7018ja4sdwkipx0-openssl-3.6.0' '/nix/store/57nxr75jbgqnxgbjsp4f6r9shyqzylm2-util-linux-minimal-2.41.2-dev' '/nix/store/rf0crwiz4z45li0n31pqwczi49jg2kwj-util-linux-minimal-2.41.2-bin' '/nix/store/z4wx1a8n24fxfl4rjpf0jg8cmp5b76b5-util-linux-minimal-2.41.2-lib' '/nix/store/g3prh2g2vsnfwrqp19cf1wnpchn5p00b-popt-1.19' '/nix/store/f03spphbp2fyq35pcbwxkivxr3av6874-libargon2-20190702' '/nix/store/1h477y97ws0m7qdyqdc6s2gj6flba3ha-cryptsetup-2.8.1-bin' '/nix/store/c7rsnyvdb0zwhj8fb9hnxpfw7nfshfxb-cryptsetup-2.8.1' '/nix/store/vpjk1z71ga010lyzn7f5abbjm4pnr7qn-protobuf-32.1' '/nix/store/7qfvcajvjs89fxqk4379zhbdmlmxjaxb-abseil-cpp-20250814.1' )
@ -691,6 +703,11 @@ addEnvHooks ()
eval "${pkgHookVar}s"'+=("$@")'; eval "${pkgHookVar}s"'+=("$@")';
done done
} }
addPythonPath ()
{
addToSearchPathWithCustomDelimiter : PYTHONPATH $1/lib/python3.13/site-packages
}
addToSearchPath () addToSearchPath ()
{ {
@ -2017,6 +2034,26 @@ substituteStream ()
done; done;
printf "%s" "${!var}" printf "%s" "${!var}"
} }
sysconfigdataHook ()
{
if [ "$1" = '/nix/store/jj6jldlw37r8yy9kc1smrax9dhnjm2x4-python3-3.13.9' ]; then
export _PYTHON_HOST_PLATFORM='linux-x86_64';
export _PYTHON_SYSCONFIGDATA_NAME='_sysconfigdata__linux_x86_64-linux-gnu';
fi
}
toPythonPath ()
{
local paths="$1";
local result=;
for i in $paths;
do
p="$i/lib/python3.13/site-packages";
result="${result}${result:+:}$p";
done;
echo $result
}
unpackFile () unpackFile ()
{ {

View file

@ -5,27 +5,27 @@ ENV SCCACHE_DIR=/build-cache
ENV RUSTC_WRAPPER=sccache ENV RUSTC_WRAPPER=sccache
WORKDIR . WORKDIR .
COPY ./api ./api COPY ./web ./web
COPY ./libseptastic/ ./libseptastic/ COPY ./libseptastic/ ./libseptastic/
COPY ./api/assets ./assets COPY ./web/assets ./assets
COPY ./api/templates ./templates COPY ./web/templates ./templates
RUN apt -y update && apt install -y libssl-dev protobuf-compiler libc-dev sccache build-essential pkg-config RUN apt -y update && apt install -y libssl-dev protobuf-compiler libc-dev sccache build-essential pkg-config
RUN cd api && cargo build --release RUN cd web && cargo build --release
FROM debian:trixie-slim FROM debian:trixie-slim
WORKDIR /app WORKDIR /app
EXPOSE 8080 EXPOSE 8080
COPY --from=build /api/target/release/septastic_api /app/septastic_api COPY --from=build /web/target/release/septastic_web /app/septastic_web
COPY --from=build /api/config.yaml /app/config.yaml COPY --from=build /web/config.yaml /app/config.yaml
COPY api/assets /app/assets COPY web/assets /app/assets
COPY api/templates /app/templates COPY web/templates /app/templates
RUN apt -y update && apt install -y curl RUN apt -y update && apt install -y curl
ENV RUST_LOG=info ENV RUST_LOG=info
ENV EXPOSE_PORT=8080 ENV EXPOSE_PORT=8080
ENTRYPOINT ["./septastic_api"] ENTRYPOINT ["./septastic_web"]

View file

@ -1,65 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n septa_stop_schedules.route_id,\n septa_stops.name as stop_name,\n trip_id, \n septa_stop_schedules.service_id, \n septa_stop_schedules.direction_id,\n arrival_time,\n stop_id,\n stop_sequence\n FROM \n septa_stop_schedules\n INNER JOIN septa_stops \n ON septa_stops.id = septa_stop_schedules.stop_id\n INNER JOIN septa_schedule_days\n ON septa_schedule_days.date = $2 \n AND\n septa_schedule_days.service_id = septa_stop_schedules.service_id\n WHERE \n septa_stop_schedules.route_id = $1\n ;",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "route_id",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "stop_name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "trip_id",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "service_id",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "direction_id",
"type_info": "Int8"
},
{
"ordinal": 5,
"name": "arrival_time",
"type_info": "Int8"
},
{
"ordinal": 6,
"name": "stop_id",
"type_info": "Int8"
},
{
"ordinal": 7,
"name": "stop_sequence",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "1e1738a3256da7058ddaff950616137b576c37e76410d6845fcf9d3e91e98733"
}

View file

@ -1,59 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n id, \n name, \n short_name, \n color_hex, \n route_type as \"route_type: libseptastic::route::RouteType\"\n FROM \n septa_routes\n WHERE \n id = $1\n ;",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "short_name",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "color_hex",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "route_type: libseptastic::route::RouteType",
"type_info": {
"Custom": {
"name": "septa_route_type",
"kind": {
"Enum": [
"trolley",
"subway_elevated",
"regional_rail",
"bus",
"trackless_trolley"
]
}
}
}
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "4c9b19351cb8e0f6d0b3a45b699f10857eb34c6a023ed2f17f1d2702348b006d"
}

View file

@ -1,57 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n id, \n name, \n short_name, \n color_hex, \n route_type as \"route_type: libseptastic::route::RouteType\"\n FROM \n septa_routes\n ORDER BY\n CASE\n WHEN id ~ '^[0-9]+$' THEN CAST(id AS INT)\n ELSE NULL\n END ASC,\n id ASC;\n ;",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "short_name",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "color_hex",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "route_type: libseptastic::route::RouteType",
"type_info": {
"Custom": {
"name": "septa_route_type",
"kind": {
"Enum": [
"trolley",
"subway_elevated",
"regional_rail",
"bus",
"trackless_trolley"
]
}
}
}
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "8479e45bc7bc3f4170102ffc78d5f25d1ff7486fcb266e7306de8c624090bc89"
}

View file

@ -1,55 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n route_id,\n direction_id,\n direction as \"direction: libseptastic::direction::CardinalDirection\",\n direction_destination\n FROM \n septa_directions\n WHERE \n route_id = $1\n ;",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "route_id",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "direction_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "direction: libseptastic::direction::CardinalDirection",
"type_info": {
"Custom": {
"name": "septa_direction_type",
"kind": {
"Enum": [
"northbound",
"southbound",
"eastbound",
"westbound",
"inbound",
"outbound",
"loop"
]
}
}
}
},
{
"ordinal": 3,
"name": "direction_destination",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "cf4e35691a709fd362b53e3d1f64b6043cbda87c0b5ebb01ba74a6ebb430425c"
}

View file

@ -1,65 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Canvas Curve Full Width</title>
<style>
body { margin: 0; }
canvas { display: block; background: white; }
</style>
</head>
<body>
<canvas id="curveCanvas" height="180"></canvas>
<script>
const canvas = document.getElementById('curveCanvas');
const ctx = canvas.getContext('2d');
// Set canvas width to window width
function resizeCanvas() {
canvas.width = window.innerWidth;
const rightEnd = canvas.width;
const flatStart = 0;
const flatEnd = 450;
const curveStart = flatEnd;
const curveControl1 = 475;
const curveControl2 = 525;
const curvePeak = 550;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Blue stripe (top)
ctx.beginPath();
ctx.moveTo(flatStart, 90);
ctx.lineTo(flatEnd, 90);
ctx.bezierCurveTo(curveControl1, 90, curveControl2, 30, curvePeak, 30);
ctx.lineTo(rightEnd, 30);
ctx.lineTo(rightEnd, 60);
ctx.lineTo(curvePeak, 60);
ctx.bezierCurveTo(curveControl2, 60, curveControl1, 120, flatEnd, 120);
ctx.lineTo(flatStart, 120);
ctx.closePath();
ctx.fillStyle = '#007ac2';
ctx.fill();
// Orange stripe (bottom)
ctx.beginPath();
ctx.moveTo(flatStart, 120);
ctx.lineTo(flatEnd, 120);
ctx.bezierCurveTo(curveControl1, 120, curveControl2, 60, curvePeak, 60);
ctx.lineTo(rightEnd, 60);
ctx.lineTo(rightEnd, 90);
ctx.lineTo(curvePeak, 90);
ctx.bezierCurveTo(curveControl2, 90, curveControl1, 150, flatEnd, 150);
ctx.lineTo(flatStart, 150);
ctx.closePath();
ctx.fillStyle = '#f15a22';
ctx.fill();
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
</script>
</body>
</html>

View file

@ -1,173 +0,0 @@
use actix_web::{get, web::{self, Data}, HttpRequest, HttpResponse, Responder};
use anyhow::anyhow;
use std::{collections::{HashMap, HashSet}, sync::Arc, time::Instant};
use libseptastic::{direction, route::RouteType, stop_schedule::Trip};
use serde::{Serialize, Deserialize};
use crate::AppState;
//use crate::{routing::{bfs_rts, construct_graph, get_stops_near}, AppState};
//use crate::routing;
#[get("/routes")]
async fn get_routes_html(req: HttpRequest, state: Data<Arc<AppState>>) -> impl Responder {
crate::perform_action(req, move || {
let statex = state.clone();
async move {
let start_time = Instant::now();
let all_routes: Vec<libseptastic::route::Route> = statex.gtfs_service.get_routes();
let rr_routes = all_routes.clone().into_iter().filter(|x| x.route_type == RouteType::RegionalRail).collect();
let subway_routes = all_routes.clone().into_iter().filter(|x| x.route_type == RouteType::SubwayElevated).collect();
let trolley_routes = all_routes.clone().into_iter().filter(|x| x.route_type == RouteType::Trolley).collect();
let bus_routes = all_routes.into_iter().filter(|x| x.route_type == RouteType::TracklessTrolley || x.route_type == RouteType::Bus).collect();
Ok(crate::templates::ContentTemplate {
page_title: Some(String::from("Routes")),
page_desc: Some(String::from("All SEPTA routes.")),
widescreen: false,
content: crate::templates::RoutesTemplate {
rr_routes,
subway_routes,
trolley_routes,
bus_routes,
},
load_time_ms: Some(start_time.elapsed().as_millis())
})
}
}).await
}
#[get("/routes.json")]
async fn get_routes_json(state: Data<Arc<AppState>>) -> impl Responder {
let all_routes: Vec<libseptastic::route::Route> = state.gtfs_service.get_routes();
HttpResponse::Ok().json(all_routes)
}
#[derive(Debug, Deserialize, Clone)]
pub struct RouteQueryParams {
#[serde(default)] // Optional: handle missing parameters with a default value
stops: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct RouteResponse {
pub route: libseptastic::route::Route,
pub directions: Vec<libseptastic::direction::Direction>,
pub schedule: Vec<Trip>
}
async fn get_route_info(route_id: String, state: Data<Arc<AppState>>) -> ::anyhow::Result<RouteResponse> {
let route = state.gtfs_service.get_route(route_id.clone())?;
let mut trips = state.gtfs_service.get_schedule(route_id)?;
let mut seen = HashSet::new();
let directions: Vec<_> = trips
.iter()
.map(|x| x.direction.clone())
.filter(|dir| seen.insert(dir.direction.to_string()))
.collect();
state.trip_tracking_service.annotate_trips(&mut trips).await;
Ok(RouteResponse{
route,
directions,
schedule: trips
})
}
//#[get("/directions")]
//async fn get_directions(state: Data<Arc<AppState>>) -> impl Responder {
// let near_thresh = 0.45;
//
// let sig_cds = routing::Coordinates {
// lat: 40.008420,
// lng: -75.213439
// };
//
// let home_cds = routing::Coordinates {
// lat: 39.957210,
// lng: -75.166214
// };
//
// let all_stops = state.gtfs_service.get_all_stops();
//
//
//
// let origin_stops: HashSet<String> = get_stops_near(home_cds, &all_stops);
// let dest_stops: HashSet<String> = get_stops_near(sig_cds.clone(), &all_stops);
//
// let mut graph = construct_graph(sig_cds, &all_stops, &state.gtfs_service);
//
// let mut response = String::new();
// for stop in &origin_stops {
// response += bfs_rts(&stop, &mut graph, &dest_stops).as_str();
// }
//
// return response;
//}
#[get("/route/{route_id}")]
async fn get_route(state: Data<Arc<AppState>>, req: HttpRequest, info: web::Query<RouteQueryParams>, path: web::Path<String>) -> impl Responder {
crate::perform_action(req, move || {
let pathx = path.clone();
let infox = info.clone();
let statex = state.clone();
async move {
let mut filters: Option<Vec<String>> = None;
if let Some (stops_v) = infox.stops.clone() {
let mut items = Vec::new();
for sid in stops_v.split(",") {
items.push(String::from(sid));
}
filters = Some(items);
}
let route_id = pathx;
let route_info_r = get_route_info(route_id.clone(), statex.clone()).await;
if let Ok(route_info) = route_info_r {
let timetables = crate::templates::build_timetables(route_info.directions, route_info.schedule);
Ok(crate::templates::ContentTemplate {
widescreen: false,
page_title: Some(format!("Schedules for {}", route_id.clone())),
page_desc: Some(format!("Schedule information for {}", route_id.clone())),
content: crate::templates::RouteTemplate {
route: route_info.route,
timetables,
filter_stops: filters.clone()
},
load_time_ms: None
})
} else {
Err(anyhow!("test"))
}
}}).await
}
#[get("/route/{route_id}.json")]
async fn api_get_route(state: Data<Arc<AppState>>, path: web::Path<String>) -> impl Responder {
let route_id = path.into_inner();
let route_info_r = get_route_info(route_id, state).await;
if let Ok(route_info) = route_info_r {
HttpResponse::Ok().json(route_info)
} else {
HttpResponse::InternalServerError().body("Error")
}
}
#[get("/api/route/{route_id}/schedule")]
async fn api_get_schedule(state: Data<Arc<AppState>>, path: web::Path<String>) -> impl Responder {
let route_id = path.into_inner();
let route_r: anyhow::Result<i32> = Ok(5);
if let Ok(route) = route_r {
HttpResponse::Ok().json(route)
} else {
HttpResponse::InternalServerError().body("Error")
}
}

View file

@ -1,253 +0,0 @@
use actix_web::{HttpRequest, HttpResponse, Responder, get, guard::Header, http::header::Header, web::{self, Data}};
use anyhow::anyhow;
use askama::Template;
use chrono::{NaiveTime, TimeDelta, Timelike};
use chrono_tz::America::New_York;
use libseptastic::stop_schedule::{self, LiveTrip, SeatAvailability, Trip, TripTracking};
use log::info;
use serde::{Deserialize, Serialize};
use serde_qs::actix::QsQuery;
use std::{collections::{BTreeSet, HashSet}, sync::Arc, time::Instant};
use crate::{AppState, templates::TripPerspective};
#[get("/stops")]
async fn get_stops_html(req: HttpRequest, state: Data<Arc<AppState>>) -> impl Responder {
crate::perform_action(req, move || {
let statex = state.clone();
async move {
let start_time = Instant::now();
let stops = statex.gtfs_service.get_all_stops().iter().filter_map(|f| {
if f.1.id.contains("ANNOTATED") {
Some(libseptastic::stop::Stop::clone(f.1))
} else {
None
}
}).collect();
Ok(crate::ContentTemplate {
page_title: Some(String::from("Stops")),
page_desc: Some(String::from("Stops")),
widescreen: false,
content: crate::templates::StopsTemplate {
tc_stops: stops
},
load_time_ms: Some(start_time.elapsed().as_millis())
})
}
}).await
}
async fn get_trip_perspective_for_stop(state: &Data<Arc<AppState>>,stop: &libseptastic::stop::Stop, filter: &StopFilter) -> Vec<TripPerspective> {
let routes: Vec<libseptastic::route::Route> = state.gtfs_service.get_routes_at_stop(&stop.id).iter().filter_map(|route| {
match state.gtfs_service.get_route(route.clone()) {
Ok(route) => Some(route),
Err(_) => None
}
}).collect();
let route_ids: HashSet<String> = routes.iter().map(|route| route.id.clone()).collect();
let mut trips = state.gtfs_service.get_all_trips().iter().filter_map(|trip| {
if route_ids.contains(trip.0) {
Some(trip.1.clone())
} else {
None
}
}).flatten().collect();
state.trip_tracking_service.annotate_trips(&mut trips).await;
let now_utc = chrono::Utc::now();
let now = now_utc.with_timezone(&New_York);
let naive_time = now.time();
let cur_time = i64::from(naive_time.num_seconds_from_midnight());
let mut filtered_trips: Vec<TripPerspective> = trips.iter().filter_map(|trip| {
// poor midnight handling? -- going to offset by 4 hours, assume next 'schedule day'
// starts at 4a. Still may miss some trips. Oh well!
if !trip.calendar_day.is_calendar_active_for_date(&now.naive_local().checked_add_signed(TimeDelta::hours(-4))?.date()) {
return None
}
let mut est_arrival_time = 0;
let mut is_tracked = false;
let stop_sched : Vec<_> = trip.schedule.iter().filter(|stop_schedule| {
if stop_schedule.stop.id != stop.id {
return false;
}
match &trip.tracking_data {
libseptastic::stop_schedule::TripTracking::Tracked(live) => {
let actual_arrival_time = stop_schedule.get_arrival_time(&live);
est_arrival_time = stop_schedule.arrival_time;
is_tracked = true;
return
(actual_arrival_time - cur_time) > -(1 * 60)
&&
(actual_arrival_time - cur_time) < (60 * 60)
;
},
libseptastic::stop_schedule::TripTracking::Untracked => {
est_arrival_time = stop_schedule.arrival_time;
return
(stop_schedule.arrival_time - cur_time) > -(3 * 60)
&&
(stop_schedule.arrival_time - cur_time) < (60 * 60)
;
},
libseptastic::stop_schedule::TripTracking::Cancelled => {
return false;
}
}
}).filter_map(|ss| Some(ss.clone())).collect();
if stop_sched.len() > 0 && filter.trip_matches(trip) {
Some(TripPerspective {
est_arrival_time,
is_tracked,
perspective_stop: stop_sched.first().unwrap().clone(),
trip: trip.clone()
})
} else {
None
}
}).collect();
filtered_trips.sort_by_key(|f|
match &f.trip.tracking_data {
TripTracking::Tracked(live) => f.perspective_stop.get_arrival_time(&live),
_ => f.perspective_stop.arrival_time
});
filtered_trips
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct StopFilter {
pub routes: Option<HashSet<String>>,
pub live_tracked: Option<bool>,
pub scheduled: Option<bool>,
pub crowding: Option<HashSet<SeatAvailability>>,
pub unknown_crowding: Option<bool>
}
impl StopFilter {
pub fn trip_matches(&self, trip: &Trip) -> bool {
let unspecified = self.live_tracked == None && self.scheduled == None;
let unknown_crowding = self.unknown_crowding.unwrap_or(true);
if (Some(false) == self.scheduled || (!unspecified && self.scheduled == None)) && match trip.tracking_data {
TripTracking::Untracked => true,
_ => false
} {
return false;
}
if (Some(false) == self.live_tracked || (!unspecified && self.live_tracked == None)) && match trip.tracking_data {
TripTracking::Tracked(_) => true,
_ => false
} {
return false;
}
if let Some(routes) = &self.routes {
let route_str = format!("{},{}", trip.route.id, trip.direction.direction);
if !routes.contains(&route_str) {
return false;
}
}
if let Some(crowding) = &self.crowding {
if let TripTracking::Tracked(live_trip) = &trip.tracking_data {
if let Some(seat_availability) = &live_trip.seat_availability {
if !crowding.contains(seat_availability) {
return false;
}
} else {
return unknown_crowding;
}
} else {
return unknown_crowding;
}
}
return true;
}
}
#[get("/stop/{stop_id}/table")]
async fn get_stop_table_html(req: HttpRequest, state: Data<Arc<AppState>>, path: web::Path<String>, query: QsQuery<StopFilter>) -> impl Responder {
let stop_id = path;
if let Some(stop) = state.gtfs_service.get_stop_by_id(&stop_id) {
let filtered_trips = get_trip_perspective_for_stop(&state, &stop, &query).await;
let now_utc = chrono::Utc::now();
let now = now_utc.with_timezone(&New_York);
let naive_time = now.time();
let cur_time = i64::from(naive_time.num_seconds_from_midnight());
let query_str =serde_qs::Config::new().array_format(serde_qs::ArrayFormat::Unindexed).serialize_string(&query.0.clone()).unwrap();
HttpResponse::Ok().append_header(("HX-Replace-Url", format!("/stop/{}?{}", stop_id,&query_str).as_str()))
.body(crate::templates::StopTableTemplate {
trips: filtered_trips,
current_time: cur_time,
filters: Some(query.0.clone()),
query_str,
stop_id: stop_id.to_string()
}.render().unwrap())
}
else {
HttpResponse::InternalServerError().body("Error")
}
}
#[get("/stop/{stop_id}")]
async fn get_stop_html(req: HttpRequest, state: Data<Arc<AppState>>, path: web::Path<String>, query: QsQuery<StopFilter>) -> impl Responder {
crate::perform_action(req, move || {
let statex = state.clone();
let pathx = path.clone();
let queryx = query.clone();
async move {
let stop_id = pathx;
let start_time = Instant::now();
if let Some(stop) = statex.gtfs_service.get_stop_by_id(&stop_id) {
let routes: Vec<libseptastic::route::Route> = statex.gtfs_service.get_routes_at_stop(&stop.id).iter().filter_map(|route| {
match statex.gtfs_service.get_route(route.clone()) {
Ok(route) => Some(route),
Err(_) => None
}
}).collect();
let filtered_trips = get_trip_perspective_for_stop(&statex, &stop, &queryx).await;
let now_utc = chrono::Utc::now();
let now = now_utc.with_timezone(&New_York);
let naive_time = now.time();
let cur_time = i64::from(naive_time.num_seconds_from_midnight());
Ok(crate::templates::ContentTemplate {
page_title: Some(stop.name.clone()),
page_desc: Some(String::from("Stop information")),
widescreen: false,
content: crate::templates::StopTemplate {
stop: stop.clone(),
routes: BTreeSet::from_iter(routes.into_iter()),
trips: filtered_trips,
current_time: cur_time,
filters: Some(queryx.0.clone()),
query_str: serde_qs::Config::new().array_format(serde_qs::ArrayFormat::Unindexed).serialize_string(&queryx.0).unwrap(),
},
load_time_ms: Some(start_time.elapsed().as_millis())
})
}
else {
Err(anyhow!("Stop not found!"))
}
}
}).await
}

View file

@ -1,122 +0,0 @@
use actix_web::{cookie::Cookie, get, web::Data, App, HttpRequest, HttpResponse, HttpServer, Responder};
use env_logger::Env;
use log::*;
use dotenv::dotenv;
use serde::Deserialize;
use services::{gtfs_pull, trip_tracking::{self}};
use templates::ContentTemplate;
use std::{fs::File, io::Read, sync::Arc, time::Instant};
use askama::Template;
mod services;
mod controllers;
mod templates;
//mod routing;
pub struct AppState {
gtfs_service: services::gtfs_pull::GtfsPullService,
trip_tracking_service: services::trip_tracking::TripTrackingService
}
#[derive(Deserialize)]
struct LocalStateQuery {
pub widescreen: Option<bool>
}
pub async fn perform_action<F, Fut, T >(req: HttpRequest, func: F) -> impl Responder
where T: Template,
F: Fn() -> Fut,
Fut: Future<Output = anyhow::Result<ContentTemplate<T>>> + 'static {
let start_time = Instant::now();
let mut enable_widescreen = true;
if let Some(widescreen_set) = req.cookie("widescreen") {
enable_widescreen = widescreen_set.value() == "true";
}
let query_params = actix_web::web::Query::<LocalStateQuery>::from_query(req.query_string()).unwrap();
if let Some(set_widescreen) = query_params.widescreen {
enable_widescreen = set_widescreen;
}
let x = func().await;
match x {
Ok(mut y) => {
y.widescreen = enable_widescreen;
y.load_time_ms = Some(start_time.elapsed().as_nanos());
let mut cookie = Cookie::new("widescreen", y.widescreen.to_string());
cookie.set_path("/");
HttpResponse::Ok()
.cookie(cookie)
.body(y.render().unwrap())
},
Err(err) => {
error!("Returned error b/c {:?}", err);
HttpResponse::InternalServerError().body("Error")
}
}
}
#[get("/")]
async fn get_index(req: HttpRequest) -> impl Responder {
perform_action(req, move || async {
Ok(templates::ContentTemplate {
page_title: None,
page_desc: None,
content: templates::IndexTemplate {},
load_time_ms: None,
widescreen: false
})
}).await
}
#[tokio::main]
async fn main() -> ::anyhow::Result<()> {
env_logger::init_from_env(Env::default().default_filter_or("septastic_api=info"));
dotenv().ok();
let version: &str = option_env!("CARGO_PKG_VERSION").expect("Expected package version");
info!("Starting the SEPTASTIC Server v{} (commit: {})", version, "NONE");
let mut file = File::open("./config.yaml")?;
let mut file_contents = String::new();
file.read_to_string(&mut file_contents);
let config_file = serde_yaml::from_str::<gtfs_pull::Config>(file_contents.as_str())?;
let tt_service = services::trip_tracking::TripTrackingService::new().await;
tt_service.start();
let svc = gtfs_pull::GtfsPullService::new(config_file);
svc.start();
svc.wait_for_ready();
let state = Arc::new(AppState {
gtfs_service: svc,
trip_tracking_service: tt_service
});
HttpServer::new(move || {
App::new()
.wrap(actix_cors::Cors::permissive())
.app_data(Data::new(state.clone()))
.service(controllers::route::api_get_route)
.service(controllers::route::api_get_schedule)
.service(controllers::route::get_route)
.service(controllers::route::get_routes_json)
.service(controllers::route::get_routes_html)
// .service(controllers::route::get_directions)
.service(controllers::stop::get_stops_html)
.service(controllers::stop::get_stop_html)
.service(controllers::stop::get_stop_table_html)
.service(get_index)
.service(actix_files::Files::new("/assets", "./assets"))
})
.bind(("0.0.0.0", 8080))?
.run()
.await?;
Ok(())
}

View file

@ -1,425 +0,0 @@
use std::{cmp::Ordering, collections::{HashMap, HashSet, hash_map::Entry}, env, hash::Hash, io::Cursor, path::PathBuf, sync::{Arc, Mutex, MutexGuard}, thread, time::Duration};
use anyhow::anyhow;
use libseptastic::{stop::Platform, stop_schedule::CalendarDay};
use log::{error, info, warn};
use serde::{Deserialize, Serialize};
use zip::ZipArchive;
macro_rules! make_global_id {
($prefix: expr, $id: expr) => (format!("{}_{}", $prefix, $id))
}
#[derive(Serialize, Deserialize, PartialEq, Debug,Clone)]
struct GtfsSource {
pub uri: String,
pub subzip: Option<String>,
pub prefix: String
}
#[derive(Serialize, Deserialize, PartialEq, Debug,Clone)]
struct MultiplatformStopConfig {
pub id: String,
pub name: String,
pub platform_station_ids: Vec<String>
}
#[derive(Serialize, Deserialize, PartialEq, Debug,Clone)]
struct Annotations {
pub multiplatform_stops: Vec<MultiplatformStopConfig>
}
#[derive(Serialize, Deserialize, PartialEq, Debug)]
pub struct Config {
pub gtfs_zips: Vec<GtfsSource>,
pub annotations: Annotations
}
#[derive(Clone)]
struct GtfsFile {
pub source: GtfsSource,
pub hash: Option<String>
}
struct TransitData {
pub routes: HashMap<String, Arc<libseptastic::route::Route>>,
pub agencies: HashMap<String, libseptastic::agency::Agency>,
pub trips: HashMap<String, Vec<libseptastic::stop_schedule::Trip>>,
pub stops: HashMap<String, Arc<libseptastic::stop::Stop>>,
pub platforms: HashMap<String, Arc<libseptastic::stop::Platform>>,
pub calendar_days: HashMap<String, Arc<libseptastic::stop_schedule::CalendarDay>>,
pub directions: HashMap<String, Vec<Arc<libseptastic::direction::Direction>>>,
// extended lookup methods
pub route_id_by_stops: HashMap<String, HashSet<String>>,
pub stops_by_route_id: HashMap<String, HashSet<String>>,
pub stops_by_platform_id: HashMap<String, Arc<libseptastic::stop::Stop>>
}
struct GtfsPullServiceState {
pub gtfs_files: Vec<GtfsFile>,
pub tmp_dir: PathBuf,
pub ready: bool,
pub annotations: Annotations,
pub transit_data: TransitData
}
pub struct GtfsPullService {
state: Arc<Mutex<GtfsPullServiceState>>
}
impl TransitData {
pub fn new() -> Self {
return TransitData { routes: HashMap::new(), agencies: HashMap::new(), trips: HashMap::new(), stops: HashMap::new(), platforms: HashMap::new(), route_id_by_stops: HashMap::new(), stops_by_route_id: HashMap::new(), stops_by_platform_id: HashMap::new(), calendar_days: HashMap::new() ,directions: HashMap::new() }
}
}
impl GtfsPullService {
const UPDATE_SECONDS: u64 = 3600*24;
const READYSTATE_CHECK_MILLISECONDS: u64 = 500;
pub fn new(config: Config) -> Self {
Self {
state: Arc::new(Mutex::new(
GtfsPullServiceState {
gtfs_files: config.gtfs_zips.iter().map(|f| { GtfsFile { source: f.clone(), hash: None} }).collect(),
tmp_dir: env::temp_dir(),
annotations: config.annotations.clone(),
ready: false,
transit_data: TransitData::new()
}
))
}
}
pub fn wait_for_ready(&self) {
while !(self.state.lock().unwrap()).ready {
thread::sleep(
Duration::from_millis(Self::READYSTATE_CHECK_MILLISECONDS)
);
}
}
pub fn start(&self) {
let cloned_state = Arc::clone(&self.state);
thread::spawn(move || {
loop {
let recloned_state = Arc::clone(&cloned_state);
let res = Self::update_gtfs_data(recloned_state);
match res {
Err(err) => {
error!("{}", err);
}
_ => {}
}
thread::sleep(Duration::from_secs(Self::UPDATE_SECONDS));
}
});
}
pub fn get_routes(&self) -> Vec<libseptastic::route::Route> {
let l_state = self.state.lock().unwrap();
l_state.transit_data.routes.iter().map(|r| libseptastic::route::Route::clone(r.1)).collect()
}
pub fn get_route(&self, route_id: String) -> anyhow::Result<libseptastic::route::Route> {
let l_state = self.state.lock().unwrap();
if let Some(route) = l_state.transit_data.routes.get(&route_id) {
Ok(libseptastic::route::Route::clone(route))
} else {
Err(anyhow!(""))
}
}
pub fn get_all_routes(&self) -> HashMap<String, libseptastic::route::Route> {
let l_state = self.state.lock().unwrap();
l_state.transit_data.routes.iter().map(|r| (r.0.clone(), libseptastic::route::Route::clone(r.1))).collect()
}
pub fn get_all_stops(&self) -> HashMap<String, Arc<libseptastic::stop::Stop>> {
let l_state = self.state.lock().unwrap();
l_state.transit_data.stops.clone()
}
pub fn get_all_trips(&self) -> HashMap<String, Vec<libseptastic::stop_schedule::Trip>> {
let l_state = self.state.lock().unwrap();
l_state.transit_data.trips.clone()
}
pub fn get_routes_at_stop(&self, id: &String) -> HashSet<String> {
let l_state = self.state.lock().unwrap();
l_state.transit_data.route_id_by_stops.get(id).unwrap_or(&HashSet::new()).clone()
}
pub fn get_stops_by_route(&self, id: &String) -> HashSet<String> {
let l_state = self.state.lock().unwrap();
l_state.transit_data.stops_by_route_id.get(id).unwrap_or(&HashSet::new()).clone()
}
pub fn get_stop_by_id(&self, id: &String) -> Option<libseptastic::stop::Stop> {
let l_state = self.state.lock().unwrap();
match l_state.transit_data.stops.get(id) {
Some(stop) => Some(libseptastic::stop::Stop::clone(stop)),
None => None
}
}
pub fn get_schedule(&self, route_id: String) -> anyhow::Result<Vec<libseptastic::stop_schedule::Trip>> {
let l_state = self.state.lock().unwrap();
if let Some(trips) = l_state.transit_data.trips.get(&route_id) {
Ok(trips.clone())
} else {
Err(anyhow!(""))
}
}
fn postprocess_stops(state: &mut MutexGuard<'_, GtfsPullServiceState>) -> anyhow::Result<()> {
for annotated_stop in state.annotations.multiplatform_stops.clone() {
let global_id = make_global_id!("ANNOTATED", annotated_stop.id.clone());
let stop = Arc::new(libseptastic::stop::Stop {
id: global_id.clone(),
name: annotated_stop.name.clone(),
platforms: libseptastic::stop::StopType::MultiPlatform(annotated_stop.platform_station_ids.iter().map(|platform_id| {
info!("Folding {} stop into stop {} as platform", platform_id.clone(), annotated_stop.id.clone());
let platform = match state.transit_data.stops.remove(platform_id).unwrap().platforms.clone() {
libseptastic::stop::StopType::SinglePlatform(plat) => Ok(plat),
_ => Err(anyhow!(""))
}.unwrap();
state.transit_data.stops_by_platform_id.remove(&platform.id).unwrap();
platform
}).collect())
});
state.transit_data.stops.insert(global_id.clone(), stop.clone());
match &stop.platforms {
libseptastic::stop::StopType::MultiPlatform(platforms) => {
for platform in platforms {
state.transit_data.stops_by_platform_id.insert(platform.id.clone(), stop.clone());
}
Ok(())
},
_ => Err(anyhow!(""))
}?
}
Ok(())
}
fn populate_stops(state: &mut MutexGuard<'_, GtfsPullServiceState>, prefix: &String, gtfs: &gtfs_structures::Gtfs) -> anyhow::Result<()> {
for stop in &gtfs.stops {
let global_id = make_global_id!(prefix, stop.1.id.clone());
let platform = Arc::new(Platform {
id : global_id.clone(),
name: stop.1.name.clone().unwrap(),
lat: stop.1.latitude.unwrap(),
lng: stop.1.longitude.unwrap(),
platform_location: libseptastic::stop::PlatformLocationType::Normal
});
let stop = Arc::new(libseptastic::stop::Stop {
id: global_id.clone(),
name: stop.1.name.clone().unwrap(),
platforms: libseptastic::stop::StopType::SinglePlatform(platform.clone())
});
state.transit_data.stops.insert(global_id.clone(), stop.clone());
state.transit_data.platforms.insert(global_id.clone(), platform.clone());
state.transit_data.stops_by_platform_id.insert(global_id.clone(), stop.clone());
}
Ok(())
}
fn populate_routes(state: &mut MutexGuard<'_, GtfsPullServiceState>, prefix: &String, gtfs: &gtfs_structures::Gtfs) -> anyhow::Result<()> {
for route in &gtfs.routes {
let global_rt_id = make_global_id!(prefix, route.1.id);
info!("{}", global_rt_id);
let rt_name = match route.1.long_name.clone() {
Some(x) => x,
_ => String::from("Unknown")
};
let dirs = match state.transit_data.directions.get(&global_rt_id) {
Some(x) => x.iter().map(|f| libseptastic::direction::Direction::clone(f)).collect(),
None => {
warn!("Excluding {} because it has no directions", global_rt_id);
continue
}
};
state.transit_data.routes.insert(global_rt_id.clone(), Arc::new(libseptastic::route::Route{
name: rt_name,
directions: dirs,
short_name: match route.1.short_name.clone() {
Some(x) => x,
_ => String::from("unknown")
},
color_hex: match route.1.color{
Some(x) => x.to_string(),
_ => String::from("unknown")
},
id: global_rt_id,
route_type: match route.1.route_type {
gtfs_structures::RouteType::Bus => libseptastic::route::RouteType::Bus,
gtfs_structures::RouteType::Rail => libseptastic::route::RouteType::RegionalRail,
gtfs_structures::RouteType::Subway => libseptastic::route::RouteType::SubwayElevated,
gtfs_structures::RouteType::Tramway => libseptastic::route::RouteType::Trolley,
_ => libseptastic::route::RouteType::TracklessTrolley
}
}));
}
Ok(())
}
fn populate_directions(state: &mut MutexGuard<'_, GtfsPullServiceState>, prefix: &String, gtfs: &gtfs_structures::Gtfs) -> anyhow::Result<()> {
for trip in &gtfs.trips {
let global_rt_id = make_global_id!(prefix, trip.1.route_id);
let dir = libseptastic::direction::Direction {
direction: match trip.1.direction_id.unwrap() {
gtfs_structures::DirectionType::Outbound => libseptastic::direction::CardinalDirection::Outbound,
gtfs_structures::DirectionType::Inbound => libseptastic::direction::CardinalDirection::Inbound
},
direction_destination: trip.1.trip_headsign.clone().unwrap()
};
match state.transit_data.directions.entry(global_rt_id) {
Entry::Vacant(e) => { e.insert(vec![Arc::new(dir)]); },
Entry::Occupied(mut e) => {
if e.get().iter().filter(|x| x.direction == dir.direction).count() == 0 {
e.get_mut().push(Arc::new(dir));
}
}
}
}
for dir in &mut state.transit_data.directions {
dir.1.sort_by(|x,y| if x.direction > y.direction {Ordering::Greater} else {Ordering::Less});
}
Ok(())
}
fn populate_trips(state: &mut MutexGuard<'_, GtfsPullServiceState>, prefix: &String, gtfs: &gtfs_structures::Gtfs) -> anyhow::Result<()> {
for trip in &gtfs.trips {
let global_rt_id = make_global_id!(prefix, trip.1.route_id);
let sched = trip.1.stop_times.iter().map(|s| {
let global_stop_id = make_global_id!(prefix, s.stop.id);
let stop = state.transit_data.stops_by_platform_id.get(&global_stop_id).unwrap().clone();
let platform = state.transit_data.platforms.get(&global_stop_id).unwrap().clone();
state.transit_data.route_id_by_stops.entry(stop.id.clone()).or_insert(HashSet::new()).insert(global_rt_id.clone());
state.transit_data.stops_by_route_id.entry(global_rt_id.clone()).or_insert(HashSet::new()).insert(stop.id.clone());
state.transit_data.route_id_by_stops.entry(platform.id.clone()).or_insert(HashSet::new()).insert(global_rt_id.clone());
state.transit_data.stops_by_route_id.entry(global_rt_id.clone()).or_insert(HashSet::new()).insert(platform.id.clone());
libseptastic::stop_schedule::StopSchedule{
arrival_time: i64::from(s.arrival_time.unwrap()),
stop_sequence: i64::from(s.stop_sequence),
stop,
platform
}
}).collect();
if let Some(calendar_day) = state.transit_data.calendar_days.get(&trip.1.service_id.clone()) {
let trip = libseptastic::stop_schedule::Trip{
trip_id: trip.1.id.clone(),
route: state.transit_data.routes.get(&make_global_id!(prefix, trip.1.route_id)).unwrap().clone(),
direction: libseptastic::direction::Direction {
direction: match trip.1.direction_id.unwrap() {
gtfs_structures::DirectionType::Outbound => libseptastic::direction::CardinalDirection::Outbound,
gtfs_structures::DirectionType::Inbound => libseptastic::direction::CardinalDirection::Inbound
},
direction_destination: trip.1.trip_headsign.clone().unwrap()
},
tracking_data: libseptastic::stop_schedule::TripTracking::Untracked,
schedule: sched,
service_id: trip.1.service_id.clone(),
calendar_day: calendar_day.clone()
};
if let Some(trip_arr) = state.transit_data.trips.get_mut(&global_rt_id) {
trip_arr.push(trip);
} else {
state.transit_data.trips.insert(global_rt_id, vec![trip]);
}
}
}
Ok(())
}
pub fn update_gtfs_data(state: Arc<Mutex<GtfsPullServiceState>>) -> anyhow::Result<()> {
let mut l_state = state.lock().unwrap();
let files = l_state.gtfs_files.clone();
l_state.transit_data = TransitData::new();
let mut gtfses = Vec::new();
for gtfs_file in files.iter() {
let gtfs = if let Some(subzip) = gtfs_file.source.subzip.clone() {
info!("Reading GTFS file at {} (subzip {})", gtfs_file.source.uri, subzip);
let res = reqwest::blocking::get(gtfs_file.source.uri.clone())?;
let outer_archive = res.bytes()?;
let mut archive = ZipArchive::new(Cursor::new(outer_archive))?;
archive.extract(l_state.tmp_dir.clone())?;
let mut file_path = l_state.tmp_dir.clone();
file_path.push(subzip.clone());
info!("Downloaded, parsing");
gtfs_structures::Gtfs::new(file_path.to_str().unwrap())?
} else {
info!("Reading GTFS file at {}", gtfs_file.source.uri);
gtfs_structures::Gtfs::new(gtfs_file.source.uri.as_str())?
};
gtfses.push((gtfs, gtfs_file.source.prefix.clone()));
}
info!("Data loaded, processing...");
for (gtfs, prefix) in &gtfses {
GtfsPullService::populate_directions(&mut l_state, &prefix, &gtfs)?;
GtfsPullService::populate_routes(&mut l_state, &prefix, &gtfs)?;
GtfsPullService::populate_stops(&mut l_state, &prefix, &gtfs)?;
for calendar in &gtfs.calendar {
l_state.transit_data.calendar_days.insert(calendar.1.id.clone(), Arc::new(CalendarDay{
id: calendar.1.id.clone(),
monday: calendar.1.monday,
tuesday: calendar.1.tuesday,
wednesday: calendar.1.wednesday,
thursday: calendar.1.thursday,
friday: calendar.1.friday,
saturday: calendar.1.saturday,
sunday: calendar.1.sunday,
start_date: calendar.1.start_date,
end_date: calendar.1.end_date
}));
}
}
GtfsPullService::postprocess_stops(&mut l_state)?;
for (gtfs, prefix) in &gtfses {
GtfsPullService::populate_trips(&mut l_state, &prefix, &gtfs)?;
}
l_state.ready = true;
info!("Finished initial sync, ready state is true");
Ok(())
}
}

View file

@ -1,76 +0,0 @@
use serde_json::Value;
use serde::de;
use std::sync::{Arc, Mutex};
use std::thread;
use std::collections::HashMap;
use std::time::Duration;
use log::error;
use serde::{Serialize, Deserialize, Deserializer};
use libseptastic::stop_schedule::{LiveTrip, TripTracking};
use prost::Message;
struct TripTrackingServiceState {
pub tracking_data: HashMap::<String, TripTracking>
}
#[derive(Clone)]
struct GtfsRtFile {
pub uri: String,
pub hash: Option<String>
}
pub struct TripTrackingService {
state: Arc<Mutex<TripTrackingServiceState>>,
//pub gtfs_files: Vec<GtfsRtFile>
}
impl TripTrackingService {
const UPDATE_SECONDS: u64 = 30;
pub fn new() -> Self {
Self {
state: Arc::new(Mutex::new(TripTrackingServiceState{ tracking_data: HashMap::new()}))
}
}
pub fn start(&self) {
let cloned_state = Arc::clone(&self.state);
thread::spawn( move || {
loop {
let clonedx_state = Arc::clone(&cloned_state);
let res = Self::update_live_trips(clonedx_state);
match res {
Err(err) => {
error!("{}", err);
}
_ => {}
}
thread::sleep(Duration::from_secs(Self::UPDATE_SECONDS));
}
});
}
pub fn annotate_trips(&self, trips: &mut Vec<libseptastic::stop_schedule::Trip>) {
for trip in trips {
trip.tracking_data = match self.state.lock().unwrap().tracking_data.get(&trip.trip_id.clone()){
Some(x) => x.clone(),
None => TripTracking::Untracked
};
}
}
fn update_live_trips(service: Arc<Mutex<TripTrackingServiceState>>) -> anyhow::Result<()> {
let url = "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-l";
let response = reqwest::blocking::get(url).unwrap();
let bytes = response.bytes().unwrap();
let data: Result<gtfs_realtime::FeedMessage, prost::DecodeError> = prost::Message::decode(bytes.as_ref());
let data = data.unwrap();
println!("{:#?}", data);
Ok(())
}
}

View file

@ -1,3 +0,0 @@
pub mod trip_tracking;
pub mod gtfs_pull;
pub mod gtfs_rt;

View file

@ -1,13 +0,0 @@
<h1>SEPTASTIC!</h1>
<p><i>A fantastic way to ride SEPTA</i></p>
<p style="margin-top: 25px;">
SEPTASTIC is a website and (a soon to be) mobile app. Its purpose is to provide
information about how to ride SEPTA (and connecting transit authorities) in a
quick and information-rich manner.
</p>
<p style="margin-top: 25px; margin-bottom: 25px;">
Currently, all this website has is <a href="/routes">timetables for every
SEPTA route</a>. More to come soon!
</p>

View file

@ -1,98 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
{% if let Some(title) = page_title %}
<title>{{ title }} | SEPTASTIC</title>
{% else %}
<title>SEPTASTIC</title>
{% endif %}
{% if let Some(desc) = page_desc %}
<meta name="{{ desc }}" />
{% else %}
<meta name="SEPTASTIC" />
{% endif %}
<link rel="stylesheet" href="/assets/style.css">
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
<script>
window.onload = function () {
setTimeout(() => {
const perfData = window.performance.timing;
const pageLoadTime = perfData.loadEventEnd - perfData.navigationStart;
const loadTimeElement = document.getElementById('js_load_time');
loadTimeElement.textContent += ` ${pageLoadTime}ms`;
}, 0); // Minimal delay to wait for `loadEventEnd` to be populated
};
</script>
<noscript>
<style>
.js-only {
display: none;
}
</style>
</noscript>
<style>
.silverliner-svg {
display: block;
width: 100%;
height: 200px; /* Fixed height matching the viewBox */
}
</style>
<body>
{% if widescreen %}
<div class="body">
{% else %}
<div class="body body-small">
{% endif %}
<div style="background-color: #ff0000; color: #ffffff; font-size: .7em; padding: 5px; margin-bottom: 10px; margin-top: 10px;">
This website is not run by SEPTA. Data may be inaccurate.
</div>
<nav>
<div style="display: flex; justify-content: space-between;">
<div>
<a class="nav-link" href="/">[ Home ]</a>
<a class="nav-link" href="/routes">[ Routes ]</a>
<a class="nav-link" href="/stops">[ Stops ]</a>
</div>
<div>
</div>
</div>
</nav>
<hr/>
{{ content|safe }}
<footer>
<hr />
<div style="display: flex; justify-content: space-between;">
<div>
<p style="margin-bottom: 0px; margin-top:0px;"><b>SEPTASTIC!</b></p>
<p style="margin-bottom: 0px;margin-top: 0px;">
<small>Copyright &#169; <a href="https://nickorlow.com">Nicholas Orlowsky</a> 2025</small>
</p>
{% if let Some(load_time) = load_time_ms %}
<p style="marin-top: 5px; color: #555555;"><small><i>Data loaded in {{ *load_time | format_load_time }}</i></small></p>
{% endif %}
<p class="js-only" style="marin-top: 5px; color: #555555;"><small><i id="js_load_time">Total load time</i></small></p>
</div>
<div>
{% if widescreen %}
<a href="?widescreen=false"><small>[ disable widescreen ]</small></a>
{% else %}
<a href="?widescreen=true"><small>[ enable widescreen ]</small></a>
{% endif %}
</div>
</div>
<noscript><p style="margin-top: 10px;"><small>[!] You do not have JavaScript enabled. Some features will be missing.</small></p></noscript>
</footer>
</div>
</body>
</html>

View file

@ -1,143 +0,0 @@
{%- import "route_symbol.html" as scope -%}
<style>
</style>
<script>
document.addEventListener("DOMContentLoaded", () => {
const scrollToNextColumn = (directionId) => {
const target = document.getElementById("next-col-" + directionId);
if (target) {
const scrollContainer = target.closest(".tscroll");
const firstCol = scrollContainer.querySelector("th:first-child");
const firstColWidth = firstCol ? firstCol.offsetWidth : 0;
// Get the target's position relative to the scroll container
const targetLeft = target.offsetLeft;
// Scroll so the target appears right after the sticky column
scrollContainer.scrollLeft = targetLeft - firstColWidth;
}
};
document.querySelectorAll("details[data-direction-id]").forEach(details => {
const directionId = details.getAttribute("data-direction-id");
// Scroll immediately if details is already open
if (details.open) {
setTimeout(() => scrollToNextColumn(directionId), 50);
}
// Also scroll when details is opened
details.addEventListener("toggle", () => {
if (details.open) {
setTimeout(() => scrollToNextColumn(directionId), 50);
}
});
});
document.querySelectorAll(".train-direction-table").forEach((table) => {
table.addEventListener("click", (e) => {
const cell = e.target.closest("td, th");
if (!cell) return;
// Clear previous highlights
table.querySelectorAll("tr").forEach(row => row.classList.remove("highlight-row"));
table.querySelectorAll("td, th").forEach(c => c.classList.remove("highlight-col"));
const row = cell.parentNode;
const colIndex = Array.from(cell.parentNode.children).indexOf(cell);
// If it's the first column (row header)
if (cell.cellIndex === 0 && cell.tagName === "TD") {
row.classList.add("highlight-row");
}
// If it's a column header
else if (row.parentNode.tagName === "THEAD") {
table.querySelectorAll("tr").forEach(r => {
const cell = r.children[colIndex];
if (cell) cell.classList.add("highlight-col");
});
}
// If it's a center cell
else {
row.classList.add("highlight-row");
table.querySelectorAll("tr").forEach(r => {
const cell = r.children[colIndex];
if (cell) cell.classList.add("highlight-col");
});
}
});
});
});
</script>
<div style="display: flex; align-items: center;">
{% call scope::route_symbol(route) %}
{% endcall %}
<h1 style="margin-left: 15px;">{{ route.name }}</h1>
</div>
{% for timetable in timetables %}
<details style="margin-top: 15px;" data-direction-id="{{ timetable.direction.direction }}">
<summary>
<div style="display: inline-block;">
<h3>{{ timetable.direction.direction | capitalize }} to</h3>
<h2>{{ timetable.direction.direction_destination }}</h2>
</div>
</summary>
<div class="tscroll">
<table class="train-direction-table" style="margin-top: 5px;">
<thead>
<tr>
<th>Stop</th>
{% for trip_id in timetable.trip_ids %}
{% if let Some(next_id_v) = timetable.next_id %}
{% if next_id_v == trip_id %}
<th class="next-col" id="next-col-{{ timetable.direction.direction }}">
{% else %}
<th>
{% endif %}
{% else %}
<th>
{% endif %}
{{ trip_id }}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in timetable.rows %}
{% if let Some(filter_stop_v) = filter_stops %}
{% if !filter_stop_v.contains(&row.stop_id) %}
{% continue %}
{% endif %}
{% endif %}
<tr>
<td>{{ row.stop_name }}</td>
{% for time in row.times %}
{% if let Some(t) = time %}
{% let live_o = timetable.tracking_data[loop.index0] %}
{% if let Tracked(live) = live_o %}
{% let time = (t + (live.delay * 60.0) as i64) %}
<td style="background-color: #003300">
<span style="color: #22bb22"> {{ time | format_time }} </span>
</td>
{% elif let TripTracking::Cancelled = live_o %}
<td style="color: #ff0000"><s>{{ t | format_time }}</s></td>
{% else %}
<td>{{ t | format_time }}</td>
{% endif %}
{% else %}
<td>
</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% endfor %}

View file

@ -1,19 +0,0 @@
{% macro route_symbol(route) %}
{% match route.route_type %}
{% when libseptastic::route::RouteType::Trolley | libseptastic::route::RouteType::SubwayElevated %}
<div class="metro-container bg-{{ route.short_name }}">
{{ route.short_name }}
</div>
{% endwhen %}
{% when libseptastic::route::RouteType::RegionalRail %}
<div class="rr-container">
{{ route.short_name }}
</div>
{% endwhen %}
{% when libseptastic::route::RouteType::Bus | libseptastic::route::RouteType::TracklessTrolley %}
<div class="bus-container">
{{ route.short_name }}
</div>
{% endwhen %}
{% endmatch %}
{% endmacro %}

View file

@ -1,60 +0,0 @@
<h1>Routes</h1>
<p>Click on a route to see details and a schedule. Schedules in prevailing local time.</p>
<fieldset>
<legend><h2>Regional Rail</h2></legend>
<p style="margin-top: 10px; margin-bottom: 10px;">For infrequent rail service to suburban locations</p>
{% for route in rr_routes %}
<a href="/route/{{ route.id }}" style="display: flex; justify-content: space-between;">
<p class="line-link">[ <b>{{ format!("{:7}", route.short_name) }}:</b> {{ route.name }} </p><p>]</p>
</a>
{% endfor %}
</fieldset>
<fieldset>
<legend><h2>Metro</h2></legend>
<p style="margin-top: 10px; margin-bottom: 10px;">For frequent rail service within Philadelphia and suburban locations</p>
<div class="lines-label" style="font-weight: bold; width: 100%; display: flex; justify-content: space-between;">
<p>[ Subway/Elevated </p><p>]</p>
</div>
{% for route in subway_routes %}
<a href="/route/{{ route.id }}" style="display: flex; justify-content: space-between;">
<p class="line-link">[ <b>{{ format!("{:7}", route.short_name) }}:</b> {{ route.name }} </p><p>]</p>
</a>
{% endfor %}
<div class="lines-label" style="font-weight: bold; width: 100%; display: flex; justify-content: space-between;">
<p>[ Trolleys </p><p>]</p>
</div>
{% for route in trolley_routes %}
<a href="/route/{{ route.id }}" style="display: flex; justify-content: space-between;">
<p class="line-link">[ <b>{{ format!("{:7}", route.short_name) }}:</b> {{ route.name }} </p><p>]</p>
</a>
{% endfor %}
</fieldset>
<fieldset>
<legend><h2>Bus</h2></legend>
<p style="margin-top: 10px; margin-bottom: 10px;">For service of varying frequency within SEPTA's entire service area</p>
{% for route in bus_routes %}
<a href="/route/{{ route.id }}" style="display: flex; justify-content: space-between;">
<p class="line-link">[ <b>{{ format!("{:7}", route.short_name) }}:</b> {{ route.name }} </p><p>]</p>
</a>
{% endfor %}
</fieldset>
<style>
.line-link, .lines-label {
white-space: pre;
margin-top: 3px;
margin-bottom: 3px;
}
.lines-label {
color: #ffffff;
background-color: #000000;
width: max-content;
}
</style>

View file

@ -1,100 +0,0 @@
{%- import "route_symbol.html" as scope -%}
{%- import "stop_table.html" as stop_table -%}
<div style="display: flex; align-items: center;">
<h1>{{ stop.name }}</h1>
</div>
<p>With service available on:</p>
<div style="display: flex; justify-content: start; padding-top: 5px; padding-bottom: 5px; flex-wrap: wrap; gap: 5px;">
{% for route in routes %}
<div style="margin-right: 5px">
{% call scope::route_symbol(route) %}
{% endcall %}
</div>
{% endfor %}
</div>
<details>
<summary><p style="font-weight: bold; font-size: large;">Filters</p></summary>
<form hx-trigger="submit" hx-get="/stop/{{ stop.id }}/table" hx-target="#nta-table" hx-swap="outerHTML" hx-push-url="/stop/{{ stop.id}}">
<div style="margin: 5px; padding: 10px; background-color: #eee;">
<div style="display: flex; flex-wrap: wrap;">
<fieldset style="flex-grow: 1;">
<legend>Route</legend>
{% for route in routes %}
{% for dir in route.directions %}
{% if let Some(fil) = filters && let Some(rts) = fil.routes %}
{% let route_filter_id = format!("{},{}", route.id, dir.direction) %}
<input type="checkbox" class="route-checkbox" name="routes" id="{{ route.id }},{{ dir.direction }}" value="{{ route.id }},{{ dir.direction}}" checked="{{ rts.contains(&*route_filter_id) }}">
{% else %}
<input type="checkbox" class="route-checkbox" name="routes" id="{{ route.id }},{{ dir.direction }}" value="{{ route.id }},{{ dir.direction}}" checked="true">
{% endif %}
<label for="{{ route.id }},{{ dir.direction }}">
<b>{{ route.short_name }}</b>: {{ dir.direction_destination }}
</label>
<br>
{% endfor %}
{% endfor %}
<input type="checkbox" id="master"
hx-on:click="document.querySelectorAll('.route-checkbox').forEach(c => c.checked = this.checked)">
<label for="master">Select/Deselect All</label>
</fieldset>
<div style="flex-grow: 1;">
<fieldset>
<legend>Ride Options</legend>
{% if let Some(fil) = filters && let Some(lt) = fil.live_tracked %}
<input type="checkbox" name="live_tracked" id="live_tracked" value="true" checked="{{ lt }}">
{% else %}
<input type="checkbox" name="live_tracked" id="live_tracked" value="true" checked="true">
{% endif %}
<label for="live-tracked">
Live Tracked
</label>
<br>
{% if let Some(fil) = filters && let Some(sc) = fil.scheduled %}
<input type="checkbox" name="scheduled" id="scheduled" value="true" checked="{{ sc }}">
{% else %}
<input type="checkbox" name="scheduled" id="scheduled" value="true" checked="true">
{% endif %}
<label for="scheduled">
Scheduled
</label>
<br>
</fieldset>
<fieldset>
<legend>Crowding</legend>
{% for avail in SeatAvailability::iter() %}
{% if let Some(fil) = filters && let Some(crd) = fil.crowding %}
<input type="checkbox" name="crowding" id="{{ avail.to_string() }}" value="{{ avail.to_string() }}" checked="{{ crd.contains(&avail) }}">
{% else %}
<input type="checkbox" name="crowding" id="{{ avail.to_string() }}" value="{{ avail.to_string() }}" checked="true">
{% endif %}
<label for="{{ avail.to_string() }}">
{{ avail.to_human_string() }}
</label>
<br>
{% endfor %}
{% if let Some(fil) = filters && let Some(uc) = fil.unknown_crowding %}
<input type="checkbox" name="unknown_crowding" id="unknown_crowding" value="true" checked="{{ uc }}">
{% else %}
<input type="checkbox" name="unknown_crowding" id="unknown_crowding" value="true" checked="true">
{% endif %}
<label for="scheduled">
Unknown
</label>
</fieldset>
</div>
</div>
<input type="submit" value="Apply">
</div>
</form>
</details>
<div style="overflow-x: scroll; max-width: 100%;">
{% call stop_table::stop_table(trips, current_time, stop.id, query_str) %}
{% endcall %}
</div>

View file

@ -1,73 +0,0 @@
{%- import "route_symbol.html" as scope -%}
{% macro stop_table(trips, current_time, stop_id, query_str) %}
<div id="nta-table" hx-get="/stop/{{ stop_id }}/table?{{ query_str }}" hx-trigger="every 5s" hx-swap="outer-html">
<table class="train-direction-table">
<tr>
<th>ROUTE</th>
<th>DESTINATION</th>
<th>BOARDING AREA</th>
<th>TIME</th>
<th>VEHICLE</th>
<th>TRIP</th>
<th>CROWDING</th>
</tr>
{% for trip in trips %}
<tr>
<td>
{% call scope::route_symbol(trip.trip.route) %}
{% endcall %}
</td>
<td>
<p>{{ trip.trip.direction.direction_destination }}</p>
</td>
<td>
<p>{{ trip.perspective_stop.platform.name }}</p>
</td>
{% if let Tracked(tracked_trip) = trip.trip.tracking_data %}
<td style="color: #008800">
<p style="font-size: small;">{{ &trip.perspective_stop.get_arrival_time(&tracked_trip) | format_time }}</p>
<p style="font-size: x-small; font-style: italic;">{{ ( trip.perspective_stop.get_arrival_time(&tracked_trip) - current_time) / 60 }} mins</p>
<p style="font-size: x-small; font-style: italic;">{{ tracked_trip.delay.round() }} late</p>
</td>
{% else %}
<td>
<p style="font-size: small;">{{ trip.perspective_stop.arrival_time | format_time }}</p>
<p style="font-size: x-small; font-style: italic;">{{ (trip.perspective_stop.arrival_time - current_time) / 60 }} mins</p>
</td>
{% endif %}
{% if let Tracked(tracked_trip) = trip.trip.tracking_data %}
<td>
{{ tracked_trip.vehicle_ids.join(", ") }}
</td>
{% else %}
<td>
-
</td>
{% endif %}
<td>{{ trip.trip.trip_id }}</td>
{% if let Tracked(tracked_trip) = trip.trip.tracking_data %}
{% if let Some(seat_avail) = tracked_trip.seat_availability %}
<td>
{{ seat_avail.to_human_string() }}
</td>
{% else %}
<td>
N/A
</td>
{% endif %}
{% else %}
<td>
-
</td>
{% endif %}
</tr>
{% endfor %}
<tr>
<td colspan="7">
<p>Updated at: {{ current_time | format_time_with_seconds }}</p>
</td>
</tr>
</table>
</div>
{% endmacro %}

View file

@ -1 +0,0 @@
DB_CONNSTR=

View file

@ -1,8 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Agency { pub struct Agency {
pub id: String, pub id: String,
pub name: String pub name: String,
} }

View file

@ -1,7 +1,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(sqlx::Type, Serialize, Deserialize, PartialEq, Debug, Clone, Copy, Eq, PartialOrd, Ord)] #[derive(
sqlx::Type, Serialize, Deserialize, PartialEq, Debug, Clone, Copy, Eq, PartialOrd, Ord,
)]
#[sqlx(type_name = "septa_direction_type", rename_all = "snake_case")] #[sqlx(type_name = "septa_direction_type", rename_all = "snake_case")]
pub enum CardinalDirection { pub enum CardinalDirection {
Northbound, Northbound,
@ -10,17 +11,17 @@ pub enum CardinalDirection {
Westbound, Westbound,
Inbound, Inbound,
Outbound, Outbound,
Loop Loop,
} }
#[derive(::sqlx::Decode, Serialize, Deserialize, Debug, Clone)] #[derive(::sqlx::Decode, Serialize, Deserialize, Debug, Clone)]
pub struct Direction { pub struct Direction {
pub direction: CardinalDirection, pub direction: CardinalDirection,
pub direction_destination: String pub direction_destination: String,
} }
impl std::fmt::Display for CardinalDirection { impl std::fmt::Display for CardinalDirection {
fn fmt (&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let output = match self { let output = match self {
CardinalDirection::Northbound => "Northbound", CardinalDirection::Northbound => "Northbound",
CardinalDirection::Southbound => "Southbound", CardinalDirection::Southbound => "Southbound",
@ -28,7 +29,7 @@ impl std::fmt::Display for CardinalDirection {
CardinalDirection::Westbound => "Westbound", CardinalDirection::Westbound => "Westbound",
CardinalDirection::Inbound => "Inbound", CardinalDirection::Inbound => "Inbound",
CardinalDirection::Outbound => "Outbound", CardinalDirection::Outbound => "Outbound",
CardinalDirection::Loop => "Loop" CardinalDirection::Loop => "Loop",
}; };
std::write!(f, "{}", output) std::write!(f, "{}", output)
} }

View file

@ -1,8 +1,8 @@
pub mod route;
pub mod agency; pub mod agency;
pub mod stop;
pub mod route_stop;
pub mod stop_schedule;
pub mod schedule_day;
pub mod direction; pub mod direction;
pub mod ridership; pub mod ridership;
pub mod route;
pub mod route_stop;
pub mod schedule_day;
pub mod stop;
pub mod stop_schedule;

View file

@ -17,5 +17,5 @@ pub struct Ridership {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineRidership { pub struct LineRidership {
pub route_id: String, pub route_id: String,
pub unlinked_trips: i64 pub unlinked_trips: i64,
} }

View file

@ -9,7 +9,7 @@ pub enum RouteType {
SubwayElevated, SubwayElevated,
RegionalRail, RegionalRail,
Bus, Bus,
TracklessTrolley TracklessTrolley,
} }
#[derive(::sqlx::FromRow, Serialize, Deserialize, Debug, Clone)] #[derive(::sqlx::FromRow, Serialize, Deserialize, Debug, Clone)]
@ -19,7 +19,7 @@ pub struct Route {
pub color_hex: String, pub color_hex: String,
pub route_type: RouteType, pub route_type: RouteType,
pub id: String, pub id: String,
pub directions: Vec<crate::direction::Direction> pub directions: Vec<crate::direction::Direction>,
} }
impl PartialEq for Route { impl PartialEq for Route {
@ -42,10 +42,9 @@ impl PartialOrd for Route {
} }
} }
#[derive(::sqlx::FromRow, Serialize, Deserialize, Debug, Clone)] #[derive(::sqlx::FromRow, Serialize, Deserialize, Debug, Clone)]
pub struct InterlinedRoute { pub struct InterlinedRoute {
pub interline_id: String, pub interline_id: String,
pub interline_name: String, pub interline_name: String,
pub interlined_routes: Vec<String> pub interlined_routes: Vec<String>,
} }

View file

@ -3,5 +3,5 @@ pub struct RouteStop {
pub route_id: String, pub route_id: String,
pub stop_id: i64, pub stop_id: i64,
pub direction_id: i64, pub direction_id: i64,
pub stop_sequence: i64 pub stop_sequence: i64,
} }

View file

@ -1,5 +1,5 @@
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ScheduleDay { pub struct ScheduleDay {
pub date: String, pub date: String,
pub service_id: String pub service_id: String,
} }

View file

@ -1,4 +1,7 @@
use std::{hash::{Hash, Hasher}, sync::Arc}; use std::{
hash::{Hash, Hasher},
sync::Arc,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -7,13 +10,13 @@ use serde::{Deserialize, Serialize};
pub enum PlatformLocationType { pub enum PlatformLocationType {
FarSide, FarSide,
MiddleBlockNearSide, MiddleBlockNearSide,
Normal Normal,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum StopType { pub enum StopType {
SinglePlatform(Arc<Platform>), SinglePlatform(Arc<Platform>),
MultiPlatform(Vec<Arc<Platform>>) MultiPlatform(Vec<Arc<Platform>>),
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -22,7 +25,7 @@ pub struct Platform {
pub name: String, pub name: String,
pub lat: f64, pub lat: f64,
pub lng: f64, pub lng: f64,
pub platform_location: PlatformLocationType pub platform_location: PlatformLocationType,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View file

@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use chrono::{Datelike, Days, TimeZone, Weekday}; use chrono::{Datelike, Days, Weekday};
use serde::{Deserialize, Serialize, Serializer, de::Error}; use serde::{Deserialize, Serialize, Serializer};
use crate::{direction::Direction, route::Route, stop::Platform}; use crate::{direction::Direction, route::Route, stop::Platform};
@ -10,7 +10,7 @@ pub struct StopSchedule {
pub arrival_time: i64, pub arrival_time: i64,
pub stop_sequence: i64, pub stop_sequence: i64,
pub stop: Arc<crate::stop::Stop>, pub stop: Arc<crate::stop::Stop>,
pub platform: Arc<Platform> pub platform: Arc<Platform>,
} }
impl StopSchedule { impl StopSchedule {
@ -27,26 +27,37 @@ pub struct Trip {
pub direction: Direction, pub direction: Direction,
pub tracking_data: TripTracking, pub tracking_data: TripTracking,
pub schedule: Vec<StopSchedule>, pub schedule: Vec<StopSchedule>,
pub calendar_day: Arc<CalendarDay> pub calendar_day: Arc<CalendarDay>,
} }
impl Trip { impl Trip {
pub fn is_active_on(&self, datetime: &chrono::NaiveDateTime) -> bool { pub fn is_active_on(&self, datetime: &chrono::NaiveDateTime) -> bool {
if !self.calendar_day.is_calendar_active_for_date(&datetime.date()) { if !self
.calendar_day
.is_calendar_active_for_date(&datetime.date())
{
return false; return false;
} }
let time_trip_start = chrono::NaiveTime::from_num_seconds_from_midnight_opt(self.schedule.first().unwrap().arrival_time as u32 % (60*60*24), 0).unwrap(); let time_trip_start = chrono::NaiveTime::from_num_seconds_from_midnight_opt(
self.schedule.first().unwrap().arrival_time as u32 % (60 * 60 * 24),
0,
)
.unwrap();
let mut dt_trip_start = chrono::NaiveDateTime::new(datetime.date(), time_trip_start); let mut dt_trip_start = chrono::NaiveDateTime::new(datetime.date(), time_trip_start);
if self.schedule.first().unwrap().arrival_time > (60*60*24) { if self.schedule.first().unwrap().arrival_time > (60 * 60 * 24) {
dt_trip_start = dt_trip_start.checked_add_days(Days::new(1)).unwrap(); dt_trip_start = dt_trip_start.checked_add_days(Days::new(1)).unwrap();
} }
let time_trip_end = chrono::NaiveTime::from_num_seconds_from_midnight_opt(self.schedule.last().unwrap().arrival_time as u32 % (60*60*24), 0).unwrap(); let time_trip_end = chrono::NaiveTime::from_num_seconds_from_midnight_opt(
self.schedule.last().unwrap().arrival_time as u32 % (60 * 60 * 24),
0,
)
.unwrap();
let mut dt_trip_end = chrono::NaiveDateTime::new(datetime.date(), time_trip_end); let mut dt_trip_end = chrono::NaiveDateTime::new(datetime.date(), time_trip_end);
if self.schedule.last().unwrap().arrival_time > (60*60*24) { if self.schedule.last().unwrap().arrival_time > (60 * 60 * 24) {
dt_trip_end = dt_trip_end.checked_add_days(Days::new(1)).unwrap(); dt_trip_end = dt_trip_end.checked_add_days(Days::new(1)).unwrap();
} }
@ -58,7 +69,7 @@ impl Trip {
pub enum TripTracking { pub enum TripTracking {
Tracked(LiveTrip), Tracked(LiveTrip),
Untracked, Untracked,
Cancelled Cancelled,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -72,7 +83,7 @@ pub struct CalendarDay {
pub saturday: bool, pub saturday: bool,
pub sunday: bool, pub sunday: bool,
pub start_date: chrono::NaiveDate, pub start_date: chrono::NaiveDate,
pub end_date: chrono::NaiveDate pub end_date: chrono::NaiveDate,
} }
impl CalendarDay { impl CalendarDay {
@ -99,17 +110,18 @@ pub enum SeatAvailability {
CrushedStandingRoomOnly = 3, CrushedStandingRoomOnly = 3,
FewSeats = 2, FewSeats = 2,
ManySeats = 1, ManySeats = 1,
Empty = 0 Empty = 0,
} }
impl<'de> Deserialize<'de> for SeatAvailability { impl<'de> Deserialize<'de> for SeatAvailability {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where where
D: serde::Deserializer<'de> { D: serde::Deserializer<'de>,
{
let string = String::deserialize(deserializer)?; let string = String::deserialize(deserializer)?;
return match SeatAvailability::from_string(&string) { return match SeatAvailability::from_string(&string) {
Some(x) => Ok(x), Some(x) => Ok(x),
None => Err(serde::de::Error::custom("")) None => Err(serde::de::Error::custom("")),
}; };
} }
} }
@ -123,10 +135,15 @@ impl Serialize for SeatAvailability {
} }
} }
impl SeatAvailability { impl SeatAvailability {
pub fn iter() -> Vec<SeatAvailability> { pub fn iter() -> Vec<SeatAvailability> {
vec![Self::Empty, Self::ManySeats, Self::FewSeats, Self::CrushedStandingRoomOnly, Self::Full] vec![
Self::Empty,
Self::ManySeats,
Self::FewSeats,
Self::CrushedStandingRoomOnly,
Self::Full,
]
} }
pub fn to_string(&self) -> String { pub fn to_string(&self) -> String {
@ -145,7 +162,7 @@ impl SeatAvailability {
Self::CrushedStandingRoomOnly => "Sardines", Self::CrushedStandingRoomOnly => "Sardines",
Self::FewSeats => "Few seats", Self::FewSeats => "Few seats",
Self::ManySeats => "Many seats", Self::ManySeats => "Many seats",
Self::Empty => "Empty" Self::Empty => "Empty",
}) })
} }
@ -156,7 +173,7 @@ impl SeatAvailability {
"FEW_SEATS_AVAILABLE" => Some(Self::FewSeats), "FEW_SEATS_AVAILABLE" => Some(Self::FewSeats),
"MANY_SEATS_AVAILABLE" => Some(Self::ManySeats), "MANY_SEATS_AVAILABLE" => Some(Self::ManySeats),
"EMPTY" => Some(Self::Empty), "EMPTY" => Some(Self::Empty),
_ => None _ => None,
} }
} }

View file

@ -1,7 +1,14 @@
with import <nixpkgs> {}; with import <nixpkgs> {};
stdenv.mkDerivation { stdenv.mkDerivation {
name = "env"; name = "env";
nativeBuildInputs = [ pkg-config postgresql_14 ]; nativeBuildInputs = [
pkg-config
postgresql_14
rustfmt
cargo
djlint
];
buildInputs = [ buildInputs = [
cryptsetup cryptsetup
protobuf protobuf

View file

@ -1,2 +1,3 @@
target/ target/
.env .env
.sqlx/

View file

@ -2779,7 +2779,7 @@ dependencies = [
] ]
[[package]] [[package]]
name = "septastic_api" name = "septastic_web"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"actix-cors", "actix-cors",

View file

@ -1,5 +1,5 @@
[package] [package]
name = "septastic_api" name = "septastic_web"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"

View file

@ -5,8 +5,6 @@ gtfs_zips:
- uri: "https://www3.septa.org/developer/gtfs_public.zip" - uri: "https://www3.septa.org/developer/gtfs_public.zip"
prefix: "SEPTABUS" prefix: "SEPTABUS"
subzip: "google_bus.zip" subzip: "google_bus.zip"
# - uri: "https://www.njtransit.com/rail_data.zip"
# - uri: "https://www.njtransit.com/bus_data.zip"
annotations: annotations:
multiplatform_stops: multiplatform_stops:
- id: 'WTC' - id: 'WTC'
@ -35,5 +33,3 @@ annotations:
- 'SEPTABUS_2687' - 'SEPTABUS_2687'
- 'SEPTABUS_18451' - 'SEPTABUS_18451'
- 'SEPTABUS_17170' - 'SEPTABUS_17170'
synthetic_routes:
- id: 'NYC'

View file

@ -0,0 +1,10 @@
use crate::{
session_middleware::{SessionResponder, SessionResponse},
templates::IndexTemplate,
};
use actix_web::{Responder, get};
#[get("/")]
async fn get_index_html(resp: SessionResponse) -> impl Responder {
resp.respond("Home", "SEPTASTIC Home Page", IndexTemplate {})
}

View file

@ -1,2 +1,3 @@
pub mod index;
pub mod route; pub mod route;
pub mod stop; pub mod stop;

View file

@ -0,0 +1,137 @@
use crate::{
AppState,
session_middleware::{SessionResponder, SessionResponse},
};
use actix_web::{
HttpResponse, Responder, get,
web::{self, Data},
};
use libseptastic::{route::RouteType, stop_schedule::Trip};
use serde::{Deserialize, Serialize};
use std::{collections::HashSet, sync::Arc};
#[derive(Debug, Deserialize, Clone)]
struct RouteQueryParams {
#[serde(default)] // Optional: handle missing parameters with a default value
stops: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct RouteResponse {
pub route: libseptastic::route::Route,
pub directions: Vec<libseptastic::direction::Direction>,
pub schedule: Vec<Trip>,
}
async fn get_route_info(
route_id: String,
state: Data<Arc<AppState>>,
) -> ::anyhow::Result<RouteResponse> {
let route = state.gtfs_service.get_route(route_id.clone())?;
let mut trips = state.gtfs_service.get_schedule(route_id)?;
let mut seen = HashSet::new();
let directions: Vec<_> = trips
.iter()
.map(|x| x.direction.clone())
.filter(|dir| seen.insert(dir.direction.to_string()))
.collect();
state.trip_tracking_service.annotate_trips(&mut trips).await;
Ok(RouteResponse {
route,
directions,
schedule: trips,
})
}
#[get("/routes")]
async fn get_routes_html(state: Data<Arc<AppState>>, resp: SessionResponse) -> impl Responder {
let all_routes: Vec<libseptastic::route::Route> = state.gtfs_service.get_routes();
let rr_routes = all_routes
.clone()
.into_iter()
.filter(|x| x.route_type == RouteType::RegionalRail)
.collect();
let subway_routes = all_routes
.clone()
.into_iter()
.filter(|x| x.route_type == RouteType::SubwayElevated)
.collect();
let trolley_routes = all_routes
.clone()
.into_iter()
.filter(|x| x.route_type == RouteType::Trolley)
.collect();
let bus_routes = all_routes
.into_iter()
.filter(|x| x.route_type == RouteType::TracklessTrolley || x.route_type == RouteType::Bus)
.collect();
resp.respond(
"Routes",
"All routes",
crate::templates::RoutesTemplate {
rr_routes,
subway_routes,
trolley_routes,
bus_routes,
},
)
}
#[get("/routes.json")]
async fn get_routes_json(state: Data<Arc<AppState>>) -> impl Responder {
let all_routes: Vec<libseptastic::route::Route> = state.gtfs_service.get_routes();
HttpResponse::Ok().json(all_routes)
}
#[get("/route/{route_id}")]
async fn get_route_html(
state: Data<Arc<AppState>>,
info: web::Query<RouteQueryParams>,
path: web::Path<String>,
resp: SessionResponse,
) -> impl Responder {
let mut filters: Option<Vec<String>> = None;
if let Some(stops_v) = info.stops.clone() {
let mut items = Vec::new();
for sid in stops_v.split(",") {
items.push(String::from(sid));
}
filters = Some(items);
}
let route_id = path;
let route_info_r = get_route_info(route_id.clone(), state.clone()).await;
if let Ok(route_info) = route_info_r {
let timetables =
crate::templates::build_timetables(route_info.directions, route_info.schedule);
resp.respond(
format!("Schedules for {}", route_id.clone()).as_str(),
format!("Schedule information for {}", route_id.clone()).as_str(),
crate::templates::RouteTemplate {
route: route_info.route,
timetables,
filter_stops: filters.clone(),
},
)
} else {
HttpResponse::InternalServerError().body("")
}
}
#[get("/route/{route_id}.json")]
async fn get_route_json(state: Data<Arc<AppState>>, path: web::Path<String>) -> impl Responder {
let route_id = path.into_inner();
let route_info_r = get_route_info(route_id, state).await;
if let Ok(route_info) = route_info_r {
HttpResponse::Ok().json(route_info)
} else {
HttpResponse::InternalServerError().body("Error")
}
}

281
web/src/controllers/stop.rs Normal file
View file

@ -0,0 +1,281 @@
use crate::{
AppState,
session_middleware::{SessionResponder, SessionResponse},
templates::TripPerspective,
};
use actix_web::{
HttpResponse, Responder, get,
web::{self, Data},
};
use askama::Template;
use chrono::{TimeDelta, Timelike};
use chrono_tz::America::New_York;
use libseptastic::stop_schedule::{SeatAvailability, Trip, TripTracking};
use serde::{Deserialize, Serialize};
use serde_qs::actix::QsQuery;
use std::{
collections::{BTreeSet, HashSet},
sync::Arc,
};
async fn get_trip_perspective_for_stop(
state: &Data<Arc<AppState>>,
stop: &libseptastic::stop::Stop,
filter: &StopFilter,
) -> Vec<TripPerspective> {
let routes: Vec<libseptastic::route::Route> = state
.gtfs_service
.get_routes_at_stop(&stop.id)
.iter()
.filter_map(|route| match state.gtfs_service.get_route(route.clone()) {
Ok(route) => Some(route),
Err(_) => None,
})
.collect();
let route_ids: HashSet<String> = routes.iter().map(|route| route.id.clone()).collect();
let mut trips = state
.gtfs_service
.get_all_trips()
.iter()
.filter_map(|trip| {
if route_ids.contains(trip.0) {
Some(trip.1.clone())
} else {
None
}
})
.flatten()
.collect();
state.trip_tracking_service.annotate_trips(&mut trips).await;
let now_utc = chrono::Utc::now();
let now = now_utc.with_timezone(&New_York);
let naive_time = now.time();
let cur_time = i64::from(naive_time.num_seconds_from_midnight());
let mut filtered_trips: Vec<TripPerspective> = trips
.iter()
.filter_map(|trip| {
// poor midnight handling? -- going to offset by 4 hours, assume next 'schedule day'
// starts at 4a. Still may miss some trips. Oh well!
if !trip.calendar_day.is_calendar_active_for_date(
&now.naive_local()
.checked_add_signed(TimeDelta::hours(-4))?
.date(),
) {
return None;
}
let stop_sched: Vec<_> = trip
.schedule
.iter()
.filter(|stop_schedule| {
if stop_schedule.stop.id != stop.id {
return false;
}
match &trip.tracking_data {
libseptastic::stop_schedule::TripTracking::Tracked(live) => {
let actual_arrival_time = stop_schedule.get_arrival_time(&live);
return (actual_arrival_time - cur_time) > -(1 * 60)
&& (actual_arrival_time - cur_time) < (60 * 60);
}
libseptastic::stop_schedule::TripTracking::Untracked => {
return (stop_schedule.arrival_time - cur_time) > -(3 * 60)
&& (stop_schedule.arrival_time - cur_time) < (60 * 60);
}
libseptastic::stop_schedule::TripTracking::Cancelled => {
return false;
}
}
})
.filter_map(|ss| Some(ss.clone()))
.collect();
if stop_sched.len() > 0 && filter.trip_matches(trip) {
Some(TripPerspective {
perspective_stop: stop_sched.first().unwrap().clone(),
trip: trip.clone(),
})
} else {
None
}
})
.collect();
filtered_trips.sort_by_key(|f| match &f.trip.tracking_data {
TripTracking::Tracked(live) => f.perspective_stop.get_arrival_time(&live),
_ => f.perspective_stop.arrival_time,
});
filtered_trips
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct StopFilter {
pub routes: Option<HashSet<String>>,
pub live_tracked: Option<bool>,
pub scheduled: Option<bool>,
pub crowding: Option<HashSet<SeatAvailability>>,
pub unknown_crowding: Option<bool>,
}
impl StopFilter {
pub fn trip_matches(&self, trip: &Trip) -> bool {
let unspecified = self.live_tracked == None && self.scheduled == None;
let unknown_crowding = self.unknown_crowding.unwrap_or(true);
if (Some(false) == self.scheduled || (!unspecified && self.scheduled == None))
&& match trip.tracking_data {
TripTracking::Untracked => true,
_ => false,
}
{
return false;
}
if (Some(false) == self.live_tracked || (!unspecified && self.live_tracked == None))
&& match trip.tracking_data {
TripTracking::Tracked(_) => true,
_ => false,
}
{
return false;
}
if let Some(routes) = &self.routes {
let route_str = format!("{},{}", trip.route.id, trip.direction.direction);
if !routes.contains(&route_str) {
return false;
}
}
if let Some(crowding) = &self.crowding {
if let TripTracking::Tracked(live_trip) = &trip.tracking_data {
if let Some(seat_availability) = &live_trip.seat_availability {
if !crowding.contains(seat_availability) {
return false;
}
} else {
return unknown_crowding;
}
} else {
return unknown_crowding;
}
}
return true;
}
}
#[get("/stops")]
async fn get_stops_html(state: Data<Arc<AppState>>, resp: SessionResponse) -> impl Responder {
let stops = state
.gtfs_service
.get_all_stops()
.iter()
.filter_map(|f| {
if f.1.id.contains("ANNOTATED") {
Some(libseptastic::stop::Stop::clone(f.1))
} else {
None
}
})
.collect();
resp.respond(
"Stops",
"Stops",
crate::templates::StopsTemplate { tc_stops: stops },
)
}
#[get("/stop/{stop_id}/table")]
async fn get_stop_table_html(
state: Data<Arc<AppState>>,
path: web::Path<String>,
query: QsQuery<StopFilter>,
) -> impl Responder {
let stop_id = path;
if let Some(stop) = state.gtfs_service.get_stop_by_id(&stop_id) {
let filtered_trips = get_trip_perspective_for_stop(&state, &stop, &query).await;
let now_utc = chrono::Utc::now();
let now = now_utc.with_timezone(&New_York);
let naive_time = now.time();
let cur_time = i64::from(naive_time.num_seconds_from_midnight());
let query_str = serde_qs::Config::new()
.array_format(serde_qs::ArrayFormat::Unindexed)
.serialize_string(&query.0.clone())
.unwrap();
HttpResponse::Ok()
.append_header((
"HX-Replace-Url",
format!("/stop/{}?{}", stop_id, &query_str).as_str(),
))
.body(
crate::templates::StopTableTemplate {
trips: filtered_trips,
current_time: cur_time,
query_str,
stop_id: stop_id.to_string(),
}
.render()
.unwrap(),
)
} else {
HttpResponse::InternalServerError().body("Error")
}
}
#[get("/stop/{stop_id}")]
async fn get_stop_html(
state: Data<Arc<AppState>>,
path: web::Path<String>,
query: QsQuery<StopFilter>,
resp: SessionResponse,
) -> impl Responder {
let stop_id = path;
if let Some(stop) = state.gtfs_service.get_stop_by_id(&stop_id) {
let routes: Vec<libseptastic::route::Route> = state
.gtfs_service
.get_routes_at_stop(&stop.id)
.iter()
.filter_map(|route| match state.gtfs_service.get_route(route.clone()) {
Ok(route) => Some(route),
Err(_) => None,
})
.collect();
let filtered_trips = get_trip_perspective_for_stop(&state, &stop, &query).await;
let now_utc = chrono::Utc::now();
let now = now_utc.with_timezone(&New_York);
let naive_time = now.time();
let cur_time = i64::from(naive_time.num_seconds_from_midnight());
resp.respond(
stop.name.as_str(),
"Stop information",
crate::templates::StopTemplate {
stop: stop.clone(),
routes: BTreeSet::from_iter(routes.into_iter()),
trips: filtered_trips,
current_time: cur_time,
filters: Some(query.0.clone()),
query_str: serde_qs::Config::new()
.array_format(serde_qs::ArrayFormat::Unindexed)
.serialize_string(&query.0)
.unwrap(),
},
)
} else {
HttpResponse::InternalServerError().body("Error")
}
}

66
web/src/main.rs Normal file
View file

@ -0,0 +1,66 @@
use actix_web::{App, HttpServer, web::Data};
use dotenv::dotenv;
use env_logger::Env;
use log::*;
use services::gtfs_pull;
use std::{fs::File, io::Read, sync::Arc};
mod controllers;
mod services;
mod session_middleware;
mod templates;
pub struct AppState {
gtfs_service: services::gtfs_pull::GtfsPullService,
trip_tracking_service: services::trip_tracking::TripTrackingService,
}
#[tokio::main]
async fn main() -> ::anyhow::Result<()> {
env_logger::init_from_env(Env::default().default_filter_or("septastic_api=info"));
dotenv().ok();
let version: &str = option_env!("CARGO_PKG_VERSION").expect("Expected package version");
info!(
"Starting the SEPTASTIC Server v{} (commit: {})",
version, "NONE"
);
let mut file = File::open("./config.yaml")?;
let mut file_contents = String::new();
file.read_to_string(&mut file_contents)?;
let config_file = serde_yaml::from_str::<gtfs_pull::Config>(file_contents.as_str())?;
let tt_service = services::trip_tracking::TripTrackingService::new().await;
tt_service.start();
let svc = gtfs_pull::GtfsPullService::new(config_file);
svc.start();
svc.wait_for_ready();
let state = Arc::new(AppState {
gtfs_service: svc,
trip_tracking_service: tt_service,
});
HttpServer::new(move || {
App::new()
.wrap(actix_cors::Cors::permissive())
.app_data(Data::new(state.clone()))
.service(controllers::route::get_route_html)
.service(controllers::route::get_route_json)
.service(controllers::route::get_routes_html)
.service(controllers::route::get_routes_json)
.service(controllers::stop::get_stops_html)
.service(controllers::stop::get_stop_html)
.service(controllers::stop::get_stop_table_html)
.service(controllers::index::get_index_html)
.service(actix_files::Files::new("/assets", "./assets"))
})
.bind(("0.0.0.0", 8080))?
.run()
.await?;
Ok(())
}

View file

@ -0,0 +1,601 @@
use anyhow::anyhow;
use libseptastic::{stop::Platform, stop_schedule::CalendarDay};
use log::{error, info, warn};
use serde::{Deserialize, Serialize};
use std::{
cmp::Ordering,
collections::{HashMap, HashSet, hash_map::Entry},
env,
io::Cursor,
path::PathBuf,
sync::{Arc, Mutex, MutexGuard},
thread,
time::Duration,
};
use zip::ZipArchive;
macro_rules! make_global_id {
($prefix: expr, $id: expr) => {
format!("{}_{}", $prefix, $id)
};
}
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
pub struct GtfsSource {
pub uri: String,
pub subzip: Option<String>,
pub prefix: String,
}
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
pub struct MultiplatformStopConfig {
pub id: String,
pub name: String,
pub platform_station_ids: Vec<String>,
}
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
pub struct Annotations {
pub multiplatform_stops: Vec<MultiplatformStopConfig>,
}
#[derive(Serialize, Deserialize, PartialEq, Debug)]
pub struct Config {
pub gtfs_zips: Vec<GtfsSource>,
pub annotations: Annotations,
}
#[derive(Clone)]
pub struct GtfsFile {
pub source: GtfsSource,
}
pub struct TransitData {
pub routes: HashMap<String, Arc<libseptastic::route::Route>>,
pub agencies: HashMap<String, libseptastic::agency::Agency>,
pub trips: HashMap<String, Vec<libseptastic::stop_schedule::Trip>>,
pub stops: HashMap<String, Arc<libseptastic::stop::Stop>>,
pub platforms: HashMap<String, Arc<libseptastic::stop::Platform>>,
pub calendar_days: HashMap<String, Arc<libseptastic::stop_schedule::CalendarDay>>,
pub directions: HashMap<String, Vec<Arc<libseptastic::direction::Direction>>>,
// extended lookup methods
pub route_id_by_stops: HashMap<String, HashSet<String>>,
pub stops_by_route_id: HashMap<String, HashSet<String>>,
pub stops_by_platform_id: HashMap<String, Arc<libseptastic::stop::Stop>>,
}
pub struct GtfsPullServiceState {
pub gtfs_files: Vec<GtfsFile>,
pub tmp_dir: PathBuf,
pub ready: bool,
pub annotations: Annotations,
pub transit_data: TransitData,
}
pub struct GtfsPullService {
state: Arc<Mutex<GtfsPullServiceState>>,
}
impl TransitData {
pub fn new() -> Self {
return TransitData {
routes: HashMap::new(),
agencies: HashMap::new(),
trips: HashMap::new(),
stops: HashMap::new(),
platforms: HashMap::new(),
route_id_by_stops: HashMap::new(),
stops_by_route_id: HashMap::new(),
stops_by_platform_id: HashMap::new(),
calendar_days: HashMap::new(),
directions: HashMap::new(),
};
}
}
impl GtfsPullService {
const UPDATE_SECONDS: u64 = 3600 * 24;
const READYSTATE_CHECK_MILLISECONDS: u64 = 500;
pub fn new(config: Config) -> Self {
Self {
state: Arc::new(Mutex::new(GtfsPullServiceState {
gtfs_files: config
.gtfs_zips
.iter()
.map(|f| GtfsFile { source: f.clone() })
.collect(),
tmp_dir: env::temp_dir(),
annotations: config.annotations.clone(),
ready: false,
transit_data: TransitData::new(),
})),
}
}
pub fn wait_for_ready(&self) {
while !(self.state.lock().unwrap()).ready {
thread::sleep(Duration::from_millis(Self::READYSTATE_CHECK_MILLISECONDS));
}
}
pub fn start(&self) {
let cloned_state = Arc::clone(&self.state);
thread::spawn(move || {
loop {
let recloned_state = Arc::clone(&cloned_state);
let res = Self::update_gtfs_data(recloned_state);
match res {
Err(err) => {
error!("{}", err);
}
_ => {}
}
thread::sleep(Duration::from_secs(Self::UPDATE_SECONDS));
}
});
}
pub fn get_routes(&self) -> Vec<libseptastic::route::Route> {
let l_state = self.state.lock().unwrap();
l_state
.transit_data
.routes
.iter()
.map(|r| libseptastic::route::Route::clone(r.1))
.collect()
}
pub fn get_route(&self, route_id: String) -> anyhow::Result<libseptastic::route::Route> {
let l_state = self.state.lock().unwrap();
if let Some(route) = l_state.transit_data.routes.get(&route_id) {
Ok(libseptastic::route::Route::clone(route))
} else {
Err(anyhow!(""))
}
}
pub fn get_all_routes(&self) -> HashMap<String, libseptastic::route::Route> {
let l_state = self.state.lock().unwrap();
l_state
.transit_data
.routes
.iter()
.map(|r| (r.0.clone(), libseptastic::route::Route::clone(r.1)))
.collect()
}
pub fn get_all_stops(&self) -> HashMap<String, Arc<libseptastic::stop::Stop>> {
let l_state = self.state.lock().unwrap();
l_state.transit_data.stops.clone()
}
pub fn get_all_trips(&self) -> HashMap<String, Vec<libseptastic::stop_schedule::Trip>> {
let l_state = self.state.lock().unwrap();
l_state.transit_data.trips.clone()
}
pub fn get_routes_at_stop(&self, id: &String) -> HashSet<String> {
let l_state = self.state.lock().unwrap();
l_state
.transit_data
.route_id_by_stops
.get(id)
.unwrap_or(&HashSet::new())
.clone()
}
pub fn get_stops_by_route(&self, id: &String) -> HashSet<String> {
let l_state = self.state.lock().unwrap();
l_state
.transit_data
.stops_by_route_id
.get(id)
.unwrap_or(&HashSet::new())
.clone()
}
pub fn get_stop_by_id(&self, id: &String) -> Option<libseptastic::stop::Stop> {
let l_state = self.state.lock().unwrap();
match l_state.transit_data.stops.get(id) {
Some(stop) => Some(libseptastic::stop::Stop::clone(stop)),
None => None,
}
}
pub fn get_schedule(
&self,
route_id: String,
) -> anyhow::Result<Vec<libseptastic::stop_schedule::Trip>> {
let l_state = self.state.lock().unwrap();
if let Some(trips) = l_state.transit_data.trips.get(&route_id) {
Ok(trips.clone())
} else {
Err(anyhow!(""))
}
}
fn postprocess_stops(state: &mut MutexGuard<'_, GtfsPullServiceState>) -> anyhow::Result<()> {
for annotated_stop in state.annotations.multiplatform_stops.clone() {
let global_id = make_global_id!("ANNOTATED", annotated_stop.id.clone());
let stop = Arc::new(libseptastic::stop::Stop {
id: global_id.clone(),
name: annotated_stop.name.clone(),
platforms: libseptastic::stop::StopType::MultiPlatform(
annotated_stop
.platform_station_ids
.iter()
.map(|platform_id| {
info!(
"Folding {} stop into stop {} as platform",
platform_id.clone(),
annotated_stop.id.clone()
);
let platform = match state
.transit_data
.stops
.remove(platform_id)
.unwrap()
.platforms
.clone()
{
libseptastic::stop::StopType::SinglePlatform(plat) => Ok(plat),
_ => Err(anyhow!("")),
}
.unwrap();
state
.transit_data
.stops_by_platform_id
.remove(&platform.id)
.unwrap();
platform
})
.collect(),
),
});
state
.transit_data
.stops
.insert(global_id.clone(), stop.clone());
match &stop.platforms {
libseptastic::stop::StopType::MultiPlatform(platforms) => {
for platform in platforms {
state
.transit_data
.stops_by_platform_id
.insert(platform.id.clone(), stop.clone());
}
Ok(())
}
_ => Err(anyhow!("")),
}?
}
Ok(())
}
fn populate_stops(
state: &mut MutexGuard<'_, GtfsPullServiceState>,
prefix: &String,
gtfs: &gtfs_structures::Gtfs,
) -> anyhow::Result<()> {
for stop in &gtfs.stops {
let global_id = make_global_id!(prefix, stop.1.id.clone());
let platform = Arc::new(Platform {
id: global_id.clone(),
name: stop.1.name.clone().unwrap(),
lat: stop.1.latitude.unwrap(),
lng: stop.1.longitude.unwrap(),
platform_location: libseptastic::stop::PlatformLocationType::Normal,
});
let stop = Arc::new(libseptastic::stop::Stop {
id: global_id.clone(),
name: stop.1.name.clone().unwrap(),
platforms: libseptastic::stop::StopType::SinglePlatform(platform.clone()),
});
state
.transit_data
.stops
.insert(global_id.clone(), stop.clone());
state
.transit_data
.platforms
.insert(global_id.clone(), platform.clone());
state
.transit_data
.stops_by_platform_id
.insert(global_id.clone(), stop.clone());
}
Ok(())
}
fn populate_routes(
state: &mut MutexGuard<'_, GtfsPullServiceState>,
prefix: &String,
gtfs: &gtfs_structures::Gtfs,
) -> anyhow::Result<()> {
for route in &gtfs.routes {
let global_rt_id = make_global_id!(prefix, route.1.id);
info!("{}", global_rt_id);
let rt_name = match route.1.long_name.clone() {
Some(x) => x,
_ => String::from("Unknown"),
};
let dirs = match state.transit_data.directions.get(&global_rt_id) {
Some(x) => x
.iter()
.map(|f| libseptastic::direction::Direction::clone(f))
.collect(),
None => {
warn!("Excluding {} because it has no directions", global_rt_id);
continue;
}
};
state.transit_data.routes.insert(
global_rt_id.clone(),
Arc::new(libseptastic::route::Route {
name: rt_name,
directions: dirs,
short_name: match route.1.short_name.clone() {
Some(x) => x,
_ => String::from("unknown"),
},
color_hex: match route.1.color {
Some(x) => x.to_string(),
_ => String::from("unknown"),
},
id: global_rt_id,
route_type: match route.1.route_type {
gtfs_structures::RouteType::Bus => libseptastic::route::RouteType::Bus,
gtfs_structures::RouteType::Rail => {
libseptastic::route::RouteType::RegionalRail
}
gtfs_structures::RouteType::Subway => {
libseptastic::route::RouteType::SubwayElevated
}
gtfs_structures::RouteType::Tramway => {
libseptastic::route::RouteType::Trolley
}
_ => libseptastic::route::RouteType::TracklessTrolley,
},
}),
);
}
Ok(())
}
fn populate_directions(
state: &mut MutexGuard<'_, GtfsPullServiceState>,
prefix: &String,
gtfs: &gtfs_structures::Gtfs,
) -> anyhow::Result<()> {
for trip in &gtfs.trips {
let global_rt_id = make_global_id!(prefix, trip.1.route_id);
let dir = libseptastic::direction::Direction {
direction: match trip.1.direction_id.unwrap() {
gtfs_structures::DirectionType::Outbound => {
libseptastic::direction::CardinalDirection::Outbound
}
gtfs_structures::DirectionType::Inbound => {
libseptastic::direction::CardinalDirection::Inbound
}
},
direction_destination: trip.1.trip_headsign.clone().unwrap(),
};
match state.transit_data.directions.entry(global_rt_id) {
Entry::Vacant(e) => {
e.insert(vec![Arc::new(dir)]);
}
Entry::Occupied(mut e) => {
if e.get()
.iter()
.filter(|x| x.direction == dir.direction)
.count()
== 0
{
e.get_mut().push(Arc::new(dir));
}
}
}
}
for dir in &mut state.transit_data.directions {
dir.1.sort_by(|x, y| {
if x.direction > y.direction {
Ordering::Greater
} else {
Ordering::Less
}
});
}
Ok(())
}
fn populate_trips(
state: &mut MutexGuard<'_, GtfsPullServiceState>,
prefix: &String,
gtfs: &gtfs_structures::Gtfs,
) -> anyhow::Result<()> {
for trip in &gtfs.trips {
let global_rt_id = make_global_id!(prefix, trip.1.route_id);
let sched = trip
.1
.stop_times
.iter()
.map(|s| {
let global_stop_id = make_global_id!(prefix, s.stop.id);
let stop = state
.transit_data
.stops_by_platform_id
.get(&global_stop_id)
.unwrap()
.clone();
let platform = state
.transit_data
.platforms
.get(&global_stop_id)
.unwrap()
.clone();
state
.transit_data
.route_id_by_stops
.entry(stop.id.clone())
.or_insert(HashSet::new())
.insert(global_rt_id.clone());
state
.transit_data
.stops_by_route_id
.entry(global_rt_id.clone())
.or_insert(HashSet::new())
.insert(stop.id.clone());
state
.transit_data
.route_id_by_stops
.entry(platform.id.clone())
.or_insert(HashSet::new())
.insert(global_rt_id.clone());
state
.transit_data
.stops_by_route_id
.entry(global_rt_id.clone())
.or_insert(HashSet::new())
.insert(platform.id.clone());
libseptastic::stop_schedule::StopSchedule {
arrival_time: i64::from(s.arrival_time.unwrap()),
stop_sequence: i64::from(s.stop_sequence),
stop,
platform,
}
})
.collect();
if let Some(calendar_day) = state
.transit_data
.calendar_days
.get(&trip.1.service_id.clone())
{
let trip = libseptastic::stop_schedule::Trip {
trip_id: trip.1.id.clone(),
route: state
.transit_data
.routes
.get(&make_global_id!(prefix, trip.1.route_id))
.unwrap()
.clone(),
direction: libseptastic::direction::Direction {
direction: match trip.1.direction_id.unwrap() {
gtfs_structures::DirectionType::Outbound => {
libseptastic::direction::CardinalDirection::Outbound
}
gtfs_structures::DirectionType::Inbound => {
libseptastic::direction::CardinalDirection::Inbound
}
},
direction_destination: trip.1.trip_headsign.clone().unwrap(),
},
tracking_data: libseptastic::stop_schedule::TripTracking::Untracked,
schedule: sched,
service_id: trip.1.service_id.clone(),
calendar_day: calendar_day.clone(),
};
if let Some(trip_arr) = state.transit_data.trips.get_mut(&global_rt_id) {
trip_arr.push(trip);
} else {
state.transit_data.trips.insert(global_rt_id, vec![trip]);
}
}
}
Ok(())
}
pub fn update_gtfs_data(state: Arc<Mutex<GtfsPullServiceState>>) -> anyhow::Result<()> {
let mut l_state = state.lock().unwrap();
let files = l_state.gtfs_files.clone();
l_state.transit_data = TransitData::new();
let mut gtfses = Vec::new();
for gtfs_file in files.iter() {
let gtfs = if let Some(subzip) = gtfs_file.source.subzip.clone() {
info!(
"Reading GTFS file at {} (subzip {})",
gtfs_file.source.uri, subzip
);
let res = reqwest::blocking::get(gtfs_file.source.uri.clone())?;
let outer_archive = res.bytes()?;
let mut archive = ZipArchive::new(Cursor::new(outer_archive))?;
archive.extract(l_state.tmp_dir.clone())?;
let mut file_path = l_state.tmp_dir.clone();
file_path.push(subzip.clone());
info!("Downloaded, parsing");
gtfs_structures::Gtfs::new(file_path.to_str().unwrap())?
} else {
info!("Reading GTFS file at {}", gtfs_file.source.uri);
gtfs_structures::Gtfs::new(gtfs_file.source.uri.as_str())?
};
gtfses.push((gtfs, gtfs_file.source.prefix.clone()));
}
info!("Data loaded, processing...");
for (gtfs, prefix) in &gtfses {
GtfsPullService::populate_directions(&mut l_state, &prefix, &gtfs)?;
GtfsPullService::populate_routes(&mut l_state, &prefix, &gtfs)?;
GtfsPullService::populate_stops(&mut l_state, &prefix, &gtfs)?;
for calendar in &gtfs.calendar {
l_state.transit_data.calendar_days.insert(
calendar.1.id.clone(),
Arc::new(CalendarDay {
id: calendar.1.id.clone(),
monday: calendar.1.monday,
tuesday: calendar.1.tuesday,
wednesday: calendar.1.wednesday,
thursday: calendar.1.thursday,
friday: calendar.1.friday,
saturday: calendar.1.saturday,
sunday: calendar.1.sunday,
start_date: calendar.1.start_date,
end_date: calendar.1.end_date,
}),
);
}
}
GtfsPullService::postprocess_stops(&mut l_state)?;
for (gtfs, prefix) in &gtfses {
GtfsPullService::populate_trips(&mut l_state, &prefix, &gtfs)?;
}
l_state.ready = true;
info!("Finished initial sync, ready state is true");
Ok(())
}
}

2
web/src/services/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod gtfs_pull;
pub mod trip_tracking;

View file

@ -1,14 +1,14 @@
use chrono::Utc; use chrono::Utc;
use serde_json::Value;
use serde::de;
use sqlx::{Postgres, QueryBuilder, Transaction};
use std::sync::{Arc};
use futures::lock::Mutex; use futures::lock::Mutex;
use std::collections::HashMap;
use std::time::Duration;
use log::{error, info};
use serde::{Serialize, Deserialize, Deserializer};
use libseptastic::stop_schedule::{LiveTrip, SeatAvailability, TripTracking}; use libseptastic::stop_schedule::{LiveTrip, SeatAvailability, TripTracking};
use log::{error, info};
use serde::de;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use sqlx::{Postgres, QueryBuilder, Transaction};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiveTripJson { pub struct LiveTripJson {
@ -34,18 +34,18 @@ pub struct LiveTripJson {
pub next_stop_sequence: Option<i64>, pub next_stop_sequence: Option<i64>,
pub seat_availability: Option<String>, pub seat_availability: Option<String>,
pub vehicle_id: Option<String>, pub vehicle_id: Option<String>,
pub timestamp: i64 pub timestamp: i64,
} }
const HOST: &str = "https://www3.septa.org"; const HOST: &str = "https://www3.septa.org";
struct TripTrackingServiceState { struct TripTrackingServiceState {
pub tracking_data: HashMap::<String, TripTracking>, pub tracking_data: HashMap<String, TripTracking>,
pub database: ::sqlx::postgres::PgPool pub database: ::sqlx::postgres::PgPool,
} }
pub struct TripTrackingService { pub struct TripTrackingService {
state: Arc<Mutex<TripTrackingServiceState>> state: Arc<Mutex<TripTrackingServiceState>>,
} }
impl TripTrackingService { impl TripTrackingService {
@ -53,10 +53,9 @@ impl TripTrackingService {
pub async fn log_delay( pub async fn log_delay(
transaction: &mut Transaction<'_, Postgres>, transaction: &mut Transaction<'_, Postgres>,
tracking_data: &HashMap::<String, TripTracking>, tracking_data: &HashMap<String, TripTracking>,
timestamp: i64 timestamp: i64,
) -> ::anyhow::Result<()> { ) -> ::anyhow::Result<()> {
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new( let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(
"INSERT INTO "INSERT INTO
live_tracking live_tracking
@ -73,7 +72,7 @@ impl TripTrackingService {
trip_id, trip_id,
route_id route_id
) )
VALUES" VALUES",
); );
let mut separated = query_builder.separated(", "); let mut separated = query_builder.separated(", ");
@ -89,7 +88,7 @@ impl TripTrackingService {
separated.push_bind(live_data.heading); separated.push_bind(live_data.heading);
separated.push_bind(match &live_data.seat_availability { separated.push_bind(match &live_data.seat_availability {
Some(s) => Some(s.to_string()), Some(s) => Some(s.to_string()),
None => None None => None,
}); });
separated.push_bind(live_data.vehicle_ids.clone()); separated.push_bind(live_data.vehicle_ids.clone());
separated.push_bind(live_data.trip_id.clone()); separated.push_bind(live_data.trip_id.clone());
@ -111,17 +110,21 @@ impl TripTrackingService {
let pool = ::sqlx::postgres::PgPoolOptions::new() let pool = ::sqlx::postgres::PgPoolOptions::new()
.max_connections(5) .max_connections(5)
.connect(&connection_string) .connect(&connection_string)
.await.unwrap(); .await
.unwrap();
Self { Self {
state: Arc::new(Mutex::new(TripTrackingServiceState{ tracking_data: HashMap::new(), database: pool})) state: Arc::new(Mutex::new(TripTrackingServiceState {
tracking_data: HashMap::new(),
database: pool,
})),
} }
} }
pub fn start(&self) { pub fn start(&self) {
info!("Starting live tracking service"); info!("Starting live tracking service");
let cloned_state = Arc::clone(&self.state); let cloned_state = Arc::clone(&self.state);
tokio::spawn( async move { tokio::spawn(async move {
loop { loop {
let clonedx_state = Arc::clone(&cloned_state); let clonedx_state = Arc::clone(&cloned_state);
let res = Self::update_live_trips(clonedx_state).await; let res = Self::update_live_trips(clonedx_state).await;
@ -140,16 +143,27 @@ impl TripTrackingService {
pub async fn annotate_trips(&self, trips: &mut Vec<libseptastic::stop_schedule::Trip>) { pub async fn annotate_trips(&self, trips: &mut Vec<libseptastic::stop_schedule::Trip>) {
for trip in trips { for trip in trips {
trip.tracking_data = match self.state.lock().await.tracking_data.get(&trip.trip_id.clone()){ trip.tracking_data = match self
.state
.lock()
.await
.tracking_data
.get(&trip.trip_id.clone())
{
Some(x) => x.clone(), Some(x) => x.clone(),
None => TripTracking::Untracked None => TripTracking::Untracked,
}; };
} }
} }
async fn update_live_trips(service: Arc<Mutex<TripTrackingServiceState>>) -> anyhow::Result<()> { async fn update_live_trips(
service: Arc<Mutex<TripTrackingServiceState>>,
) -> anyhow::Result<()> {
let mut new_map: HashMap<String, TripTracking> = HashMap::new(); let mut new_map: HashMap<String, TripTracking> = HashMap::new();
let live_tracks = reqwest::get(format!("{}/api/v2/trips/", HOST)).await?.json::<Vec<LiveTripJson>>().await?; let live_tracks = reqwest::get(format!("{}/api/v2/trips/", HOST))
.await?
.json::<Vec<LiveTripJson>>()
.await?;
for live_track in live_tracks { for live_track in live_tracks {
let track: TripTracking = { let track: TripTracking = {
@ -158,48 +172,50 @@ impl TripTrackingService {
} else if live_track.status == "CANCELED" { } else if live_track.status == "CANCELED" {
TripTracking::Cancelled TripTracking::Cancelled
} else { } else {
TripTracking::Tracked( TripTracking::Tracked(LiveTrip {
LiveTrip {
trip_id: live_track.trip_id.clone(), trip_id: live_track.trip_id.clone(),
route_id: live_track.route_id, route_id: live_track.route_id,
delay: live_track.delay, delay: live_track.delay,
seat_availability: SeatAvailability::from_opt_string(&live_track.seat_availability), seat_availability: SeatAvailability::from_opt_string(
&live_track.seat_availability,
),
heading: match live_track.heading { heading: match live_track.heading {
Some(hdg) => if hdg != "" { Some(hdg.parse::<f64>()?)} else {None}, Some(hdg) => {
None => None if hdg != "" {
Some(hdg.parse::<f64>()?)
} else {
None
}
}
None => None,
}, },
latitude: match live_track.lat { latitude: match live_track.lat {
Some(lat) => Some(lat.parse::<f64>()?), Some(lat) => Some(lat.parse::<f64>()?),
None => None None => None,
}, },
longitude: match live_track.lon { longitude: match live_track.lon {
Some(lon) => Some(lon.parse::<f64>()?), Some(lon) => Some(lon.parse::<f64>()?),
None => None None => None,
}, },
next_stop_id: match live_track.next_stop_id { next_stop_id: match live_track.next_stop_id {
Some(x) => match x.parse() { Some(x) => match x.parse() {
Ok(y) => Some(y), Ok(y) => Some(y),
Err(_) => None Err(_) => None,
}, },
None => None None => None,
}, },
timestamp: live_track.timestamp, timestamp: live_track.timestamp,
vehicle_ids: match live_track.vehicle_id { vehicle_ids: match live_track.vehicle_id {
Some(x) => x.split(",").map(|f| String::from(f)).collect(), Some(x) => x.split(",").map(|f| String::from(f)).collect(),
None => vec![] None => vec![],
} },
} })
)
} }
}; };
if let TripTracking::Cancelled = track { if let TripTracking::Cancelled = track {}
}
new_map.insert( new_map.insert(live_track.trip_id.clone(), track);
live_track.trip_id.clone(),
track
);
} }
let mut svc = service.lock().await; let mut svc = service.lock().await;
@ -217,24 +233,34 @@ impl TripTrackingService {
fn de_numstr<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> { fn de_numstr<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
Ok(match Value::deserialize(deserializer)? { Ok(match Value::deserialize(deserializer)? {
Value::String(s) => s, Value::String(s) => s,
Value::Number(num) => num.as_i64().ok_or(de::Error::custom("Invalid number"))?.to_string(), Value::Number(num) => num
_ => return Err(de::Error::custom("wrong type")) .as_i64()
.ok_or(de::Error::custom("Invalid number"))?
.to_string(),
_ => return Err(de::Error::custom("wrong type")),
}) })
} }
fn de_numstro<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> { fn de_numstro<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
Ok(match Value::deserialize(deserializer)? { Ok(match Value::deserialize(deserializer)? {
Value::String(s) => Some(s), Value::String(s) => Some(s),
Value::Number(num) => Some(num.as_i64().ok_or(de::Error::custom("Invalid number"))?.to_string()), Value::Number(num) => Some(
_ => None num.as_i64()
.ok_or(de::Error::custom("Invalid number"))?
.to_string(),
),
_ => None,
}) })
} }
fn de_numstrflo<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> { fn de_numstrflo<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
Ok(match Value::deserialize(deserializer)? { Ok(match Value::deserialize(deserializer)? {
Value::String(s) => Some(s), Value::String(s) => Some(s),
Value::Number(num) => Some(num.as_f64().ok_or(de::Error::custom("Invalid number"))?.to_string()), Value::Number(num) => Some(
_ => None num.as_f64()
.ok_or(de::Error::custom("Invalid number"))?
.to_string(),
),
_ => None,
}) })
} }

View file

@ -0,0 +1,69 @@
use actix_web::{FromRequest, HttpRequest, HttpResponse, cookie::Cookie, dev::Payload};
use askama::Template;
use serde::Deserialize;
use std::{pin::Pin, time::Instant};
#[derive(Deserialize)]
struct LocalStateQuery {
pub widescreen: Option<bool>,
}
pub trait SessionResponder<T: Template> {
fn respond(&self, page_title: &str, page_desc: &str, content: T) -> HttpResponse;
}
pub struct SessionResponse {
start_time: Instant,
widescreen: bool,
}
impl<T> SessionResponder<T> for SessionResponse
where
T: Template,
{
fn respond(&self, page_title: &str, page_desc: &str, content: T) -> HttpResponse {
let end_time = Instant::now();
let mut cookie = Cookie::new("widescreen", self.widescreen.to_string());
cookie.set_path("/");
HttpResponse::Ok().cookie(cookie).body(
crate::templates::ContentTemplate {
page_title: Some(page_title.to_string()),
page_desc: Some(page_desc.to_string()),
content,
load_time_ms: Some((end_time - self.start_time).as_nanos()),
widescreen: self.widescreen,
}
.render()
.unwrap(),
)
}
}
impl FromRequest for SessionResponse {
type Error = actix_web::Error;
type Future = Pin<Box<dyn Future<Output = Result<SessionResponse, Self::Error>>>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
let start_time = Instant::now();
let mut enable_widescreen = true;
if let Some(widescreen_set) = req.cookie("widescreen") {
enable_widescreen = widescreen_set.value() == "true";
}
let query_params =
actix_web::web::Query::<LocalStateQuery>::from_query(req.query_string()).unwrap();
if let Some(set_widescreen) = query_params.widescreen {
enable_widescreen = set_widescreen;
}
Box::pin(async move {
Ok(SessionResponse {
start_time,
widescreen: enable_widescreen,
})
})
}
}

View file

@ -1,9 +1,15 @@
use chrono_tz::America::New_York;
use libseptastic::{direction::Direction, stop_schedule::{Trip, TripTracking, SeatAvailability}};
use std::{cmp::Ordering, collections::{BTreeMap, BTreeSet}};
use serde::{Serialize};
use libseptastic::stop_schedule::TripTracking::Tracked;
use chrono::Timelike; use chrono::Timelike;
use chrono_tz::America::New_York;
use libseptastic::stop_schedule::TripTracking::Tracked;
use libseptastic::{
direction::Direction,
stop_schedule::{SeatAvailability, Trip, TripTracking},
};
use serde::Serialize;
use std::{
cmp::Ordering,
collections::{BTreeMap, BTreeSet},
};
use crate::controllers::stop::StopFilter; use crate::controllers::stop::StopFilter;
@ -14,7 +20,7 @@ pub struct ContentTemplate<T: askama::Template> {
pub page_title: Option<String>, pub page_title: Option<String>,
pub page_desc: Option<String>, pub page_desc: Option<String>,
pub load_time_ms: Option<u128>, pub load_time_ms: Option<u128>,
pub widescreen: bool pub widescreen: bool,
} }
#[derive(askama::Template)] #[derive(askama::Template)]
@ -22,7 +28,7 @@ pub struct ContentTemplate<T: askama::Template> {
pub struct RouteTemplate { pub struct RouteTemplate {
pub route: libseptastic::route::Route, pub route: libseptastic::route::Route,
pub timetables: Vec<TimetableDirection>, pub timetables: Vec<TimetableDirection>,
pub filter_stops: Option<Vec<String>> pub filter_stops: Option<Vec<String>>,
} }
#[derive(askama::Template)] #[derive(askama::Template)]
@ -31,7 +37,7 @@ pub struct RoutesTemplate {
pub rr_routes: Vec<libseptastic::route::Route>, pub rr_routes: Vec<libseptastic::route::Route>,
pub subway_routes: Vec<libseptastic::route::Route>, pub subway_routes: Vec<libseptastic::route::Route>,
pub trolley_routes: Vec<libseptastic::route::Route>, pub trolley_routes: Vec<libseptastic::route::Route>,
pub bus_routes: Vec<libseptastic::route::Route> pub bus_routes: Vec<libseptastic::route::Route>,
} }
#[derive(askama::Template)] #[derive(askama::Template)]
@ -42,32 +48,28 @@ pub struct StopsTemplate {
#[derive(askama::Template)] #[derive(askama::Template)]
#[template(path = "index.html")] #[template(path = "index.html")]
pub struct IndexTemplate { pub struct IndexTemplate {}
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct TimetableStopRow { pub struct TimetableStopRow {
pub stop_id: String, pub stop_id: String,
pub stop_name: String, pub stop_name: String,
pub stop_sequence: i64, pub stop_sequence: i64,
pub times: Vec<Option<i64>> pub times: Vec<Option<i64>>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct TimetableDirection { pub struct TimetableDirection {
pub direction: Direction, pub direction: Direction,
pub trip_ids: Vec<String>, pub trip_ids: Vec<String>,
pub tracking_data: Vec<TripTracking>, pub tracking_data: Vec<TripTracking>,
pub rows: Vec<TimetableStopRow>, pub rows: Vec<TimetableStopRow>,
pub next_id: Option<String> pub next_id: Option<String>,
} }
pub struct TripPerspective { pub struct TripPerspective {
pub trip:libseptastic::stop_schedule::Trip, pub trip: libseptastic::stop_schedule::Trip,
pub perspective_stop: libseptastic::stop_schedule::StopSchedule, pub perspective_stop: libseptastic::stop_schedule::StopSchedule,
pub est_arrival_time: i64,
pub is_tracked: bool
} }
#[derive(askama::Template)] #[derive(askama::Template)]
@ -78,7 +80,7 @@ pub struct StopTemplate {
pub trips: Vec<TripPerspective>, pub trips: Vec<TripPerspective>,
pub current_time: i64, pub current_time: i64,
pub filters: Option<StopFilter>, pub filters: Option<StopFilter>,
pub query_str: String pub query_str: String,
} }
#[derive(askama::Template)] #[derive(askama::Template)]
@ -86,15 +88,11 @@ pub struct StopTemplate {
pub struct StopTableTemplate { pub struct StopTableTemplate {
pub trips: Vec<TripPerspective>, pub trips: Vec<TripPerspective>,
pub current_time: i64, pub current_time: i64,
pub filters: Option<StopFilter>,
pub query_str: String, pub query_str: String,
pub stop_id: String pub stop_id: String,
} }
pub fn build_timetables( pub fn build_timetables(directions: Vec<Direction>, trips: Vec<Trip>) -> Vec<TimetableDirection> {
directions: Vec<Direction>,
trips: Vec<Trip>,
) -> Vec<TimetableDirection> {
let mut results = Vec::new(); let mut results = Vec::new();
for direction in directions { for direction in directions {
@ -126,24 +124,22 @@ pub fn build_timetables(
} }
} }
let trip_ids: Vec<String> = direction_trips let trip_ids: Vec<String> = direction_trips.iter().map(|t| t.trip_id.clone()).collect();
.iter()
.map(|t| t.trip_id.clone())
.collect();
let live_trips: Vec<TripTracking> = direction_trips let live_trips: Vec<TripTracking> = direction_trips
.iter() .iter()
.map(|t| t.tracking_data.clone()) .map(|t| t.tracking_data.clone())
.collect(); .collect();
let mut stop_map: BTreeMap<String, (i64, String, Vec<Option<i64>>)> = BTreeMap::new(); let mut stop_map: BTreeMap<String, (i64, String, Vec<Option<i64>>)> = BTreeMap::new();
for (trip_index, trip) in direction_trips.iter().enumerate() { for (trip_index, trip) in direction_trips.iter().enumerate() {
for stop in &trip.schedule { for stop in &trip.schedule {
let entry = stop_map let entry = stop_map.entry(stop.stop.id.clone()).or_insert((
.entry(stop.stop.id.clone()) stop.stop_sequence,
.or_insert((stop.stop_sequence, stop.stop.name.clone(), vec![None; direction_trips.len()])); stop.stop.name.clone(),
vec![None; direction_trips.len()],
));
// If this stop_id appears in multiple trips with different sequences, keep the lowest // If this stop_id appears in multiple trips with different sequences, keep the lowest
entry.0 = entry.0.max(stop.stop_sequence); entry.0 = entry.0.max(stop.stop_sequence);
@ -154,15 +150,17 @@ pub fn build_timetables(
let mut rows: Vec<TimetableStopRow> = stop_map let mut rows: Vec<TimetableStopRow> = stop_map
.into_iter() .into_iter()
.map(|(stop_id, (stop_sequence, stop_name, times))| TimetableStopRow { .map(
|(stop_id, (stop_sequence, stop_name, times))| TimetableStopRow {
stop_id, stop_id,
stop_sequence, stop_sequence,
stop_name, stop_name,
times, times,
}) },
)
.collect(); .collect();
rows.sort_by(| a, b| { rows.sort_by(|a, b| {
if a.stop_sequence < b.stop_sequence { if a.stop_sequence < b.stop_sequence {
Ordering::Less Ordering::Less
} else { } else {
@ -174,8 +172,8 @@ pub fn build_timetables(
direction: direction.clone(), direction: direction.clone(),
trip_ids, trip_ids,
rows, rows,
tracking_data: live_trips , tracking_data: live_trips,
next_id next_id,
}); });
} }
@ -186,16 +184,14 @@ mod filters {
use askama::filter_fn; use askama::filter_fn;
#[filter_fn] #[filter_fn]
pub fn format_load_time( pub fn format_load_time(nanos: &u128, _: &dyn askama::Values) -> askama::Result<String> {
nanos: &u128,
_: &dyn askama::Values,
) -> askama::Result<String> {
if *nanos >= 1000000000 { if *nanos >= 1000000000 {
return Ok(format!("{}s", (nanos/1000000000))); return Ok(format!("{}s", (nanos / 1000000000)));
} else if *nanos >= 1000000 { } else if *nanos >= 1000000 {
return Ok(format!("{}ms", nanos/1000000)); return Ok(format!("{}ms", nanos / 1000000));
} if *nanos >= 1000 { }
return Ok(format!("{}us", nanos/1000)); if *nanos >= 1000 {
return Ok(format!("{}us", nanos / 1000));
} else { } else {
return Ok(format!("{}ns", nanos)); return Ok(format!("{}ns", nanos));
} }

13
web/templates/index.html Normal file
View file

@ -0,0 +1,13 @@
<h1>SEPTASTIC!</h1>
<p>
<i>A fantastic way to ride SEPTA</i>
</p>
<p style="margin-top: 25px;">
SEPTASTIC is a website and (a soon to be) mobile app. Its purpose is to provide
information about how to ride SEPTA (and connecting transit authorities) in a
quick and information-rich manner.
</p>
<p style="margin-top: 25px; margin-bottom: 25px;">
Currently, all this website has is <a href="/routes">timetables for every
SEPTA route</a>. More to come soon!
</p>

103
web/templates/layout.html Normal file
View file

@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en">
<head>
{% if let Some(title) = page_title %}
<title>{{ title }} | SEPTASTIC</title>
{% else %}
<title>SEPTASTIC</title>
{% endif %}
{% if let Some(desc) = page_desc %}
<meta name="{{ desc }}" />
{% else %}
<meta name="SEPTASTIC" />
{% endif %}
<link rel="stylesheet" href="/assets/style.css">
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
<script>
window.onload = function () {
setTimeout(() => {
const perfData = window.performance.timing;
const pageLoadTime = perfData.loadEventEnd - perfData.navigationStart;
const loadTimeElement = document.getElementById('js_load_time');
loadTimeElement.textContent += ` ${pageLoadTime}ms`;
}, 0); // Minimal delay to wait for `loadEventEnd` to be populated
};
</script>
<noscript>
<style>
.js-only {
display: none;
}
</style>
</noscript>
<style>
.silverliner-svg {
display: block;
width: 100%;
height: 200px; /* Fixed height matching the viewBox */
}
</style>
<body>
{% if widescreen %}
<div class="body">
{% else %}
<div class="body body-small">
{% endif %}
<div style="background-color: #ff0000;
color: #ffffff;
font-size: .7em;
padding: 5px;
margin-bottom: 10px;
margin-top: 10px">This website is not run by SEPTA. Data may be inaccurate.</div>
<nav>
<div style="display: flex; justify-content: space-between;">
<div>
<a class="nav-link" href="/">[ Home ]</a>
<a class="nav-link" href="/routes">[ Routes ]</a>
<a class="nav-link" href="/stops">[ Stops ]</a>
</div>
<div></div>
</div>
</nav>
<hr />
{{ content|safe }}
<footer>
<hr />
<div style="display: flex; justify-content: space-between;">
<div>
<p style="margin-bottom: 0px; margin-top:0px;">
<b>SEPTASTIC!</b>
</p>
<p style="margin-bottom: 0px;margin-top: 0px;">
<small>Copyright &#169; <a href="https://nickorlow.com">Nicholas Orlowsky</a> 2025</small>
</p>
{% if let Some(load_time) = load_time_ms %}
<p style="marin-top: 5px; color: #555555;">
<small><i>Data loaded in {{ *load_time | format_load_time }}</i></small>
</p>
{% endif %}
<p class="js-only" style="marin-top: 5px; color: #555555;">
<small><i id="js_load_time">Total load time</i></small>
</p>
</div>
<div>
{% if widescreen %}
<a href="?widescreen=false"><small>[ disable widescreen ]</small></a>
{% else %}
<a href="?widescreen=true"><small>[ enable widescreen ]</small></a>
{% endif %}
</div>
</div>
<noscript>
<p style="margin-top: 10px;">
<small>[!] You do not have JavaScript enabled. Many features will be missing/broken!</small>
</p>
</noscript>
</footer>
</div>
</body>
</html>

138
web/templates/route.html Normal file
View file

@ -0,0 +1,138 @@
{%- import "route_symbol.html" as scope -%}
<script>
document.addEventListener("DOMContentLoaded", () => {
const scrollToNextColumn = (directionId) => {
const target = document.getElementById("next-col-" + directionId);
if (target) {
const scrollContainer = target.closest(".tscroll");
const firstCol = scrollContainer.querySelector("th:first-child");
const firstColWidth = firstCol ? firstCol.offsetWidth : 0;
// Get the target's position relative to the scroll container
const targetLeft = target.offsetLeft;
// Scroll so the target appears right after the sticky column
scrollContainer.scrollLeft = targetLeft - firstColWidth;
}
};
document.querySelectorAll("details[data-direction-id]").forEach(details => {
const directionId = details.getAttribute("data-direction-id");
// Scroll immediately if details is already open
if (details.open) {
setTimeout(() => scrollToNextColumn(directionId), 50);
}
// Also scroll when details is opened
details.addEventListener("toggle", () => {
if (details.open) {
setTimeout(() => scrollToNextColumn(directionId), 50);
}
});
});
document.querySelectorAll(".train-direction-table").forEach((table) => {
table.addEventListener("click", (e) => {
const cell = e.target.closest("td, th");
if (!cell) return;
// Clear previous highlights
table.querySelectorAll("tr").forEach(row => row.classList.remove("highlight-row"));
table.querySelectorAll("td, th").forEach(c => c.classList.remove("highlight-col"));
const row = cell.parentNode;
const colIndex = Array.from(cell.parentNode.children).indexOf(cell);
// If it's the first column (row header)
if (cell.cellIndex === 0 && cell.tagName === "TD") {
row.classList.add("highlight-row");
}
// If it's a column header
else if (row.parentNode.tagName === "THEAD") {
table.querySelectorAll("tr").forEach(r => {
const cell = r.children[colIndex];
if (cell) cell.classList.add("highlight-col");
});
}
// If it's a center cell
else {
row.classList.add("highlight-row");
table.querySelectorAll("tr").forEach(r => {
const cell = r.children[colIndex];
if (cell) cell.classList.add("highlight-col");
});
}
});
});
});
</script>
<div style="display: flex; align-items: center;">
{% call scope::route_symbol(route) %}
{% endcall %}
<h1 style="margin-left: 15px;">{{ route.name }}</h1>
</div>
{% for timetable in timetables %}
<details style="margin-top: 15px"
data-direction-id="{{ timetable.direction.direction }}">
<summary>
<div style="display: inline-block;">
<h3>{{ timetable.direction.direction | capitalize }} to</h3>
<h2>{{ timetable.direction.direction_destination }}</h2>
</div>
</summary>
<div class="tscroll">
<table class="train-direction-table" style="margin-top: 5px;">
<thead>
<tr>
<th>Stop</th>
{% for trip_id in timetable.trip_ids %}
{% if let Some(next_id_v) = timetable.next_id %}
{% if next_id_v == trip_id %}
<th class="next-col" id="next-col-{{ timetable.direction.direction }}">
{% else %}
<th>
{% endif %}
{% else %}
<th>
{% endif %}
{{ trip_id }}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in timetable.rows %}
{% if let Some(filter_stop_v) = filter_stops %}
{% if !filter_stop_v.contains(&row.stop_id) %}
{% continue %}
{% endif %}
{% endif %}
<tr>
<td>{{ row.stop_name }}</td>
{% for time in row.times %}
{% if let Some(t) = time %}
{% let live_o = timetable.tracking_data[loop.index0] %}
{% if let Tracked(live) = live_o %}
{% let time = (t + (live.delay * 60.0) as i64) %}
<td style="background-color: #003300">
<span style="color: #22bb22">{{ time | format_time }}</span>
</td>
{% elif let TripTracking::Cancelled = live_o %}
<td style="color: #ff0000">
<s>{{ t | format_time }}</s>
</td>
{% else %}
<td>{{ t | format_time }}</td>
{% endif %}
{% else %}
<td></td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% endfor %}

View file

@ -0,0 +1,13 @@
{% macro route_symbol(route) %}
{% match route.route_type %}
{% when libseptastic::route::RouteType::Trolley | libseptastic::route::RouteType::SubwayElevated %}
<div class="metro-container bg-{{ route.short_name }}">{{ route.short_name }}</div>
{% endwhen %}
{% when libseptastic::route::RouteType::RegionalRail %}
<div class="rr-container">{{ route.short_name }}</div>
{% endwhen %}
{% when libseptastic::route::RouteType::Bus | libseptastic::route::RouteType::TracklessTrolley %}
<div class="bus-container">{{ route.short_name }}</div>
{% endwhen %}
{% endmatch %}
{% endmacro %}

93
web/templates/routes.html Normal file
View file

@ -0,0 +1,93 @@
<h1>Routes</h1>
<p>Click on a route to see details and a schedule. Schedules in prevailing local time.</p>
<fieldset>
<legend>
<h2>Regional Rail</h2>
</legend>
<p style="margin-top: 10px; margin-bottom: 10px;">For infrequent rail service to suburban locations</p>
{% for route in rr_routes %}
<a href="/route/{{ route.id }}"
style="display: flex;
justify-content: space-between">
<p class="line-link">
[ <b>{{ format!("{:7}", route.short_name) }}:</b> {{ route.name }}
</p>
<p>]</p>
</a>
{% endfor %}
</fieldset>
<fieldset>
<legend>
<h2>Metro</h2>
</legend>
<p style="margin-top: 10px; margin-bottom: 10px;">
For frequent rail service within Philadelphia and suburban locations
</p>
<div class="lines-label"
style="font-weight: bold;
width: 100%;
display: flex;
justify-content: space-between">
<p>[ Subway/Elevated</p>
<p>]</p>
</div>
{% for route in subway_routes %}
<a href="/route/{{ route.id }}"
style="display: flex;
justify-content: space-between">
<p class="line-link">
[ <b>{{ format!("{:7}", route.short_name) }}:</b> {{ route.name }}
</p>
<p>]</p>
</a>
{% endfor %}
<div class="lines-label"
style="font-weight: bold;
width: 100%;
display: flex;
justify-content: space-between">
<p>[ Trolleys</p>
<p>]</p>
</div>
{% for route in trolley_routes %}
<a href="/route/{{ route.id }}"
style="display: flex;
justify-content: space-between">
<p class="line-link">
[ <b>{{ format!("{:7}", route.short_name) }}:</b> {{ route.name }}
</p>
<p>]</p>
</a>
{% endfor %}
</fieldset>
<fieldset>
<legend>
<h2>Bus</h2>
</legend>
<p style="margin-top: 10px; margin-bottom: 10px;">
For service of varying frequency within SEPTA's entire service area
</p>
{% for route in bus_routes %}
<a href="/route/{{ route.id }}"
style="display: flex;
justify-content: space-between">
<p class="line-link">
[ <b>{{ format!("{:7}", route.short_name) }}:</b> {{ route.name }}
</p>
<p>]</p>
</a>
{% endfor %}
</fieldset>
<style>
.line-link, .lines-label {
white-space: pre;
margin-top: 3px;
margin-bottom: 3px;
}
.lines-label {
color: #ffffff;
background-color: #000000;
width: max-content;
}
</style>

139
web/templates/stop.html Normal file
View file

@ -0,0 +1,139 @@
{%- import "route_symbol.html" as scope -%}
{%- import "stop_table.html" as stop_table -%}
<div style="display: flex; align-items: center;">
<h1>{{ stop.name }}</h1>
</div>
<p>With service available on:</p>
<div style="display: flex;
justify-content: start;
padding-top: 5px;
padding-bottom: 5px;
flex-wrap: wrap;
gap: 5px">
{% for route in routes %}
<div style="margin-right: 5px">
{% call scope::route_symbol(route) %}
{% endcall %}
</div>
{% endfor %}
</div>
<details>
<summary>
<p style="font-weight: bold; font-size: large;">Filters</p>
</summary>
<form hx-trigger="submit"
hx-get="/stop/{{ stop.id }}/table"
hx-target="#nta-table"
hx-swap="outerHTML"
hx-push-url="/stop/{{ stop.id }}">
<div style="margin: 5px; padding: 10px; background-color: #eee;">
<div style="display: flex; flex-wrap: wrap;">
<fieldset style="flex-grow: 1;">
<legend>Route</legend>
{% for route in routes %}
{% for dir in route.directions %}
{% if let Some(fil) = filters && let Some(rts) = fil.routes %}
{% let route_filter_id = format!("{},{}", route.id, dir.direction) %}
<input type="checkbox"
class="route-checkbox"
name="routes"
id="{{ route.id }},{{ dir.direction }}"
value="{{ route.id }},{{ dir.direction }}"
checked="{{ rts.contains(&*route_filter_id) }}">
{% else %}
<input type="checkbox"
class="route-checkbox"
name="routes"
id="{{ route.id }},{{ dir.direction }}"
value="{{ route.id }},{{ dir.direction }}"
checked="true">
{% endif %}
<label for="{{ route.id }},{{ dir.direction }}">
<b>{{ route.short_name }}</b>: {{ dir.direction_destination }}
</label>
<br>
{% endfor %}
{% endfor %}
<input type="checkbox"
id="master"
hx-on:click="document.querySelectorAll('.route-checkbox').forEach(c => c.checked = this.checked)">
<label for="master">Select/Deselect All</label>
</fieldset>
<div style="flex-grow: 1;">
<fieldset>
<legend>Ride Options</legend>
{% if let Some(fil) = filters && let Some(lt) = fil.live_tracked %}
<input type="checkbox"
name="live_tracked"
id="live_tracked"
value="true"
checked="{{ lt }}">
{% else %}
<input type="checkbox"
name="live_tracked"
id="live_tracked"
value="true"
checked="true">
{% endif %}
<label for="live-tracked">Live Tracked</label>
<br>
{% if let Some(fil) = filters && let Some(sc) = fil.scheduled %}
<input type="checkbox"
name="scheduled"
id="scheduled"
value="true"
checked="{{ sc }}">
{% else %}
<input type="checkbox"
name="scheduled"
id="scheduled"
value="true"
checked="true">
{% endif %}
<label for="scheduled">Scheduled</label>
<br>
</fieldset>
<fieldset>
<legend>Crowding</legend>
{% for avail in SeatAvailability::iter() %}
{% if let Some(fil) = filters && let Some(crd) = fil.crowding %}
<input type="checkbox"
name="crowding"
id="{{ avail.to_string() }}"
value="{{ avail.to_string() }}"
checked="{{ crd.contains(&avail) }}">
{% else %}
<input type="checkbox"
name="crowding"
id="{{ avail.to_string() }}"
value="{{ avail.to_string() }}"
checked="true">
{% endif %}
<label for="{{ avail.to_string() }}">{{ avail.to_human_string() }}</label>
<br>
{% endfor %}
{% if let Some(fil) = filters && let Some(uc) = fil.unknown_crowding %}
<input type="checkbox"
name="unknown_crowding"
id="unknown_crowding"
value="true"
checked="{{ uc }}">
{% else %}
<input type="checkbox"
name="unknown_crowding"
id="unknown_crowding"
value="true"
checked="true">
{% endif %}
<label for="scheduled">Unknown</label>
</fieldset>
</div>
</div>
<input type="submit" value="Apply">
</div>
</form>
</details>
<div style="overflow-x: scroll; max-width: 100%;">
{% call stop_table::stop_table(trips, current_time, stop.id, query_str) %}
{% endcall %}
</div>

View file

@ -0,0 +1,69 @@
{%- import "route_symbol.html" as scope -%}
{% macro stop_table(trips, current_time, stop_id, query_str) %}
<div id="nta-table"
hx-get="/stop/{{ stop_id }}/table?{{ query_str }}"
hx-trigger="every 5s"
hx-swap="outer-html">
<table class="train-direction-table">
<tr>
<th>ROUTE</th>
<th>DESTINATION</th>
<th>BOARDING AREA</th>
<th>TIME</th>
<th>VEHICLE</th>
<th>TRIP</th>
<th>CROWDING</th>
</tr>
{% for trip in trips %}
<tr>
<td>
{% call scope::route_symbol(trip.trip.route) %}
{% endcall %}
</td>
<td>
<p>{{ trip.trip.direction.direction_destination }}</p>
</td>
<td>
<p>{{ trip.perspective_stop.platform.name }}</p>
</td>
{% if let Tracked(tracked_trip) = trip.trip.tracking_data %}
<td style="color: #008800">
<p style="font-size: small;">{{ &trip.perspective_stop.get_arrival_time(&tracked_trip) | format_time }}</p>
<p style="font-size: x-small; font-style: italic;">
{{ ( trip.perspective_stop.get_arrival_time(&tracked_trip) - current_time) / 60 }} mins
</p>
<p style="font-size: x-small; font-style: italic;">{{ tracked_trip.delay.round() }} late</p>
</td>
{% else %}
<td>
<p style="font-size: small;">{{ trip.perspective_stop.arrival_time | format_time }}</p>
<p style="font-size: x-small; font-style: italic;">
{{ (trip.perspective_stop.arrival_time - current_time) / 60 }} mins
</p>
</td>
{% endif %}
{% if let Tracked(tracked_trip) = trip.trip.tracking_data %}
<td>{{ tracked_trip.vehicle_ids.join(", ") }}</td>
{% else %}
<td>-</td>
{% endif %}
<td>{{ trip.trip.trip_id }}</td>
{% if let Tracked(tracked_trip) = trip.trip.tracking_data %}
{% if let Some(seat_avail) = tracked_trip.seat_availability %}
<td>{{ seat_avail.to_human_string() }}</td>
{% else %}
<td>N/A</td>
{% endif %}
{% else %}
<td>-</td>
{% endif %}
</tr>
{% endfor %}
<tr>
<td colspan="7">
<p>Updated at: {{ current_time | format_time_with_seconds }}</p>
</td>
</tr>
</table>
</div>
{% endmacro %}

View file

@ -1,4 +1,3 @@
{%- import "stop_table.html" as stop_table -%} {%- import "stop_table.html" as stop_table -%}
{% call stop_table::stop_table(trips, current_time, stop_id, query_str) %} {% call stop_table::stop_table(trips, current_time, stop_id, query_str) %}
{% endcall %} {% endcall %}

View file

@ -1,17 +1,19 @@
<h1>Stops</h1> <h1>Stops</h1>
<p>Click on a route to see details and a schedule. Schedules in prevailing local time.</p> <p>Click on a route to see details and a schedule. Schedules in prevailing local time.</p>
<fieldset> <fieldset>
<legend><h2>Transit Centers</h2></legend> <legend>
<h2>Transit Centers</h2>
</legend>
<p style="margin-top: 10px; margin-bottom: 10px;">Hubs to connect between different modes of transit</p> <p style="margin-top: 10px; margin-bottom: 10px;">Hubs to connect between different modes of transit</p>
{% for stop in tc_stops %} {% for stop in tc_stops %}
<a href="/stop/{{ stop.id }}" style="display: flex; justify-content: space-between;"> <a href="/stop/{{ stop.id }}"
<p class="line-link">[ {{ stop.name }} </p><p>]</p> style="display: flex;
justify-content: space-between">
<p class="line-link">[ {{ stop.name }}</p>
<p>]</p>
</a> </a>
{% endfor %} {% endfor %}
</fieldset> </fieldset>
<style> <style>
.line-link, .lines-label { .line-link, .lines-label {
white-space: pre; white-space: pre;