MultiTrain/public/app.js
2026-07-10 10:54:05 +02:00

252 lines
8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
let datalistCounter = 0;
let lastResults = [];
let arriveBy = false;
function addStationRow(container, values = {}) {
const row = document.createElement("div");
row.className = "station-row";
const input = document.createElement("input");
input.type = "text";
input.required = true;
input.placeholder = "Station name";
input.autocomplete = "off";
input.value = values.query ?? "";
const datalist = document.createElement("datalist");
datalist.id = `stations-${datalistCounter++}`;
input.setAttribute("list", datalist.id);
wireAutocomplete(input, datalist);
const penalty = document.createElement("input");
penalty.type = "number";
penalty.className = "penalty";
penalty.min = "0";
penalty.step = "1";
penalty.value = values.penalty ?? "0";
penalty.title = "Penalty in minutes (added to this station's journeys for ranking)";
const unit = document.createElement("span");
unit.className = "unit";
unit.textContent = "min";
const remove = document.createElement("button");
remove.type = "button";
remove.className = "remove";
remove.textContent = "×";
remove.title = "Remove station";
remove.addEventListener("click", () => {
if (container.querySelectorAll(".station-row").length > 1) row.remove();
});
row.append(input, penalty, unit, remove, datalist);
container.appendChild(row);
return input;
}
function wireAutocomplete(input, datalist) {
let timer;
input.addEventListener("input", () => {
clearTimeout(timer);
const query = input.value.trim();
if (query.length < 2) return;
timer = setTimeout(async () => {
try {
const res = await fetch(`/api/locations?query=${encodeURIComponent(query)}`);
if (!res.ok) return;
const stations = await res.json();
datalist.replaceChildren(
...stations.map((s) => {
const option = document.createElement("option");
option.value = s.name;
return option;
}),
);
} catch {
/* autocomplete is best-effort */
}
}, 250);
});
}
function collectSide(containerId) {
return [...document.querySelectorAll(`#${containerId} .station-row`)]
.map((row) => ({
query: row.querySelector("input[type=text]").value.trim(),
penalty: Number(row.querySelector(".penalty").value) || 0,
}))
.filter((s) => s.query !== "");
}
async function runSearch(event) {
event.preventDefault();
const button = document.getElementById("search-button");
const feedback = document.getElementById("feedback");
const resultsSection = document.getElementById("results-section");
const body = { from: collectSide("from-rows"), to: collectSide("to-rows") };
const mode = document.getElementById("time-mode").value;
const timeValue = document.getElementById("time-value").value;
if (mode !== "now") {
if (!timeValue) {
showFeedback([`Please pick a ${mode === "departure" ? "departure" : "arrival"} time.`], "error");
return;
}
body[mode] = new Date(timeValue).toISOString();
}
arriveBy = mode === "arrival";
button.disabled = true;
button.textContent = "Searching…";
feedback.hidden = true;
try {
const res = await fetch("/api/search", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
showFeedback([data.error ?? `Search failed (${res.status})`], "error");
resultsSection.hidden = true;
return;
}
lastResults = data.journeys;
const notes = [...(data.warnings ?? []), ...resolutionNotes(data.resolved)];
if (notes.length > 0) showFeedback(notes, "warning");
renderResults();
resultsSection.hidden = false;
} catch (error) {
showFeedback([`Search failed: ${error.message}`], "error");
} finally {
button.disabled = false;
button.textContent = "Search";
}
}
function resolutionNotes(resolved) {
const notes = [];
for (const side of ["from", "to"]) {
for (const r of resolved?.[side] ?? []) {
if (r.station && r.station.name.toLowerCase() !== r.query.toLowerCase()) {
notes.push(`"${r.query}" was interpreted as "${r.station.name}"`);
}
}
}
return notes;
}
function showFeedback(messages, kind) {
const feedback = document.getElementById("feedback");
feedback.className = kind;
feedback.replaceChildren(
...messages.map((m) => {
const p = document.createElement("p");
p.textContent = m;
return p;
}),
);
feedback.hidden = false;
}
function renderResults() {
const sortMode = document.getElementById("sort-mode").value;
const sorted = [...lastResults].sort((a, b) => {
if (sortMode === "time") {
return arriveBy ? b.arrival.localeCompare(a.arrival) : a.departure.localeCompare(b.departure);
}
return a.score - b.score;
});
const list = document.getElementById("results");
list.replaceChildren(...sorted.map(journeyCard));
if (sorted.length === 0) {
const empty = document.createElement("p");
empty.textContent = "No connections found.";
list.replaceChildren(empty);
}
}
function journeyCard(j) {
const item = document.createElement("li");
item.className = "journey";
const head = document.createElement("div");
head.className = "journey-head";
const times = document.createElement("span");
times.className = "times";
times.textContent = `${fmtTime(j.departure)}${fmtTime(j.arrival)}`;
const meta = document.createElement("span");
meta.className = "meta";
const penalty = j.fromPenalty + j.toPenalty;
meta.textContent =
`${fmtDuration(j.durationMinutes)} · ${j.transfers} transfer${j.transfers === 1 ? "" : "s"}` +
(penalty > 0 ? ` · +${penalty} min penalty → score ${j.score}` : "");
head.append(times, meta);
const route = document.createElement("div");
route.className = "route";
route.textContent = `${j.fromStation.name}${j.toStation.name}`;
const lines = document.createElement("div");
lines.className = "lines";
lines.replaceChildren(
...j.journey.legs
.filter((leg) => !leg.walking && leg.line)
.map((leg) => {
const badge = document.createElement("span");
badge.className = "line";
badge.textContent = leg.line.name;
return badge;
}),
);
const details = document.createElement("details");
const summary = document.createElement("summary");
summary.textContent = "Legs";
details.appendChild(summary);
for (const leg of j.journey.legs) {
const p = document.createElement("p");
p.className = "leg";
const what = leg.walking
? "walk"
: `${leg.line?.name ?? "?"}${leg.direction ? `${leg.direction}` : ""}`;
p.textContent = `${fmtTime(leg.departure)} ${leg.origin.name}${what}${fmtTime(leg.arrival)} ${leg.destination.name}`;
details.appendChild(p);
}
item.append(head, route, lines, details);
return item;
}
function fmtTime(iso) {
const date = new Date(iso);
const time = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const today = new Date();
const sameDay =
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
return sameDay ? time : `${date.toLocaleDateString([], { day: "numeric", month: "numeric" })} ${time}`;
}
function fmtDuration(minutes) {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return h > 0 ? `${h} h ${m} min` : `${m} min`;
}
document.getElementById("search-form").addEventListener("submit", runSearch);
document.getElementById("sort-mode").addEventListener("change", renderResults);
document.getElementById("time-mode").addEventListener("change", (event) => {
document.getElementById("time-value").hidden = event.target.value === "now";
});
for (const button of document.querySelectorAll("button.add")) {
button.addEventListener("click", () =>
addStationRow(document.getElementById(button.dataset.target)),
);
}
addStationRow(document.getElementById("from-rows"));
addStationRow(document.getElementById("to-rows"));