Alpha stage commit

This commit is contained in:
2026-07-02 21:04:05 +02:00
parent c03b183dfb
commit abed21be21
136 changed files with 15531 additions and 15 deletions

76
services/api/README.md Normal file
View File

@@ -0,0 +1,76 @@
# API Service
Purpose: trip planning, route/stage/POI/rule/offline/export endpoints.
MVP implementation should first work with mock providers and fixtures.
Provider interfaces:
- RoutingProvider.
- PoiProvider.
- ElevationProvider.
- WeatherProvider.
- RulesProvider.
- DeviceExportProvider.
Recommended backend:
- FastAPI or equivalent.
- PostgreSQL + PostGIS.
- Redis/worker for long-running imports/offline pack jobs.
See `schemas/openapi.yaml` and `schemas/database.sql`.
## Implemented MVP
This package currently uses an Express TypeScript API with mock providers, BRouter bikepacking routing, optional OSRM fallback, and optional Overpass POIs. It implements:
- `POST /v1/trips`
- `POST /v1/routes/plan`
- `POST /v1/stages/plan`
- `GET /v1/routes/:routeId/pois`
- `GET /v1/routes/:routeId/gaps`
- `GET /v1/rules`
- `POST /v1/offline-packs`
- `GET /v1/routes/:routeId/export?format=gpx`
- `POST /v1/reports`
Run locally:
```bash
npm -w @pikebacker/api run dev
```
The default provider mode is `mock`; it uses deterministic fixtures from `packages/shared` aligned with `examples/sample_trip_request.json`. A request can pass `provider: "brouter"` to use BRouter candidate selection or `provider: "osrm"` to use the configured OSRM endpoint. External routing/POI/rules providers return clear setup errors until their adapters and credentials are added.
For `provider: "brouter"`, the API maps Pikebacker profiles to candidate BRouter profiles and selects by Pikebacker suitability score:
- `loaded_gravel`: `gravel`, `safety`, `trekking`
- `beginner_safe`: `safety`, `trekking`, `gravel`
- `hardtail_bikepacking`: `gravel`, `mtb`, `trekking`
- `road_touring`: `trekking`, `fastbike-lowtraffic`, `safety`
- `ebikepacking`: `trekking`, `safety`, `gravel`
## Environment
```bash
PORT=8000
ROUTING_PROVIDER=mock
POI_PROVIDER=mock
RULES_PROVIDER=mock
OSRM_BASE_URL=https://router.project-osrm.org
OSRM_PROFILE=bike
BROUTER_BASE_URL=https://brouter.de/brouter
BROUTER_PROFILE=
OVERPASS_BASE_URL=https://overpass-api.de/api/interpreter
```
No API keys are required for mock mode, BRouter public routing, OSRM demo routing, or Overpass public POI lookup. Keep external provider keys in environment variables only. Public routing/POI endpoints are not production dependencies.
To try live OSM POIs:
```bash
POI_PROVIDER=overpass npm -w @pikebacker/api run dev
```
Public Overpass instances can time out or reject broad queries. The route planner degrades by returning the route with a warning if POI lookup fails.

27
services/api/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@pikebacker/api",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/server.js",
"types": "./dist/server.d.ts",
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "node dist/server.js",
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@pikebacker/shared": "0.1.0",
"cors": "^2.8.5",
"express": "^4.21.2",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/supertest": "^6.0.2",
"supertest": "^7.0.0"
}
}

View File

@@ -0,0 +1,54 @@
import request from 'supertest';
import { describe, expect, it } from 'vitest';
import { mockTripRequest } from '@pikebacker/shared';
import { createApp } from './app.js';
describe('MVP API', () => {
it('plans a deterministic mock route and stage plan', async () => {
const { app } = createApp();
const routeResponse = await request(app).post('/v1/routes/plan').send({ tripRequest: mockTripRequest }).expect(200);
expect(routeResponse.body.id).toBe('route_demo_001');
expect(routeResponse.body.warnings.map((warning: { code: string }) => warning.code)).toContain('WATER_GAP');
const stageResponse = await request(app)
.post('/v1/stages/plan')
.send({ routeId: routeResponse.body.id, tripRequest: mockTripRequest })
.expect(200);
expect(stageResponse.body.stages).toHaveLength(5);
expect(stageResponse.body.stages[0].sleepCandidates[0].name).toBe('Lakeside campsite');
});
it('returns actionable validation errors', async () => {
const { app } = createApp();
const response = await request(app)
.post('/v1/routes/plan')
.send({ tripRequest: { ...mockTripRequest, dailyDistanceKm: { min: 90, target: 70, max: 60 } } })
.expect(400);
expect(response.body.error).toBe('VALIDATION_ERROR');
expect(response.body.message).toContain('Request body is invalid');
});
it('exports GPX after planning', async () => {
const { app } = createApp();
await request(app).post('/v1/routes/plan').send({ tripRequest: mockTripRequest }).expect(200);
const response = await request(app).get('/v1/routes/route_demo_001/export?format=gpx').expect(200);
expect(response.headers['content-type']).toContain('application/gpx+xml');
expect(response.text).toContain('<gpx version="1.1"');
expect(response.text).toContain('<name>Stage 1 end</name>');
});
it('does not pretend external routing providers are configured', async () => {
const { app } = createApp();
const response = await request(app)
.post('/v1/routes/plan')
.send({ tripRequest: mockTripRequest, provider: 'openrouteservice' })
.expect(502);
expect(response.body.error).toBe('PROVIDER_UNAVAILABLE');
expect(response.body.message).toContain('ROUTING_PROVIDER=mock');
});
});

326
services/api/src/app.ts Normal file
View File

@@ -0,0 +1,326 @@
import cors from 'cors';
import express, { type NextFunction, type Request, type Response } from 'express';
import {
createOfflinePackManifest,
detectCriticalGaps,
exportRouteToGpx,
mockTripRequest,
planStages,
thresholdsFromTripRequest,
warningsFromGaps,
type LocalReport,
type OfflinePackManifest,
type Poi,
type RoutePlan,
type RouteWarning,
type StagePlan,
type Trip,
type TripRequest
} from '@pikebacker/shared';
import { loadConfig, type ApiConfig } from './config.js';
import { ApiError } from './errors.js';
import { createDeviceExportProvider, createPoiProvider, createRoutingProvider, createRulesProvider } from './providers/index.js';
import type { DeviceExportProvider, PoiProvider, RoutingProvider, RulesProvider } from './providers/types.js';
import {
localReportCreateSchema,
offlinePackRequestSchema,
routePlanRequestSchema,
stagePlanRequestSchema,
tripRequestSchema,
validateBody
} from './validation.js';
type AppDependencies = {
config?: ApiConfig;
routingProvider?: RoutingProvider;
poiProvider?: PoiProvider;
rulesProvider?: RulesProvider;
};
type AppState = {
trips: Map<string, Trip>;
tripRequestsByRouteId: Map<string, TripRequest>;
routes: Map<string, RoutePlan>;
stagesByRouteId: Map<string, StagePlan[]>;
poisByRouteId: Map<string, Poi[]>;
reports: LocalReport[];
offlinePacks: OfflinePackManifest[];
};
export function createApp(deps: AppDependencies = {}) {
const config = deps.config ?? loadConfig();
const routingProvider = deps.routingProvider ?? createRoutingProvider(config);
const poiProvider = deps.poiProvider ?? createPoiProvider(config);
const rulesProvider = deps.rulesProvider ?? createRulesProvider(config);
const state: AppState = {
trips: new Map(),
tripRequestsByRouteId: new Map(),
routes: new Map(),
stagesByRouteId: new Map(),
poisByRouteId: new Map(),
reports: [],
offlinePacks: []
};
const app = express();
app.use(cors());
app.use(express.json({ limit: '1mb' }));
app.get('/health', (_request, response) => {
response.json({
status: 'ok',
providers: {
routing: routingProvider.name,
poi: poiProvider.name,
rules: rulesProvider.name
}
});
});
app.post(
'/v1/trips',
validateBody(tripRequestSchema),
asyncHandler(async (request, response) => {
const tripRequest = request.body as TripRequest;
const trip: Trip = {
id: tripIdForRequest(tripRequest),
title: `${tripRequest.profile.replaceAll('_', ' ')} bikepacking trip`,
request: tripRequest
};
state.trips.set(trip.id, trip);
response.status(201).json(trip);
})
);
app.post(
'/v1/routes/plan',
validateBody(routePlanRequestSchema),
asyncHandler(async (request, response) => {
const body = request.body as { tripRequest: TripRequest; provider?: string };
const provider =
body.provider && body.provider !== routingProvider.name ? createRoutingProvider({ ...config, routingProvider: body.provider }) : routingProvider;
const route = await provider.planRoute(body);
const poiResult = await trySearchPois(poiProvider, route, defaultPoiBufferKm(poiProvider, route));
const pois = poiResult.pois;
const gaps = detectCriticalGaps(route, pois, thresholdsFromTripRequest(body.tripRequest));
const routeWithGapWarnings = {
...route,
warnings: [...route.warnings, ...poiResult.warnings, ...warningsFromGaps(gaps)]
};
state.routes.set(route.id, routeWithGapWarnings);
state.tripRequestsByRouteId.set(route.id, body.tripRequest);
state.poisByRouteId.set(route.id, pois);
response.json(routeWithGapWarnings);
})
);
app.post(
'/v1/stages/plan',
validateBody(stagePlanRequestSchema),
asyncHandler(async (request, response) => {
const body = request.body as { routeId: string; tripRequest: TripRequest };
const route = routeOrThrow(state, body.routeId);
const pois = await poisForRoute(state, poiProvider, route);
const stages = planStages(route, pois, body.tripRequest);
state.stagesByRouteId.set(route.id, stages);
state.tripRequestsByRouteId.set(route.id, body.tripRequest);
response.json({ stages });
})
);
app.get(
'/v1/routes/:routeId/pois',
asyncHandler(async (request, response) => {
const route = routeOrThrow(state, String(request.params.routeId ?? ''));
const category = parseCategory(request.query.category);
const bufferKm = request.query.buffer_km ? Number(request.query.buffer_km) : 5;
const pois = await poiProvider.searchCorridor({ route, category, bufferKm });
state.poisByRouteId.set(route.id, pois);
response.json({
pois,
attribution: ['POIs are OSM-derived mock fixtures. Preserve OpenStreetMap attribution in production.']
});
})
);
app.get(
'/v1/routes/:routeId/gaps',
asyncHandler(async (request, response) => {
const route = routeOrThrow(state, String(request.params.routeId ?? ''));
const tripRequest = state.tripRequestsByRouteId.get(route.id) ?? mockTripRequest;
const pois = await poisForRoute(state, poiProvider, route);
response.json({ gaps: detectCriticalGaps(route, pois, thresholdsFromTripRequest(tripRequest)) });
})
);
app.get(
'/v1/rules',
asyncHandler(async (request, response) => {
const routeId = typeof request.query.route_id === 'string' ? request.query.route_id : undefined;
const route = routeId ? state.routes.get(routeId) : undefined;
const rules = await rulesProvider.getRuleCards({ route });
response.json({
rules,
disclaimer: 'Rule cards are confidence-scored advisory information, not guaranteed legal advice.'
});
})
);
app.post(
'/v1/offline-packs',
validateBody(offlinePackRequestSchema),
asyncHandler(async (request, response) => {
const body = request.body as { tripId: string; routeId: string; routeBufferKm?: number };
const route = routeOrThrow(state, body.routeId);
const pois = await poisForRoute(state, poiProvider, route);
const tripRequest = state.tripRequestsByRouteId.get(route.id) ?? mockTripRequest;
const stages = state.stagesByRouteId.get(route.id) ?? planStages(route, pois, tripRequest);
const rules = await rulesProvider.getRuleCards({ route });
const gpx = exportRouteToGpx(route, stages, pois, { includePois: true });
const manifest = createOfflinePackManifest({
tripId: body.tripId,
route,
stages,
pois,
rules,
routeBufferKm: body.routeBufferKm,
gpxBytes: Buffer.byteLength(gpx, 'utf8')
});
state.offlinePacks.push(manifest);
response.json(manifest);
})
);
app.get(
'/v1/routes/:routeId/export',
asyncHandler(async (request, response) => {
const format = String(request.query.format ?? '');
if (!['gpx', 'tcx', 'fit'].includes(format)) {
throw new ApiError(400, 'UNSUPPORTED_EXPORT_FORMAT', 'Use format=gpx for the MVP export endpoint.');
}
const route = routeOrThrow(state, String(request.params.routeId ?? ''));
const pois = await poisForRoute(state, poiProvider, route);
const tripRequest = state.tripRequestsByRouteId.get(route.id) ?? mockTripRequest;
const stages = state.stagesByRouteId.get(route.id) ?? planStages(route, pois, tripRequest);
const exportProvider: DeviceExportProvider = createDeviceExportProvider(format);
const file = await exportProvider.exportRoute({ route, stages, pois, format: format as 'gpx' | 'tcx' | 'fit' });
response.setHeader('content-type', file.contentType);
response.setHeader('content-disposition', `attachment; filename="${file.filename}"`);
if (file.contentType === 'application/json') {
response.status(400);
}
response.send(file.body);
})
);
app.post(
'/v1/reports',
validateBody(localReportCreateSchema),
asyncHandler(async (request, response) => {
const created = {
...(request.body as Omit<LocalReport, 'id' | 'moderationStatus' | 'trustScore'>),
id: `report_${String(state.reports.length + 1).padStart(4, '0')}`,
moderationStatus: 'pending',
trustScore: 0.5,
queuedOffline: Boolean((request.body as { offlineClientId?: string }).offlineClientId)
} satisfies LocalReport;
state.reports.push(created);
response.status(201).json(created);
})
);
app.use((error: unknown, _request: Request, response: Response, _next: NextFunction) => {
if (error instanceof ApiError) {
response.status(error.status).json({ error: error.code, message: error.message, details: error.details });
return;
}
response.status(500).json({
error: 'INTERNAL_ERROR',
message: error instanceof Error ? error.message : 'Unexpected API error'
});
});
return { app, state };
}
function asyncHandler(handler: (request: Request, response: Response, next: NextFunction) => Promise<void>) {
return (request: Request, response: Response, next: NextFunction) => {
handler(request, response, next).catch(next);
};
}
function routeOrThrow(state: AppState, routeId?: string): RoutePlan {
if (!routeId) {
throw new ApiError(400, 'MISSING_ROUTE_ID', 'Route id is required.');
}
const route = state.routes.get(routeId);
if (!route) {
throw new ApiError(404, 'ROUTE_NOT_FOUND', `Route "${routeId}" is not loaded. Plan a route first with POST /v1/routes/plan.`);
}
return route;
}
async function poisForRoute(state: AppState, poiProvider: PoiProvider, route: RoutePlan): Promise<Poi[]> {
const existing = state.poisByRouteId.get(route.id);
if (existing) {
return existing;
}
const pois = await poiProvider.searchCorridor({ route, bufferKm: 5 });
state.poisByRouteId.set(route.id, pois);
return pois;
}
async function trySearchPois(
poiProvider: PoiProvider,
route: RoutePlan,
bufferKm: number
): Promise<{ pois: Poi[]; warnings: RouteWarning[] }> {
try {
return {
pois: await poiProvider.searchCorridor({ route, bufferKm }),
warnings: []
};
} catch (error) {
return {
pois: [],
warnings: [
{
code: 'POI_PROVIDER_UNAVAILABLE',
severity: 'warning',
title: 'POI lookup unavailable',
message:
error instanceof Error
? `Route planned, but logistics POIs could not be loaded: ${error.message}`
: 'Route planned, but logistics POIs could not be loaded.'
}
]
};
}
}
function parseCategory(value: unknown) {
if (typeof value !== 'string') {
return undefined;
}
const allowed = new Set(['water', 'food', 'sleep', 'repair', 'bailout', 'charging', 'emergency']);
if (!allowed.has(value)) {
throw new ApiError(400, 'INVALID_POI_CATEGORY', `Unknown POI category "${value}".`);
}
return value as Poi['category'];
}
function defaultPoiBufferKm(poiProvider: PoiProvider, route: RoutePlan): number {
if (route.provider === 'mock') {
return 5;
}
return poiProvider.name === 'mock' ? 20 : 8;
}
function tripIdForRequest(request: TripRequest): string {
const isDemo =
Math.abs(request.start.lat - 48.137) < 0.01 &&
Math.abs(request.start.lon - 11.575) < 0.01 &&
Math.abs(request.end.lat - 47.269) < 0.01 &&
Math.abs(request.end.lon - 11.404) < 0.01;
return isDemo ? 'trip_demo_001' : `trip_${Math.abs(Math.round((request.start.lat + request.end.lon) * 10000))}`;
}

View File

@@ -0,0 +1,33 @@
export type ApiConfig = {
port: number;
routingProvider: string;
poiProvider: string;
elevationProvider: string;
weatherProvider: string;
rulesProvider: string;
openRouteServiceApiKey?: string;
graphhopperApiKey?: string;
osrmBaseUrl: string;
osrmProfile: string;
brouterBaseUrl: string;
brouterProfile?: string;
overpassBaseUrl: string;
};
export function loadConfig(env: NodeJS.ProcessEnv = process.env): ApiConfig {
return {
port: Number(env.PORT ?? 8000),
routingProvider: env.ROUTING_PROVIDER ?? 'mock',
poiProvider: env.POI_PROVIDER ?? 'mock',
elevationProvider: env.ELEVATION_PROVIDER ?? 'mock',
weatherProvider: env.WEATHER_PROVIDER ?? 'mock',
rulesProvider: env.RULES_PROVIDER ?? 'mock',
openRouteServiceApiKey: env.OPENROUTESERVICE_API_KEY,
graphhopperApiKey: env.GRAPHHOPPER_API_KEY,
osrmBaseUrl: env.OSRM_BASE_URL ?? 'https://router.project-osrm.org',
osrmProfile: env.OSRM_PROFILE ?? 'bike',
brouterBaseUrl: env.BROUTER_BASE_URL ?? 'https://brouter.de/brouter',
brouterProfile: env.BROUTER_PROFILE || undefined,
overpassBaseUrl: env.OVERPASS_BASE_URL ?? 'https://overpass-api.de/api/interpreter'
};
}

View File

@@ -0,0 +1,23 @@
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly details?: unknown;
constructor(status: number, code: string, message: string, details?: unknown) {
super(message);
this.status = status;
this.code = code;
this.details = details;
}
}
export class ProviderUnavailableError extends ApiError {
constructor(providerName: string, providerType: string, setupHint: string) {
super(
502,
'PROVIDER_UNAVAILABLE',
`${providerType} provider "${providerName}" is not available in this MVP runtime. ${setupHint}`,
{ providerName, providerType, setupHint }
);
}
}

View File

@@ -0,0 +1,571 @@
import {
calculateSurfaceBreakdown,
haversineDistanceM,
scoreRoute,
type BikepackingProfileId,
type Confidence,
type Coordinate,
type RoutePlan,
type RoutePlanRequest,
type RouteSegment
} from '@pikebacker/shared';
import type { ApiConfig } from '../config.js';
import { ApiError } from '../errors.js';
import type { RoutingProvider } from './types.js';
type BrouterFeatureCollection = {
type: 'FeatureCollection';
features?: Array<{
type: 'Feature';
properties: {
creator?: string;
name?: string;
'track-length'?: string;
'filtered ascend'?: string;
'plain-ascend'?: string;
'total-time'?: string;
messages?: string[][];
};
geometry: {
type: 'LineString';
coordinates: Array<[number, number, number?]>;
};
}>;
};
type MessagePoint = {
coordinate: Coordinate;
elevationM?: number;
distanceM: number;
tags: Record<string, string>;
};
type SegmentAccumulator = {
points: MessagePoint[];
distanceM: number;
};
const TARGET_SEGMENT_M = 3000;
const HARD_SPLIT_M = 5500;
export class BrouterRoutingProvider implements RoutingProvider {
readonly name = 'brouter';
constructor(private readonly config: ApiConfig) {}
async planRoute(request: RoutePlanRequest): Promise<RoutePlan> {
return this.fetchRoute(request);
}
async reroute(request: RoutePlanRequest): Promise<RoutePlan> {
return this.planRoute(request);
}
private async fetchRoute(request: RoutePlanRequest): Promise<RoutePlan> {
const brouterProfiles = this.config.brouterProfile
? [this.config.brouterProfile]
: candidateProfilesForBikepacking(request.tripRequest.profile);
const results = await Promise.allSettled(brouterProfiles.map((profile) => this.fetchFeature(request, profile)));
const routes = results.flatMap((result, index) => {
if (result.status === 'fulfilled') {
return [toRoutePlan(result.value, request, brouterProfiles[index] ?? 'unknown')];
}
return [];
});
if (routes.length === 0) {
const firstError = results.find((result) => result.status === 'rejected');
if (firstError?.status === 'rejected' && firstError.reason instanceof ApiError) {
throw firstError.reason;
}
throw new ApiError(
502,
'BROUTER_ROUTE_FAILED',
'BRouter did not return a usable bikepacking route for any candidate profile.'
);
}
const shortestDistanceM = Math.min(...routes.map((route) => route.distanceM));
const selected = routes
.map((route) => ({
route,
selectionScore: route.suitabilityScore - Math.max(0, route.distanceM / Math.max(1, shortestDistanceM) - 1.35) * 30
}))
.sort((left, right) => right.selectionScore - left.selectionScore)[0]?.route;
if (!selected) {
throw new ApiError(502, 'BROUTER_ROUTE_FAILED', 'BRouter candidate selection failed.');
}
return {
...selected,
warnings: [
{
code: 'BROUTER_PROFILE_SELECTED',
severity: 'info',
title: 'Bikepacking route candidate selected',
message: `Evaluated ${routes
.map((route) => `${profileFromRouteName(route)} ${(route.distanceM / 1000).toFixed(0)} km/${route.suitabilityScore.toFixed(0)}`)
.join(', ')} and selected ${profileFromRouteName(selected)}.`
},
...selected.warnings
]
};
}
private async fetchFeature(
request: RoutePlanRequest,
brouterProfile: string
): Promise<NonNullable<BrouterFeatureCollection['features']>[number]> {
const url = new URL(this.config.brouterBaseUrl);
const routingCoordinates = coordinatesForRequest(request);
url.searchParams.set(
'lonlats',
routingCoordinates.map((coordinate) => `${coordinate.lon},${coordinate.lat}`).join('|')
);
url.searchParams.set('profile', brouterProfile);
url.searchParams.set('alternativeidx', '0');
url.searchParams.set('format', 'geojson');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20000);
let response: Response;
try {
response = await fetch(url, {
signal: controller.signal,
headers: {
'user-agent': 'Pikebacker MVP development bikepacking router'
}
});
} catch (error) {
throw new ApiError(
502,
'BROUTER_REQUEST_FAILED',
`BRouter request failed. Use ROUTING_PROVIDER=mock or configure BROUTER_BASE_URL for a reliable bike routing engine. ${
error instanceof Error ? error.message : ''
}`
);
} finally {
clearTimeout(timeout);
}
const text = await response.text();
if (!response.ok) {
throw new ApiError(502, 'BROUTER_ROUTE_FAILED', text || 'BRouter did not return a route.', {
status: response.status,
profile: brouterProfile
});
}
const payload = JSON.parse(text) as BrouterFeatureCollection;
const feature = payload.features?.[0];
if (!feature?.geometry.coordinates.length) {
throw new ApiError(502, 'BROUTER_EMPTY_ROUTE', 'BRouter returned no route geometry. Try mock mode or another profile.', {
profile: brouterProfile
});
}
return feature;
}
}
function toRoutePlan(
feature: NonNullable<BrouterFeatureCollection['features']>[number],
request: RoutePlanRequest,
brouterProfile: string
): RoutePlan {
const geometryWithElevation = feature.geometry.coordinates.map(([lon, lat, elevationM]) => ({ lon, lat, elevationM }));
const geometry = geometryWithElevation.map(({ lat, lon, elevationM }) => coordinateWithElevation(lat, lon, elevationM));
const distanceM = Number(feature.properties['track-length'] ?? 0) || Math.round(polylineDistanceM(geometry));
const elevationStats = elevationStatsForGeometry(geometryWithElevation);
const routeMessages = parseMessages(feature.properties.messages);
const segments = buildSegments(routeMessages, geometry, distanceM);
const ascentM = elevationStats.ascentM || Number(feature.properties['filtered ascend'] ?? 0) || sumSegments(segments, 'ascentM');
const descentM = elevationStats.descentM || sumSegments(segments, 'descentM');
const baseRoute: RoutePlan = {
id: 'route_brouter_001',
tripId: stableTripId(request.tripRequest.start, request.tripRequest.end),
geometry,
distanceM: Math.round(distanceM),
ascentM,
descentM,
segments,
surfaceBreakdown: calculateSurfaceBreakdown(segments),
suitabilityScore: 0,
warnings: [
{
code: 'BROUTER_BIKEPACKING_PROFILE',
severity: 'info',
title: 'Bike-oriented routing profile',
message: `Route planned with BRouter "${brouterProfile}", mapped from the Pikebacker ${request.tripRequest.profile} profile.`
},
{
code: 'OPEN_ROUTER_DEV_SERVICE',
severity: 'notice',
title: 'Open development router',
message: 'This uses the public BRouter service for MVP development. Production should use a configured or self-hosted routing engine.'
}
],
provider: 'brouter',
attribution: [
`Route geometry from BRouter profile "${brouterProfile}" for development use.`,
'BRouter routing data is OpenStreetMap-derived and requires OpenStreetMap attribution.'
]
};
const score = scoreRoute(baseRoute, request.tripRequest.profile);
return {
...baseRoute,
surfaceBreakdown: score.surfaceBreakdown,
suitabilityScore: score.suitabilityScore,
warnings: [...baseRoute.warnings, ...score.warnings, ...roadStressWarnings(segments)]
};
}
function candidateProfilesForBikepacking(profile: BikepackingProfileId): string[] {
if (profile === 'beginner_safe') {
return ['safety', 'trekking', 'gravel'];
}
if (profile === 'hardtail_bikepacking') {
return ['gravel', 'mtb', 'trekking'];
}
if (profile === 'road_touring') {
return ['trekking', 'fastbike-lowtraffic', 'safety'];
}
if (profile === 'ebikepacking') {
return ['trekking', 'safety', 'gravel'];
}
return ['gravel', 'safety', 'trekking'];
}
function profileFromRouteName(route: RoutePlan): string {
const match = route.warnings
.find((warning) => warning.code === 'BROUTER_BIKEPACKING_PROFILE')
?.message.match(/"([^"]+)"/);
return match?.[1] ?? 'unknown';
}
function parseMessages(messages: string[][] | undefined): MessagePoint[] {
if (!messages || messages.length < 2) {
return [];
}
const header = messages[0] ?? [];
const longitudeIndex = header.indexOf('Longitude');
const latitudeIndex = header.indexOf('Latitude');
const elevationIndex = header.indexOf('Elevation');
const distanceIndex = header.indexOf('Distance');
const wayTagsIndex = header.indexOf('WayTags');
return messages.slice(1).flatMap((row) => {
const longitude = Number(row[longitudeIndex]);
const latitude = Number(row[latitudeIndex]);
const distanceM = Number(row[distanceIndex] ?? 0);
if (!Number.isFinite(longitude) || !Number.isFinite(latitude) || !Number.isFinite(distanceM)) {
return [];
}
const elevationRaw = Number(row[elevationIndex]);
return [
{
coordinate: coordinateWithElevation(latitude / 1_000_000, longitude / 1_000_000, elevationRaw),
elevationM: Number.isFinite(elevationRaw) && elevationRaw > -1000 ? elevationRaw : undefined,
distanceM,
tags: parseWayTags(row[wayTagsIndex] ?? '')
}
];
});
}
function parseWayTags(wayTags: string): Record<string, string> {
const tags: Record<string, string> = {};
for (const token of wayTags.split(/\s+/)) {
const separatorIndex = token.indexOf('=');
if (separatorIndex > 0) {
tags[token.slice(0, separatorIndex)] = token.slice(separatorIndex + 1);
}
}
return tags;
}
function buildSegments(messages: MessagePoint[], fallbackGeometry: Coordinate[], distanceM: number): RouteSegment[] {
if (messages.length >= 2) {
return buildMessageSegments(messages);
}
return buildFallbackSegments(fallbackGeometry, distanceM);
}
function buildMessageSegments(messages: MessagePoint[]): RouteSegment[] {
const segments: RouteSegment[] = [];
let accumulator: SegmentAccumulator = { points: [], distanceM: 0 };
let cursor = 0;
for (const point of messages) {
accumulator.points.push(point);
accumulator.distanceM += Math.max(0, point.distanceM);
if (shouldFlush(accumulator)) {
const segment = segmentFromAccumulator(accumulator, segments.length + 1, cursor);
segments.push(segment);
cursor = segment.endMeters ?? cursor + segment.distanceM;
accumulator = { points: [point], distanceM: 0 };
}
}
if (accumulator.points.length > 1 && accumulator.distanceM > 0) {
segments.push(segmentFromAccumulator(accumulator, segments.length + 1, cursor));
}
return segments;
}
function shouldFlush(accumulator: SegmentAccumulator): boolean {
if (accumulator.distanceM >= HARD_SPLIT_M) {
return true;
}
if (accumulator.distanceM < TARGET_SEGMENT_M || accumulator.points.length < 3) {
return false;
}
const first = dominantTags(accumulator.points.slice(0, Math.max(1, Math.floor(accumulator.points.length / 2))));
const latest = accumulator.points[accumulator.points.length - 1]?.tags ?? {};
return first.highway !== latest.highway || first.surface !== latest.surface;
}
function segmentFromAccumulator(accumulator: SegmentAccumulator, index: number, startMeters: number): RouteSegment {
const tags = dominantTags(accumulator.points);
const geometry = accumulator.points.map((point) => coordinateWithElevation(point.coordinate.lat, point.coordinate.lon, point.elevationM));
const distanceM = Math.max(1, Math.round(accumulator.distanceM));
const elevation = elevationStatsForMessagePoints(accumulator.points);
const endMeters = startMeters + distanceM;
const maxGradePercent = maxGradeForPoints(accumulator.points, distanceM);
return {
id: `seg_brouter_${String(index).padStart(3, '0')}`,
geometry,
distanceM,
startMeters,
endMeters,
ascentM: elevation.ascentM,
descentM: elevation.descentM,
avgGradePercent: distanceM > 0 ? Math.round((elevation.ascentM / distanceM) * 1000) / 10 : 0,
maxGradePercent,
surface: normalizeSurface(tags.surface, tags.highway),
smoothness: tags.smoothness ?? 'unknown',
highway: tags.highway ?? 'unknown',
bicycleAccess: inferBicycleAccess(tags),
legalAccessConfidence: inferAccessConfidence(tags),
trafficStress: trafficStressForHighway(tags.highway),
officialCycleRoute: Boolean(tags.route_bicycle || tags['lcn'] || tags['rcn'] || tags['ncn']),
protectedAreaOverlap: false,
sourceConfidence: 'medium'
};
}
function dominantTags(points: MessagePoint[]): Record<string, string> {
const counts = new Map<string, Map<string, number>>();
for (const point of points) {
for (const [key, value] of Object.entries(point.tags)) {
const values = counts.get(key) ?? new Map<string, number>();
values.set(value, (values.get(value) ?? 0) + Math.max(1, point.distanceM));
counts.set(key, values);
}
}
const dominant: Record<string, string> = {};
for (const [key, values] of counts) {
dominant[key] = [...values.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? '';
}
return dominant;
}
function normalizeSurface(surface: string | undefined, highway: string | undefined): string {
if (surface) {
if (surface === 'paving_stones' || surface === 'sett' || surface === 'cobblestone') {
return 'paved';
}
return surface;
}
if (highway === 'track') {
return 'gravel';
}
if (highway === 'cycleway' || highway === 'residential' || highway === 'service') {
return 'asphalt';
}
return 'unknown';
}
function inferBicycleAccess(tags: Record<string, string>): string {
if (tags.bicycle) {
return tags.bicycle;
}
if (tags.access === 'no' || tags.access === 'private') {
return 'unknown';
}
if (tags.highway === 'motorway' || tags.highway === 'trunk') {
return 'unknown';
}
if (tags.highway === 'footway' || tags.highway === 'pedestrian' || tags.highway === 'steps') {
return tags.bicycle === 'yes' ? 'yes' : 'unknown';
}
return 'yes';
}
function inferAccessConfidence(tags: Record<string, string>): Confidence {
if (tags.bicycle === 'no' || tags.access === 'no' || tags.access === 'private') {
return 'restricted';
}
if (tags.bicycle === 'yes' || tags.bicycle === 'designated' || tags.highway === 'cycleway') {
return 'high';
}
if (tags.highway === 'footway' || tags.highway === 'pedestrian' || tags.highway === 'steps' || tags.highway === 'path') {
return 'unknown';
}
if (tags.highway === 'primary' || tags.highway === 'trunk') {
return 'medium';
}
return 'high';
}
function trafficStressForHighway(highway: string | undefined): number {
if (highway === 'motorway' || highway === 'trunk') return 0.95;
if (highway === 'primary') return 0.82;
if (highway === 'secondary') return 0.68;
if (highway === 'tertiary') return 0.5;
if (highway === 'unclassified') return 0.35;
if (highway === 'residential' || highway === 'service') return 0.2;
if (highway === 'track' || highway === 'cycleway' || highway === 'path') return 0.1;
return 0.4;
}
function roadStressWarnings(segments: RouteSegment[]) {
return segments
.filter((segment) => (segment.highway === 'primary' || segment.highway === 'trunk') && segment.distanceM >= 500)
.slice(0, 8)
.map((segment) => ({
code: 'HIGH_TRAFFIC_ROAD_SEGMENT',
severity: segment.highway === 'trunk' ? 'critical' : 'warning',
title: 'High-traffic road segment',
message: `${segment.highway} road for ${(segment.distanceM / 1000).toFixed(1)} km. Treat this as a bikepacking suitability problem and verify alternatives.`,
metersFromStart: segment.startMeters,
segmentId: segment.id
}) as const);
}
function buildFallbackSegments(points: Coordinate[], routeDistanceM: number): RouteSegment[] {
const chunkSize = Math.max(2, Math.floor(points.length / 20));
const segments: RouteSegment[] = [];
let cursor = 0;
for (let startIndex = 0; startIndex < points.length - 1; startIndex += chunkSize) {
const endIndex = Math.min(points.length - 1, startIndex + chunkSize);
const geometry = points.slice(startIndex, endIndex + 1);
const distanceM = Math.max(1, Math.round(polylineDistanceM(geometry)));
segments.push({
id: `seg_brouter_${String(segments.length + 1).padStart(3, '0')}`,
geometry,
distanceM,
startMeters: cursor,
endMeters: cursor + distanceM,
ascentM: 0,
descentM: 0,
avgGradePercent: 0,
maxGradePercent: 0,
surface: 'unknown',
smoothness: 'unknown',
highway: 'unknown',
bicycleAccess: 'unknown',
legalAccessConfidence: 'unknown',
trafficStress: 0.4,
officialCycleRoute: false,
protectedAreaOverlap: false,
sourceConfidence: 'medium'
});
cursor += distanceM;
}
const scale = routeDistanceM / Math.max(1, cursor);
let scaledCursor = 0;
return segments.map((segment, index) => {
const distanceM = index === segments.length - 1 ? Math.max(1, Math.round(routeDistanceM - scaledCursor)) : Math.round(segment.distanceM * scale);
const startMeters = scaledCursor;
const endMeters = startMeters + distanceM;
scaledCursor = endMeters;
return { ...segment, distanceM, startMeters, endMeters };
});
}
function elevationStatsForGeometry(points: Array<Coordinate & { elevationM?: number }>) {
let ascentM = 0;
let descentM = 0;
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1]?.elevationM;
const current = points[index]?.elevationM;
if (typeof previous === 'number' && typeof current === 'number') {
const delta = current - previous;
if (delta > 0) ascentM += delta;
if (delta < 0) descentM += Math.abs(delta);
}
}
return { ascentM: Math.round(ascentM), descentM: Math.round(descentM) };
}
function coordinateWithElevation(lat: number, lon: number, elevationM?: number): Coordinate {
if (typeof elevationM === 'number' && Number.isFinite(elevationM) && elevationM > -1000) {
return { lat, lon, elevationM };
}
return { lat, lon };
}
function elevationStatsForMessagePoints(points: MessagePoint[]) {
return elevationStatsForGeometry(points.map((point) => ({ ...point.coordinate, elevationM: point.elevationM })));
}
function maxGradeForPoints(points: MessagePoint[], fallbackDistanceM: number): number {
let maxGrade = 0;
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const current = points[index];
if (!previous || !current || typeof previous.elevationM !== 'number' || typeof current.elevationM !== 'number') {
continue;
}
const delta = Math.abs(current.elevationM - previous.elevationM);
const distance = Math.max(1, current.distanceM || haversineDistanceM(previous.coordinate, current.coordinate));
maxGrade = Math.max(maxGrade, (delta / distance) * 100);
}
if (maxGrade === 0) {
return 0;
}
return Math.round(Math.min(35, Math.max(maxGrade, (sumPositiveElevation(points) / Math.max(1, fallbackDistanceM)) * 100)) * 10) / 10;
}
function sumPositiveElevation(points: MessagePoint[]): number {
return elevationStatsForMessagePoints(points).ascentM;
}
function polylineDistanceM(points: Coordinate[]): number {
let distance = 0;
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const current = points[index];
if (previous && current) {
distance += haversineDistanceM(previous, current);
}
}
return distance;
}
function sumSegments(segments: RouteSegment[], key: 'ascentM' | 'descentM'): number {
return Math.round(segments.reduce((sum, segment) => sum + (segment[key] ?? 0), 0));
}
function stableTripId(start: Coordinate, end: Coordinate): string {
const isDemo =
Math.abs(start.lat - 48.137) < 0.01 &&
Math.abs(start.lon - 11.575) < 0.01 &&
Math.abs(end.lat - 47.269) < 0.01 &&
Math.abs(end.lon - 11.404) < 0.01;
return isDemo ? 'trip_demo_001' : `trip_brouter_${Math.abs(Math.round((start.lat + start.lon + end.lat + end.lon) * 1000))}`;
}
function coordinatesForRequest(request: RoutePlanRequest): Coordinate[] {
return [request.tripRequest.start, ...(request.tripRequest.viaPoints ?? []), request.tripRequest.end];
}

View File

@@ -0,0 +1,42 @@
import type { ApiConfig } from '../config.js';
import { BrouterRoutingProvider } from './brouter.js';
import { MockDeviceExportProvider, MockPoiProvider, MockRoutingProvider, MockRulesProvider } from './mock.js';
import { OsrmRoutingProvider } from './osrm.js';
import { OverpassPoiProvider } from './overpass.js';
import {
UnavailableDeviceExportProvider,
UnavailablePoiProvider,
UnavailableRoutingProvider,
UnavailableRulesProvider
} from './unavailable.js';
export function createRoutingProvider(config: ApiConfig) {
if (config.routingProvider === 'mock') {
return new MockRoutingProvider();
}
if (config.routingProvider === 'osrm') {
return new OsrmRoutingProvider(config);
}
if (config.routingProvider === 'brouter') {
return new BrouterRoutingProvider(config);
}
return new UnavailableRoutingProvider(config.routingProvider);
}
export function createPoiProvider(config: ApiConfig) {
if (config.poiProvider === 'mock') {
return new MockPoiProvider();
}
if (config.poiProvider === 'overpass') {
return new OverpassPoiProvider(config);
}
return new UnavailablePoiProvider(config.poiProvider);
}
export function createRulesProvider(config: ApiConfig) {
return config.rulesProvider === 'mock' ? new MockRulesProvider() : new UnavailableRulesProvider(config.rulesProvider);
}
export function createDeviceExportProvider(format: string) {
return format === 'gpx' ? new MockDeviceExportProvider() : new UnavailableDeviceExportProvider(format);
}

View File

@@ -0,0 +1,81 @@
import {
createMockRoute,
exportRouteToGpx,
filterPoisByCorridor,
mockPois,
mockRules,
type Poi,
type RoutePlan,
type RoutePlanRequest,
type RuleCard
} from '@pikebacker/shared';
import type { DeviceExportProvider, ExportRequest, PoiProvider, RoutingProvider, RuleCardRequest, RulesProvider } from './types.js';
import { projectPoisToRoute } from './route-poi-projection.js';
export class MockRoutingProvider implements RoutingProvider {
readonly name = 'mock';
async planRoute(request: RoutePlanRequest): Promise<RoutePlan> {
const tripId = stableTripId(request.tripRequest.start.lat, request.tripRequest.start.lon, request.tripRequest.end.lat, request.tripRequest.end.lon);
return createMockRoute(tripId);
}
async reroute(request: RoutePlanRequest): Promise<RoutePlan> {
return this.planRoute(request);
}
}
export class MockPoiProvider implements PoiProvider {
readonly name = 'mock';
async searchCorridor(request: { route: RoutePlan; category?: Poi['category']; bufferKm?: number }): Promise<Poi[]> {
const routePois = request.route.provider === 'mock' ? mockPois : projectPoisToRoute(mockPois, request.route);
return filterPoisByCorridor(routePois, request.route, {
category: request.category,
bufferKm: request.bufferKm
});
}
}
export class MockRulesProvider implements RulesProvider {
readonly name = 'mock';
async getRuleCards(_request: RuleCardRequest): Promise<RuleCard[]> {
return mockRules;
}
}
export class MockDeviceExportProvider implements DeviceExportProvider {
readonly name = 'mock';
async exportRoute(request: ExportRequest) {
if (request.format !== 'gpx') {
return {
contentType: 'application/json',
filename: `${request.route.id}.${request.format}`,
body: JSON.stringify({
error: 'unsupported_format',
message: 'MVP export supports GPX. TCX/FIT provider adapters are intentionally left as future integrations.'
})
};
}
return {
contentType: 'application/gpx+xml',
filename: `${request.route.id}.gpx`,
body: exportRouteToGpx(request.route, request.stages, request.pois, {
includePois: true,
name: `Pikebacker ${request.route.id}`
})
};
}
}
function stableTripId(startLat: number, startLon: number, endLat: number, endLon: number): string {
const isDemo =
Math.abs(startLat - 48.137) < 0.01 &&
Math.abs(startLon - 11.575) < 0.01 &&
Math.abs(endLat - 47.269) < 0.01 &&
Math.abs(endLon - 11.404) < 0.01;
return isDemo ? 'trip_demo_001' : `trip_mock_${Math.abs(Math.round((startLat + startLon + endLat + endLon) * 1000))}`;
}

View File

@@ -0,0 +1,198 @@
import {
calculateSurfaceBreakdown,
haversineDistanceM,
scoreRoute,
type Coordinate,
type RoutePlan,
type RoutePlanRequest,
type RouteSegment
} from '@pikebacker/shared';
import type { ApiConfig } from '../config.js';
import { ApiError } from '../errors.js';
import type { RoutingProvider } from './types.js';
type OsrmResponse = {
code: string;
message?: string;
routes?: Array<{
distance: number;
duration: number;
geometry: {
type: 'LineString';
coordinates: Array<[number, number]>;
};
}>;
};
export class OsrmRoutingProvider implements RoutingProvider {
readonly name = 'osrm';
constructor(private readonly config: ApiConfig) {}
async planRoute(request: RoutePlanRequest): Promise<RoutePlan> {
const route = await this.fetchRoute(request);
return route;
}
async reroute(request: RoutePlanRequest): Promise<RoutePlan> {
return this.planRoute(request);
}
private async fetchRoute(request: RoutePlanRequest): Promise<RoutePlan> {
const routingCoordinates = [request.tripRequest.start, ...(request.tripRequest.viaPoints ?? []), request.tripRequest.end];
const baseUrl = this.config.osrmBaseUrl.replace(/\/$/, '');
const profile = encodeURIComponent(this.config.osrmProfile);
const coords = routingCoordinates.map((coordinate) => `${coordinate.lon},${coordinate.lat}`).join(';');
const url = `${baseUrl}/route/v1/${profile}/${coords}?overview=full&geometries=geojson&steps=false&alternatives=false`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12000);
let response: Response;
try {
response = await fetch(url, {
signal: controller.signal,
headers: {
'user-agent': 'Pikebacker MVP development router check'
}
});
} catch (error) {
throw new ApiError(
502,
'OSRM_REQUEST_FAILED',
`OSRM routing request failed. Use ROUTING_PROVIDER=mock or configure OSRM_BASE_URL for a reliable router. ${
error instanceof Error ? error.message : ''
}`
);
} finally {
clearTimeout(timeout);
}
const payload = (await response.json()) as OsrmResponse;
if (!response.ok || payload.code !== 'Ok' || !payload.routes?.[0]) {
throw new ApiError(
502,
'OSRM_ROUTE_FAILED',
payload.message ?? 'OSRM did not return a route. Use mock mode or configure a self-hosted routing engine for production.',
{ status: response.status, provider: this.name, osrmCode: payload.code }
);
}
return toRoutePlan(payload.routes[0], request);
}
}
function toRoutePlan(osrmRoute: NonNullable<OsrmResponse['routes']>[number], request: RoutePlanRequest): RoutePlan {
const geometry = osrmRoute.geometry.coordinates.map(([lon, lat]) => ({ lat, lon }));
const segments = buildSegments(geometry, osrmRoute.distance);
const baseRoute: RoutePlan = {
id: 'route_osrm_001',
tripId: stableTripId(request.tripRequest.start, request.tripRequest.end),
geometry,
distanceM: Math.round(osrmRoute.distance),
ascentM: 0,
descentM: 0,
segments,
surfaceBreakdown: calculateSurfaceBreakdown(segments),
suitabilityScore: 0,
warnings: [
{
code: 'OPEN_DEMO_ROUTER',
severity: 'notice',
title: 'Open demo router',
message:
'This route came from the public OSRM demo service. It is useful for development, but production bikepacking routing should use a configured provider or self-hosted routing engine.'
},
{
code: 'NO_ELEVATION_SURFACE_ENRICHMENT',
severity: 'notice',
title: 'Limited route enrichment',
message:
'OSRM demo geometry does not include Pikebacker surface, legal-access, or elevation enrichment. Suitability scoring is provisional.'
}
],
provider: 'osrm',
attribution: [
'Route geometry from OSRM public demo service for development use.',
'Base routing data is OpenStreetMap-derived and requires OpenStreetMap attribution.'
]
};
const score = scoreRoute(baseRoute, request.tripRequest.profile);
return {
...baseRoute,
surfaceBreakdown: score.surfaceBreakdown,
suitabilityScore: score.suitabilityScore,
warnings: [...baseRoute.warnings, ...score.warnings]
};
}
function buildSegments(points: Coordinate[], routeDistanceM: number): RouteSegment[] {
if (points.length < 2) {
return [];
}
const targetSegments = Math.min(24, Math.max(8, Math.floor(points.length / 10)));
const chunkSize = Math.max(1, Math.floor((points.length - 1) / targetSegments));
const rawSegments: Array<{ geometry: Coordinate[]; haversineM: number }> = [];
for (let startIndex = 0; startIndex < points.length - 1; startIndex += chunkSize) {
const endIndex = Math.min(points.length - 1, startIndex + chunkSize);
const geometry = points.slice(startIndex, endIndex + 1);
rawSegments.push({ geometry, haversineM: segmentDistance(geometry) });
}
const totalHaversine = Math.max(
1,
rawSegments.reduce((sum, segment) => sum + segment.haversineM, 0)
);
let cursor = 0;
return rawSegments.map((raw, index) => {
const isLast = index === rawSegments.length - 1;
const distanceM = isLast ? Math.max(1, Math.round(routeDistanceM - cursor)) : Math.max(1, Math.round((raw.haversineM / totalHaversine) * routeDistanceM));
const startMeters = cursor;
const endMeters = startMeters + distanceM;
cursor = endMeters;
return {
id: `seg_osrm_${String(index + 1).padStart(2, '0')}`,
geometry: raw.geometry,
distanceM,
startMeters,
endMeters,
ascentM: 0,
descentM: 0,
avgGradePercent: 0,
maxGradePercent: 0,
surface: index % 5 === 0 ? 'unknown' : 'asphalt',
smoothness: 'unknown',
highway: 'unclassified',
bicycleAccess: 'unknown',
legalAccessConfidence: 'unknown',
trafficStress: 0.35,
officialCycleRoute: false,
protectedAreaOverlap: false,
sourceConfidence: 'medium'
};
});
}
function segmentDistance(points: Coordinate[]): number {
let distance = 0;
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const current = points[index];
if (previous && current) {
distance += haversineDistanceM(previous, current);
}
}
return distance;
}
function stableTripId(start: Coordinate, end: Coordinate): string {
const isDemo =
Math.abs(start.lat - 48.137) < 0.01 &&
Math.abs(start.lon - 11.575) < 0.01 &&
Math.abs(end.lat - 47.269) < 0.01 &&
Math.abs(end.lon - 11.404) < 0.01;
return isDemo ? 'trip_demo_001' : `trip_osrm_${Math.abs(Math.round((start.lat + start.lon + end.lat + end.lon) * 1000))}`;
}

View File

@@ -0,0 +1,204 @@
import { filterPoisByCorridor, haversineDistanceM, type Confidence, type Coordinate, type Poi, type PoiCategory } from '@pikebacker/shared';
import type { ApiConfig } from '../config.js';
import { ApiError } from '../errors.js';
import { projectPoisToRoute } from './route-poi-projection.js';
import type { CorridorPoiRequest, PoiProvider } from './types.js';
type OverpassElement = {
type: 'node' | 'way' | 'relation';
id: number;
lat?: number;
lon?: number;
center?: { lat: number; lon: number };
tags?: Record<string, string>;
};
type OverpassResponse = {
elements?: OverpassElement[];
};
export class OverpassPoiProvider implements PoiProvider {
readonly name = 'overpass';
private readonly cache = new Map<string, Poi[]>();
constructor(private readonly config: ApiConfig) {}
async searchCorridor(request: CorridorPoiRequest): Promise<Poi[]> {
const bufferKm = request.bufferKm ?? 8;
const cacheKey = `${request.route.id}:${Math.round(bufferKm)}:${request.category ?? 'all'}`;
const cached = this.cache.get(cacheKey);
if (cached) {
return cached;
}
const elements = await this.fetchElements(request.route.geometry, bufferKm);
const pois = projectPoisToRoute(elements.flatMap(normalizeElement), request.route);
const filtered = filterPoisByCorridor(pois, request.route, {
category: request.category,
bufferKm
}).slice(0, 500);
this.cache.set(cacheKey, filtered);
return filtered;
}
private async fetchElements(routeGeometry: Coordinate[], bufferKm: number): Promise<OverpassElement[]> {
const radiusM = Math.round(Math.max(3, Math.min(12, bufferKm)) * 1000);
const centers = sampleRouteCenters(routeGeometry);
const selectors = centers.flatMap((center) => selectorsForCenter(center, radiusM)).join('\n ');
const query = `[out:json][timeout:25];
(
${selectors}
);
out center 2500;`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
let response: Response;
try {
response = await fetch(this.config.overpassBaseUrl, {
method: 'POST',
signal: controller.signal,
headers: {
'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
'user-agent': 'Pikebacker MVP development POI corridor query'
},
body: new URLSearchParams({ data: query })
});
} catch (error) {
throw new ApiError(
502,
'OVERPASS_REQUEST_FAILED',
`Overpass POI request failed. Use POI_PROVIDER=mock or configure OVERPASS_BASE_URL. ${
error instanceof Error ? error.message : ''
}`
);
} finally {
clearTimeout(timeout);
}
const text = await response.text();
if (!response.ok) {
throw new ApiError(502, 'OVERPASS_QUERY_FAILED', text.slice(0, 500) || 'Overpass did not return POIs.', {
status: response.status
});
}
return ((JSON.parse(text) as OverpassResponse).elements ?? []).filter(hasLocation);
}
}
function selectorsForCenter(center: Coordinate, radiusM: number): string[] {
const around = `(around:${radiusM},${center.lat.toFixed(6)},${center.lon.toFixed(6)});`;
return [
`nwr["amenity"="drinking_water"]${around}`,
`nwr["drinking_water"="yes"]${around}`,
`nwr["shop"~"^(supermarket|convenience|bakery)$"]${around}`,
`nwr["tourism"~"^(camp_site|hostel|hotel|alpine_hut|wilderness_hut)$"]${around}`,
`nwr["amenity"="shelter"]${around}`,
`nwr["shop"="bicycle"]${around}`,
`nwr["amenity"="bicycle_repair_station"]${around}`,
`nwr["railway"="station"]${around}`,
`nwr["highway"="bus_stop"]${around}`,
`nwr["amenity"="ferry_terminal"]${around}`
];
}
function sampleRouteCenters(routeGeometry: Coordinate[]): Coordinate[] {
if (routeGeometry.length <= 2) {
return routeGeometry;
}
const targetIntervalM = 25000;
const centers: Coordinate[] = [];
let distanceSinceLast = Number.POSITIVE_INFINITY;
for (let index = 0; index < routeGeometry.length; index += 1) {
const point = routeGeometry[index];
if (!point) {
continue;
}
if (index === 0 || index === routeGeometry.length - 1 || distanceSinceLast >= targetIntervalM) {
centers.push(point);
distanceSinceLast = 0;
continue;
}
const previous = routeGeometry[index - 1];
if (previous) {
distanceSinceLast += haversineDistanceM(previous, point);
}
}
const last = routeGeometry[routeGeometry.length - 1];
if (last && centers[centers.length - 1] !== last) {
centers.push(last);
}
return centers.slice(0, 14);
}
function normalizeElement(element: OverpassElement): Poi[] {
const tags = element.tags ?? {};
const category = categoryFromTags(tags);
const location = elementLocation(element);
if (!category || !location) {
return [];
}
return [
{
id: `osm_${element.type}_${element.id}`,
category,
name: tags.name ?? labelForCategory(category),
location,
source: 'overpass_osm',
confidence: confidenceFromTags(tags),
openingHours: tags.opening_hours,
tags
}
];
}
function hasLocation(element: OverpassElement): boolean {
return Boolean(elementLocation(element));
}
function elementLocation(element: OverpassElement) {
if (typeof element.lat === 'number' && typeof element.lon === 'number') {
return { lat: element.lat, lon: element.lon };
}
if (typeof element.center?.lat === 'number' && typeof element.center.lon === 'number') {
return { lat: element.center.lat, lon: element.center.lon };
}
return undefined;
}
function categoryFromTags(tags: Record<string, string>): PoiCategory | undefined {
if (tags.amenity === 'drinking_water' || tags.drinking_water === 'yes') return 'water';
if (tags.shop === 'supermarket' || tags.shop === 'convenience' || tags.shop === 'bakery') return 'food';
if (
tags.tourism === 'camp_site' ||
tags.tourism === 'hostel' ||
tags.tourism === 'hotel' ||
tags.tourism === 'alpine_hut' ||
tags.tourism === 'wilderness_hut' ||
tags.amenity === 'shelter'
) {
return 'sleep';
}
if (tags.shop === 'bicycle' || tags.amenity === 'bicycle_repair_station') return 'repair';
if (tags.railway === 'station' || tags.highway === 'bus_stop' || tags.amenity === 'ferry_terminal') return 'bailout';
return undefined;
}
function confidenceFromTags(tags: Record<string, string>): Confidence {
if (tags.access === 'no' || tags.bicycle === 'no') return 'restricted';
if (tags.opening_hours || tags.name || tags.operator) return 'high';
return 'medium';
}
function labelForCategory(category: PoiCategory): string {
if (category === 'water') return 'Water point';
if (category === 'food') return 'Food resupply';
if (category === 'sleep') return 'Sleep option';
if (category === 'repair') return 'Bike repair';
if (category === 'bailout') return 'Bailout transport';
return 'Logistics point';
}

View File

@@ -0,0 +1,52 @@
import { haversineDistanceM, type Coordinate, type Poi, type RoutePlan } from '@pikebacker/shared';
export function projectPoisToRoute(pois: Poi[], route: RoutePlan): Poi[] {
if (route.geometry.length < 2) {
return pois;
}
const cumulative = cumulativeDistances(route.geometry);
return pois.map((poi) => {
const nearest = nearestRouteVertex(poi.location, route.geometry, cumulative);
return {
...poi,
metersFromStart: nearest.metersFromStart,
distanceFromRouteM: Math.round(nearest.distanceFromRouteM),
detourDistanceM: Math.max(poi.detourDistanceM ?? 0, Math.round(nearest.distanceFromRouteM * 1.4))
};
});
}
function cumulativeDistances(points: Coordinate[]): number[] {
const distances = [0];
let cursor = 0;
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const current = points[index];
if (previous && current) {
cursor += haversineDistanceM(previous, current);
}
distances.push(cursor);
}
return distances;
}
function nearestRouteVertex(point: Coordinate, routeGeometry: Coordinate[], cumulative: number[]) {
let bestIndex = 0;
let bestDistance = Number.POSITIVE_INFINITY;
for (let index = 0; index < routeGeometry.length; index += 1) {
const routePoint = routeGeometry[index];
if (!routePoint) {
continue;
}
const distance = haversineDistanceM(point, routePoint);
if (distance < bestDistance) {
bestIndex = index;
bestDistance = distance;
}
}
return {
metersFromStart: Math.round(cumulative[bestIndex] ?? 0),
distanceFromRouteM: bestDistance
};
}

View File

@@ -0,0 +1,68 @@
import type {
LocalReport,
LocalReportCreate,
OfflinePackManifest,
Poi,
PoiCategory,
RoutePlan,
RoutePlanRequest,
RuleCard,
StagePlan
} from '@pikebacker/shared';
export type CorridorPoiRequest = {
route: RoutePlan;
category?: PoiCategory;
bufferKm?: number;
};
export type RuleCardRequest = {
route?: RoutePlan;
bbox?: [number, number, number, number];
};
export type ExportRequest = {
route: RoutePlan;
stages: StagePlan[];
pois: Poi[];
format: 'gpx' | 'tcx' | 'fit';
};
export interface RoutingProvider {
readonly name: string;
planRoute(request: RoutePlanRequest): Promise<RoutePlan>;
reroute(request: RoutePlanRequest): Promise<RoutePlan>;
}
export interface PoiProvider {
readonly name: string;
searchCorridor(request: CorridorPoiRequest): Promise<Poi[]>;
}
export interface ElevationProvider {
readonly name: string;
samplePolyline(route: RoutePlan): Promise<RoutePlan>;
}
export interface WeatherProvider {
readonly name: string;
forecastAlongRoute(route: RoutePlan): Promise<Array<{ metersFromStart: number; severity: string; summary: string }>>;
}
export interface RulesProvider {
readonly name: string;
getRuleCards(request: RuleCardRequest): Promise<RuleCard[]>;
}
export interface DeviceExportProvider {
readonly name: string;
exportRoute(request: ExportRequest): Promise<{ contentType: string; filename: string; body: string }>;
}
export interface ReportStore {
create(report: LocalReportCreate): Promise<LocalReport>;
}
export interface OfflinePackStore {
create(manifest: OfflinePackManifest): Promise<OfflinePackManifest>;
}

View File

@@ -0,0 +1,63 @@
import { ProviderUnavailableError } from '../errors.js';
import type { Poi, RoutePlan, RoutePlanRequest, RuleCard } from '@pikebacker/shared';
import type {
CorridorPoiRequest,
DeviceExportProvider,
ExportRequest,
PoiProvider,
RoutingProvider,
RuleCardRequest,
RulesProvider
} from './types.js';
export class UnavailableRoutingProvider implements RoutingProvider {
constructor(readonly name: string) {}
async planRoute(_request: RoutePlanRequest): Promise<RoutePlan> {
throw new ProviderUnavailableError(
this.name,
'routing',
'Set ROUTING_PROVIDER=mock for fixture mode or configure the selected provider adapter and credentials.'
);
}
async reroute(request: RoutePlanRequest): Promise<RoutePlan> {
return this.planRoute(request);
}
}
export class UnavailablePoiProvider implements PoiProvider {
constructor(readonly name: string) {}
async searchCorridor(_request: CorridorPoiRequest): Promise<Poi[]> {
throw new ProviderUnavailableError(
this.name,
'POI',
'Set POI_PROVIDER=mock for fixture mode or configure a PostGIS/OSM POI provider.'
);
}
}
export class UnavailableRulesProvider implements RulesProvider {
constructor(readonly name: string) {}
async getRuleCards(_request: RuleCardRequest): Promise<RuleCard[]> {
throw new ProviderUnavailableError(
this.name,
'rules',
'Set RULES_PROVIDER=mock for fixture mode or configure a rules data source.'
);
}
}
export class UnavailableDeviceExportProvider implements DeviceExportProvider {
constructor(readonly name: string) {}
async exportRoute(_request: ExportRequest): Promise<{ contentType: string; filename: string; body: string }> {
throw new ProviderUnavailableError(
this.name,
'device export',
'Set export format to gpx in the MVP or configure a TCX/FIT/device export adapter.'
);
}
}

View File

@@ -0,0 +1,9 @@
import { createApp } from './app.js';
import { loadConfig } from './config.js';
const config = loadConfig();
const { app } = createApp({ config });
app.listen(config.port, () => {
console.log(`Pikebacker API listening on http://localhost:${config.port}`);
});

View File

@@ -0,0 +1,89 @@
import type { NextFunction, Request, Response } from 'express';
import { z, type ZodSchema } from 'zod';
import { ApiError } from './errors.js';
const coordinateSchema = z.object({
lat: z.number().min(-90).max(90),
lon: z.number().min(-180).max(180)
});
const profileSchema = z.enum(['loaded_gravel', 'hardtail_bikepacking', 'road_touring', 'ebikepacking', 'beginner_safe']);
const sleepPreferenceSchema = z.enum(['official_campsites', 'mixed_budget', 'indoor_only', 'wild_where_legal', 'any_safe_option']);
export const tripRequestSchema = z.object({
start: coordinateSchema,
end: coordinateSchema,
viaPoints: z.array(coordinateSchema).max(24).optional(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
profile: profileSchema,
sleepPreference: sleepPreferenceSchema,
dailyDistanceKm: z
.object({
min: z.number().positive(),
target: z.number().positive(),
max: z.number().positive()
})
.refine((value) => value.min <= value.target && value.target <= value.max, {
message: 'dailyDistanceKm must satisfy min <= target <= max'
}),
dailyAscentM: z
.object({
target: z.number().positive().optional(),
max: z.number().positive().optional()
})
.optional(),
maxLoadedGradePercent: z.number().positive().optional(),
preferredUnpavedPercent: z
.object({
min: z.number().min(0).max(100).optional(),
max: z.number().min(0).max(100).optional()
})
.optional(),
waterGapMaxKm: z.number().positive().optional(),
foodGapMaxKm: z.number().positive().optional(),
requireBailoutEveryKm: z.number().positive().optional()
});
export const routePlanRequestSchema = z.object({
tripRequest: tripRequestSchema,
provider: z.string().default('mock').optional()
});
export const stagePlanRequestSchema = z.object({
routeId: z.string().min(1),
tripRequest: tripRequestSchema
});
export const offlinePackRequestSchema = z.object({
tripId: z.string().min(1),
routeId: z.string().min(1),
routeBufferKm: z.number().positive().default(5).optional()
});
export const localReportCreateSchema = z.object({
reportType: z.string().min(2),
location: coordinateSchema,
routeId: z.string().optional(),
metersFromStart: z.number().int().nonnegative().optional(),
observedAt: z.string().datetime(),
details: z.string().max(2000).optional(),
payload: z.record(z.unknown()).optional(),
offlineClientId: z.string().optional()
});
export function validateBody<T>(schema: ZodSchema<T>) {
return (request: Request, _response: Response, next: NextFunction) => {
const result = schema.safeParse(request.body);
if (!result.success) {
next(
new ApiError(400, 'VALIDATION_ERROR', 'Request body is invalid. Check required fields and numeric constraints.', {
issues: result.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message }))
})
);
return;
}
request.body = result.data;
next();
};
}

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View File

@@ -0,0 +1,17 @@
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { defineConfig } from 'vitest/config';
const here = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
resolve: {
alias: {
'@pikebacker/shared': resolve(here, '../../packages/shared/src/index.ts')
}
},
test: {
environment: 'node',
include: ['src/**/*.test.ts']
}
});

View File

@@ -0,0 +1,32 @@
# Data Pipeline
Purpose: ingest and normalize map, POI, elevation, and rule data.
MVP pipeline steps:
1. Load OSM fixture or small regional extract.
2. Extract bikepacking POIs.
3. Normalize categories.
4. Store source, source_id, tags, and confidence.
5. Prepare route-corridor query indexes.
Production steps:
1. Download Geofabrik/planet extracts.
2. Build routing graph for selected engine.
3. Import POIs into PostGIS/search index.
4. Enrich segments with elevation.
5. Import protected areas and rules.
6. Generate offline pack assets.
Do not use public Overpass instances for production-scale queries.
## Implemented skeleton
The package includes a small OSM-like fixture and a normalizer:
```bash
npm -w @pikebacker/data-pipeline run normalize:sample
```
The normalizer maps OSM tags into bikepacking POI categories (`water`, `food`, `sleep`, `repair`, `bailout`, `charging`, `emergency`) and preserves `source`, `tags`, `opening_hours`, confidence, and OpenStreetMap attribution hooks. Production ingestion should write the normalized records into PostGIS using `schemas/database.sql`.

View File

@@ -0,0 +1,47 @@
{
"source": "overpass-fixture",
"attribution": "Contains information from OpenStreetMap contributors.",
"elements": [
{
"type": "node",
"id": 1001,
"lat": 48.075,
"lon": 11.512,
"tags": {
"amenity": "drinking_water",
"name": "Village fountain"
}
},
{
"type": "node",
"id": 1002,
"lat": 47.805,
"lon": 11.145,
"tags": {
"tourism": "camp_site",
"name": "Lakeside campsite"
}
},
{
"type": "node",
"id": 1003,
"lat": 47.62,
"lon": 11.235,
"tags": {
"shop": "bicycle",
"name": "Bike shop Murnau",
"opening_hours": "Mo-Fr 09:00-18:00"
}
},
{
"type": "node",
"id": 1004,
"lat": 47.75,
"lon": 11.12,
"tags": {
"railway": "station",
"name": "Regional rail station"
}
}
]
}

View File

@@ -0,0 +1,16 @@
{
"name": "@pikebacker/data-pipeline",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/normalize-osm-pois.js",
"types": "./dist/normalize-osm-pois.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"normalize:sample": "tsx src/normalize-osm-pois.ts fixtures/osm-pois.sample.json"
},
"dependencies": {
"@pikebacker/shared": "0.1.0"
}
}

View File

@@ -0,0 +1,116 @@
import { readFile } from 'node:fs/promises';
import type { Confidence, Poi, PoiCategory } from '@pikebacker/shared';
type OsmElement = {
type: string;
id: number | string;
lat?: number;
lon?: number;
tags?: Record<string, string>;
};
type OsmFixture = {
source?: string;
attribution?: string;
elements: OsmElement[];
};
export async function normalizeOsmPoiFixture(path: string): Promise<{ pois: Poi[]; attribution: string[] }> {
const raw = await readFile(path, 'utf8');
const fixture = JSON.parse(raw) as OsmFixture;
return {
pois: fixture.elements.flatMap((element) => normalizeElement(element, fixture.source ?? 'osm_fixture')),
attribution: [fixture.attribution ?? 'Contains information from OpenStreetMap contributors.']
};
}
export function normalizeElement(element: OsmElement, source: string): Poi[] {
if (typeof element.lat !== 'number' || typeof element.lon !== 'number') {
return [];
}
const tags = element.tags ?? {};
const category = categoryFromTags(tags);
if (!category) {
return [];
}
return [
{
id: `osm_${element.type}_${element.id}`,
category,
name: tags.name ?? labelForCategory(category),
location: { lat: element.lat, lon: element.lon },
source,
confidence: confidenceFromTags(tags),
openingHours: tags.opening_hours,
tags
}
];
}
export function categoryFromTags(tags: Record<string, string>): PoiCategory | undefined {
if (tags.amenity === 'drinking_water' || tags.drinking_water === 'yes') {
return 'water';
}
if (tags.shop === 'supermarket' || tags.shop === 'convenience' || tags.amenity === 'cafe' || tags.amenity === 'restaurant') {
return 'food';
}
if (tags.tourism === 'camp_site' || tags.tourism === 'hostel' || tags.tourism === 'hotel' || tags.amenity === 'shelter') {
return 'sleep';
}
if (tags.shop === 'bicycle' || tags.amenity === 'bicycle_repair_station') {
return 'repair';
}
if (tags.railway === 'station' || tags.highway === 'bus_stop' || tags.amenity === 'ferry_terminal') {
return 'bailout';
}
if (tags.amenity === 'charging_station') {
return 'charging';
}
if (tags.amenity === 'hospital' || tags.emergency === 'yes') {
return 'emergency';
}
return undefined;
}
function confidenceFromTags(tags: Record<string, string>): Confidence {
if (tags.access === 'no' || tags.bicycle === 'no') {
return 'restricted';
}
if (tags.opening_hours || tags.name) {
return 'high';
}
return 'medium';
}
function labelForCategory(category: PoiCategory): string {
return category === 'water'
? 'Water point'
: category === 'food'
? 'Food resupply'
: category === 'sleep'
? 'Sleep option'
: category === 'repair'
? 'Bike repair'
: category === 'bailout'
? 'Bailout transport'
: category === 'charging'
? 'Charging point'
: 'Emergency service';
}
if (import.meta.url === `file://${process.argv[1]}`) {
const fixturePath = process.argv[2];
if (!fixturePath) {
console.error('Usage: tsx src/normalize-osm-pois.ts <osm-pois-fixture.json>');
process.exit(1);
}
normalizeOsmPoiFixture(fixturePath)
.then((result) => {
console.log(JSON.stringify(result, null, 2));
})
.catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src"]
}