From 3f788afb8e82279f19e68c8a43f952ad42bccee5 Mon Sep 17 00:00:00 2001 From: Cedrik Hoffmann Date: Fri, 21 Aug 2026 09:28:09 +0200 Subject: [PATCH 01/30] feat(streamlet): add content data for the streamlet page --- src/data/streamlet.ts | 222 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 src/data/streamlet.ts diff --git a/src/data/streamlet.ts b/src/data/streamlet.ts new file mode 100644 index 00000000..90c74559 --- /dev/null +++ b/src/data/streamlet.ts @@ -0,0 +1,222 @@ +import { + Boxes, + Clock, + Container, + Fuel, + GitBranch, + Layers, + Repeat, + Scale, + ServerCog, + Warehouse, +} from 'lucide-react' +import type { ComponentType } from 'react' + +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', + release: '/releases/v0.4.0', +} as const + +export const streamletBadges: string[] = ['Rust', 'AGPL-3.0', 'v0.1.0', 'Zustandslose HTTP-API'] + +interface FitCriterion { + label: string + icon: ComponentType<{ className?: string }> +} + +export const fitCriteria: FitCriterion[] = [ + { label: 'Fahrzeuge mit begrenzter Ladekapazität', icon: Container }, + { label: 'Haltepunkte mit Zeitfenstern', icon: Clock }, + { label: 'Nachfüllen oder Umladen mitten in der Tour', icon: Fuel }, + { label: 'Mehrere mögliche Nachfüllstationen', icon: Repeat }, + { label: 'Rückkehr zum Depot am Schichtende', icon: Warehouse }, +] + +interface SolveFlowStep { + step: string + label: string + description: string +} + +export const solveFlowSteps: SolveFlowStep[] = [ + { + step: '01', + label: 'Problem senden', + description: + 'Fahrzeuge mit Tank und Schicht, Depots, Halte mit Bedarf und Zeitfenster, Nachfüllstationen gehen als JSON an POST /v1/solve.', + }, + { + step: '02', + label: 'Kosten holen und lösen', + description: + 'Streamlet fragt die Reisezeit- und Distanzmatrix bei der Routing-Engine ab und löst das Problem im eigenen Solver.', + }, + { + step: '03', + label: 'Routen erhalten', + description: + 'Die Antwort enthält die Routen mit Reihenfolge der Stopps, Distanz, Fahrt- und Wartezeit, Geometrie sowie die Halte, die unbedient bleiben.', + }, +] + +interface ApiEndpoint { + method: string + path: string + description: string +} + +export const apiEndpoints: ApiEndpoint[] = [ + { method: 'POST', path: '/v1/solve', description: 'Problem lösen und Routen zurückgeben' }, + { method: 'GET', path: '/health', description: 'Liveness-Prüfung' }, + { method: 'GET', path: '/api-docs/openapi.json', description: 'OpenAPI-Schema der API' }, +] + +export const solveRequestExample = `{ + "problem": { + "vehicles": [{ + "id": 1, + "start": {"lat": 54.78, "lon": 9.43}, + "tank": {"capacity": 100.0, "level": 100.0}, + "kind": {"Car": {"width": 2.0, "height": 2.0}}, + "shift": {"start": 0.0, "end": 28800.0}, + "max_trips": null + }], + "depots": [{"id": 1, "location": {"lat": 54.78, "lon": 9.43}}], + "customers": [ + {"id": 1, "location": {"lat": 54.79, "lon": 9.44}, + "demand": 40.0, "service_time": 300.0, "time_window": null}, + {"id": 2, "location": {"lat": 54.80, "lon": 9.45}, + "demand": 40.0, "service_time": 300.0, "time_window": null} + ], + "refill_stations": [{"id": 1, "location": {"lat": 54.785, "lon": 9.435}, + "refill_duration": 600.0}] + }, + "options": {"geometry": "polyline"} +}` + +interface SolverStage { + label: string + description: string + icon: ComponentType<{ className?: string }> +} + +export const solverStages: SolverStage[] = [ + { + label: 'Konstruktion', + description: + 'Cheapest Insertion baut die erste Lösung. Reicht der Tank für den nächsten Halt nicht, wird die günstigste Nachfüllstation davor eingefügt.', + icon: Boxes, + }, + { + label: 'Lokale Suche', + description: + 'Eine VND-artige Suche verbessert die Lösung mit Relocate, Swap, 2-opt, Cross-Exchange, 2-opt* und Relocate-with-Refill.', + icon: GitBranch, + }, + { + label: 'Zulässigkeit in O(1)', + description: + 'Load- und Duration-Segmente nach Vidal et al. (2014) bewerten jeden Zug in konstanter Zeit, statt die Route neu durchzurechnen.', + icon: Layers, + }, +] + +interface Benchmark { + instance: string + gap: string +} + +export const solverBenchmarks: Benchmark[] = [ + { instance: 'c101', gap: '3,1 %' }, + { instance: 'r101', gap: '1,9 %' }, + { instance: 'rc101', gap: '1,5 %' }, +] + +interface TileChangeset { + name: string + status: 'available' | 'in-progress' + statusLabel: string + description: string +} + +export const tileChangesets: TileChangeset[] = [ + { + name: 'construction', + status: 'available', + statusLabel: 'Verfügbar', + description: + 'Markiert Straßen, die wegen Baustellen gesperrt sind, als access=no. Routen führen dann nicht mehr durch die Sperrung. Die Baustellendaten kommen beim ersten Konsumenten aus dem Flensburger Verkehrsticker.', + }, + { + name: 'allowed-paths', + status: 'in-progress', + statusLabel: 'In Entwicklung', + description: + 'Öffnet Wege rund um Zielpunkte, die von keiner befahrbaren Straße aus erreichbar sind, für Kraftfahrzeuge. Die Zielpunkte kommen als GeoJSON-FeatureCollection herein, damit der Patcher ohne Bezug zu einem fremden System läuft.', + }, +] + +interface OperationsFact { + label: string + description: string + icon: ComponentType<{ className?: string }> +} + +export const operationsFacts: OperationsFact[] = [ + { + label: 'Zustandslos', + description: + 'Keine Datenbank, keine Job-Queue. Anfrage rein, Lösung raus. Beim Herunterfahren laufen offene Anfragen aus, statt abzubrechen.', + icon: ServerCog, + }, + { + label: 'Engine austauschbar', + description: + 'Die Routing-Engine hängt hinter dem Port-Trait Router. Valhalla ist die Implementierung, die es gibt; eine weitere käme ohne Eingriff in den Solver dazu.', + icon: Repeat, + }, + { + label: 'Kern ohne Ballast', + description: + 'Der Solver liegt in der eigenen Crate streamlet-core, frei von tokio, axum und HTTP-Zubehör. Nur Serialisierung und Fehlertypen kommen dazu.', + icon: Layers, + }, + { + label: 'AGPL-3.0', + description: 'Der Quellcode steht unter der GNU Affero General Public License, Version 3.', + icon: Scale, + }, +] + +interface EnvVar { + name: string + fallback: string + description: string +} + +export const envVars: EnvVar[] = [ + { name: 'STREAMLET_ADDR', fallback: '0.0.0.0:3000', description: 'Adresse und Port des Servers' }, + { + name: 'STREAMLET_VALHALLA_URL', + fallback: 'http://localhost:8002', + description: 'Basis-URL der Routing-Engine', + }, + { + name: 'STREAMLET_ENGINE_TIMEOUT_MS', + fallback: '10000', + description: 'Timeout für Anfragen an die Engine', + }, + { + name: 'STREAMLET_SOLVER_TIME_LIMIT_MS', + fallback: '2000', + description: 'Zeitbudget des Solvers je Anfrage', + }, +] + +export const streamletLimitations: string[] = [ + 'Die Geometrie kommt als encoded Polyline zurück. Die Option geojson verhält sich derzeit wie polyline.', + 'Mehrere Depots werden im Problem angenommen, jede Route kehrt aber zum ersten Depot zurück.', + 'Kein GPX-Endpoint, keine Persistenz, keine eingebettete Routing-Engine.', +] From 23dc9d1341eb8908cf66fe9ab68954bf77b054f5 Mon Sep 17 00:00:00 2001 From: Cedrik Hoffmann Date: Fri, 21 Aug 2026 09:36:58 +0200 Subject: [PATCH 02/30] refactor(streamlet): derive changeset status label in the consumer --- src/data/streamlet.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/data/streamlet.ts b/src/data/streamlet.ts index 90c74559..334c2f02 100644 --- a/src/data/streamlet.ts +++ b/src/data/streamlet.ts @@ -134,10 +134,9 @@ export const solverBenchmarks: Benchmark[] = [ { instance: 'rc101', gap: '1,5 %' }, ] -interface TileChangeset { +export interface TileChangeset { name: string status: 'available' | 'in-progress' - statusLabel: string description: string } @@ -145,14 +144,12 @@ export const tileChangesets: TileChangeset[] = [ { name: 'construction', status: 'available', - statusLabel: 'Verfügbar', description: 'Markiert Straßen, die wegen Baustellen gesperrt sind, als access=no. Routen führen dann nicht mehr durch die Sperrung. Die Baustellendaten kommen beim ersten Konsumenten aus dem Flensburger Verkehrsticker.', }, { name: 'allowed-paths', status: 'in-progress', - statusLabel: 'In Entwicklung', description: 'Öffnet Wege rund um Zielpunkte, die von keiner befahrbaren Straße aus erreichbar sind, für Kraftfahrzeuge. Die Zielpunkte kommen als GeoJSON-FeatureCollection herein, damit der Patcher ohne Bezug zu einem fremden System läuft.', }, From 232e1377c20fce311e8b8972b470a058b8fad1fc Mon Sep 17 00:00:00 2001 From: Cedrik Hoffmann Date: Fri, 21 Aug 2026 09:41:06 +0200 Subject: [PATCH 03/30] feat(streamlet): add /streamlet route with hero section --- src/routeTree.gen.ts | 21 ++++++ src/routes/$lang/streamlet.tsx | 6 ++ .../components/streamlet/StreamletHero.tsx | 71 +++++++++++++++++++ src/tsx/pages/StreamletPage.tsx | 29 ++++++++ 4 files changed, 127 insertions(+) create mode 100644 src/routes/$lang/streamlet.tsx create mode 100644 src/tsx/components/streamlet/StreamletHero.tsx create mode 100644 src/tsx/pages/StreamletPage.tsx 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/streamlet/StreamletHero.tsx b/src/tsx/components/streamlet/StreamletHero.tsx new file mode 100644 index 00000000..8769c76d --- /dev/null +++ b/src/tsx/components/streamlet/StreamletHero.tsx @@ -0,0 +1,71 @@ +import { BookOpen } from 'lucide-react' +import Button from '../Button' +import Github from '../../icons/Github' +import Arrow from '../../icons/Arrow' +import { streamletBadges, streamletLinks } from '../../../data/streamlet' + +function StreamletHero() { + return ( +
+
+ + Open-Source-Projekt + +
+
+ +

+ Streamlet +

+ +

+ Tourenplanung für Fahrzeuge, deren Ladung nicht für die ganze Tour reicht. Streamlet legt + die Reihenfolge der Halte fest und plant die Nachfüllstopps unterwegs gleich mit, als + eigenständiger Dienst hinter einer HTTP-API. +

+ +

+ Entstanden ist Streamlet für Green Ecolution, weil kein verfügbarer Solver dieses Problem + abbilden konnte. Das Projekt liegt in seinem eigenen Repository, kennt keinen seiner + Konsumenten und lässt sich für jedes Vorhaben mit demselben Problem einsetzen. +

+ +
    + {streamletBadges.map((badge) => ( +
  • + {badge} +
  • + ))} +
+ +
+ + + + + Dokumentation + +
+
+ ) +} + +export default StreamletHero diff --git a/src/tsx/pages/StreamletPage.tsx b/src/tsx/pages/StreamletPage.tsx new file mode 100644 index 00000000..e1243da9 --- /dev/null +++ b/src/tsx/pages/StreamletPage.tsx @@ -0,0 +1,29 @@ +import { useEffect } from 'react' +import BreadcrumbSchema from '../components/BreadcrumbSchema' +import StreamletHero from '../components/streamlet/StreamletHero' +import { applyDocumentMeta } from '../helper/documentMeta' + +function StreamletPage() { + useEffect(() => { + applyDocumentMeta({ + title: 'Streamlet | Tourenplanung mit Nachfüllstopps | 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.', + url: `${window.location.origin}/streamlet`, + }) + }, []) + + return ( +
+ + +
+ ) +} + +export default StreamletPage From ad225ca7f75397f0af11ffc52db2970107af2948 Mon Sep 17 00:00:00 2001 From: Cedrik Hoffmann Date: Fri, 21 Aug 2026 09:51:20 +0200 Subject: [PATCH 04/30] fix(streamlet): hide decorative hero icon from assistive tech --- src/tsx/components/streamlet/StreamletHero.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tsx/components/streamlet/StreamletHero.tsx b/src/tsx/components/streamlet/StreamletHero.tsx index 8769c76d..1263ebcd 100644 --- a/src/tsx/components/streamlet/StreamletHero.tsx +++ b/src/tsx/components/streamlet/StreamletHero.tsx @@ -58,9 +58,9 @@ function StreamletHero() { target="_blank" rel="noopener noreferrer" aria-label="Dokumentation von Streamlet öffnen" - className="flex items-center justify-center gap-x-3 rounded-2xl w-max font-semibold px-5 py-2 group cursor-pointer transition-all ease-in-out duration-300 text-green-dark-900 border border-green-dark-900/30 hover:border-green-dark-900 hover:bg-green-light-100/60" + className="flex items-center justify-center gap-x-3 rounded-2xl w-max font-semibold px-5 py-2 cursor-pointer transition-all ease-in-out duration-300 text-green-dark-900 border border-green-dark-900/30 hover:border-green-dark-900 hover:bg-green-light-100/60" > - +