Initial commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
example/
|
||||||
|
node_modules/
|
||||||
|
.astro/
|
||||||
|
.DS_Store
|
||||||
@@ -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
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
Generated
+6728
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -0,0 +1,5 @@
|
|||||||
|
import { vitePreprocess } from '@astrojs/svelte';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
}
|
||||||
@@ -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: [],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user