diff --git a/scripts/check-i18n-parity.mjs b/scripts/check-i18n-parity.mjs index 5228e1cf..33c7b2aa 100644 --- a/scripts/check-i18n-parity.mjs +++ b/scripts/check-i18n-parity.mjs @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' -const NAMESPACES = ['common', 'home', 'project', 'contact', 'releases', 'legal'] +const NAMESPACES = ['common', 'home', 'project', 'streamlet', 'contact', 'releases', 'legal'] const LANGUAGES = ['de', 'en'] const REFERENCE = 'de' diff --git a/src/data/streamlet.ts b/src/data/streamlet.ts new file mode 100644 index 00000000..020a9aff --- /dev/null +++ b/src/data/streamlet.ts @@ -0,0 +1,124 @@ +import { + Boxes, + Clock, + Container, + Fuel, + GitBranch, + Layers, + Repeat, + Scale, + ServerCog, + Warehouse, +} from 'lucide-react' +import type { ComponentType } from 'react' + +type Icon = ComponentType<{ className?: string }> + +export const streamletLinks = { + repo: 'https://github.com/green-ecolution/streamlet', + readme: 'https://github.com/green-ecolution/streamlet#readme', + issues: 'https://github.com/green-ecolution/streamlet/issues', +} as const + +export type FitCriterionId = + 'tankCapacity' | 'timeWindows' | 'midTourRefill' | 'multipleStations' | 'depotReturn' + +interface FitCriterion { + id: FitCriterionId + icon: Icon +} + +export const fitCriteria: FitCriterion[] = [ + { id: 'tankCapacity', icon: Container }, + { id: 'timeWindows', icon: Clock }, + { id: 'midTourRefill', icon: Fuel }, + { id: 'multipleStations', icon: Repeat }, + { id: 'depotReturn', icon: Warehouse }, +] + +export type SolveFlowStepId = 'send' | 'solve' | 'receive' + +interface SolveFlowStep { + id: SolveFlowStepId + step: string +} + +export const solveFlowSteps: SolveFlowStep[] = [ + { id: 'send', step: '01' }, + { id: 'solve', step: '02' }, + { id: 'receive', step: '03' }, +] + +export type ApiEndpointId = 'solve' | 'health' | 'openapi' + +interface ApiEndpoint { + id: ApiEndpointId + method: string + path: string +} + +export const apiEndpoints: ApiEndpoint[] = [ + { id: 'solve', method: 'POST', path: '/v1/solve' }, + { id: 'health', method: 'GET', path: '/health' }, + { id: 'openapi', method: 'GET', path: '/api-docs/openapi.json' }, +] + +export type SolverStageId = 'construction' | 'localSearch' + +interface SolverStage { + id: SolverStageId + icon: Icon +} + +export const solverStages: SolverStage[] = [ + { id: 'construction', icon: Boxes }, + { id: 'localSearch', icon: GitBranch }, +] + +export const solverMoveEvaluationIcon: Icon = Layers + +interface Benchmark { + instance: string + gap: number +} + +export const solverBenchmarks: Benchmark[] = [ + { instance: 'c101', gap: 3.1 }, + { instance: 'r101', gap: 1.9 }, + { instance: 'rc101', gap: 1.5 }, +] + +export type TileChangesetId = 'construction' | 'allowedPaths' + +export interface TileChangeset { + id: TileChangesetId + name: string + status: 'available' | 'inProgress' +} + +export const tileChangesets: TileChangeset[] = [ + { id: 'construction', name: 'construction', status: 'available' }, + { id: 'allowedPaths', name: 'allowed-paths', status: 'inProgress' }, +] + +export type OperationsFactId = 'stateless' | 'swappableEngine' | 'separateCrate' | 'license' + +interface OperationsFact { + id: OperationsFactId + icon: Icon +} + +export const operationsFacts: OperationsFact[] = [ + { id: 'stateless', icon: ServerCog }, + { id: 'swappableEngine', icon: Repeat }, + { id: 'separateCrate', icon: Layers }, + { id: 'license', icon: Scale }, +] + +export type StreamletLimitationId = 'geometry' | 'depots' | 'missingFeatures' + +export const streamletLimitations: StreamletLimitationId[] = [ + 'geometry', + 'depots', + 'missingFeatures', +] diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 1d9775d6..26a4d9e3 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -2,7 +2,15 @@ import i18next from 'i18next' import { initReactI18next } from 'react-i18next' import { DEFAULT_LANGUAGE, type Language } from './languages' -export const NAMESPACES = ['common', 'home', 'project', 'contact', 'releases', 'legal'] as const +export const NAMESPACES = [ + 'common', + 'home', + 'project', + 'streamlet', + 'contact', + 'releases', + 'legal', +] as const type Catalog = Record> diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 57246ddf..a84eca5e 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -15,9 +15,9 @@ "ariaLabel": "Hauptnavigation", "heading": "Hauptnavigation", "project": "Das Projekt", + "streamlet": "Streamlet", "releases": "Releases", "contact": "Kontakt", - "github": "GitHub", "tryNow": "Jetzt ausprobieren", "imprint": "Impressum", "privacy": "Datenschutz" @@ -72,6 +72,7 @@ "breadcrumb": { "home": "Startseite", "project": "Projekt", + "streamlet": "Streamlet", "contact": "Kontakt", "imprint": "Impressum", "privacy": "Datenschutz" diff --git a/src/i18n/locales/de/index.ts b/src/i18n/locales/de/index.ts index 318a4e2e..2ed04b3f 100644 --- a/src/i18n/locales/de/index.ts +++ b/src/i18n/locales/de/index.ts @@ -4,5 +4,6 @@ import home from './home.json' import legal from './legal.json' import project from './project.json' import releases from './releases.json' +import streamlet from './streamlet.json' -export default { common, home, project, contact, releases, legal } +export default { common, home, project, streamlet, contact, releases, legal } diff --git a/src/i18n/locales/de/project.json b/src/i18n/locales/de/project.json index 8c6729ea..d96091dc 100644 --- a/src/i18n/locales/de/project.json +++ b/src/i18n/locales/de/project.json @@ -59,6 +59,7 @@ "sectionLabel": "System-Architektur", "title": "Der Weg der Daten", "description": "So gelangen die Messwerte vom Baum bis zur optimierten Bewässerungsroute durch das Green Ecolution System.", + "streamletRich": "Die Routenoptimierung im Backend übernimmt Streamlet, ein eigenes Open-Source-Projekt, das sich auch außerhalb von Green Ecolution einsetzen lässt.", "steps": { "sensor": { "label": "Sensor", diff --git a/src/i18n/locales/de/streamlet.json b/src/i18n/locales/de/streamlet.json new file mode 100644 index 00000000..b2962c2f --- /dev/null +++ b/src/i18n/locales/de/streamlet.json @@ -0,0 +1,155 @@ +{ + "meta": { + "title": "Streamlet | Tourenplanung mit Nachfüllstationen | Green Ecolution", + "description": "Streamlet ist ein eigenständiger Open-Source-Dienst für Tourenplanung mit Zeitfenstern und mehreren Nachfüllstationen. Zustandslose HTTP-API, deterministischer Solver, AGPL-3.0." + }, + "hero": { + "sectionLabel": "Open-Source-Projekt", + "title": "Streamlet", + "lead": "Tourenplanung für Fahrzeuge, deren Tankfüllung nicht für die ganze Tour reicht. Streamlet legt die Reihenfolge der Halte fest und plant das Nachfüllen unterwegs gleich mit. Es läuft als eigenständiger Dienst hinter einer HTTP-API.", + "origin": "Entstanden ist Streamlet für Green Ecolution, weil die geprüften Solver dieses Problem nicht abbilden konnten. Heute liegt es in einem eigenen Repository und hat keine Abhängigkeit zu Green Ecolution. Wer dasselbe Planungsproblem hat, kann es unverändert übernehmen.", + "repo": "Repository", + "repoAriaLabel": "Streamlet-Repository auf GitHub öffnen", + "docs": "Dokumentation", + "docsAriaLabel": "Dokumentation von Streamlet öffnen" + }, + "problem": { + "sectionLabel": "Das Problem", + "title": "Reihenfolge und Nachfüllen hängen voneinander ab", + "coupling": "Sobald ein Fahrzeug unterwegs nachfüllen muss, hängt die beste Reihenfolge der Halte davon ab, wann und wo nachgefüllt wird. Beides zugleich zu entscheiden, ist der Kern des Problems.", + "workarounds": "Wer das dem Solver nicht mitgeben kann, hat zwei Möglichkeiten, und beide kosten Lösungsqualität: die Tour ohne das Nachfüllen planen und ein unzulässiges Ergebnis bekommen, oder die Tour von Hand in Teilstücke schneiden und damit die Optimierung über das Ganze aufgeben.", + "approach": "Streamlet plant die Nachfüllstationen im selben Optimierungslauf und rechnet sie nicht hinterher hinein. Bei Green Ecolution lief die Routenoptimierung vorher auf Vroom, das mehrere Nachfüllstationen mitten in der Tour nicht abbilden konnte.", + "diagram": { + "ariaLabel": "Diagramm einer Tour: Der Füllstand des Fahrzeugs sinkt an jedem Halt und wird an einer Nachfüllstation mitten in der Tour wieder aufgefüllt.", + "depot": "Depot", + "depotMarker": "D", + "refillStation": "Nachfüllstation", + "refillMarker": "R", + "tankFull": "Tank voll", + "tankEmpty": "leer", + "refilled": "nachgefüllt" + } + }, + "fit": { + "sectionLabel": "Einsatzfeld", + "title": "Passt Streamlet zu deinem Problem?", + "description": "Streamlet ist nicht auf eine Branche zugeschnitten, entscheidend ist die Struktur der Tour. Je mehr dieser Merkmale auf deine Touren zutreffen, desto eher passt es.", + "criteria": { + "tankCapacity": "Fahrzeuge mit begrenzter Tankkapazität", + "timeWindows": "Halte mit Zeitfenstern", + "midTourRefill": "Nachfüllen oder Umladen mitten in der Tour", + "multipleStations": "Mehrere mögliche Nachfüllstationen", + "depotReturn": "Rückkehr zum Depot am Schichtende" + }, + "cargo": { + "title": "Unabhängig vom Transportgut", + "description": "Streamlet kennt Tankkapazität und Bedarf, nicht Bewässerung. Was du transportierst, bleibt offen. Wasser ist der erste Anwendungsfall, die API setzt ihn an keiner Stelle voraus." + } + }, + "api": { + "sectionLabel": "Schnittstelle", + "title": "Ein Aufruf, eine Lösung", + "description": "Streamlet arbeitet synchron. Du schickst das Problem, du bekommst die Routen zurück, ohne Job-Handling und ohne Polling dazwischen.", + "flow": { + "send": { + "label": "Problem senden", + "description": "Ein JSON-Body an POST /v1/solve beschreibt die Fahrzeuge mit Tankkapazität und Schichtzeitfenster, die Depots, die Halte mit Bedarf und Zeitfenster sowie die Nachfüllstationen." + }, + "solve": { + "label": "Kosten holen und lösen", + "description": "Streamlet fragt die Reisezeit- und Distanzmatrix bei der Routing-Engine ab und löst das Problem im Solver." + }, + "receive": { + "label": "Routen erhalten", + "description": "Die Antwort enthält für jede Route die Reihenfolge der Halte, Distanz, Fahrt- und Wartezeit sowie die Geometrie. Dazu kommt die Liste der Halte, die unbedient bleiben." + } + }, + "endpoints": { + "solve": "Problem lösen und Routen zurückgeben", + "health": "Liveness-Prüfung", + "openapi": "OpenAPI-Schema der API" + }, + "validation": "Streamlet prüft das Problem schon beim Einlesen. Eine Startladung über der Tankkapazität, ein Zeitfenster, das vor seinem Beginn endet, oder ein Problem ohne Fahrzeug wird mit 422 abgelehnt, statt später im Solver aufzulaufen. Fehlerantworten geben keine internen Details preis, weder Engine-URLs noch fremde Response-Bodies." + }, + "solver": { + "sectionLabel": "Solver", + "title": "Nachvollziehbar statt zufällig", + "description": "Der Solver arbeitet in zwei Phasen und ohne Zufall. Solange das Zeitbudget nicht greift, ergibt dieselbe Eingabe dieselbe Ausgabe, das macht Ergebnisse reproduzierbar und Regressionen prüfbar.", + "stages": { + "construction": { + "label": "Konstruktion", + "description": "Cheapest Insertion baut die erste Lösung. Reicht der Tank für den nächsten Halt nicht, fügt der Solver davor die günstigste Nachfüllstation ein." + }, + "localSearch": { + "label": "Lokale Suche", + "description": "Eine VND-artige Suche verbessert die Lösung. Zuerst laufen die günstigen Züge innerhalb einer Route, danach die teureren zwischen den Routen. Findet keiner davon mehr etwas, setzt sie alle Nachfüllbesuche neu und verwirft die, die sich nicht mehr lohnen." + } + }, + "moveEvaluation": { + "label": "Load- und Duration-Segmente", + "description": "Kapazität und Zeitfenster prüft der Solver über Load- und Duration-Segmente nach Vidal et al. (2014). Sie lassen sich in konstanter Zeit zusammenführen, bewertet wird jeder Zug an der betroffenen Route." + }, + "benchmarks": { + "title": "Gemessen an den Solomon-Instanzen", + "description": "Die Regressionstests lösen bekannte Instanzen des VRPTW, also Tourenplanung mit Zeitfenstern. Sie prüfen, ob die Lösung Kapazität und Zeitfenster einhält und ob sie nah genug am Optimum liegt. Nachfüllstationen enthalten diese Instanzen nicht, gemessen wird also die Grundqualität des Solvers.", + "gap": "{{value}}\u00a0%", + "footnote": "Abstand zur besten bekannten Lösung, Schwelle 5\u00a0%" + } + }, + "tiles": { + "sectionLabel": "Kartendaten", + "title": "Das Straßennetz an deine Flotte anpassen", + "fleetMismatch": "Eine gute Route nützt nichts, wenn das Straßennetz nicht zur Flotte passt. Ein Betriebsfahrzeug darf über Wirtschafts- und Dienstwege fahren, die für den allgemeinen Verkehr gesperrt sind. Eine Standardkarte weiß das nicht und lässt Ziele unerreichbar aussehen, die es nicht sind.", + "patcher": "Die Routing-Engine rechnet auf Tiles, die aus einem OSM-Extrakt entstehen. Zum Streamlet-Repository gehört ein CLI, das dieses Extrakt vorher über OSC-Changesets verändert, also über Änderungsdateien im OSM-Format. Damit bildest du ab, was deine Fahrzeuge tatsächlich befahren dürfen.", + "status": { + "available": "Verfügbar", + "inProgress": "In Entwicklung" + }, + "changesets": { + "construction": "Markiert Straßen, die wegen Baustellen gesperrt sind, als access=no. Routen führen dann nicht mehr durch die Sperrung.", + "allowedPaths": "Öffnet Wege für Kraftfahrzeuge, wenn ein Zielpunkt von keiner befahrbaren Straße aus erreichbar ist. Die Zielpunkte soll der Patcher aus der API von Green Ecolution lesen." + }, + "disclaimer": "Der Patcher verändert nur dein Extrakt für deine Routing-Instanz. Er beeinflusst, was die Engine für deine Fahrzeuge als befahrbar ansieht. Eine Befahrungserlaubnis erteilt er damit nicht. Wer ihn einbindet, braucht dafür eine eigene Tile-Pipeline." + }, + "operations": { + "sectionLabel": "Betrieb", + "title": "Einbauen und betreiben", + "description": "Streamlet ist ein einzelner Dienst neben deiner Anwendung. Jeder Stand von main wird als Container-Image veröffentlicht, konfiguriert wird es über Umgebungsvariablen. Dazu brauchst du eine erreichbare Routing-Engine.", + "facts": { + "stateless": { + "label": "Zustandslos", + "description": "Keine Datenbank, keine Job-Queue. Beim Herunterfahren laufen offene Anfragen aus, statt abzubrechen." + }, + "swappableEngine": { + "label": "Engine austauschbar", + "description": "Die Routing-Engine hängt hinter dem Port-Trait Router. Implementiert ist Valhalla. Eine andere Engine lässt sich dahinter setzen, ohne den Solver zu ändern." + }, + "separateCrate": { + "label": "Solver als separate Crate", + "description": "Der Solver liegt in der Crate streamlet-core, ohne tokio, axum und HTTP-Code. Dazu kommen nur Serialisierung und Fehlertypen." + }, + "license": { + "label": "AGPL-3.0", + "description": "Der Quellcode steht unter der GNU Affero General Public License, Version 3. Wer Streamlet verändert und als Netzwerkdienst betreibt, muss den geänderten Quellcode dessen Nutzern anbieten." + } + } + }, + "status": { + "sectionLabel": "Stand", + "title": "Was heute geht und was nicht", + "description": "Streamlet steht am Anfang und läuft bei Green Ecolution in der Einsatzplanung. Diese Punkte stehen hier, damit niemand sie erst beim Einbauen entdeckt.", + "limitations": { + "geometry": "Die Geometrie kommt als kodierte Polyline zurück. Die Option geojson verhält sich derzeit wie polyline.", + "depots": "Die API nimmt mehrere Depots entgegen, jede Route kehrt aber zum ersten zurück.", + "missingFeatures": "Es gibt keinen GPX-Endpoint und keine eingebettete Routing-Engine." + }, + "cta": { + "title": "Mitmachen oder selbst einsetzen", + "descriptionRich": "Fragen, Fehlerberichte und Erfahrungen aus anderen Anwendungsfällen sind im Repository willkommen. Wie Streamlet bei Green Ecolution eingebunden ist, beschreibt Release 0.4.0.", + "repo": "Zum Repository", + "repoAriaLabel": "Streamlet-Repository auf GitHub öffnen", + "issues": "Issues", + "issuesAriaLabel": "Issues von Streamlet auf GitHub öffnen" + } + } +} diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 730f5035..b7a8bea6 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -15,9 +15,9 @@ "ariaLabel": "Main navigation", "heading": "Main navigation", "project": "The Project", + "streamlet": "Streamlet", "releases": "Releases", "contact": "Contact", - "github": "GitHub", "tryNow": "Try it now", "imprint": "Imprint", "privacy": "Privacy Policy" @@ -72,6 +72,7 @@ "breadcrumb": { "home": "Home", "project": "Project", + "streamlet": "Streamlet", "contact": "Contact", "imprint": "Imprint", "privacy": "Privacy Policy" diff --git a/src/i18n/locales/en/index.ts b/src/i18n/locales/en/index.ts index 318a4e2e..2ed04b3f 100644 --- a/src/i18n/locales/en/index.ts +++ b/src/i18n/locales/en/index.ts @@ -4,5 +4,6 @@ import home from './home.json' import legal from './legal.json' import project from './project.json' import releases from './releases.json' +import streamlet from './streamlet.json' -export default { common, home, project, contact, releases, legal } +export default { common, home, project, streamlet, contact, releases, legal } diff --git a/src/i18n/locales/en/project.json b/src/i18n/locales/en/project.json index 076af521..1b728d60 100644 --- a/src/i18n/locales/en/project.json +++ b/src/i18n/locales/en/project.json @@ -59,6 +59,7 @@ "sectionLabel": "System Architecture", "title": "The path of the data", "description": "This is how the readings travel from the tree to the optimized irrigation route through the Green Ecolution system.", + "streamletRich": "Route optimisation in the backend is handled by Streamlet, a separate open source project that can also be used outside Green Ecolution.", "steps": { "sensor": { "label": "Sensor", diff --git a/src/i18n/locales/en/streamlet.json b/src/i18n/locales/en/streamlet.json new file mode 100644 index 00000000..c3b1124b --- /dev/null +++ b/src/i18n/locales/en/streamlet.json @@ -0,0 +1,155 @@ +{ + "meta": { + "title": "Streamlet | Vehicle routing with refill stations | Green Ecolution", + "description": "Streamlet is a standalone open source service for vehicle routing with time windows and multiple refill stations. Stateless HTTP API, deterministic solver, AGPL-3.0." + }, + "hero": { + "sectionLabel": "Open source project", + "title": "Streamlet", + "lead": "Route planning for vehicles whose tank does not hold enough for the whole tour. Streamlet fixes the order of the stops and plans the refills along the way in the same pass. It runs as a standalone service behind an HTTP API.", + "origin": "Streamlet was built for Green Ecolution because none of the solvers we evaluated could express this problem. It now lives in a repository of its own and has no dependency on Green Ecolution. Anyone with the same planning problem can adopt it as it is.", + "repo": "Repository", + "repoAriaLabel": "Open the Streamlet repository on GitHub", + "docs": "Documentation", + "docsAriaLabel": "Open the Streamlet documentation" + }, + "problem": { + "sectionLabel": "The problem", + "title": "Stop order and refills depend on each other", + "coupling": "As soon as a vehicle has to refill along the way, the best order of the stops depends on when and where it refills. Deciding both at once is the heart of the problem.", + "workarounds": "Without a solver that takes this as input, there are two options, and both cost solution quality: plan the tour without the refills and end up with an infeasible result, or cut the tour into pieces by hand and give up optimising across the whole of it.", + "approach": "Streamlet plans the refill stations in the same optimisation run instead of fitting them in afterwards. At Green Ecolution route optimisation previously ran on Vroom, which could not express several refill stations in the middle of a tour.", + "diagram": { + "ariaLabel": "Diagram of a tour: the vehicle's fill level drops at every stop and is topped up again at a refill station in the middle of the tour.", + "depot": "Depot", + "depotMarker": "D", + "refillStation": "Refill station", + "refillMarker": "R", + "tankFull": "Tank full", + "tankEmpty": "empty", + "refilled": "refilled" + } + }, + "fit": { + "sectionLabel": "Where it fits", + "title": "Does Streamlet fit your problem?", + "description": "Streamlet is not tailored to one industry, what matters is the structure of the tour. The more of these traits your tours have, the better the fit.", + "criteria": { + "tankCapacity": "Vehicles with limited tank capacity", + "timeWindows": "Stops with time windows", + "midTourRefill": "Refilling or reloading in the middle of the tour", + "multipleStations": "Several possible refill stations", + "depotReturn": "Return to the depot at the end of the shift" + }, + "cargo": { + "title": "Independent of what you carry", + "description": "Streamlet knows about tank capacity and demand, not about irrigation. What you transport is up to you. Water is the first use case, and nothing in the API assumes it." + } + }, + "api": { + "sectionLabel": "Interface", + "title": "One call, one solution", + "description": "Streamlet works synchronously. You send the problem, you get the routes back, with no job handling and no polling in between.", + "flow": { + "send": { + "label": "Send the problem", + "description": "A JSON body sent to POST /v1/solve describes the vehicles with their tank capacity and shift time window, the depots, the stops with their demand and time windows, and the refill stations." + }, + "solve": { + "label": "Fetch costs and solve", + "description": "Streamlet requests the travel time and distance matrix from the routing engine and solves the problem in the solver." + }, + "receive": { + "label": "Receive the routes", + "description": "For every route the response holds the order of the stops, distance, driving and waiting time, and the geometry. It also lists the stops that stay unserved." + } + }, + "endpoints": { + "solve": "Solve a problem and return the routes", + "health": "Liveness check", + "openapi": "OpenAPI schema of the API" + }, + "validation": "Streamlet validates the problem while reading it. An initial load above the tank capacity, a time window that ends before it starts, or a problem without a vehicle is rejected with a 422 instead of failing later in the solver. Error responses give away no internal detail, neither engine URLs nor third-party response bodies." + }, + "solver": { + "sectionLabel": "Solver", + "title": "Traceable instead of random", + "description": "The solver works in two phases and without randomness. As long as the time budget does not cut in, the same input yields the same output, which makes results reproducible and regressions testable.", + "stages": { + "construction": { + "label": "Construction", + "description": "Cheapest insertion builds the first solution. If the tank does not hold enough for the next stop, the solver inserts the cheapest refill station before it." + }, + "localSearch": { + "label": "Local search", + "description": "A VND-style search improves the solution. The cheap moves within a single route run first, the more expensive ones between routes afterwards. Once neither finds anything, it places all refill visits anew and drops the ones that no longer pay off." + } + }, + "moveEvaluation": { + "label": "Load and duration segments", + "description": "The solver checks capacity and time windows through load and duration segments following Vidal et al. (2014). They can be merged in constant time, and every move is evaluated on the route it affects." + }, + "benchmarks": { + "title": "Measured against the Solomon instances", + "description": "The regression tests solve well-known VRPTW instances, that is vehicle routing with time windows. They check whether the solution respects capacity and time windows and whether it lands close enough to the optimum. These instances contain no refill stations, so what is measured is the solver's baseline quality.", + "gap": "{{value}}%", + "footnote": "Gap to the best known solution, threshold 5%" + } + }, + "tiles": { + "sectionLabel": "Map data", + "title": "Fitting the road network to your fleet", + "fleetMismatch": "A good route is worth nothing if the road network does not match the fleet. A municipal vehicle may use service and access roads that are closed to general traffic. A standard map does not know that and makes destinations look unreachable when they are not.", + "patcher": "The routing engine works on tiles built from an OSM extract. The Streamlet repository ships a CLI that modifies that extract beforehand through OSC changesets, that is change files in the OSM format. This is how you model what your vehicles are actually allowed to drive on.", + "status": { + "available": "Available", + "inProgress": "In development" + }, + "changesets": { + "construction": "Marks roads closed for construction work as access=no. Routes then no longer lead through the closure.", + "allowedPaths": "Opens paths to motor vehicles when a destination cannot be reached from any drivable road. The patcher is meant to read those destinations from the Green Ecolution API." + }, + "disclaimer": "The patcher only changes your extract for your routing instance. It affects what the engine considers drivable for your vehicles. It does not grant permission to drive there. Anyone adopting it needs a tile pipeline of their own." + }, + "operations": { + "sectionLabel": "Operations", + "title": "Deploying and running it", + "description": "Streamlet is a single service alongside your application. Every state of main is published as a container image, and it is configured through environment variables. Beyond that you need a reachable routing engine.", + "facts": { + "stateless": { + "label": "Stateless", + "description": "No database, no job queue. On shutdown, open requests are allowed to finish instead of being cut off." + }, + "swappableEngine": { + "label": "Swappable engine", + "description": "The routing engine sits behind the Router port trait. Valhalla is the implementation that ships. Another engine can take its place without touching the solver." + }, + "separateCrate": { + "label": "Solver as a separate crate", + "description": "The solver lives in the streamlet-core crate, without tokio, axum or HTTP code. All it adds is serialisation and error types." + }, + "license": { + "label": "AGPL-3.0", + "description": "The source code is licensed under the GNU Affero General Public License, version 3. Anyone who modifies Streamlet and runs it as a network service has to offer the modified source to its users." + } + } + }, + "status": { + "sectionLabel": "Current state", + "title": "What works today and what does not", + "description": "Streamlet is early and runs in operational planning at Green Ecolution. These points are listed here so that nobody discovers them only while integrating.", + "limitations": { + "geometry": "Geometry comes back as an encoded polyline. The geojson option currently behaves like polyline.", + "depots": "The API accepts several depots, but every route returns to the first one.", + "missingFeatures": "There is no GPX endpoint and no embedded routing engine." + }, + "cta": { + "title": "Contribute or adopt it yourself", + "descriptionRich": "Questions, bug reports and experience from other use cases are welcome in the repository. How Streamlet is wired into Green Ecolution is described in release 0.4.0.", + "repo": "Go to the repository", + "repoAriaLabel": "Open the Streamlet repository on GitHub", + "issues": "Issues", + "issuesAriaLabel": "Open the Streamlet issues on GitHub" + } + } +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index c734998a..d8cd5529 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as LangDatenschutzRouteImport } from './routes/$lang/datenschutz' import { Route as LangImpressumRouteImport } from './routes/$lang/impressum' import { Route as LangProjectRouteImport } from './routes/$lang/project' import { Route as LangReleasesRouteImport } from './routes/$lang/releases' +import { Route as LangStreamletRouteImport } from './routes/$lang/streamlet' import { Route as LangReleasesSlugRouteImport } from './routes/$lang/releases_.$slug' const IndexRoute = IndexRouteImport.update({ @@ -65,6 +66,11 @@ const LangReleasesRoute = LangReleasesRouteImport.update({ path: '/releases', getParentRoute: () => LangRouteRoute, } as any) +const LangStreamletRoute = LangStreamletRouteImport.update({ + id: '/streamlet', + path: '/streamlet', + getParentRoute: () => LangRouteRoute, +} as any) const LangReleasesSlugRoute = LangReleasesSlugRouteImport.update({ id: '/releases_/$slug', path: '/releases/$slug', @@ -80,6 +86,7 @@ export interface FileRoutesByFullPath { '/$lang/impressum': typeof LangImpressumRoute '/$lang/project': typeof LangProjectRoute '/$lang/releases': typeof LangReleasesRoute + '/$lang/streamlet': typeof LangStreamletRoute '/$lang/': typeof LangIndexRoute '/$lang/releases/$slug': typeof LangReleasesSlugRoute } @@ -91,6 +98,7 @@ export interface FileRoutesByTo { '/$lang/impressum': typeof LangImpressumRoute '/$lang/project': typeof LangProjectRoute '/$lang/releases': typeof LangReleasesRoute + '/$lang/streamlet': typeof LangStreamletRoute '/$lang': typeof LangIndexRoute '/$lang/releases/$slug': typeof LangReleasesSlugRoute } @@ -104,6 +112,7 @@ export interface FileRoutesById { '/$lang/impressum': typeof LangImpressumRoute '/$lang/project': typeof LangProjectRoute '/$lang/releases': typeof LangReleasesRoute + '/$lang/streamlet': typeof LangStreamletRoute '/$lang/': typeof LangIndexRoute '/$lang/releases_/$slug': typeof LangReleasesSlugRoute } @@ -118,6 +127,7 @@ export interface FileRouteTypes { | '/$lang/impressum' | '/$lang/project' | '/$lang/releases' + | '/$lang/streamlet' | '/$lang/' | '/$lang/releases/$slug' fileRoutesByTo: FileRoutesByTo @@ -129,6 +139,7 @@ export interface FileRouteTypes { | '/$lang/impressum' | '/$lang/project' | '/$lang/releases' + | '/$lang/streamlet' | '/$lang' | '/$lang/releases/$slug' id: @@ -141,6 +152,7 @@ export interface FileRouteTypes { | '/$lang/impressum' | '/$lang/project' | '/$lang/releases' + | '/$lang/streamlet' | '/$lang/' | '/$lang/releases_/$slug' fileRoutesById: FileRoutesById @@ -216,6 +228,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LangReleasesRouteImport parentRoute: typeof LangRouteRoute } + '/$lang/streamlet': { + id: '/$lang/streamlet' + path: '/streamlet' + fullPath: '/$lang/streamlet' + preLoaderRoute: typeof LangStreamletRouteImport + parentRoute: typeof LangRouteRoute + } '/$lang/releases_/$slug': { id: '/$lang/releases_/$slug' path: '/releases/$slug' @@ -232,6 +251,7 @@ interface LangRouteRouteChildren { LangImpressumRoute: typeof LangImpressumRoute LangProjectRoute: typeof LangProjectRoute LangReleasesRoute: typeof LangReleasesRoute + LangStreamletRoute: typeof LangStreamletRoute LangIndexRoute: typeof LangIndexRoute LangReleasesSlugRoute: typeof LangReleasesSlugRoute } @@ -242,6 +262,7 @@ const LangRouteRouteChildren: LangRouteRouteChildren = { LangImpressumRoute: LangImpressumRoute, LangProjectRoute: LangProjectRoute, LangReleasesRoute: LangReleasesRoute, + LangStreamletRoute: LangStreamletRoute, LangIndexRoute: LangIndexRoute, LangReleasesSlugRoute: LangReleasesSlugRoute, } diff --git a/src/routes/$lang/streamlet.tsx b/src/routes/$lang/streamlet.tsx new file mode 100644 index 00000000..daece2f4 --- /dev/null +++ b/src/routes/$lang/streamlet.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import StreamletPage from '../../tsx/pages/StreamletPage' + +export const Route = createFileRoute('/$lang/streamlet')({ + component: StreamletPage, +}) diff --git a/src/tsx/components/navigation/MainNavigation.tsx b/src/tsx/components/navigation/MainNavigation.tsx index 2f3a8ab6..9f80d814 100644 --- a/src/tsx/components/navigation/MainNavigation.tsx +++ b/src/tsx/components/navigation/MainNavigation.tsx @@ -90,13 +90,9 @@ const MainNavigation: React.FC = ({ isOpen, onClose }) => {

{t('nav.heading')}