init
This commit is contained in:
commit
28a45440aa
18 changed files with 4276 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
result
|
||||
65
README.md
Normal file
65
README.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# MultiTrain
|
||||
|
||||
Search train connections from **multiple start stations to multiple destination stations** in one query. Results are merged, deduplicated, and ranked; each station can carry a **penalty in minutes** (e.g. "Wien Meidling +10 because it takes me longer to get there") that is added to a journey's duration for ranking only — displayed times stay real.
|
||||
|
||||
Data comes from [Transitous](https://transitous.org), a community-run [MOTIS](https://github.com/motis-project/motis) instance aggregating open timetable data from many carriers (ÖBB, DB, and many more). Please respect their fair-use policy: set a `USER_AGENT` that identifies you.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
nix develop # provides node 22 + npm + typescript-language-server
|
||||
npm install
|
||||
npm run dev # dev server on http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
Note: `tsx watch` does not detect file changes on Windows-mounted drives (`/mnt/*` in WSL) — restart manually there.
|
||||
|
||||
## Architecture
|
||||
|
||||
Lightweight hexagonal: `src/model` is the pure domain core (domain model, `JourneySource` port, search use case — fan-out over station pairs, merge, dedupe, penalty scoring). `src/adapter` holds the Fastify HTTP adapter and the Transitous adapter (the only place touching the MOTIS client libraries). `src/main.ts` wires everything by hand.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/locations?query=wien` — station suggestions
|
||||
- `POST /api/search` — body:
|
||||
|
||||
```json
|
||||
{
|
||||
"from": [{ "query": "Wien Hbf", "penalty": 0 }, { "query": "Wien Meidling", "penalty": 10 }],
|
||||
"to": [{ "query": "München Hbf", "penalty": 0 }],
|
||||
"departure": "2026-07-11T08:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
`departure` and `arrival` are mutually exclusive; omit both to depart now.
|
||||
|
||||
## Configuration (environment)
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `PORT` | `3000` | HTTP port |
|
||||
| `HOST` | `127.0.0.1` | Bind address |
|
||||
| `USER_AGENT` | `multitrain` | Sent to the MOTIS API — please personalize |
|
||||
| `MOTIS_BASE_URL` | api.transitous.org | Self-hosted MOTIS instance |
|
||||
|
||||
## Deployment on NixOS
|
||||
|
||||
```nix
|
||||
# flake inputs
|
||||
inputs.multitrain.url = "github:you/multitrain"; # or a path/git url
|
||||
|
||||
# NixOS configuration
|
||||
{
|
||||
imports = [ inputs.multitrain.nixosModules.default ];
|
||||
|
||||
services.multitrain = {
|
||||
enable = true;
|
||||
port = 3000;
|
||||
host = "127.0.0.1";
|
||||
userAgent = "multitrain (you@example.org)";
|
||||
# motisBaseUrl = "https://motis.example.org";
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`nix build` produces the server as `result/bin/multitrain`. After changing `package-lock.json`, refresh `npmDepsHash` in `flake.nix` (`nix run nixpkgs#prefetch-npm-deps -- package-lock.json`).
|
||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1783389287,
|
||||
"narHash": "sha256-0xIy4dVLqq47rA+mRy0hXDfjhQd4E5PoIns/RmB7nR4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0ad6f47ea4fe188f4bc8f0380f93ae8523337c6c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-26.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
123
flake.nix
Normal file
123
flake.nix
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
{
|
||||
description = "MultiTrain — multi-origin/multi-destination train connection search";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
in
|
||||
{
|
||||
devShells = forAllSystems (pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
nodejs_22
|
||||
typescript-language-server
|
||||
];
|
||||
};
|
||||
});
|
||||
|
||||
packages = forAllSystems (pkgs: {
|
||||
default = (pkgs.buildNpmPackage.override { nodejs = pkgs.nodejs_22; }) {
|
||||
pname = "multitrain";
|
||||
version = "0.1.0";
|
||||
src = self;
|
||||
npmDepsHash = "sha256-HtdR6XNMXgMYdbty7mWxPvpRdQLbnja+fPNA9fUCqZM=";
|
||||
|
||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
npm prune --omit=dev
|
||||
mkdir -p $out/lib/multitrain $out/bin
|
||||
cp -r dist public node_modules package.json $out/lib/multitrain/
|
||||
makeWrapper ${pkgs.nodejs_22}/bin/node $out/bin/multitrain \
|
||||
--add-flags "$out/lib/multitrain/dist/main.js"
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
});
|
||||
|
||||
nixosModules.default = { config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.services.multitrain;
|
||||
in
|
||||
{
|
||||
options.services.multitrain = {
|
||||
enable = lib.mkEnableOption "MultiTrain train connection search";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
defaultText = lib.literalExpression "multitrain from its flake";
|
||||
description = "The MultiTrain package to run.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 3000;
|
||||
description = "Port the HTTP server listens on.";
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address the HTTP server binds to.";
|
||||
};
|
||||
|
||||
userAgent = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "multitrain";
|
||||
description = "User-Agent sent to the MOTIS API (identify yourself per Transitous fair-use policy, e.g. with a contact address).";
|
||||
};
|
||||
|
||||
motisBaseUrl = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "https://motis.example.org";
|
||||
description = "Base URL of a self-hosted MOTIS instance; null uses api.transitous.org.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
systemd.services.multitrain = {
|
||||
description = "MultiTrain train connection search";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
|
||||
environment = {
|
||||
PORT = toString cfg.port;
|
||||
HOST = cfg.host;
|
||||
USER_AGENT = cfg.userAgent;
|
||||
} // lib.optionalAttrs (cfg.motisBaseUrl != null) {
|
||||
MOTIS_BASE_URL = cfg.motisBaseUrl;
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
ExecStart = "${cfg.package}/bin/multitrain";
|
||||
DynamicUser = true;
|
||||
Restart = "on-failure";
|
||||
|
||||
CapabilityBoundingSet = "";
|
||||
LockPersonality = true;
|
||||
NoNewPrivileges = true;
|
||||
PrivateDevices = true;
|
||||
PrivateTmp = true;
|
||||
ProtectControlGroups = true;
|
||||
ProtectHome = true;
|
||||
ProtectHostname = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectSystem = "strict";
|
||||
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
|
||||
RestrictNamespaces = true;
|
||||
RestrictRealtime = true;
|
||||
SystemCallArchitectures = "native";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
3020
package-lock.json
generated
Normal file
3020
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
package.json
Normal file
23
package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "multitrain",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Multi-origin/multi-destination train connection search",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"start": "node dist/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/static": "^9.3.0",
|
||||
"@motis-project/motis-fptf-client": "^6.5.2",
|
||||
"fastify": "^5.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/hafas-client": "^6.3.1",
|
||||
"@types/node": "^26.1.1",
|
||||
"tsx": "^4.23.0",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
252
public/app.js
Normal file
252
public/app.js
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
"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"));
|
||||
56
public/index.html
Normal file
56
public/index.html
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MultiTrain</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>MultiTrain</h1>
|
||||
<p class="subtitle">Train connections from many starts to many destinations, ranked with station penalties.</p>
|
||||
|
||||
<form id="search-form">
|
||||
<div class="stations">
|
||||
<fieldset>
|
||||
<legend>From</legend>
|
||||
<div id="from-rows" class="rows" data-side="from"></div>
|
||||
<button type="button" class="add" data-target="from-rows">+ add start</button>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>To</legend>
|
||||
<div id="to-rows" class="rows" data-side="to"></div>
|
||||
<button type="button" class="add" data-target="to-rows">+ add destination</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="time-row">
|
||||
<select id="time-mode">
|
||||
<option value="now" selected>Depart now</option>
|
||||
<option value="departure">Depart at</option>
|
||||
<option value="arrival">Arrive by</option>
|
||||
</select>
|
||||
<input type="datetime-local" id="time-value" hidden>
|
||||
<button type="submit" id="search-button">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section id="feedback" hidden></section>
|
||||
|
||||
<section id="results-section" hidden>
|
||||
<div class="results-header">
|
||||
<h2>Connections</h2>
|
||||
<label>Sort by
|
||||
<select id="sort-mode">
|
||||
<option value="score" selected>best (incl. penalty)</option>
|
||||
<option value="time">departure time</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<ol id="results"></ol>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
153
public/style.css
Normal file
153
public/style.css
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #fafafa;
|
||||
--card: #ffffff;
|
||||
--text: #1a1a1a;
|
||||
--muted: #666;
|
||||
--accent: #c8102e;
|
||||
--border: #ddd;
|
||||
--warning-bg: #fff6e0;
|
||||
--error-bg: #ffe5e5;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #16181c;
|
||||
--card: #1f2228;
|
||||
--text: #eceff4;
|
||||
--muted: #9aa2ad;
|
||||
--accent: #ff5a70;
|
||||
--border: #34383f;
|
||||
--warning-bg: #3a3323;
|
||||
--error-bg: #40272b;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, sans-serif;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 44rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem 4rem;
|
||||
}
|
||||
|
||||
h1 { margin-bottom: 0.2rem; }
|
||||
.subtitle { margin-top: 0; color: var(--muted); }
|
||||
|
||||
.stations {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 38rem) {
|
||||
.stations { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.station-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.station-row input[type="text"] { flex: 1; min-width: 0; }
|
||||
|
||||
input, select, button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
}
|
||||
|
||||
.penalty { width: 4.2rem; }
|
||||
.unit { color: var(--muted); font-size: 0.85rem; }
|
||||
|
||||
button { cursor: pointer; }
|
||||
button.add { background: none; border: 1px dashed var(--border); width: 100%; color: var(--muted); }
|
||||
button.remove { padding: 0.2rem 0.55rem; color: var(--muted); }
|
||||
|
||||
.time-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#search-button {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
padding: 0.45rem 1.4rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#search-button:disabled { opacity: 0.6; }
|
||||
|
||||
#feedback { border-radius: 8px; padding: 0.6rem 0.9rem; margin-top: 1rem; }
|
||||
#feedback.warning { background: var(--warning-bg); }
|
||||
#feedback.error { background: var(--error-bg); }
|
||||
#feedback p { margin: 0.2rem 0; }
|
||||
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.results-header label { color: var(--muted); font-size: 0.9rem; }
|
||||
|
||||
#results { list-style: none; padding: 0; margin: 0; }
|
||||
|
||||
.journey {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.journey-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.times { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.meta { color: var(--muted); font-size: 0.9rem; }
|
||||
.route { color: var(--muted); font-size: 0.9rem; margin-top: 0.15rem; }
|
||||
|
||||
.lines { margin-top: 0.4rem; display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
|
||||
.line {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.45rem;
|
||||
}
|
||||
|
||||
details { margin-top: 0.5rem; }
|
||||
summary { cursor: pointer; color: var(--muted); font-size: 0.9rem; }
|
||||
.leg { margin: 0.25rem 0 0.25rem 1rem; font-size: 0.9rem; }
|
||||
27
src/adapter/http/routes.ts
Normal file
27
src/adapter/http/routes.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { FastifyInstance } from "fastify";
|
||||
import type { SearchRequest } from "../../model/model.js";
|
||||
import type { JourneySource } from "../../model/ports.js";
|
||||
import { SearchError, SearchService } from "../../model/search.js";
|
||||
|
||||
export function registerRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: { search: SearchService; source: JourneySource },
|
||||
): void {
|
||||
app.get<{ Querystring: { query?: string } }>("/api/locations", async (request) => {
|
||||
const query = request.query.query?.trim() ?? "";
|
||||
if (query.length < 2) return [];
|
||||
return deps.source.suggestStations(query, 8);
|
||||
});
|
||||
|
||||
app.post<{ Body: SearchRequest }>("/api/search", async (request, reply) => {
|
||||
try {
|
||||
return await deps.search.search(request.body ?? ({} as SearchRequest));
|
||||
} catch (error) {
|
||||
if (error instanceof SearchError) {
|
||||
reply.code(400);
|
||||
return { error: error.message };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
130
src/adapter/transitous/client.ts
Normal file
130
src/adapter/transitous/client.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { geocode } from "@motis-project/motis-client";
|
||||
import { createClient } from "@motis-project/motis-fptf-client";
|
||||
import { profile as transitousProfile } from "@motis-project/motis-fptf-client/p/transitous/index.js";
|
||||
import type { Journey, Station } from "../../model/model.js";
|
||||
import type { JourneySource, JourneysOptions } from "../../model/ports.js";
|
||||
import { toGeocodeStation, toJourney } from "./mapper.js";
|
||||
|
||||
export interface TransitousConfig {
|
||||
userAgent: string;
|
||||
/** override api.transitous.org, e.g. for a self-hosted MOTIS */
|
||||
baseUrl?: string;
|
||||
stationCacheTtlMs?: number;
|
||||
}
|
||||
|
||||
const SUGGESTIONS = 8;
|
||||
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function createTransitousSource(config: TransitousConfig): JourneySource {
|
||||
const baseUrl = config.baseUrl ?? (transitousProfile.baseUrl as string);
|
||||
const profile = { ...transitousProfile, baseUrl };
|
||||
// enrichStations would load a full station dataset into memory — not needed here
|
||||
const client = createClient(profile, config.userAgent, { enrichStations: false });
|
||||
const cache = new TtlCache<Station[]>(config.stationCacheTtlMs ?? DEFAULT_TTL_MS);
|
||||
|
||||
async function suggestStations(query: string, limit: number): Promise<Station[]> {
|
||||
const key = query.trim().toLowerCase();
|
||||
let stations = cache.get(key);
|
||||
if (!stations) {
|
||||
// straight to the MOTIS geocoder: the FPTF locations() parse drops the
|
||||
// area info needed for recognizable station names
|
||||
const result = await geocode({
|
||||
throwOnError: true,
|
||||
baseUrl,
|
||||
headers: { "User-Agent": config.userAgent },
|
||||
query: { text: query, type: ["STOP"] },
|
||||
});
|
||||
stations = (result.data ?? [])
|
||||
.map(toGeocodeStation)
|
||||
.filter((station): station is Station => station !== undefined);
|
||||
cache.set(key, stations);
|
||||
}
|
||||
return stations.slice(0, limit);
|
||||
}
|
||||
|
||||
return {
|
||||
suggestStations,
|
||||
|
||||
async resolveStation(query: string): Promise<Station | undefined> {
|
||||
return pickBestMatch(query, await suggestStations(query, SUGGESTIONS));
|
||||
},
|
||||
|
||||
async journeys(from: Station, to: Station, options: JourneysOptions): Promise<Journey[]> {
|
||||
// the client treats key *presence* as intent, so undefined keys must be omitted
|
||||
const opt: Parameters<typeof client.journeys>[2] = {
|
||||
results: options.results,
|
||||
stopovers: false,
|
||||
};
|
||||
if (options.departure) opt.departure = options.departure;
|
||||
if (options.arrival) opt.arrival = options.arrival;
|
||||
const result = await client.journeys(from.id, to.id, opt);
|
||||
return (result.journeys ?? [])
|
||||
.map(toJourney)
|
||||
.filter((journey): journey is Journey => journey !== undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The geocoder's first hit is not always the intended station (e.g. "München
|
||||
* Hbf" ranking "Hauptbahnhof Süd" first) — prefer the candidate whose name
|
||||
* matches the query best.
|
||||
*/
|
||||
function pickBestMatch(query: string, stations: Station[]): Station | undefined {
|
||||
const queryTokens = tokenize(query);
|
||||
let best: Station | undefined;
|
||||
let bestScore = -Infinity;
|
||||
for (const [index, station] of stations.entries()) {
|
||||
const name = tokenize(station.name);
|
||||
let score = 0;
|
||||
if (name.join(" ") === queryTokens.join(" ")) score += 100;
|
||||
score += 10 * queryTokens.filter((token) => name.includes(token)).length;
|
||||
score -= name.length - queryTokens.length; // prefer names without extra words
|
||||
score -= index * 0.1; // geocoder relevance as tie-breaker
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = station;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
const TOKEN_ALIASES: Record<string, string> = {
|
||||
hbf: "hauptbahnhof",
|
||||
bf: "bahnhof",
|
||||
};
|
||||
|
||||
function tokenize(value: string): string[] {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.split(/[\s,()./-]+/)
|
||||
.filter((token) => token !== "")
|
||||
.map((token) => TOKEN_ALIASES[token] ?? token);
|
||||
}
|
||||
|
||||
class TtlCache<V> {
|
||||
private readonly entries = new Map<string, { value: V; expires: number }>();
|
||||
|
||||
constructor(
|
||||
private readonly ttlMs: number,
|
||||
private readonly maxEntries = 1000,
|
||||
) {}
|
||||
|
||||
get(key: string): V | undefined {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() > entry.expires) {
|
||||
this.entries.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key: string, value: V): void {
|
||||
if (this.entries.size >= this.maxEntries) {
|
||||
const oldest = this.entries.keys().next().value;
|
||||
if (oldest !== undefined) this.entries.delete(oldest);
|
||||
}
|
||||
this.entries.set(key, { value, expires: Date.now() + this.ttlMs });
|
||||
}
|
||||
}
|
||||
62
src/adapter/transitous/mapper.ts
Normal file
62
src/adapter/transitous/mapper.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { Match } from "@motis-project/motis-client";
|
||||
import type * as fptf from "hafas-client";
|
||||
import type { Journey, Leg, Line, Station } from "../../model/model.js";
|
||||
|
||||
export function toStation(
|
||||
location: fptf.Station | fptf.Stop | fptf.Location | undefined,
|
||||
): Station | undefined {
|
||||
if (!location) return undefined;
|
||||
const { id, name } = location;
|
||||
if (!id && !name) return undefined;
|
||||
return { id: id ?? name ?? "", name: name ?? id ?? "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop names in the feeds often lack the city ("Hauptbahnhof Süd" for München
|
||||
* Hbf) — append the geocoder's display area so names are recognizable.
|
||||
*/
|
||||
export function toGeocodeStation(match: Match): Station | undefined {
|
||||
if (!match.id || !match.name) return undefined;
|
||||
const city = match.areas.find((area) => area.default)?.name ?? match.areas[0]?.name;
|
||||
const name =
|
||||
city && !match.name.toLowerCase().includes(city.toLowerCase())
|
||||
? `${match.name}, ${city}`
|
||||
: match.name;
|
||||
return { id: match.id, name };
|
||||
}
|
||||
|
||||
function toLine(line: fptf.Line | undefined): Line | undefined {
|
||||
if (!line?.name) return undefined;
|
||||
return {
|
||||
name: line.name,
|
||||
mode: typeof line.mode === "string" ? line.mode : undefined,
|
||||
product: line.product,
|
||||
};
|
||||
}
|
||||
|
||||
function toLeg(leg: fptf.Leg): Leg | undefined {
|
||||
const origin = toStation(leg.origin);
|
||||
const destination = toStation(leg.destination);
|
||||
const departure = leg.departure ?? leg.plannedDeparture;
|
||||
const arrival = leg.arrival ?? leg.plannedArrival;
|
||||
if (!origin || !destination || !departure || !arrival) return undefined;
|
||||
return {
|
||||
origin,
|
||||
destination,
|
||||
departure,
|
||||
arrival,
|
||||
walking: leg.walking === true,
|
||||
direction: leg.direction ?? undefined,
|
||||
line: toLine(leg.line),
|
||||
};
|
||||
}
|
||||
|
||||
export function toJourney(journey: fptf.Journey): Journey | undefined {
|
||||
const legs: Leg[] = [];
|
||||
for (const raw of journey.legs ?? []) {
|
||||
const leg = toLeg(raw);
|
||||
if (!leg) return undefined;
|
||||
legs.push(leg);
|
||||
}
|
||||
return legs.length > 0 ? { legs } : undefined;
|
||||
}
|
||||
13
src/adapter/transitous/motis-fptf-client.d.ts
vendored
Normal file
13
src/adapter/transitous/motis-fptf-client.d.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
declare module "@motis-project/motis-fptf-client" {
|
||||
import type { HafasClient } from "hafas-client";
|
||||
|
||||
export function createClient(
|
||||
profile: unknown,
|
||||
userAgent: string,
|
||||
opt?: { enrichStations?: boolean },
|
||||
): HafasClient;
|
||||
}
|
||||
|
||||
declare module "@motis-project/motis-fptf-client/p/transitous/index.js" {
|
||||
export const profile: Record<string, unknown>;
|
||||
}
|
||||
26
src/main.ts
Normal file
26
src/main.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import fastifyStatic from "@fastify/static";
|
||||
import Fastify from "fastify";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { registerRoutes } from "./adapter/http/routes.js";
|
||||
import { createTransitousSource } from "./adapter/transitous/client.js";
|
||||
import { SearchService } from "./model/search.js";
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
const host = process.env.HOST ?? "127.0.0.1";
|
||||
const userAgent = process.env.USER_AGENT ?? "multitrain";
|
||||
const baseUrl = process.env.MOTIS_BASE_URL;
|
||||
|
||||
const source = createTransitousSource({ userAgent, baseUrl });
|
||||
const search = new SearchService(source);
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
app.register(fastifyStatic, {
|
||||
root: path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "public"),
|
||||
});
|
||||
registerRoutes(app, { search, source });
|
||||
|
||||
app.listen({ port, host }).catch((error) => {
|
||||
app.log.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
67
src/model/model.ts
Normal file
67
src/model/model.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
export interface Station {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Line {
|
||||
name: string;
|
||||
mode?: string;
|
||||
product?: string;
|
||||
}
|
||||
|
||||
export interface Leg {
|
||||
origin: Station;
|
||||
destination: Station;
|
||||
/** ISO 8601, realtime where known */
|
||||
departure: string;
|
||||
/** ISO 8601, realtime where known */
|
||||
arrival: string;
|
||||
/** absent for walking legs */
|
||||
line?: Line;
|
||||
direction?: string;
|
||||
walking: boolean;
|
||||
}
|
||||
|
||||
export interface Journey {
|
||||
legs: Leg[];
|
||||
}
|
||||
|
||||
export interface StationQuery {
|
||||
query: string;
|
||||
/** minutes added to the score of journeys using this station */
|
||||
penalty: number;
|
||||
}
|
||||
|
||||
export interface SearchRequest {
|
||||
from: StationQuery[];
|
||||
to: StationQuery[];
|
||||
/** ISO 8601; mutually exclusive with arrival; neither = depart now */
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedStation {
|
||||
query: string;
|
||||
penalty: number;
|
||||
station?: Station;
|
||||
}
|
||||
|
||||
export interface ScoredJourney {
|
||||
journey: Journey;
|
||||
fromStation: Station;
|
||||
toStation: Station;
|
||||
fromPenalty: number;
|
||||
toPenalty: number;
|
||||
departure: string;
|
||||
arrival: string;
|
||||
durationMinutes: number;
|
||||
transfers: number;
|
||||
/** durationMinutes + fromPenalty + toPenalty — ranking only */
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
journeys: ScoredJourney[];
|
||||
resolved: { from: ResolvedStation[]; to: ResolvedStation[] };
|
||||
warnings: string[];
|
||||
}
|
||||
14
src/model/ports.ts
Normal file
14
src/model/ports.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { Journey, Station } from "./model.js";
|
||||
|
||||
export interface JourneysOptions {
|
||||
departure?: Date;
|
||||
arrival?: Date;
|
||||
results?: number;
|
||||
}
|
||||
|
||||
/** Driven port: where journeys and stations come from. */
|
||||
export interface JourneySource {
|
||||
suggestStations(query: string, limit: number): Promise<Station[]>;
|
||||
resolveStation(query: string): Promise<Station | undefined>;
|
||||
journeys(from: Station, to: Station, options: JourneysOptions): Promise<Journey[]>;
|
||||
}
|
||||
201
src/model/search.ts
Normal file
201
src/model/search.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import type {
|
||||
Journey,
|
||||
ResolvedStation,
|
||||
ScoredJourney,
|
||||
SearchRequest,
|
||||
SearchResult,
|
||||
Station,
|
||||
StationQuery,
|
||||
} from "./model.js";
|
||||
import type { JourneySource, JourneysOptions } from "./ports.js";
|
||||
|
||||
/** Invalid input from the caller — the HTTP adapter maps this to a 400. */
|
||||
export class SearchError extends Error {}
|
||||
|
||||
const MAX_STATIONS_PER_SIDE = 5;
|
||||
const RESULTS_PER_PAIR = 4;
|
||||
|
||||
interface Pair {
|
||||
from: ResolvedStation & { station: Station };
|
||||
to: ResolvedStation & { station: Station };
|
||||
}
|
||||
|
||||
export class SearchService {
|
||||
constructor(
|
||||
private readonly source: JourneySource,
|
||||
private readonly concurrency = 4,
|
||||
) {}
|
||||
|
||||
async search(request: SearchRequest): Promise<SearchResult> {
|
||||
validate(request);
|
||||
const warnings: string[] = [];
|
||||
|
||||
const [from, to] = await Promise.all([
|
||||
this.resolveAll(request.from),
|
||||
this.resolveAll(request.to),
|
||||
]);
|
||||
for (const r of [...from, ...to]) {
|
||||
if (!r.station) warnings.push(`No station found for "${r.query}"`);
|
||||
}
|
||||
|
||||
const pairs: Pair[] = [];
|
||||
for (const f of from) {
|
||||
for (const t of to) {
|
||||
if (!f.station || !t.station) continue;
|
||||
if (f.station.id === t.station.id) {
|
||||
warnings.push(`Skipped ${f.station.name} → ${t.station.name}: same station`);
|
||||
continue;
|
||||
}
|
||||
pairs.push({ from: f, to: t } as Pair);
|
||||
}
|
||||
}
|
||||
if (pairs.length === 0) {
|
||||
throw new SearchError("No usable station pair — check the station names");
|
||||
}
|
||||
|
||||
const options: JourneysOptions = {
|
||||
departure: request.departure
|
||||
? new Date(request.departure)
|
||||
: request.arrival
|
||||
? undefined
|
||||
: new Date(),
|
||||
arrival: request.arrival ? new Date(request.arrival) : undefined,
|
||||
results: RESULTS_PER_PAIR,
|
||||
};
|
||||
|
||||
const settled = await mapWithConcurrency(pairs, this.concurrency, (pair) =>
|
||||
this.source.journeys(pair.from.station, pair.to.station, options),
|
||||
);
|
||||
|
||||
const best = new Map<string, ScoredJourney>();
|
||||
settled.forEach((result, i) => {
|
||||
const pair = pairs[i];
|
||||
if (result.status === "rejected") {
|
||||
warnings.push(
|
||||
`${pair.from.station.name} → ${pair.to.station.name}: ${errorMessage(result.reason)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const journey of result.value) {
|
||||
const scored = score(journey, pair);
|
||||
if (!scored) continue;
|
||||
const key = dedupeKey(journey);
|
||||
const existing = best.get(key);
|
||||
if (!existing || scored.score < existing.score) best.set(key, scored);
|
||||
}
|
||||
});
|
||||
|
||||
const arriveBy = request.arrival !== undefined;
|
||||
const journeys = [...best.values()].sort((a, b) => {
|
||||
if (a.score !== b.score) return a.score - b.score;
|
||||
// ties: leave as late as possible when arriving by, else depart early
|
||||
return arriveBy
|
||||
? b.departure.localeCompare(a.departure)
|
||||
: a.departure.localeCompare(b.departure);
|
||||
});
|
||||
|
||||
return { journeys, resolved: { from, to }, warnings };
|
||||
}
|
||||
|
||||
private resolveAll(queries: StationQuery[]): Promise<ResolvedStation[]> {
|
||||
return Promise.all(
|
||||
queries.map(async (q) => ({
|
||||
query: q.query,
|
||||
penalty: q.penalty,
|
||||
station: await this.source.resolveStation(q.query),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validate(request: SearchRequest): void {
|
||||
for (const [side, queries] of [
|
||||
["from", request.from],
|
||||
["to", request.to],
|
||||
] as const) {
|
||||
if (!Array.isArray(queries) || queries.length === 0) {
|
||||
throw new SearchError(`"${side}" needs at least one station`);
|
||||
}
|
||||
if (queries.length > MAX_STATIONS_PER_SIDE) {
|
||||
throw new SearchError(`"${side}" allows at most ${MAX_STATIONS_PER_SIDE} stations`);
|
||||
}
|
||||
for (const q of queries) {
|
||||
if (typeof q.query !== "string" || q.query.trim() === "") {
|
||||
throw new SearchError(`"${side}" contains an empty station name`);
|
||||
}
|
||||
if (typeof q.penalty !== "number" || !Number.isFinite(q.penalty) || q.penalty < 0) {
|
||||
throw new SearchError(`Penalty for "${q.query}" must be a non-negative number of minutes`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (request.departure && request.arrival) {
|
||||
throw new SearchError('Only one of "departure" and "arrival" may be set');
|
||||
}
|
||||
for (const [name, value] of [
|
||||
["departure", request.departure],
|
||||
["arrival", request.arrival],
|
||||
] as const) {
|
||||
if (value !== undefined && Number.isNaN(Date.parse(value))) {
|
||||
throw new SearchError(`"${name}" is not a valid ISO 8601 time`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function score(journey: Journey, pair: Pair): ScoredJourney | undefined {
|
||||
const legs = journey.legs;
|
||||
if (legs.length === 0) return undefined;
|
||||
const departure = legs[0].departure;
|
||||
const arrival = legs[legs.length - 1].arrival;
|
||||
const durationMinutes = Math.round((Date.parse(arrival) - Date.parse(departure)) / 60_000);
|
||||
if (!Number.isFinite(durationMinutes) || durationMinutes < 0) return undefined;
|
||||
return {
|
||||
journey,
|
||||
fromStation: pair.from.station,
|
||||
toStation: pair.to.station,
|
||||
fromPenalty: pair.from.penalty,
|
||||
toPenalty: pair.to.penalty,
|
||||
departure,
|
||||
arrival,
|
||||
durationMinutes,
|
||||
transfers: Math.max(0, legs.filter((l) => !l.walking).length - 1),
|
||||
score: durationMinutes + pair.from.penalty + pair.to.penalty,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlapping station queries (e.g. a "Wien" meta-station and "Wien Hbf") can
|
||||
* yield the same physical journey twice — identify it by its timing and lines.
|
||||
*/
|
||||
function dedupeKey(journey: Journey): string {
|
||||
const legs = journey.legs;
|
||||
const lines = legs
|
||||
.filter((l) => !l.walking)
|
||||
.map((l) => l.line?.name ?? "?")
|
||||
.join(",");
|
||||
return `${legs[0].departure}|${legs[legs.length - 1].arrival}|${lines}`;
|
||||
}
|
||||
|
||||
function errorMessage(reason: unknown): string {
|
||||
return reason instanceof Error ? reason.message : String(reason);
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>,
|
||||
): Promise<PromiseSettledResult<R>[]> {
|
||||
const results: PromiseSettledResult<R>[] = new Array(items.length);
|
||||
let next = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (next < items.length) {
|
||||
const i = next++;
|
||||
try {
|
||||
results[i] = { status: "fulfilled", value: await fn(items[i]) };
|
||||
} catch (reason) {
|
||||
results[i] = { status: "rejected", reason };
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue