Initial commit
This commit is contained in:
@@ -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
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
@@ -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 |
Reference in New Issue
Block a user