Omnigraph
그래프 엔진으로 컨텍스트를 조립하고, 객체 저장소 기반에서 에이전트가 코드처럼 그래프 데이터를 분기·타입·검토·병합·감사·거버넌스합니다
2019년 이후 MBTA 이용량 회복 현황을 월별 통근열차·버스·지하철도 데이터로 보여줍니다
How MBTA ridership has recovered since 2019: monthly trips on commuter rail, bus and subway, from National Transit Database data.
데이터TypeScript · CSSGitHub @jakesolomon온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Next.js data vis portfolio piece
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗MBTA ridership recovery by mode, from FTA National Transit Database monthly data.
Live URL: https://mbta-vis.vercel.app
Status: the pipeline, database, chart page and annotation admin are built.
Has MBTA ridership recovered since 2019, and how does recovery differ by mode?
Hypothesis, written before looking at the data: commuting hasn't come back, and what recovery there is comes from off-peak and leisure trips. That predicts two things:
Findings, with data through August 2026:
Rejected. Over the last 12 months, commuter rail averaged 100% of its 2019 monthly ridership, bus 86% and subway 70%. Commuter rail has fully recovered, and the largest gap is on the subway, which is still climbing (58% in 2023, 71% so far in 2026).
Mixed at best. Comparing each month with the same month of 2019 (the ridership_vs_2019 view), commuter rail's summers (June–August) beat the winter before them (December–February) every year from 2023 to 2026. Bus and subway go back and forth:
| Summer vs. winter, % of 2019 | 2023 | 2024 | 2025 | 2026 |
|---|---|---|---|---|
| Commuter rail | 85 vs. 72 | 101 vs. 90 | 92 vs. 88 | 105 vs. 95 |
| Bus | 78 vs. 75 | 85 vs. 95 | 83 vs. 83 | 93 vs. 81 |
| Subway | 60 vs. 57 | 57 vs. 54 | 63 vs. 66 | 72 vs. 66 |
This is an indirect test, since NTD's monthly totals don't split weekday from weekend trips, and seasons carry other differences (weather, service changes, shutdowns). The table was computed once from the August 2026 release. It's left off the site because the result is inconclusive.
The events marked on the chart are managed from /admin. Sign in, add an event, and it shows up on the chart on the next page load:
https://github.com/user-attachments/assets/854a87c5-98c4-49f5-9cbf-e8328a26f3b0
Sign-ups are off and RLS only lets my account write. Saving invalidates the cached annotations, so the static page picks up the change without a redeploy. Details in App Router architecture.
UPT (unlinked passenger trips) tab.data/raw/ and is gitignored (/data/raw/*.xlsx). data/ridership.csv is the cleaned output and is meant to be committed.NTD workbook (.xlsx) --extract--> data/ridership.csv --seed--> ridership_raw --views--> app
| Step | What it does | Status |
|---|---|---|
Extract (scripts/extract.ts) | Reads the UPT sheet, keeps rows with NTD ID 10003 (MBTA), turns each month column into its own row, converts month headers (Excel serial dates or M/YYYY) to YYYY-MM-01, strips thousands separators, skips blank cells. Writes mode,tos,month,trips. | Built |
| Extract: duplicate check | Fail if any mode/tos/month appears twice. | Not in the script, but data/ridership.test.ts fails if the committed CSV has a duplicate. |
Seed (scripts/seed.ts) | Replaces ridership_raw with the CSV in one transaction, with no transformation. | Built |
| Views | Mode grouping (ridership_monthly). The app indexes each mode to its 2019 monthly average. | Built |
The reason for this split: ridership_raw should stay a faithful copy of NTD, and grouping decisions should change through a migration, not by re-running the extract. The 2019 baseline ended up in the app (indexTo2019Average in lib/ridership.ts) rather than in a view, so changing it is a code change.
The script takes a workbook path as its first argument. Without one, it uses the most recently modified .xlsx in data/raw/, because the file name changes with every NTD release. If the workbook has no NTD ID, Mode or TOS column, the script stops with an error instead of writing a bad CSV.
These are my choices for the analysis. The grouping lives in the ridership_monthly view.
| NTD mode | Description | Group |
|---|---|---|
| HR | Heavy rail | Subway |
| LR | Light rail | Subway |
| MB | Bus | Bus |
| RB | Bus rapid transit | Bus |
| TB | Trolleybus | Bus |
| CR | Commuter rail | Commuter rail |
| DR | Demand response (paratransit) | Excluded |
| FB | Ferry | Excluded for now (see Next steps) |
ridership_vs_2019 view), which takes seasonality out. The two agree on the 12-month averages.Caveats:
Migrations live in supabase/migrations/ and are applied to the hosted project with bunx supabase db push --linked. I never edit a migration that has already been applied; changes go in a new one, and schema and data changes go in separate migrations.
ridership_raw (mode, tos, month, trips): a faithful copy of the CSV. RLS is on, with a select-only policy for anon and authenticated, so the API can't write to it. The seed script writes as the table owner.ridership_monthly: trips summed per mode group and month, using the groupings above.ridership_vs_2019: each month against the same calendar month of 2019. Not used by the app.annotations (id, date, end_date, label, mode_group, detail): the events marked on the chart. Anyone can read them; only my admin account can insert, update or delete, enforced by RLS policies that check its user id.security_invoker = true, so they apply ridership_raw's RLS instead of the view owner's privileges.Ridership is loaded by scripts/seed.ts, not by a migration. The first five annotations come from a seed migration; later ones are added through the site.
Next.js 16 with Cache Components (cacheComponents: true), so caching uses 'use cache' and cacheLife instead of the older fetch cache.
app/page.tsx is a server component. It reads ridership_monthly and the annotations through 'use cache' functions (lib/ridership.ts, lib/events.ts) with plain supabase-js and the publishable key, cached for days because the data changes monthly. The page prerenders as static HTML.app/ridership-chart.tsx is the client component: a visx line chart with event annotations, a keyboard- and pointer-driven tooltip, and a data table./admin signs in with Supabase Auth (email and password, sign-ups off). Its shell is static and the annotations table streams in behind <Suspense>. Server actions write through the signed-in user's session, so RLS still applies, then invalidate the annotations cache tag so the chart picks up the change.bun test runs the unit tests (Bun's built-in runner):
mode/tos/month, and all of 2019 for every mode group.The SQL views aren't tested, because that needs a database. What I checked by hand in the CSV: first and last month for each mode/TOS pair, which is how I found the commuter rail DO-to-PT switch and the trolleybus end date above.
Requires Bun ([email protected]). The steps run in pipeline order.
bun install
.xlsx). The extract reads its UPT tab.data/raw/. Keep the file name as downloaded. It changes with each release, and the extract finds the file without it.The workbook is gitignored. Only the CSV the extract produces is committed.
bun run extract # uses the newest .xlsx in data/raw/
bun run extract data/raw/<file>.xlsx # or name a specific workbook
This writes data/ridership.csv and prints the file it read and the number of rows it wrote. Before committing a new CSV, check it against the hand checks in Verification: no duplicate mode/tos/month rows, and first and last months for each mode that make sense. The script doesn't enforce these yet.
The live site runs against my hosted Supabase project. To run your own copy:
bunx supabase login
bunx supabase link --project-ref <your-project-ref>
bunx supabase db push --linked
Local Postgres via bunx supabase start (requires Docker) should also work but is untested.
bun run seed # loads data/ridership.csv
bun run seed path/to/file.csv # or another CSV
This deletes and reinserts all of ridership_raw in one transaction, connecting as the table owner. Re-run it after every new extract. The views pick up the new data automatically, so a new data release doesn't need a migration.
bun dev # http://localhost:3000
.env.local (gitignored) holds:
NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: read by the app. The publishable key is safe in the browser because RLS limits what it can do.SUPABASE_DB_PASSWORD: used only by the seed script, together with the pooler URL that supabase link saves in supabase/.temp/. Set SUPABASE_DB_URL instead to seed a different database.I used Claude to help plan the architecture and cut the scope down to a weekend's worth of work, and Claude Code to speed up development. I review all of its output and test the code it writes before keeping it.
This is a weekend project with one developer and one user, so I skipped processes that a client project would need. What I'd do differently:
main. On a client project, every change would go through a pull request, with main protected and a reviewer other than the author.bun test, bun run lint and bun run build on every pull request and block merging when they fail.bunx supabase db push --linked from my machine. I'd use the Supabase GitHub integration (or a CI step) so migrations deploy on merge, and nobody changes the schema by hand./admin exist only in the database. I'd set up scheduled backups or point-in-time recovery, and test a restore.Analysis
ridership_vs_2019 (or a small script) instead of a one-off calculation, so it can be rerun on each NTD release.Pipeline and data
extract.ts itself fail on duplicate mode/tos/month rows, not just the CSV test.Database
App
loading.tsx.Testing