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