Initial commit

This commit is contained in:
2024-10-07 23:06:00 -04:00
commit c6ce596acf
18 changed files with 7447 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
example/
node_modules/
.astro/
.DS_Store
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
import svelte from '@astrojs/svelte';
import tailwind from '@astrojs/tailwind';
// https://astro.build/config
export default defineConfig({
integrations: [svelte(), tailwind()]
});
File diff suppressed because one or more lines are too long
Generated
+28
View File
@@ -0,0 +1,28 @@
{
"version": "3",
"packages": {
"specifiers": {
"jsr:@zip-js/zip-js": "jsr:@zip-js/zip-js@2.7.52",
"npm:csv-parse": "npm:csv-parse@5.5.6"
},
"jsr": {
"@zip-js/zip-js@2.7.52": {
"integrity": "c8d9fd23b1965fdf58071e886b3b8dbd32a68f780ccb60cb256cfc41b4423e32"
}
},
"npm": {
"csv-parse@5.5.6": {
"integrity": "sha512-uNpm30m/AGSkLxxy7d9yRXpJQFrZzVWLFBkS+6ngPcZkw/5k3L/jjFuj7tVnEpRn+QgmiXr21nDlhCiUK4ij2A==",
"dependencies": {}
}
}
},
"remote": {},
"workspace": {
"packageJson": {
"dependencies": [
"npm:astro@^4.15.11"
]
}
}
}
+135
View File
@@ -0,0 +1,135 @@
import { TextWriter } from "jsr:@zip-js/zip-js";
import { ZipReader } from "jsr:@zip-js/zip-js";
import { parse } from "npm:csv-parse/sync";
function indexRecords(records, key) {
const result = {};
for (const record of records) {
result[record[key]] = record;
}
return result;
}
const reader = new ZipReader((await Deno.open(Deno.args[0])).readable);
const files = await reader.getEntries();
const wantedFiles = [
"routes",
"trips",
"stops",
"stop_times",
"calendar",
] as const;
const entries: Partial<Record<(typeof wantedFiles)[number], any[]>> = {};
console.log("reading files");
await Promise.all(
files
.filter((f) => {
//@ts-ignore
return wantedFiles.includes(f.filename.replace(".txt", ""));
})
.map(async (f) => {
const writer = new TextWriter();
entries[f.filename.replace(".txt", "") as (typeof wantedFiles)[number]] =
parse(await f.getData!(writer), { columns: true });
})
);
reader.close();
console.log(`Indexing ${entries.stops?.length} stops...`);
function actualStop(stop_id: string): string {
return indexedStops[stop_id]?.parent_station || stop_id;
}
const indexedStops = indexRecords(entries.stops, "stop_id");
const metroRoutes = entries.routes!.filter(
(r) => r.route_type == 1 || r.route_type == 0
);
const metroRouteIds = metroRoutes.map((r) => r.route_id);
console.log(`Indexing trips...`);
const trips = entries.trips!.filter(
(t) => t.service_id == "canonical" && metroRouteIds.includes(t.route_id)
);
const indexedTrips = indexRecords(trips, "trip_id");
console.log("sorting stop times");
const stop_times = entries
.stop_times!.filter((s) => s.trip_id in indexedTrips)
.sort((a, b) => {
if (a.trip_id < b.trip_id) {
return -1;
} else if (a.trip_id > b.trip_id) {
return 1;
} else {
return a.stop_sequence - b.stop_sequence;
}
});
const edges: Record<string, any> = {};
console.log("calculating edges...");
for (let i = 0; i < stop_times.length - 1; i++) {
const a = stop_times[i];
const b = stop_times[i + 1];
if (a.trip_id == b.trip_id) {
const trip = indexedTrips[a.trip_id];
const route_id = trip.route_id;
const aStop = actualStop(a.stop_id);
const bStop = actualStop(b.stop_id);
const key = `${aStop}-${bStop}`;
if (key in edges) {
edges[key].route.add(route_id);
} else {
edges[key] = {
a: aStop,
b: bStop,
route: new Set([route_id]),
headsign: trip.trip_headsign,
};
}
}
}
const stations = [
...new Set(
Object.values(edges)
.flatMap((e) => [e.a, e.b])
.map((e) => actualStop(e))
),
];
const output = {
stations: indexRecords(
stations.map((s) => indexedStops[s]),
"stop_id"
),
edges: Object.values(edges).map((e) => ({ ...e, route: [...e.route] })),
routes: indexRecords(metroRoutes, "route_id"),
agency: {
city: "Boston",
slug: "boston",
},
};
console.log(
entries.calendar?.sort(
(a, b) => parseInt(b.end_date) - parseInt(a.end_date)
)[0]
);
await Deno.writeTextFile("cities/boston.json", JSON.stringify(output));
+6728
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "transit-quiz",
"type": "module",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"dev": "astro dev"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@astrojs/svelte": "^5.7.1",
"@astrojs/tailwind": "^5.1.1",
"astro": "^4.15.11",
"gsap": "^3.12.5",
"svelte": "^4.2.19",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" style="enable-background:new 0 0 1000 1000" xml:space="preserve"><path d="M500 44.3c-251.7 0-455.7 204-455.7 455.7s204 455.7 455.7 455.7 455.7-204 455.7-455.7S751.7 44.3 500 44.3zm315.7 390.8H579.1v389.2H420.9V435.1H184.3V276.9h631.3v158.2z" style="fill:#fff"/><path d="M500 0C223.9 0 0 223.9 0 500s223.9 500 500 500 500-223.9 500-500S776.1 0 500 0zm0 955.7c-251.7 0-455.7-204-455.7-455.7S248.3 44.3 500 44.3s455.7 204 455.7 455.7-204 455.7-455.7 455.7z"/><path d="M184.3 435.1h236.6v389.3h158.2V435.1h236.6V276.9H184.3z"/></svg>

After

Width:  |  Height:  |  Size: 593 B

+144
View File
@@ -0,0 +1,144 @@
import _agency from "../cities/boston.json";
const agency = {
..._agency,
// Convert `route` arrays to `Set`s
edges: _agency.edges.map((e) => ({ ...e, route: new Set(e.route) })),
};
export function linesForStation(station_id) {
return new Set(
agency.edges
.filter((e) => e.a == station_id || e.b == station_id)
.flatMap((e) => [...e.route])
);
}
export function randomStationPair() {
const keys = Object.keys(agency.stations);
// select a random source station
const a = keys[Math.floor(Math.random() * keys.length)];
const aLines = linesForStation(a);
const stationsOnSameLine = agency.edges
.filter((e) => !aLines.isDisjointFrom(e.route))
.flatMap((e) => [e.a.toString(), e.b.toString()]);
// make a set of all stations that aren't on the same line as `a`
const possibleStations = keys.filter((s) => !stationsOnSameLine.includes(s));
const b =
possibleStations[Math.floor(Math.random() * possibleStations.length)];
return [a, b];
}
function stationNeighbors(station) {
return agency.edges
.filter((e) => e.a == station)
.map((e) => ({
station: e.b,
via: e.route,
}));
}
// caleb's patented graph traversal algorithm (tm)
function routeGoesBetweenStations(route, stationA, stationB) {
let currentNodes = [stationA];
let visited = [];
let stops = 0;
let possible = false;
let errorStation;
i: while (currentNodes.length > 0) {
stops++;
for (const node of [...currentNodes]) {
const neighbors = stationNeighbors(node)
.filter((n) => n.via.has(route) && !visited.includes(n.station))
.map((n) => n.station);
for (const n of neighbors) {
if (n == stationB) {
possible = true;
break i;
}
currentNodes.push(n);
}
currentNodes.splice(currentNodes.indexOf(node), 1);
visited.push(node);
}
}
if (!possible) {
errorStation = visited.length == 1 ? stationA : stationB;
}
return [possible, errorStation, stops];
}
export function stepsValidForStationPair(steps, stationA, stationB) {
if (steps[steps.length - 1].station != stationB)
return [
false,
`Route doesn't end at ${agency.stations[stationB].stop_name}.`,
];
let stops = 0;
for (let i = 0; i < steps.length; i++) {
// cosplaying as a golang developer
const [possible, err, _stops] = routeGoesBetweenStations(
steps[i].line,
steps[i - 1]?.station ?? stationA,
steps[i].station
);
if (!possible) {
return [
false,
`${steps[i].line} doesn't serve ${agency.stations[err].stop_name}`,
];
}
stops += _stops;
}
return [true, null, stops];
}
export function minStops(stationA, stationB) {
return Dijkstra(agency, stationA)[0][stationB];
}
function Dijkstra(graph, source) {
let dist = {};
let prev = {};
let unvisited = [];
for (const v of Object.keys(graph.stations)) {
dist[v] = Infinity;
prev[v] = undefined;
unvisited.push(v);
}
dist[source] = 0;
while (unvisited.length != 0) {
const u = unvisited.sort((a, b) => dist[a] - dist[b])[0];
unvisited.splice(unvisited.indexOf(u), 1);
for (const n of stationNeighbors(u)) {
const v = n.station;
const alt = dist[u] + 1;
if (alt < dist[v]) {
dist[v] = alt;
prev[v] = [u];
}
}
}
return [dist, prev];
}
export default agency;
+101
View File
@@ -0,0 +1,101 @@
<script>
import { onMount, createEventDispatcher } from "svelte";
import agency, { linesForStation, minStops, randomStationPair, stepsValidForStationPair } from "../agency"
import MbtaLine from "./MbtaLine.svelte";
import RouteBuilder from "./RouteBuilder.svelte";
import gsap from "gsap";
const dispatch = createEventDispatcher()
const questionCount = 10
let route = []
let question = 0
let from
let to
let points = 0
function setStationPair() {
const stations = randomStationPair()
from = stations[0]
to = stations[1]
}
onMount(() => {
setStationPair()
})
let pointFlourish
let pointCounter
async function submit() {
if(route[route.length - 1].station != to) {
return alert(`Oops, your route doesn't end at ${agency.stations[to].stop_name}!`)
}
const [valid, err, stops] = (stepsValidForStationPair(route, from, to))
if(!valid) {
gsap.to(pointCounter, {
keyframes: {
rotate: [0, -10, 10, -10, 10, -10, 10, -10, 10, 0]
}
})
} else if(stops > minStops(from, to)) {
await addPoints(1)
} else {
await addPoints(2)
}
if(question >= 9) {
dispatch("done", points)
return
}
question++
route = []
setStationPair()
}
async function addPoints(p) {
points += p
pointFlourish.innerText = `+${p}`
gsap.to(pointFlourish, {x: -40, y: -50, rotate: -30, scale: 2, opacity: 0, duration: 1, startAt: {opacity: 1, y: 0, x: 0, scale: 1, rotate: 0}})
}
</script>
<div class="border p-5 rounded-lg w-[550px] mb-5">
<div class="flex justify-between mx-auto">
<p>Route {question + 1}/{questionCount}</p>
<p class="relative" bind:this={pointCounter}>{points} pts <span class="opacity-0 absolute left-0 top-0 text-green-600 font-bold" bind:this={pointFlourish}>+1</span></p>
</div>
{#if from && to}
<h3 class="text-3xl">
<span class="inline-flex items-center justify-center gap-2">
<span class="font-bold">{agency.stations[from].stop_name}</span>
{#each linesForStation(from) as line}
<MbtaLine name={line} compact />
{/each}
to
</span>
<br>
<span class="inline-flex items-center justify-center gap-2">
<span class="font-bold">{agency.stations[to].stop_name}</span>
{#each linesForStation(to) as line}
<MbtaLine name={line} compact />
{/each}
</span>
</h3>
{:else}
Loading...
{/if}
</div>
<RouteBuilder lines={agency.routes} stations={agency.stations} origin={agency.stations[from]?.stop_name} bind:route />
<button class="text-md border border-blue-500 text-blue-500 font-bold rounded-lg px-5 py-1 uppercase cursor-pointer transition-colors hover:bg-blue-50" on:click={submit} class:opacity-50={route.length == 0} disabled={route.length == 0}>
Submit
</button>
+80
View File
@@ -0,0 +1,80 @@
<script>
import Game from "./Game.svelte";
import screenshot from "../screenshot.png"
let page = "welcome"
let score = 0
</script>
<div class="flex flex-col h-screen justify-center items-center">
{#if page == "welcome" || page == "done"}
<div class="grid gap-20" class:grid-cols-2={page == "done"}>
{#if page == "done" }
<div class="max-w-96 text-center rounded-lg border py-20">
<p>You scored</p>
<h1 class="font-black text-5xl">{score} <span class="font-normal">point{score != 1 && "s"}</span></h1>
<p class="italic mt-2">
{#if score == 20}
"The Obsessed Local"
{:else if score >= 15}
"The Over-Achiever"
{:else if score >= 10}
"The Regular Rider"
{:else}
"The Occasional Tripper"
{/if}
</p>
</div>
{/if}
<div class="flex flex-col items-center justify-center">
<div class="inline-block relative text-left mb-5">
<h1 class="font-black text-5xl">The Train Quiz</h1>
<div class="max-w-72">
{page == "welcome" ? (
"Google Maps? Bleh. You can navigate the T on your own. Right?"
) : "🔗 trains.clb.li"}
</div>
<span
class="bg-blue-500 text-white rounded-full px-2 absolute -right-16 top-9 animate-bump"
>Boston edition</span
>
</div>
<button
class="block text-lg border border-blue-500 text-blue-500 font-bold rounded-lg px-10 py-3 uppercase cursor-pointer transition-colors hover:bg-blue-50"
on:click={() => page == "welcome" ? page = "start" : page = "play"}
>Play {#if page == "done"}
again
{/if}</button
>
</div>
</div>
{:else if page == "start"}
<div class="mb-5 max-w-96">
<h1 class="font-black text-5xl">How It Works</h1>
<p class="mb-2">We'll show you a pair of T stations in Boston: an origin and a destination.</p>
<img src={screenshot.src} class="w-full border rounded-lg mb-2" />
<p class="mb-2">Use the tool to create the route you'd take between the stations.</p>
<p>You can score a maximum of 20 points: 1 for each correct answer, plus a bonus for the <strong class="font-bold">most optimal route</strong> (fewest number of stops possible).</p>
</div>
<button
class="text-lg border border-blue-500 text-blue-500 font-bold rounded-lg px-10 py-3 uppercase cursor-pointer transition-colors hover:bg-blue-50"
on:click={() => page = "play"}
>Start</button
>
{:else if page == "play"}
<Game on:done={e => {score = e.detail; page = "done"}} />
{:else if page == "done"}
<div class="grid grid-cols-2">
<div class="max-w-96 text-center">
<h1 class="font-black text-5xl">The Train Quiz</h1>
</div>
</div>
{/if}
</div>
+30
View File
@@ -0,0 +1,30 @@
<script>
import agency from "../../cities/boston.json"
export let name
export let compact = false
</script>
<span style="background-color: #{agency.routes[name].route_color}" class="text-white h-5 w-12 inline-flex items-center justify-center text-lg font-bold rounded-full relative {compact && "h-7 w-7"}" class:mr-4={!compact}>
{#if name == "Red"}
RL
{:else if name == "Mattapan"}
{#if compact}
M
{:else}
RL
<span class="ring-2 ring-white rounded-full absolute -right-3 top-0 bg-inherit h-5 w-5 flex items-center justify-center">M</span>
{/if}
{:else if name == "Blue"}
BL
{:else if name == "Orange"}
OL
{:else if name.startsWith("Green")}
{#if compact}
{name.split("-")[1]}
{:else}
GL
<span class="ring-2 ring-white rounded-full absolute -right-3 top-0 bg-inherit h-5 w-5 flex items-center justify-center">{name.split("-")[1]}</span>
{/if}
{/if}
</span>
+112
View File
@@ -0,0 +1,112 @@
<script>
import { onMount } from "svelte";
import MbtaLine from "./MbtaLine.svelte";
export let route = []
export let lines
export let stations
export let origin
let selectedLine = ""
let selectedStation = ""
let lineSelector
onMount(() => {
lineSelector.focus()
})
function addStep() {
route = [
...route,
{
line: selectedLine,
station: selectedStation,
},
]
selectedLine = ""
selectedStation = ""
lineSelector.focus()
}
</script>
<div class="p-5 rounded-lg mb-5">
<ul class="w-full mb-4 empty:mb-0">
<li class="relative font-bold">{origin}</li>
{#each route as step, index }
<li class="relative flex items-center">
<MbtaLine name={step.line} />
<span>
to
<span class="font-bold">{stations[step.station].stop_name}</span>
</span>
<button class="ml-auto text-gray-500" on:click={() => {route = route.filter((v,idx) => idx != index)}}>x</button>
</li>
{/each}
</ul>
<div class="flex gap-2 items-center">
<form on:submit|preventDefault={() => addStep()}>
<select bind:value={selectedLine} bind:this={lineSelector}>
<option value="">Select a line...</option>
{#each Object.values(lines) as line (line.route_id)}
<option value={line.route_id}>{line.route_long_name}</option>
{/each}
</select>
<span>to</span>
<select bind:value={selectedStation}>
<option value="">Select a station...</option>
{#each Object.values(stations) as station (station.stop_id)}
<option value={station.stop_id}>{station.stop_name}</option>
{/each}
</select>
<button class="text-sm border border-blue-500 text-blue-500 font-bold rounded-lg px-4 py-1 uppercase cursor-pointer transition-colors hover:bg-blue-50" on:click={addStep} class:opacity-50={!selectedLine || !selectedStation} disabled={!selectedLine || !selectedStation}>Add to route</button>
</form>
</div>
</div>
<style>
li {
@apply pl-5;
}
li:before {
content: "";
width: 10px;
height: 10px;
@apply bg-white rounded-full ring ring-gray-800;
display: block;
position: absolute;
top: calc(50% - 5px);
left: 0;
z-index: 5;
}
li:after {
content: "";
height: 100%;
width: 4px;
@apply bg-gray-800;
position: absolute;
left: 3px;
}
li:first-of-type::after {
@apply rounded-t-full;
}
li:last-of-type::after {
@apply rounded-b-full;
}
select {
@apply border border-blue-500 rounded-md;
}
</style>
+1
View File
@@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />
+29
View File
@@ -0,0 +1,29 @@
---
import Guesser from "../components/Guesser.svelte";
import screenshot from "../screenshot.png";
---
<html>
<head>
<link rel="preload" as="image" href={screenshot.src} />
<title>The Boston Train Quiz</title>
</head>
<body>
<Guesser client:load />
<p
class="fixed bottom-0 left-0 text-gray-400 transition-transform translate-y-3 hover:translate-y-0 hover:translate-x-2 cursor-default"
>
<a href="https://calebden.io" class="underline" target="_blank">caleb</a> made
this
</p>
<p class="fixed bottom-1 right-2 text-gray-400">
Data provided by the <a
href="https://www.mbta.com/developers/gtfs"
class="underline"
target="_blank">MBTA</a
>
</p>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from '@astrojs/svelte';
export default {
preprocess: vitePreprocess(),
}
+19
View File
@@ -0,0 +1,19 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
theme: {
extend: {
keyframes: {
bump: {
"0%": { transform: "rotate(-30deg) scale(1)" },
"50%": { transform: "rotate(-30deg) scale(1.05)" },
"100%": { transform: "rotate(-30deg) scale(1)" },
},
},
animation: {
bump: "bump 0.5s ease infinite",
},
},
},
plugins: [],
};