Alpha stage commit
This commit is contained in:
54
services/api/src/app.test.ts
Normal file
54
services/api/src/app.test.ts
Normal 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
326
services/api/src/app.ts
Normal 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))}`;
|
||||
}
|
||||
33
services/api/src/config.ts
Normal file
33
services/api/src/config.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
23
services/api/src/errors.ts
Normal file
23
services/api/src/errors.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
571
services/api/src/providers/brouter.ts
Normal file
571
services/api/src/providers/brouter.ts
Normal 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];
|
||||
}
|
||||
42
services/api/src/providers/index.ts
Normal file
42
services/api/src/providers/index.ts
Normal 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);
|
||||
}
|
||||
81
services/api/src/providers/mock.ts
Normal file
81
services/api/src/providers/mock.ts
Normal 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))}`;
|
||||
}
|
||||
198
services/api/src/providers/osrm.ts
Normal file
198
services/api/src/providers/osrm.ts
Normal 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))}`;
|
||||
}
|
||||
204
services/api/src/providers/overpass.ts
Normal file
204
services/api/src/providers/overpass.ts
Normal 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';
|
||||
}
|
||||
52
services/api/src/providers/route-poi-projection.ts
Normal file
52
services/api/src/providers/route-poi-projection.ts
Normal 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
|
||||
};
|
||||
}
|
||||
68
services/api/src/providers/types.ts
Normal file
68
services/api/src/providers/types.ts
Normal 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>;
|
||||
}
|
||||
63
services/api/src/providers/unavailable.ts
Normal file
63
services/api/src/providers/unavailable.ts
Normal 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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
9
services/api/src/server.ts
Normal file
9
services/api/src/server.ts
Normal 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}`);
|
||||
});
|
||||
89
services/api/src/validation.ts
Normal file
89
services/api/src/validation.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user