Alpha stage commit
This commit is contained in:
34
.env.example
Normal file
34
.env.example
Normal file
@@ -0,0 +1,34 @@
|
||||
# Provider selection
|
||||
ROUTING_PROVIDER=mock
|
||||
POI_PROVIDER=mock
|
||||
ELEVATION_PROVIDER=mock
|
||||
WEATHER_PROVIDER=mock
|
||||
RULES_PROVIDER=mock
|
||||
PORT=8000
|
||||
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
|
||||
|
||||
# Web app
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
|
||||
# External providers; keep blank for mock mode
|
||||
OPENROUTESERVICE_API_KEY=
|
||||
GRAPHHOPPER_API_KEY=
|
||||
BOOKING_API_TOKEN=
|
||||
GARMIN_CLIENT_ID=
|
||||
GARMIN_CLIENT_SECRET=
|
||||
WAHOO_CLIENT_ID=
|
||||
WAHOO_CLIENT_SECRET=
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://bikepackpilot:bikepackpilot@localhost:5432/bikepackpilot
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# Storage
|
||||
OBJECT_STORAGE_BUCKET=
|
||||
OBJECT_STORAGE_ENDPOINT=
|
||||
OBJECT_STORAGE_ACCESS_KEY=
|
||||
OBJECT_STORAGE_SECRET_KEY=
|
||||
32
.gitignore
vendored
32
.gitignore
vendored
@@ -1,14 +1,18 @@
|
||||
# ---> VisualStudioCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
node_modules/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
*.sqlite
|
||||
*.pbf
|
||||
*.pmtiles
|
||||
*.mbtiles
|
||||
*.gpkg
|
||||
*.zip
|
||||
|
||||
34
AGENTS.md
Normal file
34
AGENTS.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Agent Instructions
|
||||
|
||||
This repository is a product and implementation blueprint for a bikepacking app. Treat the docs as the source of truth.
|
||||
|
||||
## Product constraints
|
||||
|
||||
- The app is for loaded bikepacking, not generic cycling.
|
||||
- Optimize for multi-day trips, offline use, resupply, water, sleeping, repair, local rules, and bailout options.
|
||||
- Routing quality should come from OSM/elevation/routing engines plus a proprietary bikepacking suitability layer.
|
||||
- Never hardcode API keys or commercial credentials.
|
||||
- Keep data-source adapters interchangeable.
|
||||
- Treat legal access, protected areas, wild camping, and safety warnings as confidence-scored advisory information, not guaranteed legal advice.
|
||||
|
||||
## Engineering constraints
|
||||
|
||||
- Prefer a monorepo with `apps/web`, `apps/mobile`, `services/api`, `services/data-pipeline`, and `packages/shared`.
|
||||
- Use TypeScript for user-facing apps and shared domain types.
|
||||
- Use a backend service with geospatial database support. The recommended default is FastAPI + PostgreSQL/PostGIS, but an equivalent stack is acceptable if it preserves the API contract.
|
||||
- Use MapLibre-compatible map rendering.
|
||||
- Keep routing behind provider interfaces: `RoutingProvider`, `PoiProvider`, `ElevationProvider`, `WeatherProvider`, `RulesProvider`, `DeviceExportProvider`.
|
||||
- Implement testable pure functions for scoring, staging, POI filtering, and warning generation.
|
||||
- Add unit tests for scoring and staging before UI refinements.
|
||||
- Add fixtures from `examples/` and keep sample outputs deterministic.
|
||||
|
||||
## Acceptance standard
|
||||
|
||||
A task is complete only when:
|
||||
|
||||
- code compiles,
|
||||
- tests pass,
|
||||
- API contracts are updated when behavior changes,
|
||||
- environment variables are documented,
|
||||
- user-facing errors are actionable,
|
||||
- OSM and third-party attribution requirements are preserved in the UI and docs.
|
||||
5
LICENSE_NOT_SELECTED.md
Normal file
5
LICENSE_NOT_SELECTED.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# License Not Selected
|
||||
|
||||
This blueprint does not assign an application license. Choose a license before publishing code.
|
||||
|
||||
Important: using OpenStreetMap-derived data can create Open Database License obligations for derived databases. Keep legal review separate from code licensing.
|
||||
139
README.md
139
README.md
@@ -1,2 +1,139 @@
|
||||
# pikebacker
|
||||
# Bikepacking App Blueprint
|
||||
|
||||
This repository is a **build brief for a specialized bikepacking application**. It is written so a coding agent such as Codex can turn the specification into a working monorepo.
|
||||
|
||||
Working name: **BikepackPilot**.
|
||||
|
||||
Core product idea:
|
||||
|
||||
> Plan and ride multi-day bikepacking routes with confidence: where you can ride, sleep, refill, repair, reroute, and bail out — even offline.
|
||||
|
||||
The app should not be a generic route planner. It should combine route planning, daily staging, sleep/water/food logistics, offline use, local rules, local reports, and device export.
|
||||
|
||||
## Repository purpose
|
||||
|
||||
This repository contains:
|
||||
|
||||
- product summary and PRD,
|
||||
- MVP scope,
|
||||
- technical architecture,
|
||||
- data-source matrix,
|
||||
- routing/scoring model,
|
||||
- offline strategy,
|
||||
- API and database schemas,
|
||||
- UX flows,
|
||||
- risk/legal notes,
|
||||
- Codex-ready implementation prompts and task breakdown.
|
||||
|
||||
## Recommended build strategy
|
||||
|
||||
Build in this order:
|
||||
|
||||
1. **Planning web app**: map, trip request form, route card, stage cards, route-corridor POIs.
|
||||
2. **Backend API**: route planning adapter, stage planner, POI corridor, GPX export.
|
||||
3. **Data foundation**: OSM-derived POI import, elevation enrichment, protected-area/rule model.
|
||||
4. **Mobile riding app**: offline packs, next-critical-thing panel, rerouting, reports.
|
||||
5. **Advanced intelligence**: bikepacking suitability score, local trust, weather-aware rerouting.
|
||||
|
||||
## Suggested monorepo shape
|
||||
|
||||
```text
|
||||
apps/
|
||||
web/ # desktop/mobile web planner
|
||||
mobile/ # riding/offline app
|
||||
services/
|
||||
api/ # backend API
|
||||
data-pipeline/ # OSM, POI, elevation, rule ingestion
|
||||
packages/
|
||||
shared/ # shared domain types, scoring helpers
|
||||
schemas/ # OpenAPI, SQL, JSON schemas
|
||||
codex/ # coding-agent prompts and task plan
|
||||
docs/ # product and architecture documentation
|
||||
examples/ # sample requests, responses, scoring configs
|
||||
```
|
||||
|
||||
## MVP definition
|
||||
|
||||
The first useful version should allow a rider to:
|
||||
|
||||
1. enter a start/end point, trip dates, bike/load type, daily distance/climb tolerance, sleep preference, and water/resupply constraints;
|
||||
2. receive a bikepacking-oriented route with daily stages;
|
||||
3. see route-corridor water/food/sleep/repair POIs;
|
||||
4. view warnings for long no-water/no-food gaps, uncertain access, steep loaded-bike sections, and missing bailout options;
|
||||
5. export GPX/TCX/FIT-ready route files;
|
||||
6. cache the route, POIs, stage plan, and rule cards offline.
|
||||
|
||||
## Key differentiator
|
||||
|
||||
The app wins by optimizing the **whole trip**, not only the route geometry:
|
||||
|
||||
```text
|
||||
route + stages + sleep + water + food + repair + rules + weather + bailout + offline
|
||||
```
|
||||
|
||||
## Start here for Codex
|
||||
|
||||
Use [`codex/CODEX_MASTER_PROMPT.md`](codex/CODEX_MASTER_PROMPT.md), then execute the tasks in [`codex/IMPLEMENTATION_TASKS.md`](codex/IMPLEMENTATION_TASKS.md).
|
||||
|
||||
## Current MVP implementation
|
||||
|
||||
This repo now includes a runnable TypeScript monorepo MVP:
|
||||
|
||||
- `packages/shared`: domain types, deterministic mock fixtures, scoring, stage planning, POI corridor filtering, critical gap detection, GPX export, and offline manifest helpers.
|
||||
- `services/api`: Express API implementing the MVP OpenAPI endpoints with mock providers by default, BRouter bikepacking route candidate selection, optional OSRM fallback, and optional Overpass POIs.
|
||||
- `apps/web`: Vite/React/MapLibre planner UI with OSM raster tiles, route overlay, linked route/elevation profile hover, collapsible planning/export dock, layer controls, bottom route-details drawer, GPX download, offline manifest creation, structured local reports, and offline report queue/sync.
|
||||
- `apps/mobile`: compiled placeholder for offline pack indexing and queued report storage.
|
||||
- `services/data-pipeline`: OSM-like POI fixture normalizer preserving source and attribution fields.
|
||||
|
||||
Mock mode requires no external API keys. Non-mock providers intentionally return actionable setup errors until real adapters are configured.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
Run the API and web planner in separate terminals:
|
||||
|
||||
```bash
|
||||
npm run dev:api
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
Default URLs:
|
||||
|
||||
- API: `http://localhost:8000`
|
||||
- Web: `http://localhost:5173`
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run fixtures:pois
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
Copy `.env.example` if local overrides are needed. The default MVP uses:
|
||||
|
||||
- `ROUTING_PROVIDER=mock`
|
||||
- `POI_PROVIDER=mock`
|
||||
- `RULES_PROVIDER=mock`
|
||||
- `VITE_API_BASE_URL=http://localhost:8000`
|
||||
- `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`
|
||||
|
||||
Keep commercial provider keys blank in mock mode. Do not commit populated credentials.
|
||||
|
||||
The web planner defaults to `provider: "brouter"`. The API evaluates multiple BRouter bicycle profiles for the selected Pikebacker profile and chooses the highest-scored bikepacking candidate. OSRM remains available as an explicit road-biased demo fallback. Public BRouter, OSRM, Overpass, and OSM tile endpoints are development conveniences only; production should use compliant tile hosting and configured or self-hosted routing/POI services.
|
||||
|
||||
The first routing editor supports ordered points: `start`, optional `viaPoints`, and `end`. The map can add points from right-click actions, waypoint pins are draggable, and dragging the route inserts a new via point between the nearest existing routing points. BRouter and mock routes expose optional per-coordinate `elevationM` samples for the web elevation profile; providers without elevation show aggregate ascent/descent only.
|
||||
|
||||
## Attribution and uncertainty
|
||||
|
||||
The web UI, API responses, GPX metadata, and data-pipeline output preserve OSM attribution hooks. Local rules, protected-area warnings, access status, and safety warnings are exposed as confidence-scored advisory information, not guaranteed legal advice.
|
||||
|
||||
29
REPO_INDEX.md
Normal file
29
REPO_INDEX.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Repo Index
|
||||
|
||||
- `README.md`: concept and build strategy.
|
||||
- `AGENTS.md`: coding-agent instructions.
|
||||
- `docs/00_executive_summary.md`: high-level concept.
|
||||
- `docs/01_product_requirements.md`: requirements.
|
||||
- `docs/02_mvp_scope.md`: MVP boundaries.
|
||||
- `docs/03_system_architecture.md`: stack and service design.
|
||||
- `docs/04_data_sources.md`: data-source matrix.
|
||||
- `docs/05_routing_and_scoring.md`: route/stage scoring model.
|
||||
- `docs/06_offline_strategy.md`: offline pack strategy.
|
||||
- `docs/07_ux_flows.md`: UX flows.
|
||||
- `docs/08_data_model.md`: domain model.
|
||||
- `docs/09_api_contract.md`: endpoint explanations.
|
||||
- `docs/10_integrations.md`: providers and device/accommodation integrations.
|
||||
- `docs/11_local_rules_and_liability.md`: rules and legal risk model.
|
||||
- `docs/12_community_intel.md`: local reports/trust model.
|
||||
- `docs/13_security_privacy.md`: privacy/security requirements.
|
||||
- `docs/14_testing_plan.md`: QA plan.
|
||||
- `docs/15_roadmap.md`: phased roadmap.
|
||||
- `docs/source_references.md`: official links verified 2026-06-30.
|
||||
- `codex/CODEX_MASTER_PROMPT.md`: prompt to start implementation.
|
||||
- `codex/IMPLEMENTATION_TASKS.md`: task breakdown.
|
||||
- `codex/BACKLOG.csv`: backlog import file.
|
||||
- `schemas/openapi.yaml`: API draft.
|
||||
- `schemas/database.sql`: PostGIS schema draft.
|
||||
- `schemas/domain_types.ts`: shared TypeScript types.
|
||||
- `examples/`: fixtures and prototype queries.
|
||||
- `design/`: text wireframes and map layers.
|
||||
31
apps/mobile/README.md
Normal file
31
apps/mobile/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Mobile App
|
||||
|
||||
Purpose: riding/offline companion.
|
||||
|
||||
MVP mobile scope can be a placeholder until the web planner and backend are stable.
|
||||
|
||||
Primary future screens:
|
||||
|
||||
- Trip list.
|
||||
- Offline pack status.
|
||||
- Riding mode: next water/food/sleep/repair/bailout.
|
||||
- Map view.
|
||||
- Report issue.
|
||||
- Export/share route.
|
||||
|
||||
Offline-critical data:
|
||||
|
||||
- route geometry,
|
||||
- stages,
|
||||
- POIs,
|
||||
- rules,
|
||||
- exports,
|
||||
- report queue.
|
||||
|
||||
## Implemented placeholder
|
||||
|
||||
The current package is not a full React Native shell. It provides compiled TypeScript contracts for the future mobile app:
|
||||
|
||||
- `OfflineReportQueue` for dated, location-bound reports captured without network access;
|
||||
- `OfflinePackIndex` for downloaded route/stage/POI/rule/export manifests;
|
||||
- storage abstraction that can later be backed by SQLite, AsyncStorage, or a native key-value store.
|
||||
15
apps/mobile/package.json
Normal file
15
apps/mobile/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@pikebacker/mobile",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/offlineStore.js",
|
||||
"types": "./dist/offlineStore.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pikebacker/shared": "0.1.0"
|
||||
}
|
||||
}
|
||||
80
apps/mobile/src/offlineStore.ts
Normal file
80
apps/mobile/src/offlineStore.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { LocalReportCreate, OfflinePackManifest } from '@pikebacker/shared';
|
||||
|
||||
export type OfflineReportQueueItem = LocalReportCreate & {
|
||||
offlineClientId: string;
|
||||
queuedAt: string;
|
||||
syncAttempts: number;
|
||||
lastSyncError?: string;
|
||||
};
|
||||
|
||||
export type OfflinePackRecord = {
|
||||
manifest: OfflinePackManifest;
|
||||
downloadedAt: string;
|
||||
routeJsonPath?: string;
|
||||
poiJsonPath?: string;
|
||||
gpxPath?: string;
|
||||
tilePackPath?: string;
|
||||
};
|
||||
|
||||
export interface OfflineKeyValueStore {
|
||||
getItem(key: string): Promise<string | null>;
|
||||
setItem(key: string, value: string): Promise<void>;
|
||||
}
|
||||
|
||||
const REPORT_QUEUE_KEY = 'pikebacker.mobile.offlineReportQueue';
|
||||
const PACK_INDEX_KEY = 'pikebacker.mobile.offlinePackIndex';
|
||||
|
||||
export class OfflineReportQueue {
|
||||
constructor(private readonly store: OfflineKeyValueStore) {}
|
||||
|
||||
async enqueue(report: LocalReportCreate, now = new Date().toISOString()): Promise<OfflineReportQueueItem> {
|
||||
const item: OfflineReportQueueItem = {
|
||||
...report,
|
||||
offlineClientId: report.offlineClientId ?? crypto.randomUUID(),
|
||||
queuedAt: now,
|
||||
syncAttempts: 0
|
||||
};
|
||||
const queue = await this.list();
|
||||
await this.store.setItem(REPORT_QUEUE_KEY, JSON.stringify([...queue, item]));
|
||||
return item;
|
||||
}
|
||||
|
||||
async list(): Promise<OfflineReportQueueItem[]> {
|
||||
const raw = await this.store.getItem(REPORT_QUEUE_KEY);
|
||||
return raw ? (JSON.parse(raw) as OfflineReportQueueItem[]) : [];
|
||||
}
|
||||
|
||||
async recordSyncFailure(offlineClientId: string, error: string): Promise<void> {
|
||||
const queue = await this.list();
|
||||
const next = queue.map((item) =>
|
||||
item.offlineClientId === offlineClientId
|
||||
? { ...item, syncAttempts: item.syncAttempts + 1, lastSyncError: error }
|
||||
: item
|
||||
);
|
||||
await this.store.setItem(REPORT_QUEUE_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
async removeSynced(offlineClientId: string): Promise<void> {
|
||||
const queue = await this.list();
|
||||
await this.store.setItem(REPORT_QUEUE_KEY, JSON.stringify(queue.filter((item) => item.offlineClientId !== offlineClientId)));
|
||||
}
|
||||
}
|
||||
|
||||
export class OfflinePackIndex {
|
||||
constructor(private readonly store: OfflineKeyValueStore) {}
|
||||
|
||||
async upsert(record: OfflinePackRecord): Promise<void> {
|
||||
const records = await this.list();
|
||||
const next = [...records.filter((item) => item.manifest.packId !== record.manifest.packId), record];
|
||||
await this.store.setItem(PACK_INDEX_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
async list(): Promise<OfflinePackRecord[]> {
|
||||
const raw = await this.store.getItem(PACK_INDEX_KEY);
|
||||
return raw ? (JSON.parse(raw) as OfflinePackRecord[]) : [];
|
||||
}
|
||||
|
||||
async get(packId: string): Promise<OfflinePackRecord | undefined> {
|
||||
return (await this.list()).find((record) => record.manifest.packId === packId);
|
||||
}
|
||||
}
|
||||
11
apps/mobile/tsconfig.json
Normal file
11
apps/mobile/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
apps/web/.env.example
Normal file
1
apps/web/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
53
apps/web/README.md
Normal file
53
apps/web/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Web App
|
||||
|
||||
Purpose: planning interface for BikepackPilot.
|
||||
|
||||
MVP screens:
|
||||
|
||||
- Trip setup form.
|
||||
- Map view.
|
||||
- Stage timeline.
|
||||
- POI corridor list.
|
||||
- Warnings panel.
|
||||
- Export/offline panel.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Use shared domain types from `packages/shared`.
|
||||
- Use API client generated from or aligned to `schemas/openapi.yaml`.
|
||||
- Use MapLibre-compatible rendering when map implementation begins.
|
||||
- Start with fixture route rendering if map provider is not configured.
|
||||
|
||||
## Implemented MVP
|
||||
|
||||
The current web planner is a Vite/React/MapLibre app. It calls the API provider flow and renders:
|
||||
|
||||
- trip request controls;
|
||||
- full-screen OSM raster tile map;
|
||||
- route, stage endpoint, and POI map layers;
|
||||
- collapsible left planning/export/stats dock;
|
||||
- ordered routing point editor with reorder/delete controls;
|
||||
- right-click map actions for `from here`, `via here`, and `to here`;
|
||||
- draggable waypoint pins and route-drag insertion of via points;
|
||||
- compact floating layer controls;
|
||||
- bottom drawer for stages, warnings, gaps, rule cards, POIs, and linked route/elevation profile hover;
|
||||
- GPX download and offline manifest actions;
|
||||
- structured local report submission with report type, observed time, route-km attachment, confidence payload, localStorage queue fallback, and queued-report sync.
|
||||
|
||||
Run locally after starting the API:
|
||||
|
||||
```bash
|
||||
npm -w @pikebacker/web run dev
|
||||
```
|
||||
|
||||
Configure the API base with `VITE_API_BASE_URL`, defaulting to `http://localhost:8000`.
|
||||
|
||||
The route provider selector includes:
|
||||
|
||||
- `Bikepacking gravel`: calls the API with `provider: "brouter"` and lets the API compare BRouter bike profiles against Pikebacker scoring.
|
||||
- `OSRM road demo`: calls the API with `provider: "osrm"` and uses the configured OSRM endpoint.
|
||||
- `Mock fixture`: deterministic route and POI fixtures for repeatable tests.
|
||||
|
||||
The profile tab renders per-coordinate `elevationM` samples when the selected routing provider supplies them. BRouter and mock routes currently do; OSRM demo routes expose only aggregate ascent/descent warnings.
|
||||
|
||||
The OSM tile layer uses the public OSM raster URL for MVP development and keeps visible OpenStreetMap attribution in the map control.
|
||||
12
apps/web/index.html
Normal file
12
apps/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Pikebacker Planner</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
apps/web/package.json
Normal file
25
apps/web/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@pikebacker/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pikebacker/shared": "0.1.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"maplibre-gl": "^5.13.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"vite": "^8.1.3"
|
||||
}
|
||||
}
|
||||
2155
apps/web/src/App.tsx
Normal file
2155
apps/web/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
92
apps/web/src/api.ts
Normal file
92
apps/web/src/api.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
LocalReport,
|
||||
LocalReportCreate,
|
||||
OfflinePackManifest,
|
||||
Poi,
|
||||
RoutePlan,
|
||||
RuleCard,
|
||||
ServiceGap,
|
||||
StagePlan,
|
||||
Trip,
|
||||
TripRequest
|
||||
} from '@pikebacker/shared';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000';
|
||||
|
||||
export type PlannerBundle = {
|
||||
trip: Trip;
|
||||
route: RoutePlan;
|
||||
stages: StagePlan[];
|
||||
pois: Poi[];
|
||||
gaps: ServiceGap[];
|
||||
rules: RuleCard[];
|
||||
};
|
||||
|
||||
export async function createPlannerBundle(tripRequest: TripRequest, provider = 'mock'): Promise<PlannerBundle> {
|
||||
const trip = await postJson<Trip>('/v1/trips', tripRequest);
|
||||
const route = await postJson<RoutePlan>('/v1/routes/plan', { tripRequest, provider });
|
||||
const stageResponse = await postJson<{ stages: StagePlan[] }>('/v1/stages/plan', {
|
||||
routeId: route.id,
|
||||
tripRequest
|
||||
});
|
||||
const poiBufferKm = route.provider === 'mock' ? 5 : 20;
|
||||
const poiResponse = await getJson<{ pois: Poi[] }>(`/v1/routes/${route.id}/pois?buffer_km=${poiBufferKm}`);
|
||||
const gapResponse = await getJson<{ gaps: ServiceGap[] }>(`/v1/routes/${route.id}/gaps`);
|
||||
const ruleResponse = await getJson<{ rules: RuleCard[] }>(`/v1/rules?route_id=${encodeURIComponent(route.id)}`);
|
||||
|
||||
return {
|
||||
trip,
|
||||
route,
|
||||
stages: stageResponse.stages,
|
||||
pois: poiResponse.pois,
|
||||
gaps: gapResponse.gaps,
|
||||
rules: ruleResponse.rules
|
||||
};
|
||||
}
|
||||
|
||||
export async function createOfflinePack(tripId: string, routeId: string): Promise<OfflinePackManifest> {
|
||||
return postJson<OfflinePackManifest>('/v1/offline-packs', { tripId, routeId, routeBufferKm: 5 });
|
||||
}
|
||||
|
||||
export async function downloadGpx(routeId: string): Promise<Blob> {
|
||||
const response = await fetch(`${API_BASE_URL}/v1/routes/${routeId}/export?format=gpx`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorMessage(response));
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function submitReport(report: LocalReportCreate): Promise<LocalReport> {
|
||||
return postJson<LocalReport>('/v1/reports', report);
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorMessage(response));
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorMessage(response));
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function errorMessage(response: Response): Promise<string> {
|
||||
try {
|
||||
const json = (await response.json()) as { message?: string; error?: string };
|
||||
return json.message ?? json.error ?? response.statusText;
|
||||
} catch {
|
||||
return response.statusText;
|
||||
}
|
||||
}
|
||||
6
apps/web/src/main.tsx
Normal file
6
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import App from './App.js';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root') as HTMLElement).render(<App />);
|
||||
992
apps/web/src/styles.css
Normal file
992
apps/web/src/styles.css
Normal file
@@ -0,0 +1,992 @@
|
||||
:root {
|
||||
background: #111814;
|
||||
color: #19231f;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
align-items: center;
|
||||
background: #f6f7f3;
|
||||
border: 1px solid #c9d1c8;
|
||||
border-radius: 6px;
|
||||
color: #17231e;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-weight: 750;
|
||||
gap: 0.45rem;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.5rem 0.72rem;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #52625a;
|
||||
display: grid;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 850;
|
||||
gap: 0.35rem;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background: #ffffff;
|
||||
border: 1px solid #c7d1c8;
|
||||
border-radius: 6px;
|
||||
color: #18251f;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.48rem 0.58rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 4.5rem;
|
||||
resize: vertical;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.plannerShell {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.mapSurface {
|
||||
background: #e9eee7;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-attrib {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.emptyMapOverlay {
|
||||
align-items: center;
|
||||
background: rgba(245, 248, 243, 0.9);
|
||||
border: 1px solid rgba(42, 62, 53, 0.22);
|
||||
border-radius: 8px;
|
||||
color: #495950;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
justify-items: center;
|
||||
left: 50%;
|
||||
max-width: 24rem;
|
||||
padding: 1rem 1.2rem;
|
||||
position: absolute;
|
||||
top: 42%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.brandPlate,
|
||||
.statusPlate,
|
||||
.leftDock,
|
||||
.layerPanel,
|
||||
.bottomDrawer {
|
||||
backdrop-filter: blur(16px);
|
||||
background: rgba(250, 251, 247, 0.94);
|
||||
border: 1px solid rgba(73, 91, 79, 0.22);
|
||||
box-shadow: 0 18px 60px rgba(15, 24, 20, 0.2);
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.brandPlate {
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
left: var(--left-panel-space, 386px);
|
||||
min-width: 13rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
top: 1rem;
|
||||
transition: left 180ms ease;
|
||||
}
|
||||
|
||||
.leftCollapsed .brandPlate {
|
||||
left: 5.5rem;
|
||||
}
|
||||
|
||||
.brandPlate strong {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #617369;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.statusPlate {
|
||||
border-radius: 999px;
|
||||
color: #23342d;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 750;
|
||||
left: 50%;
|
||||
max-width: min(46rem, calc(100vw - 43rem));
|
||||
overflow: hidden;
|
||||
padding: 0.55rem 0.85rem;
|
||||
text-overflow: ellipsis;
|
||||
top: 1rem;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leftDock {
|
||||
border-radius: 0 12px 12px 0;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
left: 0;
|
||||
max-height: calc(100vh - 2rem);
|
||||
top: 1rem;
|
||||
transition: width 180ms ease;
|
||||
width: 23rem;
|
||||
}
|
||||
|
||||
.leftCollapsed .leftDock {
|
||||
width: 3.75rem;
|
||||
}
|
||||
|
||||
.dockToggle {
|
||||
align-self: stretch;
|
||||
background: #244a3b;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
color: #ffffff;
|
||||
min-height: 100%;
|
||||
width: 2.4rem;
|
||||
}
|
||||
|
||||
.dockContent {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
width: calc(23rem - 2.4rem);
|
||||
}
|
||||
|
||||
.planningForm,
|
||||
.dockSection,
|
||||
.reportForm {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dockSection {
|
||||
border-top: 1px solid #d8dfd8;
|
||||
padding-top: 0.85rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.sectionTitle h2 {
|
||||
font-size: 0.98rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fieldGrid {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.fieldGrid.two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.fieldGrid.three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.reportRange {
|
||||
accent-color: #245741;
|
||||
min-height: 1.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.reportLocation {
|
||||
align-items: center;
|
||||
background: #f5f7f1;
|
||||
border: 1px solid #d8dfd8;
|
||||
border-radius: 8px;
|
||||
color: #4f5f57;
|
||||
display: flex;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 750;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
}
|
||||
|
||||
.reportActions {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.reportActions button {
|
||||
justify-content: center;
|
||||
padding-inline: 0.45rem;
|
||||
}
|
||||
|
||||
.reportQueue {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8dfd8;
|
||||
border-radius: 8px;
|
||||
color: #5d6b63;
|
||||
display: grid;
|
||||
font-size: 0.78rem;
|
||||
gap: 0.22rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
|
||||
.reportQueue span,
|
||||
.reportQueue small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
background: #245741;
|
||||
border-color: #245741;
|
||||
color: #ffffff;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.metricGrid {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.metric {
|
||||
background: #eef3eb;
|
||||
border: 1px solid #d6ded5;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.58rem;
|
||||
}
|
||||
|
||||
.metric span,
|
||||
.muted,
|
||||
.emptyText {
|
||||
color: #66746c;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
font-size: 0.98rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actionRow,
|
||||
.filterRow,
|
||||
.tabRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.manifestBox,
|
||||
.rule {
|
||||
background: #f1f5ef;
|
||||
border: 1px solid #d9e0d8;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
.routingPoints {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.routePointHeader {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.routePointHeader span {
|
||||
color: #52625a;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.routePointHeader button {
|
||||
min-height: 1.9rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
|
||||
.routePointList {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.routePointRow {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8e0d8;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
.routePointRow:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.routePointHandle {
|
||||
align-items: center;
|
||||
color: #829187;
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.routePointBadge {
|
||||
align-items: center;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
color: #ffffff;
|
||||
display: inline-flex;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
height: 1.6rem;
|
||||
justify-content: center;
|
||||
width: 1.6rem;
|
||||
}
|
||||
|
||||
.routePointBadge.start {
|
||||
background: #245741;
|
||||
}
|
||||
|
||||
.routePointBadge.via {
|
||||
background: #6b5aa7;
|
||||
}
|
||||
|
||||
.routePointBadge.end {
|
||||
background: #b75f31;
|
||||
}
|
||||
|
||||
.routePointFields {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.routePointRole {
|
||||
color: #24352d;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.coordinateGrid {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.coordinateGrid label {
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.coordinateGrid input {
|
||||
min-height: 2rem;
|
||||
padding: 0.35rem 0.45rem;
|
||||
}
|
||||
|
||||
.deletePointButton {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #8d3d34;
|
||||
justify-content: center;
|
||||
min-height: 1.9rem;
|
||||
padding: 0.25rem;
|
||||
width: 1.9rem;
|
||||
}
|
||||
|
||||
.deletePointButton:disabled {
|
||||
color: #bdc6bf;
|
||||
}
|
||||
|
||||
.mapContextMenu {
|
||||
background: rgba(250, 251, 247, 0.97);
|
||||
border: 1px solid rgba(73, 91, 79, 0.25);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 16px 50px rgba(15, 24, 20, 0.24);
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
padding: 0.4rem;
|
||||
position: absolute;
|
||||
width: 10rem;
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.mapContextMenu button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
justify-content: flex-start;
|
||||
min-height: 2rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
|
||||
.manifestBox span,
|
||||
.rule span,
|
||||
.rule small {
|
||||
color: #5e6c64;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.layerPanel {
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 0.75rem;
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
width: 10.5rem;
|
||||
}
|
||||
|
||||
.layerTitle,
|
||||
.switchRow,
|
||||
.poiStrip span,
|
||||
.poiRow {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.layerTitle strong {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.switchRow {
|
||||
color: #34463d;
|
||||
display: grid;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 750;
|
||||
grid-template-columns: auto 1fr;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.switchRow input {
|
||||
accent-color: #245741;
|
||||
min-height: auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.bottomDrawer {
|
||||
border-radius: 12px 12px 0 0;
|
||||
bottom: 0;
|
||||
display: grid;
|
||||
gap: 0;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
height: 18rem;
|
||||
left: 24.2rem;
|
||||
right: 1rem;
|
||||
transition:
|
||||
height 180ms ease,
|
||||
left 180ms ease;
|
||||
}
|
||||
|
||||
.leftCollapsed .bottomDrawer {
|
||||
left: 4.8rem;
|
||||
}
|
||||
|
||||
.bottomDrawer.collapsed {
|
||||
height: 3.25rem;
|
||||
}
|
||||
|
||||
.bottomDrawer.profileMode {
|
||||
height: 22rem;
|
||||
}
|
||||
|
||||
.bottomHeader {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #d8dfd8;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.55rem 0.7rem;
|
||||
}
|
||||
|
||||
.bottomDrawer.collapsed .bottomHeader {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.bottomContent {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.profileMode .bottomContent {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabButton.active,
|
||||
.filter.active {
|
||||
background: #234638;
|
||||
border-color: #234638;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
justify-content: center;
|
||||
min-width: 2.35rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.stageStrip {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-auto-columns: minmax(18rem, 22rem);
|
||||
grid-auto-flow: column;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stageCard {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8e0d8;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.stageMain {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.stageMain span {
|
||||
color: #55645c;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.poiStrip {
|
||||
color: #52625a;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.82rem;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.profilePanel {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
grid-template-rows: minmax(8rem, 1fr) auto auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding-block: 0.15rem 0.25rem;
|
||||
}
|
||||
|
||||
.profileChart {
|
||||
background: linear-gradient(180deg, #f7f8f2 0%, #eef3ec 100%);
|
||||
border: 1px solid #d8e0d8;
|
||||
border-radius: 8px;
|
||||
cursor: crosshair;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profileChart svg {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-height: 8rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.elevationArea {
|
||||
fill: rgba(106, 112, 65, 0.22);
|
||||
}
|
||||
|
||||
.elevationLine {
|
||||
fill: none;
|
||||
stroke: #245741;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.stageTick line {
|
||||
stroke: rgba(74, 88, 78, 0.38);
|
||||
stroke-dasharray: 5 5;
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.stageLabelLayer {
|
||||
inset: 0.45rem 0.5rem auto 0.5rem;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.stageLabel {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(93, 107, 99, 0.24);
|
||||
border-radius: 999px;
|
||||
color: #24352d;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 850;
|
||||
padding: 0.12rem 0.38rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profileHoverOverlay {
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.profileHoverLine {
|
||||
border-left: 2px dashed #9a5f14;
|
||||
bottom: 0;
|
||||
display: block;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.profileHoverDot {
|
||||
background: #c9851a;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 4px 14px rgba(25, 35, 31, 0.25);
|
||||
height: 0.85rem;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 0.85rem;
|
||||
}
|
||||
|
||||
.profileAxis {
|
||||
bottom: 0.5rem;
|
||||
color: #5d6b63;
|
||||
display: flex;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 750;
|
||||
justify-content: space-between;
|
||||
left: 0.75rem;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
}
|
||||
|
||||
.profileHoverReadout {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8e0d8;
|
||||
border-radius: 8px;
|
||||
color: #52625a;
|
||||
display: grid;
|
||||
font-size: 0.84rem;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
min-height: 2.35rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
}
|
||||
|
||||
.profileHoverReadout span,
|
||||
.profileHoverReadout strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profileHoverReadout strong {
|
||||
color: #24352d;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mutedReadout {
|
||||
color: #7b8980;
|
||||
}
|
||||
|
||||
.profileStats {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.profileStat {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8e0d8;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
padding: 0.42rem 0.55rem;
|
||||
}
|
||||
|
||||
.profileStat span {
|
||||
color: #607169;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.profileStat strong {
|
||||
color: #24352d;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.emptyProfile .profileStats {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.infoGrid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: 1.2fr 1fr 1.1fr;
|
||||
}
|
||||
|
||||
.infoColumn {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.warningList,
|
||||
.gapList,
|
||||
.ruleList,
|
||||
.poiList {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
align-items: start;
|
||||
background: #f7f8f4;
|
||||
border-left: 4px solid #aab7ad;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.55rem;
|
||||
}
|
||||
|
||||
.warning strong,
|
||||
.warning span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.warning span {
|
||||
color: #52625a;
|
||||
font-size: 0.82rem;
|
||||
margin-top: 0.12rem;
|
||||
}
|
||||
|
||||
.warning.critical {
|
||||
background: #fff0ee;
|
||||
border-color: #b93d37;
|
||||
}
|
||||
|
||||
.warning.warning {
|
||||
background: #fff7e4;
|
||||
border-color: #c9851a;
|
||||
}
|
||||
|
||||
.warning.notice {
|
||||
background: #eef6fc;
|
||||
border-color: #5f86ae;
|
||||
}
|
||||
|
||||
.warningList.compact {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.warningList.compact .warning {
|
||||
padding: 0.35rem 0.45rem;
|
||||
}
|
||||
|
||||
.gap {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8e0d8;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.55rem;
|
||||
}
|
||||
|
||||
.gap span {
|
||||
color: #5d6b63;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.gap.critical {
|
||||
border-color: #d59a92;
|
||||
}
|
||||
|
||||
.gap.warning {
|
||||
border-color: #e1bd72;
|
||||
}
|
||||
|
||||
.poiPanel {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.poiList {
|
||||
grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
|
||||
}
|
||||
|
||||
.poiRow {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8e0d8;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
|
||||
.poiRow div {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.poiRow span {
|
||||
color: #65756d;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.poiDot {
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
height: 0.72rem;
|
||||
width: 0.72rem;
|
||||
}
|
||||
|
||||
.poiDot.water {
|
||||
background: #2379b7;
|
||||
}
|
||||
|
||||
.poiDot.food {
|
||||
background: #b75f31;
|
||||
}
|
||||
|
||||
.poiDot.sleep {
|
||||
background: #6750a4;
|
||||
}
|
||||
|
||||
.poiDot.repair {
|
||||
background: #6a7041;
|
||||
}
|
||||
|
||||
.poiDot.bailout {
|
||||
background: #1f7774;
|
||||
}
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.statusPlate {
|
||||
left: auto;
|
||||
max-width: calc(100vw - 13rem);
|
||||
right: 12.3rem;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.bottomDrawer,
|
||||
.leftCollapsed .bottomDrawer {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.infoGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profileStats {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.profileHoverReadout {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.brandPlate,
|
||||
.statusPlate,
|
||||
.layerPanel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.leftDock,
|
||||
.leftCollapsed .leftDock {
|
||||
border-radius: 0 0 12px 0;
|
||||
left: 0;
|
||||
max-height: 68vh;
|
||||
top: 0;
|
||||
width: min(23rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.leftCollapsed .leftDock {
|
||||
width: 3.75rem;
|
||||
}
|
||||
|
||||
.bottomDrawer,
|
||||
.leftCollapsed .bottomDrawer {
|
||||
height: 17rem;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
.bottomDrawer.profileMode,
|
||||
.leftCollapsed .bottomDrawer.profileMode {
|
||||
height: 20rem;
|
||||
}
|
||||
|
||||
.fieldGrid.two,
|
||||
.fieldGrid.three,
|
||||
.metricGrid,
|
||||
.reportActions,
|
||||
.profileHoverReadout,
|
||||
.profileStats,
|
||||
.emptyProfile .profileStats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
9
apps/web/tsconfig.json
Normal file
9
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
18
apps/web/vite.config.ts
Normal file
18
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@pikebacker/shared': resolve(here, '../../packages/shared/src/index.ts')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
});
|
||||
13
codex/BACKLOG.csv
Normal file
13
codex/BACKLOG.csv
Normal file
@@ -0,0 +1,13 @@
|
||||
id,epic,story,priority,acceptance
|
||||
BP-001,Planning,As a rider I can create a trip request with bike/load and daily constraints,P0,Form submits typed request and persists trip shell
|
||||
BP-002,Routing,As a rider I can get a bikepacking route from a mock provider,P0,Route geometry score warnings returned deterministically
|
||||
BP-003,Scoring,As a rider I can see why a route is risky or suitable,P0,Score includes surface grade access water and food warnings
|
||||
BP-004,Staging,As a rider I can split a route into daily stages,P0,Stages respect daily distance range and attach sleep options
|
||||
BP-005,POI Corridor,As a rider I can see water food sleep and repair near my route,P0,POIs sorted by meters from start and detour distance
|
||||
BP-006,Gaps,As a rider I can see long water food repair and bailout gaps,P0,Warnings produced when thresholds exceeded
|
||||
BP-007,Export,As a rider I can export GPX,P0,GPX validates and includes stage waypoints
|
||||
BP-008,Offline,As a rider I can generate an offline pack manifest,P0,Manifest includes route stages POIs rules and exports
|
||||
BP-009,Rules,As a rider I can see local rule cards,P1,Rule card has source date confidence and advisory status
|
||||
BP-010,Reports,As a rider I can submit structured local reports,P1,Report stores type location observed_at trust and expiry
|
||||
BP-011,Weather,As a rider I can see weather warnings along stages,P2,Forecast endpoint returns route/stage risk summaries
|
||||
BP-012,Devices,As a rider I can export TCX/FIT or sync devices,P2,Device export provider interface and first implementation exist
|
||||
|
52
codex/CODEX_MASTER_PROMPT.md
Normal file
52
codex/CODEX_MASTER_PROMPT.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Codex Master Prompt
|
||||
|
||||
Use this prompt with Codex or another coding agent.
|
||||
|
||||
```text
|
||||
You are building pikebacker, a specialized bikepacking planner and riding companion.
|
||||
|
||||
Read the repository docs before coding, especially:
|
||||
- README.md
|
||||
- AGENTS.md
|
||||
- docs/01_product_requirements.md
|
||||
- docs/02_mvp_scope.md
|
||||
- docs/03_system_architecture.md
|
||||
- docs/05_routing_and_scoring.md
|
||||
- docs/06_offline_strategy.md
|
||||
- docs/08_data_model.md
|
||||
- schemas/openapi.yaml
|
||||
- schemas/database.sql
|
||||
- codex/IMPLEMENTATION_TASKS.md
|
||||
|
||||
Build the MVP as a monorepo with:
|
||||
- apps/web: TypeScript web planner with map placeholder or MapLibre integration.
|
||||
- apps/mobile: placeholder/mobile offline architecture if full mobile implementation is too large.
|
||||
- services/api: backend API implementing the MVP endpoints.
|
||||
- packages/shared: shared TypeScript domain types and scoring logic.
|
||||
- services/data-pipeline: import/normalization scripts and docs.
|
||||
|
||||
First implement deterministic mock-provider functionality from examples/ fixtures. Do not block on external API keys.
|
||||
|
||||
Core MVP behavior:
|
||||
1. Create a trip request.
|
||||
2. Plan or load a route from fixture/mock routing provider.
|
||||
3. Score route segments for bikepacking suitability.
|
||||
4. Split route into daily stages.
|
||||
5. Find route-corridor POIs from fixture/database.
|
||||
6. Generate critical gap warnings.
|
||||
7. Render route and stage cards in the web planner.
|
||||
8. Export GPX.
|
||||
9. Create offline-pack manifest.
|
||||
10. Submit local reports and queue offline reports.
|
||||
|
||||
Engineering rules:
|
||||
- Keep provider interfaces abstract and testable.
|
||||
- Do not hardcode API keys.
|
||||
- Use environment variables for providers.
|
||||
- Add unit tests for scoring, staging, POI corridor, and GPX export.
|
||||
- Preserve attribution hooks for OSM-derived data.
|
||||
- Expose uncertainty and warning severity in API responses.
|
||||
- Make the app useful with mock data before adding real providers.
|
||||
|
||||
After each task, update README or docs if setup/behavior changes.
|
||||
```
|
||||
157
codex/IMPLEMENTATION_TASKS.md
Normal file
157
codex/IMPLEMENTATION_TASKS.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Implementation Tasks for Codex
|
||||
|
||||
## Task 1 — Create monorepo scaffold
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
apps/web
|
||||
apps/mobile
|
||||
services/api
|
||||
services/data-pipeline
|
||||
packages/shared
|
||||
```
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- root package/workspace or equivalent created;
|
||||
- lint/test scripts documented;
|
||||
- shared domain types imported by web/API where applicable;
|
||||
- `.env.example` files created;
|
||||
- no API keys committed.
|
||||
|
||||
## Task 2 — Implement shared domain types and scoring
|
||||
|
||||
Implement TypeScript types from `schemas/domain_types.ts`.
|
||||
|
||||
Add pure functions:
|
||||
|
||||
- `scoreSegment(segment, profile)`;
|
||||
- `scoreRoute(route, profile)`;
|
||||
- `detectCriticalGaps(route, pois, thresholds)`;
|
||||
- `estimateHikeABikeRisk(segment, profile)`.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- unit tests pass;
|
||||
- sample scoring fixture returns expected warnings;
|
||||
- profile thresholds are configurable.
|
||||
|
||||
## Task 3 — Backend API mock MVP
|
||||
|
||||
Implement endpoints from `schemas/openapi.yaml` with mock data:
|
||||
|
||||
- `POST /v1/routes/plan`,
|
||||
- `POST /v1/stages/plan`,
|
||||
- `GET /v1/routes/{id}/pois`,
|
||||
- `GET /v1/routes/{id}/gaps`,
|
||||
- `POST /v1/offline-packs`,
|
||||
- `GET /v1/routes/{id}/export?format=gpx`,
|
||||
- `POST /v1/reports`.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- returns deterministic responses from `examples/`;
|
||||
- API validation is implemented;
|
||||
- tests cover success and error cases.
|
||||
|
||||
## Task 4 — Stage planner
|
||||
|
||||
Implement stage splitting:
|
||||
|
||||
- respect min/max daily distance;
|
||||
- prefer endpoints near sleep options;
|
||||
- avoid long water/food gaps at stage end;
|
||||
- include Plan A/B endpoints;
|
||||
- generate warnings.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- 3-day and 5-day fixture routes produce plausible stages;
|
||||
- no stage exceeds hard constraints unless warning returned;
|
||||
- sleep candidates attached to each stage.
|
||||
|
||||
## Task 5 — Web planner UI
|
||||
|
||||
Build a web UI:
|
||||
|
||||
- trip request form;
|
||||
- map panel;
|
||||
- stage timeline;
|
||||
- POI filter toggles;
|
||||
- warnings panel;
|
||||
- export/offline buttons.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- user can submit sample trip and see route/stages;
|
||||
- route-corridor POIs shown/listed;
|
||||
- critical warnings visible;
|
||||
- GPX export downloads.
|
||||
|
||||
## Task 6 — GPX export
|
||||
|
||||
Generate valid GPX with:
|
||||
|
||||
- route geometry;
|
||||
- waypoints for stage endpoints;
|
||||
- optional POIs/course points;
|
||||
- metadata and attribution.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- validates as XML;
|
||||
- imports into common route viewers;
|
||||
- includes stage waypoints.
|
||||
|
||||
## Task 7 — Offline pack manifest
|
||||
|
||||
Implement offline manifest generation.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- includes route, stages, POIs, rules, exports, data versions;
|
||||
- marks unavailable layers clearly;
|
||||
- supports future tile pack file references.
|
||||
|
||||
## Task 8 — Data pipeline skeleton
|
||||
|
||||
Implement scripts/docs for:
|
||||
|
||||
- reading OSM PBF extract;
|
||||
- extracting POI categories;
|
||||
- normalizing tags;
|
||||
- inserting into PostGIS;
|
||||
- importing protected areas/rule cards later.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- one small sample extract/fixture works;
|
||||
- schema migrations documented;
|
||||
- source attribution stored.
|
||||
|
||||
## Task 9 — Provider adapters
|
||||
|
||||
Add adapters:
|
||||
|
||||
- mock routing provider;
|
||||
- openrouteservice provider placeholder;
|
||||
- GraphHopper/Valhalla adapter interfaces;
|
||||
- OSM POI provider.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- provider selected via environment/config;
|
||||
- mock provider is default;
|
||||
- external provider failures return actionable errors.
|
||||
|
||||
## Task 10 — Community reports
|
||||
|
||||
Implement structured report model and UI submission.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- reports typed and dated;
|
||||
- offline queue represented;
|
||||
- reports can attach to route meters/location;
|
||||
- moderation status supported.
|
||||
32
design/map_layers.md
Normal file
32
design/map_layers.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Map Layers
|
||||
|
||||
## MVP layers
|
||||
|
||||
- Route.
|
||||
- Stage boundaries.
|
||||
- Water.
|
||||
- Food.
|
||||
- Sleep.
|
||||
- Repair.
|
||||
- Bailout.
|
||||
- Warnings.
|
||||
- Rule cards.
|
||||
|
||||
## Production layers
|
||||
|
||||
- Surface/roughness.
|
||||
- Known/unknown access.
|
||||
- Protected areas.
|
||||
- Weather exposure.
|
||||
- Dry stretches.
|
||||
- Last-resupply-before-remote-section.
|
||||
- Local reports.
|
||||
- Heat/cold risk.
|
||||
- E-bike charging.
|
||||
|
||||
## Visual language
|
||||
|
||||
- Critical warnings should be visually distinct.
|
||||
- Uncertain data should look uncertain, not hidden.
|
||||
- POIs should default to route-corridor relevance.
|
||||
- Show stale data markers on POIs and rules.
|
||||
37
design/wireframes_text.md
Normal file
37
design/wireframes_text.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Text Wireframes
|
||||
|
||||
## Planner page
|
||||
|
||||
```text
|
||||
+--------------------------------------------------------------------------------+
|
||||
| Header: BikepackPilot | New Trip | Import GPX | Offline Packs | Account |
|
||||
+------------------------------+-------------------------------------------------+
|
||||
| Trip Setup | Map |
|
||||
| Start: [____________] | |
|
||||
| End: [____________] | route line |
|
||||
| Dates: [____] - [____] | stage markers |
|
||||
| Bike: [Loaded gravel v] | POI icons |
|
||||
| Daily km: 55 / 75 / 95 | |
|
||||
| Sleep: [Mixed budget v] | |
|
||||
| Water gap max: 40 km | |
|
||||
| [Plan Trip] | |
|
||||
+------------------------------+-------------------------------------------------+
|
||||
| Warnings | Stage Timeline | POIs | Rules | Export | Offline |
|
||||
+--------------------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
## Riding mode
|
||||
|
||||
```text
|
||||
+-----------------------------------+
|
||||
| Day 2 / 81.7 km |
|
||||
| Next water: 14.3 km |
|
||||
| Next food: 23.0 km |
|
||||
| Sleep Plan A: 81.7 km |
|
||||
| Bailout: train +9.1 km detour |
|
||||
| Warning: steep climb in 12 km |
|
||||
| |
|
||||
| [Reroute to water] [Find sleep] |
|
||||
| [Bike shop] [Bail out] [Report] |
|
||||
+-----------------------------------+
|
||||
```
|
||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
db:
|
||||
image: postgis/postgis:16-3.4
|
||||
environment:
|
||||
POSTGRES_DB: bikepackpilot
|
||||
POSTGRES_USER: bikepackpilot
|
||||
POSTGRES_PASSWORD: bikepackpilot
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
# Optional future self-hosted routing service.
|
||||
# routing:
|
||||
# image: ghcr.io/gis-ops/valhalla:latest
|
||||
# ports:
|
||||
# - "8002:8002"
|
||||
volumes:
|
||||
postgres_data:
|
||||
43
docs/00_executive_summary.md
Normal file
43
docs/00_executive_summary.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Executive Summary
|
||||
|
||||
## Product
|
||||
|
||||
A specialized bikepacking planner and riding companion for multi-day trips. It should combine route planning, daily stages, route-corridor logistics, local rules, offline use, and bikepacking-specific route suitability.
|
||||
|
||||
## Positioning
|
||||
|
||||
**BikepackPilot** is for riders who need answers beyond “which road/path connects A to B?”
|
||||
|
||||
It answers:
|
||||
|
||||
- Can I ride this with a loaded bike?
|
||||
- Where can I sleep tonight?
|
||||
- How far to the next reliable water?
|
||||
- Is there food before shops close?
|
||||
- What if this forest road is closed?
|
||||
- Where is the nearest bike shop or bailout train?
|
||||
- Can the plan still work offline?
|
||||
|
||||
## Primary users
|
||||
|
||||
1. **Weekend bikepacker**: 2–3 days, wants low stress, legal sleep options, reliable water/food.
|
||||
2. **Long-distance tourer**: multi-week trip, needs offline maps, resupply, device export, budget sleep.
|
||||
3. **Gravel/adventure rider**: wants maximum dirt but still rideable on a loaded bike.
|
||||
4. **Remote-route rider**: needs dry-stretch warnings, bailout options, weather exposure, repair planning.
|
||||
5. **E-bikepacker**: needs charging points, range-aware stages, charger-friendly overnight stops.
|
||||
|
||||
## Differentiation from general route planners
|
||||
|
||||
The differentiator is not access to a secret map dataset. Major outdoor planners often use OpenStreetMap as a foundation. The differentiator is the proprietary layer built on top:
|
||||
|
||||
- bikepacking suitability score,
|
||||
- rideability with loaded bike,
|
||||
- water/food/sleep gaps,
|
||||
- local rules confidence,
|
||||
- trusted local reports,
|
||||
- offline route-corridor intelligence,
|
||||
- multi-day staging optimized around logistics.
|
||||
|
||||
## MVP outcome
|
||||
|
||||
A user should be able to plan a 3–7 day trip, inspect stage cards, see critical POIs, export a route, and save an offline pack.
|
||||
133
docs/01_product_requirements.md
Normal file
133
docs/01_product_requirements.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Product Requirements Document
|
||||
|
||||
## Vision
|
||||
|
||||
Build the best planning and riding tool for self-supported bikepacking trips.
|
||||
|
||||
## Core promise
|
||||
|
||||
Plan a rideable, legal, enjoyable, and logistically safe route for a loaded bike over multiple days, including sleep, water, food, repair, local rules, weather, and bailout options.
|
||||
|
||||
## Non-goals for MVP
|
||||
|
||||
- Do not build a social network first.
|
||||
- Do not build full turn-by-turn voice navigation first.
|
||||
- Do not build a complete global legal camping database first.
|
||||
- Do not depend on one commercial booking/routing API.
|
||||
- Do not attempt global correctness for local rules. Use confidence levels.
|
||||
|
||||
## Functional requirements
|
||||
|
||||
### Trip planning
|
||||
|
||||
Users can create a trip with:
|
||||
|
||||
- start/end locations or imported GPX,
|
||||
- start date and optional end date,
|
||||
- bike type,
|
||||
- load level,
|
||||
- rider fitness preference,
|
||||
- daily target distance range,
|
||||
- daily target climbing range,
|
||||
- maximum loaded-bike grade,
|
||||
- preferred surface mix,
|
||||
- sleep preference,
|
||||
- water interval limit,
|
||||
- resupply interval limit,
|
||||
- bailout preference.
|
||||
|
||||
### Bikepacking routing
|
||||
|
||||
The planner returns:
|
||||
|
||||
- route geometry,
|
||||
- elevation profile,
|
||||
- distance and climb,
|
||||
- surface breakdown,
|
||||
- traffic/access confidence,
|
||||
- steep segment warnings,
|
||||
- long no-water/no-food warnings,
|
||||
- possible hike-a-bike warnings,
|
||||
- stage suggestions.
|
||||
|
||||
### Multi-day staging
|
||||
|
||||
The app divides the route into daily stages based on:
|
||||
|
||||
- daily distance/climb targets,
|
||||
- daylight window,
|
||||
- sleep options,
|
||||
- food/water availability,
|
||||
- bailout access,
|
||||
- weather risk if available,
|
||||
- user preference for indoor/outdoor nights.
|
||||
|
||||
Each stage has:
|
||||
|
||||
- distance,
|
||||
- ascent/descent,
|
||||
- expected riding time range,
|
||||
- surface mix,
|
||||
- sleep candidates,
|
||||
- food/water critical points,
|
||||
- warnings,
|
||||
- Plan A and Plan B endpoint.
|
||||
|
||||
### Route-corridor logistics
|
||||
|
||||
The app supports corridor queries around the route:
|
||||
|
||||
- water,
|
||||
- supermarkets,
|
||||
- bakeries,
|
||||
- cafés/restaurants,
|
||||
- gas stations,
|
||||
- campsites,
|
||||
- shelters,
|
||||
- huts,
|
||||
- hostels/hotels,
|
||||
- Warmshowers-style hosts via partnership/deep link if available,
|
||||
- bike shops,
|
||||
- repair stations,
|
||||
- train/bus/ferry bailout.
|
||||
|
||||
### Offline
|
||||
|
||||
Offline packs should include:
|
||||
|
||||
- route geometry,
|
||||
- map tiles/vector map region,
|
||||
- route-corridor POIs,
|
||||
- stage plan,
|
||||
- elevation profile,
|
||||
- rule cards,
|
||||
- emergency/bailout points,
|
||||
- saved notes,
|
||||
- exports.
|
||||
|
||||
### Community/local intelligence
|
||||
|
||||
Users can report:
|
||||
|
||||
- water available/unavailable,
|
||||
- surface mismatch,
|
||||
- locked gate,
|
||||
- access restriction,
|
||||
- dangerous dogs,
|
||||
- mud/sand/washout,
|
||||
- bridge/ferry closure,
|
||||
- good/bad sleep option,
|
||||
- bike shop availability,
|
||||
- reroute recommendation.
|
||||
|
||||
Reports must be dated, location-bound, typed, confidence-scored, and expire unless reconfirmed.
|
||||
|
||||
## Non-functional requirements
|
||||
|
||||
- Offline-first for riding-critical functions.
|
||||
- Clear attribution for OSM-derived data.
|
||||
- Explicit uncertainty indicators for legal/safety information.
|
||||
- Fast route-corridor search.
|
||||
- Provider abstraction to avoid lock-in.
|
||||
- Export support for GPX first, then TCX/FIT.
|
||||
- Privacy-conscious location handling.
|
||||
71
docs/02_mvp_scope.md
Normal file
71
docs/02_mvp_scope.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# MVP Scope
|
||||
|
||||
## MVP goal
|
||||
|
||||
Deliver a planner that is useful before the ride and minimally useful during the ride.
|
||||
|
||||
## MVP features
|
||||
|
||||
### Must have
|
||||
|
||||
1. Trip request form.
|
||||
2. Routing adapter using a configurable provider.
|
||||
3. Bikepacking route scoring summary.
|
||||
4. Stage planner.
|
||||
5. Route-corridor POIs for water, food, sleep, and bike repair.
|
||||
6. Critical-gap warnings.
|
||||
7. GPX export.
|
||||
8. Offline-pack manifest and cached trip data.
|
||||
9. Text-based local rule cards.
|
||||
10. Structured report submission.
|
||||
|
||||
### Should have
|
||||
|
||||
1. Map layer toggles.
|
||||
2. Surface/elevation/stage chart.
|
||||
3. Bailout POI detection.
|
||||
4. Weather overlay and wind/rain warnings.
|
||||
5. TCX export.
|
||||
6. Deep links to accommodation/search platforms.
|
||||
|
||||
### Could have
|
||||
|
||||
1. FIT export.
|
||||
2. Garmin Courses API integration.
|
||||
3. Wahoo integration.
|
||||
4. In-app booking.
|
||||
5. AI route explanation.
|
||||
6. Trusted-local reputation system.
|
||||
|
||||
### Not in MVP
|
||||
|
||||
1. Full global offline routing.
|
||||
2. Real-time social feed.
|
||||
3. Payments marketplace.
|
||||
4. Full legal advice engine.
|
||||
5. Fully automated OSM editing.
|
||||
|
||||
## MVP success criteria
|
||||
|
||||
A test user can plan a 5-day gravel bikepacking route and identify:
|
||||
|
||||
- daily endpoints,
|
||||
- longest water gap,
|
||||
- last shop before remote section,
|
||||
- sleep options each night,
|
||||
- bike repair options,
|
||||
- uncertain access segments,
|
||||
- bailout points,
|
||||
- exported GPX.
|
||||
|
||||
## Target regions for MVP validation
|
||||
|
||||
Pick one or two data-rich regions first, for example:
|
||||
|
||||
- Germany/Austria/Switzerland,
|
||||
- Netherlands/Belgium/Germany,
|
||||
- UK,
|
||||
- Pacific Northwest,
|
||||
- Colorado/Utah.
|
||||
|
||||
Data quality varies heavily by region. Start where OSM and POI data are strong and bikepacking demand exists.
|
||||
120
docs/03_system_architecture.md
Normal file
120
docs/03_system_architecture.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# System Architecture
|
||||
|
||||
## Recommended stack
|
||||
|
||||
### Apps
|
||||
|
||||
- `apps/web`: trip planning interface.
|
||||
- `apps/mobile`: mobile riding/offline interface.
|
||||
|
||||
Recommended technologies:
|
||||
|
||||
- TypeScript.
|
||||
- React for web.
|
||||
- React Native for mobile.
|
||||
- MapLibre-compatible rendering.
|
||||
|
||||
### Backend
|
||||
|
||||
- API service: FastAPI or equivalent.
|
||||
- Database: PostgreSQL + PostGIS.
|
||||
- Queue: Redis + worker for imports/scoring/offline-pack jobs.
|
||||
- Object storage: S3-compatible bucket for generated exports/offline manifests.
|
||||
|
||||
### Routing
|
||||
|
||||
Routing must be accessed through a provider interface. Supported target providers:
|
||||
|
||||
- openrouteservice for prototype/API-based routing,
|
||||
- GraphHopper for self-hosted or API routing,
|
||||
- Valhalla for self-hosted routing and isochrones,
|
||||
- BRouter-inspired offline routing later.
|
||||
|
||||
### Map display
|
||||
|
||||
- MapLibre-compatible client.
|
||||
- Vector tiles from self-hosted OpenMapTiles, Protomaps/PMTiles, MapTiler, or equivalent.
|
||||
- Do not rely on public OSM tile servers for production app traffic.
|
||||
|
||||
### Data pipeline
|
||||
|
||||
Pipeline jobs:
|
||||
|
||||
1. Download OSM extracts.
|
||||
2. Normalize bikepacking-relevant tags.
|
||||
3. Build route graph or send extract to routing engine.
|
||||
4. Import POIs.
|
||||
5. Attach elevation.
|
||||
6. Import protected areas/rules.
|
||||
7. Generate derived segment scores.
|
||||
8. Build offline pack manifests.
|
||||
|
||||
## Services
|
||||
|
||||
```text
|
||||
Client Apps
|
||||
|-- Web planner
|
||||
|-- Mobile rider app
|
||||
|
||||
Backend API
|
||||
|-- Auth/session
|
||||
|-- Trip planner
|
||||
|-- Stage planner
|
||||
|-- Route scorer
|
||||
|-- POI corridor service
|
||||
|-- Offline pack service
|
||||
|-- Export service
|
||||
|-- Community report service
|
||||
|-- Rules service
|
||||
|
||||
Provider Layer
|
||||
|-- Routing provider
|
||||
|-- Elevation provider
|
||||
|-- POI provider
|
||||
|-- Weather provider
|
||||
|-- Accommodation provider
|
||||
|-- Device export provider
|
||||
|
||||
Data Stores
|
||||
|-- PostGIS
|
||||
|-- Object storage
|
||||
|-- Cache/queue
|
||||
```
|
||||
|
||||
## Provider interfaces
|
||||
|
||||
Every external dependency should be swappable.
|
||||
|
||||
```ts
|
||||
interface RoutingProvider {
|
||||
planRoute(request: RoutePlanRequest): Promise<RouteGeometryResult>;
|
||||
reroute(request: RerouteRequest): Promise<RouteGeometryResult>;
|
||||
}
|
||||
|
||||
interface PoiProvider {
|
||||
searchCorridor(request: CorridorPoiRequest): Promise<Poi[]>;
|
||||
}
|
||||
|
||||
interface ElevationProvider {
|
||||
samplePolyline(polyline: Coordinate[]): Promise<ElevationSample[]>;
|
||||
}
|
||||
|
||||
interface WeatherProvider {
|
||||
forecastAlongRoute(request: WeatherRouteRequest): Promise<RouteWeatherRisk[]>;
|
||||
}
|
||||
|
||||
interface RulesProvider {
|
||||
getRuleCards(request: RuleCardRequest): Promise<RuleCard[]>;
|
||||
}
|
||||
```
|
||||
|
||||
## MVP architecture simplification
|
||||
|
||||
For first implementation:
|
||||
|
||||
- use a hosted routing provider or mock routing fixture;
|
||||
- use OSM-derived POI fixtures in PostGIS;
|
||||
- implement stage planning and scoring locally;
|
||||
- render a route and stage cards;
|
||||
- export GPX;
|
||||
- define offline pack manifest without full tile download implementation.
|
||||
124
docs/04_data_sources.md
Normal file
124
docs/04_data_sources.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Data Sources
|
||||
|
||||
Verified source links are listed in `docs/source_references.md`.
|
||||
|
||||
## Core principle
|
||||
|
||||
The raw route network can be built from OpenStreetMap. The competitive advantage comes from derived intelligence:
|
||||
|
||||
- route segment suitability,
|
||||
- loaded-bike rideability,
|
||||
- route-corridor logistics,
|
||||
- water/food/sleep reliability,
|
||||
- local rules confidence,
|
||||
- trusted local reports,
|
||||
- actual ride feedback.
|
||||
|
||||
## Data-source matrix
|
||||
|
||||
| Domain | Source candidates | MVP use | Production use | Caveats |
|
||||
|---|---|---|---|---|
|
||||
| Base map/network | OpenStreetMap extracts via Geofabrik or planet.osm | Road/path graph, surfaces, access, POIs | Full regional/global ingestion | ODbL obligations; data quality varies |
|
||||
| Routing engine | openrouteservice, GraphHopper, Valhalla, BRouter | Hosted API or local engine adapter | Self-hosted customized profiles | Requires tuning; public APIs have limits |
|
||||
| Elevation | Copernicus DEM GLO-30/GLO-90, NASA SRTM | Elevation profile and grade warnings | Segment-level grade/hike-a-bike modeling | DEM artifacts; bridges/tunnels need handling |
|
||||
| Vector maps | OpenMapTiles, Protomaps/PMTiles, MapTiler, custom tiles | Web/mobile map display | Offline vector packs | Attribution and tile generation costs |
|
||||
| POIs | OSM POIs, Overture Places, partner APIs | Water/food/sleep/repair corridor | Reliability scoring and deduplication | POIs stale/incomplete; licensing varies |
|
||||
| Protected areas | Protected Planet WDPA/WDPCA, national/regional open data | Rule warnings | Rule confidence engine | Rules are jurisdiction-specific |
|
||||
| Weather | Open-Meteo, MET Norway/Yr, national weather services, commercial APIs | Forecast along route | Weather-aware rerouting | Forecast uncertainty; API licensing |
|
||||
| Accommodation | OSM campsites/shelters, Booking Demand API, direct campsite/hut partners, Warmshowers-style partnerships | Deep links and search cards | Reservation/check availability where allowed | API access/partner terms |
|
||||
| Bike devices | GPX/TCX/FIT export, Garmin Courses API, Wahoo Cloud API | GPX export | Direct sync | API approval required |
|
||||
|
||||
## OSM tags to prioritize
|
||||
|
||||
### Route network
|
||||
|
||||
- `highway=*`
|
||||
- `bicycle=*`
|
||||
- `access=*`
|
||||
- `surface=*`
|
||||
- `smoothness=*`
|
||||
- `tracktype=*`
|
||||
- `sac_scale=*`
|
||||
- `mtb:scale=*`
|
||||
- `incline=*`
|
||||
- `ford=*`
|
||||
- `bridge=*`
|
||||
- `tunnel=*`
|
||||
- `barrier=*`
|
||||
- `route=bicycle`
|
||||
- `network=*`
|
||||
|
||||
### Water
|
||||
|
||||
- `amenity=drinking_water`
|
||||
- `drinking_water=yes/no`
|
||||
- `natural=spring`
|
||||
- `man_made=water_well`
|
||||
- `amenity=fountain`
|
||||
|
||||
### Food and resupply
|
||||
|
||||
- `shop=supermarket`
|
||||
- `shop=convenience`
|
||||
- `shop=bakery`
|
||||
- `amenity=cafe`
|
||||
- `amenity=restaurant`
|
||||
- `amenity=fast_food`
|
||||
- `amenity=fuel`
|
||||
|
||||
### Sleep
|
||||
|
||||
- `tourism=camp_site`
|
||||
- `tourism=caravan_site`
|
||||
- `tourism=alpine_hut`
|
||||
- `tourism=wilderness_hut`
|
||||
- `tourism=hostel`
|
||||
- `tourism=hotel`
|
||||
- `amenity=shelter`
|
||||
|
||||
### Bike repair
|
||||
|
||||
- `shop=bicycle`
|
||||
- `amenity=bicycle_repair_station`
|
||||
- `service:bicycle:repair=yes`
|
||||
- `service:bicycle:pump=yes`
|
||||
|
||||
### Bailout
|
||||
|
||||
- `railway=station`
|
||||
- `public_transport=station`
|
||||
- `amenity=bus_station`
|
||||
- `route=ferry`
|
||||
|
||||
## Data ingestion notes
|
||||
|
||||
### OSM
|
||||
|
||||
Use regional PBF extracts for development. Do not query public Overpass for production-scale route-corridor POI needs. Import regional extracts into PostGIS or a search index.
|
||||
|
||||
### POI deduplication
|
||||
|
||||
POIs may appear in multiple sources. Use a deduplication key based on:
|
||||
|
||||
- geohash proximity,
|
||||
- normalized name,
|
||||
- category,
|
||||
- phone/website if available,
|
||||
- source reliability.
|
||||
|
||||
### Reliability scoring
|
||||
|
||||
Each POI should have:
|
||||
|
||||
- `source`,
|
||||
- `last_seen_at`,
|
||||
- `last_confirmed_by_user_at`,
|
||||
- `seasonality`,
|
||||
- `opening_hours`,
|
||||
- `confidence`,
|
||||
- `bikepacker_notes_count`,
|
||||
- `negative_reports_count`.
|
||||
|
||||
### Rules
|
||||
|
||||
Rules must be jurisdiction-specific and confidence-scored. Never present legal/camping rules as absolute unless sourced from official/local authority data and current.
|
||||
156
docs/05_routing_and_scoring.md
Normal file
156
docs/05_routing_and_scoring.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Routing and Scoring Model
|
||||
|
||||
## Goal
|
||||
|
||||
Optimize for a loaded bikepacking trip, not a generic bike route.
|
||||
|
||||
## Route cost model
|
||||
|
||||
A candidate route is scored by segment and by whole-trip logistics.
|
||||
|
||||
```text
|
||||
segment_cost =
|
||||
distance_cost
|
||||
+ climb_cost
|
||||
+ steepness_cost
|
||||
+ poor_surface_cost
|
||||
+ traffic_stress_cost
|
||||
+ access_uncertainty_cost
|
||||
+ hike_a_bike_risk_cost
|
||||
+ remoteness_cost
|
||||
+ weather_sensitivity_cost
|
||||
- official_cycle_route_bonus
|
||||
- confirmed_good_segment_bonus
|
||||
- scenic_bonus
|
||||
```
|
||||
|
||||
```text
|
||||
stage_cost =
|
||||
ride_effort_cost
|
||||
+ sleep_risk_cost
|
||||
+ water_gap_cost
|
||||
+ food_gap_cost
|
||||
+ daylight_risk_cost
|
||||
+ weather_risk_cost
|
||||
+ bailout_gap_cost
|
||||
+ repair_gap_cost
|
||||
```
|
||||
|
||||
## Segment attributes
|
||||
|
||||
Each segment should carry:
|
||||
|
||||
- distance meters,
|
||||
- ascent/descent,
|
||||
- max grade,
|
||||
- average grade,
|
||||
- surface,
|
||||
- smoothness,
|
||||
- OSM highway class,
|
||||
- bicycle/access tags,
|
||||
- traffic stress proxy,
|
||||
- cycle route membership,
|
||||
- protected-area overlap,
|
||||
- POI distance to next water/food/repair/sleep,
|
||||
- community report score,
|
||||
- confidence score.
|
||||
|
||||
## Bikepacking profiles
|
||||
|
||||
### Loaded gravel
|
||||
|
||||
- Prefer gravel/compact dirt and low-traffic roads.
|
||||
- Penalize technical MTB trails.
|
||||
- Penalize grades above 12% more heavily when loaded.
|
||||
- Penalize unknown surfaces in remote areas.
|
||||
|
||||
### Hardtail bikepacking
|
||||
|
||||
- Allow more rough surfaces.
|
||||
- Allow singletrack where bicycle access and difficulty are acceptable.
|
||||
- Penalize sustained paved highways and high traffic.
|
||||
|
||||
### Road touring
|
||||
|
||||
- Prefer paved low-traffic roads and official cycle routes.
|
||||
- Avoid rough surfaces, tracks, and paths with unknown rideability.
|
||||
|
||||
### E-bikepacking
|
||||
|
||||
- Add range and charging constraints.
|
||||
- Penalize long remote stretches without accommodation/charging.
|
||||
- Respect local e-bike access distinctions where available.
|
||||
|
||||
### Beginner-safe
|
||||
|
||||
- Strongly prefer known surfaces and legal certainty.
|
||||
- Avoid high traffic, remote stretches, long dry gaps, and steep grades.
|
||||
|
||||
## Confidence model
|
||||
|
||||
The app should show both recommendation and confidence.
|
||||
|
||||
Example route summary:
|
||||
|
||||
```text
|
||||
Distance: 382 km
|
||||
Ascent: 5,420 m
|
||||
Known surface: 88%
|
||||
Unpaved: 42%
|
||||
Longest water gap: 36 km
|
||||
Longest food gap: 58 km
|
||||
Access confidence: Medium-high
|
||||
Sleep certainty: 4/5 nights confirmed options
|
||||
Bailout gap: Max 71 km from train/ferry/bus
|
||||
Warnings: 2 steep loaded-bike climbs, 1 uncertain access segment
|
||||
```
|
||||
|
||||
## Hike-a-bike risk
|
||||
|
||||
Estimate using:
|
||||
|
||||
- steep grade,
|
||||
- poor smoothness,
|
||||
- `mtb:scale`,
|
||||
- `sac_scale`,
|
||||
- surface type,
|
||||
- trail width if available,
|
||||
- rider profile,
|
||||
- actual ride speed reports,
|
||||
- user reports.
|
||||
|
||||
A route can include hike-a-bike if the profile allows it, but it must be visible.
|
||||
|
||||
## Critical gap detection
|
||||
|
||||
Calculate gaps along the route:
|
||||
|
||||
- water gap,
|
||||
- food gap,
|
||||
- sleep gap,
|
||||
- repair gap,
|
||||
- bailout gap,
|
||||
- charging gap for e-bike.
|
||||
|
||||
Warnings should be generated when a gap exceeds profile thresholds.
|
||||
|
||||
## Stage planning algorithm
|
||||
|
||||
1. Generate candidate endpoints every 5–15 km around the daily target window.
|
||||
2. Score each endpoint by sleep options, water/food, daylight fit, safety, and bailout.
|
||||
3. Prefer endpoints with at least one high-confidence sleep option.
|
||||
4. Add Plan B endpoints before and after Plan A.
|
||||
5. Rebalance subsequent stages to avoid one impossible day.
|
||||
6. Surface warnings for stages where no good endpoint exists.
|
||||
|
||||
## Example thresholds
|
||||
|
||||
| Profile | Target km/day | Max grade warning | Max water gap | Max food gap |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Beginner-safe | 40–70 | 10% | 25 km | 40 km |
|
||||
| Loaded gravel | 60–100 | 12% | 40 km | 70 km |
|
||||
| Hardtail remote | 50–90 | 15% | 55 km | 90 km |
|
||||
| Road touring | 70–130 | 10% | 40 km | 70 km |
|
||||
| E-bikepacking | 50–100 | 12% | 40 km | 60 km + charging |
|
||||
|
||||
Thresholds should be configurable per user.
|
||||
89
docs/06_offline_strategy.md
Normal file
89
docs/06_offline_strategy.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Offline Strategy
|
||||
|
||||
## Offline requirement
|
||||
|
||||
Offline use must cover riding-critical information, not only maps.
|
||||
|
||||
## Offline pack contents
|
||||
|
||||
A trip offline pack should include:
|
||||
|
||||
- route geometry,
|
||||
- route alternatives,
|
||||
- daily stages,
|
||||
- elevation profile,
|
||||
- selected vector/raster map area,
|
||||
- route-corridor POIs,
|
||||
- emergency/bailout POIs,
|
||||
- local rule cards,
|
||||
- cached weather forecast at departure,
|
||||
- saved bookings/deep links/addresses,
|
||||
- user notes,
|
||||
- GPX/TCX/FIT exports,
|
||||
- report queue for later upload.
|
||||
|
||||
## Pack scope
|
||||
|
||||
Use a corridor-based pack:
|
||||
|
||||
```text
|
||||
primary corridor: 5 km on either side of route
|
||||
logistics corridor: 20 km around stage endpoints and bailout towns
|
||||
map context: bounding box with simplification by zoom level
|
||||
```
|
||||
|
||||
## Offline modes
|
||||
|
||||
### MVP offline mode
|
||||
|
||||
- Download planned route, POIs, stage plan, rules, and GPX.
|
||||
- Allow map viewing if tile pack is present.
|
||||
- Allow report capture and sync later.
|
||||
- Allow basic distance-to-next-critical-thing calculation from cached data.
|
||||
|
||||
### Production offline mode
|
||||
|
||||
- Offline rerouting using a compact regional routing graph.
|
||||
- Offline POI search.
|
||||
- Offline rule lookup.
|
||||
- Offline elevation sampling.
|
||||
- Offline exports.
|
||||
|
||||
## Storage model
|
||||
|
||||
Mobile app storage:
|
||||
|
||||
- SQLite for structured trip data and POIs.
|
||||
- File storage for tile packs and exports.
|
||||
- Key-value storage for user preferences and sync state.
|
||||
|
||||
## Pack versioning
|
||||
|
||||
Each offline pack should include:
|
||||
|
||||
```json
|
||||
{
|
||||
"pack_id": "pack_123",
|
||||
"trip_id": "trip_123",
|
||||
"created_at": "2026-06-30T00:00:00Z",
|
||||
"data_versions": {
|
||||
"osm_extract": "geofabrik-europe-germany-2026-06-29",
|
||||
"poi_index": "2026-06-29",
|
||||
"rules": "2026-06-01",
|
||||
"weather_cached_at": "2026-06-30T06:00:00Z"
|
||||
},
|
||||
"bbox": [6.0, 47.0, 15.0, 55.0],
|
||||
"route_buffer_km": 5,
|
||||
"expires_at": "2026-07-14T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## User-facing warnings
|
||||
|
||||
Before departure, show:
|
||||
|
||||
- which items are available offline,
|
||||
- which items are stale,
|
||||
- which route sections lack cached POIs,
|
||||
- whether offline rerouting is available,
|
||||
- which exports are saved.
|
||||
108
docs/07_ux_flows.md
Normal file
108
docs/07_ux_flows.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# UX Flows
|
||||
|
||||
## Main app surfaces
|
||||
|
||||
### 1. Trip planner
|
||||
|
||||
Map plus side panel:
|
||||
|
||||
- start/end/import GPX,
|
||||
- dates,
|
||||
- bike/load type,
|
||||
- daily target,
|
||||
- surface preference,
|
||||
- sleep preference,
|
||||
- water/food interval,
|
||||
- routing strictness,
|
||||
- bailout preference.
|
||||
|
||||
Output:
|
||||
|
||||
- route line,
|
||||
- stage cards,
|
||||
- warnings,
|
||||
- POI layer toggles,
|
||||
- export/offline actions.
|
||||
|
||||
### 2. Stage timeline
|
||||
|
||||
A vertical list of days:
|
||||
|
||||
```text
|
||||
Day 1 — 74 km — 1,150 m climb — 38% unpaved
|
||||
Sleep: campsite at km 72, hostel at km 78
|
||||
Water: km 18, 51, 69
|
||||
Food: supermarket km 42, bakery km 63
|
||||
Warning: last reliable water before 11 km climb
|
||||
Bailout: train station +5 km from route at km 58
|
||||
```
|
||||
|
||||
### 3. Riding mode
|
||||
|
||||
Large simple panel:
|
||||
|
||||
```text
|
||||
Next water: 12.4 km
|
||||
Next food: 28.0 km, closes 18:00
|
||||
Sleep Plan A: 31.5 km
|
||||
Daylight left: 3h 40m
|
||||
Bailout: train station 9.1 km detour
|
||||
Warning: remote section starts in 6 km
|
||||
```
|
||||
|
||||
Actions:
|
||||
|
||||
- reroute to water,
|
||||
- reroute to sleep,
|
||||
- skip climb,
|
||||
- find bike shop,
|
||||
- find bailout,
|
||||
- report issue.
|
||||
|
||||
### 4. POI detail card
|
||||
|
||||
For a water point:
|
||||
|
||||
```text
|
||||
Village fountain
|
||||
Type: drinking water
|
||||
Distance: 12.4 km on route
|
||||
Confidence: Medium
|
||||
Last confirmed: 18 days ago
|
||||
Notes: Tap on cemetery wall; sometimes locked after 20:00
|
||||
Actions: Navigate / Save / Report unavailable
|
||||
```
|
||||
|
||||
### 5. Rule card
|
||||
|
||||
```text
|
||||
Wild camping rule — Bavaria, Germany
|
||||
Status: Restricted / unclear
|
||||
Summary: Overnight camping outside designated sites may be restricted. Bivouac rules vary by land type and protected areas.
|
||||
Confidence: Medium
|
||||
Source: official/regional source URL
|
||||
Last reviewed: 2026-06-01
|
||||
App advice: prefer listed campsites/shelters; avoid protected-area bivouac unless locally permitted.
|
||||
```
|
||||
|
||||
## Map layers
|
||||
|
||||
- Route/stages.
|
||||
- Surface and roughness.
|
||||
- Legal/access confidence.
|
||||
- Water.
|
||||
- Food/resupply.
|
||||
- Sleep.
|
||||
- Repair.
|
||||
- Bailout.
|
||||
- Local reports.
|
||||
- Weather risk.
|
||||
- Protected areas/local rules.
|
||||
|
||||
## Interaction principles
|
||||
|
||||
- Always show confidence and stale data indicators.
|
||||
- Optimize for quick decisions during riding.
|
||||
- Avoid hiding critical logistics behind filters.
|
||||
- Default to route-corridor relevance instead of global POI clutter.
|
||||
- Explain routing choices with concrete reasons.
|
||||
181
docs/08_data_model.md
Normal file
181
docs/08_data_model.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Data Model
|
||||
|
||||
The authoritative DDL is in `schemas/database.sql`. This document explains the domain model.
|
||||
|
||||
## Core entities
|
||||
|
||||
### User
|
||||
|
||||
Stores account, preferences, privacy settings, and default bikepacking profile.
|
||||
|
||||
### Trip
|
||||
|
||||
A planned multi-day journey.
|
||||
|
||||
Fields:
|
||||
|
||||
- id,
|
||||
- owner_id,
|
||||
- title,
|
||||
- start/end coordinates,
|
||||
- date range,
|
||||
- profile,
|
||||
- status,
|
||||
- created_at,
|
||||
- updated_at.
|
||||
|
||||
### Route
|
||||
|
||||
A route generated for a trip.
|
||||
|
||||
Fields:
|
||||
|
||||
- id,
|
||||
- trip_id,
|
||||
- provider,
|
||||
- geometry,
|
||||
- distance,
|
||||
- ascent/descent,
|
||||
- surface breakdown,
|
||||
- access confidence,
|
||||
- warnings.
|
||||
|
||||
### Route segment
|
||||
|
||||
A normalized route chunk with scoring attributes.
|
||||
|
||||
Fields:
|
||||
|
||||
- geometry,
|
||||
- distance,
|
||||
- grade,
|
||||
- surface,
|
||||
- smoothness,
|
||||
- highway class,
|
||||
- access tags,
|
||||
- risk scores,
|
||||
- confidence.
|
||||
|
||||
### Stage
|
||||
|
||||
A daily ride slice.
|
||||
|
||||
Fields:
|
||||
|
||||
- day_index,
|
||||
- route_start_m,
|
||||
- route_end_m,
|
||||
- distance,
|
||||
- ascent,
|
||||
- estimated_time,
|
||||
- endpoint,
|
||||
- sleep candidates,
|
||||
- warnings,
|
||||
- Plan A/B.
|
||||
|
||||
### POI
|
||||
|
||||
A point of interest for logistics.
|
||||
|
||||
Categories:
|
||||
|
||||
- water,
|
||||
- food,
|
||||
- sleep,
|
||||
- repair,
|
||||
- bailout,
|
||||
- charging,
|
||||
- emergency.
|
||||
|
||||
### Service gap
|
||||
|
||||
A computed gap between relevant POIs along the route.
|
||||
|
||||
Fields:
|
||||
|
||||
- type,
|
||||
- start_m,
|
||||
- end_m,
|
||||
- distance,
|
||||
- severity,
|
||||
- explanation.
|
||||
|
||||
### Local report
|
||||
|
||||
A typed, dated user/local report.
|
||||
|
||||
Fields:
|
||||
|
||||
- report type,
|
||||
- geometry/point,
|
||||
- route segment optional,
|
||||
- payload,
|
||||
- trust score,
|
||||
- expiry date,
|
||||
- moderation status.
|
||||
|
||||
### Rule card
|
||||
|
||||
A localized advisory card about riding/camping/access/fire/protected areas.
|
||||
|
||||
Fields:
|
||||
|
||||
- jurisdiction,
|
||||
- rule type,
|
||||
- summary,
|
||||
- status,
|
||||
- confidence,
|
||||
- source URL,
|
||||
- last reviewed,
|
||||
- geometry.
|
||||
|
||||
### Offline pack
|
||||
|
||||
A generated set of data for a trip.
|
||||
|
||||
Fields:
|
||||
|
||||
- trip,
|
||||
- bbox,
|
||||
- corridor buffer,
|
||||
- included layers,
|
||||
- version metadata,
|
||||
- file references,
|
||||
- status.
|
||||
|
||||
## Important modeling choices
|
||||
|
||||
### Route distance index
|
||||
|
||||
Store positions along a route as `meters_from_start`. This allows POIs, warnings, reports, and stage boundaries to attach to the route even when coordinates are simplified.
|
||||
|
||||
### Confidence levels
|
||||
|
||||
Use a normalized enum:
|
||||
|
||||
- `known_good`,
|
||||
- `high`,
|
||||
- `medium`,
|
||||
- `low`,
|
||||
- `unknown`,
|
||||
- `restricted`,
|
||||
- `conflicting`.
|
||||
|
||||
### Warning severity
|
||||
|
||||
Use:
|
||||
|
||||
- `info`,
|
||||
- `notice`,
|
||||
- `warning`,
|
||||
- `critical`.
|
||||
|
||||
### Report expiration
|
||||
|
||||
Most local reports should expire unless reconfirmed:
|
||||
|
||||
- water status: 30–90 days,
|
||||
- closure: 7–60 days depending source,
|
||||
- surface condition: 14–90 days,
|
||||
- access/legal: reviewed manually,
|
||||
- sleep hints: 90–365 days.
|
||||
121
docs/09_api_contract.md
Normal file
121
docs/09_api_contract.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# API Contract
|
||||
|
||||
The machine-readable OpenAPI draft is in `schemas/openapi.yaml`.
|
||||
|
||||
## Core endpoints
|
||||
|
||||
### Create trip
|
||||
|
||||
`POST /v1/trips`
|
||||
|
||||
Creates a trip shell from user inputs.
|
||||
|
||||
### Plan route
|
||||
|
||||
`POST /v1/routes/plan`
|
||||
|
||||
Input:
|
||||
|
||||
- start/end/imported GPX,
|
||||
- profile,
|
||||
- constraints,
|
||||
- preferred providers.
|
||||
|
||||
Output:
|
||||
|
||||
- route id,
|
||||
- geometry,
|
||||
- distance/elevation,
|
||||
- scores,
|
||||
- warnings,
|
||||
- candidate alternatives.
|
||||
|
||||
### Plan stages
|
||||
|
||||
`POST /v1/stages/plan`
|
||||
|
||||
Input:
|
||||
|
||||
- route id,
|
||||
- day count/date range,
|
||||
- daily constraints,
|
||||
- sleep preferences.
|
||||
|
||||
Output:
|
||||
|
||||
- stage cards,
|
||||
- Plan A/B endpoints,
|
||||
- sleep/food/water/repair/bailout candidates,
|
||||
- stage warnings.
|
||||
|
||||
### Corridor POIs
|
||||
|
||||
`GET /v1/routes/{route_id}/pois?category=water&buffer_km=5`
|
||||
|
||||
Returns route-relevant POIs with distance along route, detour distance, confidence, and source.
|
||||
|
||||
### Critical gaps
|
||||
|
||||
`GET /v1/routes/{route_id}/gaps`
|
||||
|
||||
Returns computed gaps for water, food, sleep, repair, bailout, and charging.
|
||||
|
||||
### Rule cards
|
||||
|
||||
`GET /v1/rules?bbox=minLon,minLat,maxLon,maxLat&route_id=...`
|
||||
|
||||
Returns route- or area-specific advisory cards.
|
||||
|
||||
### Offline pack
|
||||
|
||||
`POST /v1/offline-packs`
|
||||
|
||||
Generates a manifest and download URLs for cached trip data.
|
||||
|
||||
### Export
|
||||
|
||||
`GET /v1/routes/{route_id}/export?format=gpx`
|
||||
|
||||
MVP: GPX. Later: TCX and FIT.
|
||||
|
||||
### Local report
|
||||
|
||||
`POST /v1/reports`
|
||||
|
||||
Submits a dated, location-bound structured report. The MVP response includes `moderationStatus`, `trustScore`, and `queuedOffline` when an offline client id is supplied.
|
||||
|
||||
### Report sync
|
||||
|
||||
`POST /v1/reports/sync`
|
||||
|
||||
Future endpoint. The current MVP queues reports client-side while offline and resubmits them through `POST /v1/reports`.
|
||||
|
||||
## Error handling
|
||||
|
||||
Errors should include:
|
||||
|
||||
- machine-readable code,
|
||||
- human-readable message,
|
||||
- remediation suggestion,
|
||||
- provider/source if relevant.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "PROVIDER_UNAVAILABLE",
|
||||
"message": "routing provider \"openrouteservice\" is not available in this MVP runtime. Set ROUTING_PROVIDER=mock for fixture mode or configure the selected provider adapter and credentials.",
|
||||
"details": {
|
||||
"providerName": "openrouteservice",
|
||||
"providerType": "routing",
|
||||
"setupHint": "Set ROUTING_PROVIDER=mock for fixture mode or configure the selected provider adapter and credentials."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Current MVP notes
|
||||
|
||||
- `schemas/openapi.yaml` reflects implemented response fields including route segments, surface breakdown, attribution, stage POI buckets, offline manifest metadata, and report queue ids.
|
||||
- `format=gpx` is implemented. TCX/FIT are reserved for future `DeviceExportProvider` adapters.
|
||||
- Mock providers are deterministic and require no external keys.
|
||||
- Rule cards and access/protected-area warnings are advisory and confidence-scored.
|
||||
70
docs/10_integrations.md
Normal file
70
docs/10_integrations.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Integrations
|
||||
|
||||
## Routing integrations
|
||||
|
||||
Use provider adapters.
|
||||
|
||||
### Prototype
|
||||
|
||||
- openrouteservice API adapter.
|
||||
- Mock adapter for tests.
|
||||
|
||||
### Production
|
||||
|
||||
- GraphHopper or Valhalla self-hosted adapter.
|
||||
- Custom bikepacking profile layer on top of base routing.
|
||||
- Offline routing adapter later.
|
||||
|
||||
## Map integrations
|
||||
|
||||
- MapLibre-compatible rendering.
|
||||
- Vector tiles from OpenMapTiles, Protomaps/PMTiles, MapTiler, or self-hosted pipeline.
|
||||
- OSM attribution must be visible.
|
||||
|
||||
## Accommodation integrations
|
||||
|
||||
MVP should use:
|
||||
|
||||
- OSM campsites/shelters/huts/hostels/hotels,
|
||||
- deep links to search platforms,
|
||||
- manual entries/imports for partner datasets.
|
||||
|
||||
Later:
|
||||
|
||||
- Booking.com Demand API if accepted as partner,
|
||||
- direct campsite/hut reservation systems,
|
||||
- Warmshowers-style partner/deep links if official access exists.
|
||||
|
||||
## Weather integrations
|
||||
|
||||
MVP:
|
||||
|
||||
- forecast at stage endpoints and exposed segments.
|
||||
- warnings for heat, rain, wind, storms, freezing nights.
|
||||
|
||||
Later:
|
||||
|
||||
- route-time weather simulation,
|
||||
- weather-aware rerouting,
|
||||
- mud risk after rainfall.
|
||||
|
||||
## Device integrations
|
||||
|
||||
MVP:
|
||||
|
||||
- GPX export.
|
||||
|
||||
Next:
|
||||
|
||||
- TCX export with course points.
|
||||
- FIT export.
|
||||
|
||||
Later:
|
||||
|
||||
- Garmin Courses API subject to approval.
|
||||
- Wahoo Cloud API subject to approval and use-case fit.
|
||||
- Hammerhead/Karoo sync if public/partner APIs allow.
|
||||
|
||||
## Authentication and partnerships
|
||||
|
||||
Do not assume public access to commercial APIs. Use deep links first and provider interfaces so partner integrations can be added without changing the domain model.
|
||||
78
docs/11_local_rules_and_liability.md
Normal file
78
docs/11_local_rules_and_liability.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Local Rules and Liability
|
||||
|
||||
## Principle
|
||||
|
||||
Rules must be shown as advisory information with source, date, jurisdiction, confidence, and limits.
|
||||
|
||||
Do not claim legal certainty unless the rule comes from a current official source and the geographic/jurisdiction match is clear.
|
||||
|
||||
## Rule categories
|
||||
|
||||
- cycling access,
|
||||
- trail access,
|
||||
- private land,
|
||||
- protected area,
|
||||
- wild camping,
|
||||
- bivouac,
|
||||
- fire bans,
|
||||
- e-bike access/class,
|
||||
- ferry/border constraints,
|
||||
- seasonal closures,
|
||||
- hunting/forestry closures,
|
||||
- drone restrictions if relevant.
|
||||
|
||||
## Confidence states
|
||||
|
||||
- `known_allowed`,
|
||||
- `known_restricted`,
|
||||
- `seasonal_or_conditional`,
|
||||
- `likely_allowed`,
|
||||
- `likely_restricted`,
|
||||
- `unclear`,
|
||||
- `conflicting_sources`,
|
||||
- `needs_local_confirmation`.
|
||||
|
||||
## Source hierarchy
|
||||
|
||||
Prefer:
|
||||
|
||||
1. official authority datasets/pages,
|
||||
2. protected-area authority pages,
|
||||
3. national/regional open data portals,
|
||||
4. OSM access tags,
|
||||
5. partner/local expert data,
|
||||
6. user reports.
|
||||
|
||||
## User-facing copy examples
|
||||
|
||||
### Access warning
|
||||
|
||||
```text
|
||||
Access uncertainty ahead
|
||||
This forest track has incomplete bicycle access data and crosses a protected-area boundary. Use caution and follow local signs.
|
||||
```
|
||||
|
||||
### Camping warning
|
||||
|
||||
```text
|
||||
Wild-camping rules unclear
|
||||
This area may restrict overnight camping outside designated sites. Prefer listed campsite/shelter options unless you have local permission.
|
||||
```
|
||||
|
||||
### Seasonal closure
|
||||
|
||||
```text
|
||||
Possible seasonal restriction
|
||||
This segment overlaps a protected area. Check local signs and current authority notices before entering.
|
||||
```
|
||||
|
||||
## Liability mitigations
|
||||
|
||||
- Show source and review date.
|
||||
- Show uncertainty.
|
||||
- Provide alternatives.
|
||||
- Let users report errors.
|
||||
- Avoid routing through known restricted/private segments by default.
|
||||
- Keep critical legal rules server-updatable.
|
||||
- Do not monetize illegal camping hints.
|
||||
- Moderate sensitive reports.
|
||||
80
docs/12_community_intel.md
Normal file
80
docs/12_community_intel.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Community and Local Intelligence
|
||||
|
||||
## Objective
|
||||
|
||||
Turn field reports into a structured, trust-scored, time-aware route quality layer.
|
||||
|
||||
## Report types
|
||||
|
||||
- `water_available`
|
||||
- `water_unavailable`
|
||||
- `surface_wrong`
|
||||
- `rough_loaded_bike`
|
||||
- `hike_a_bike`
|
||||
- `locked_gate`
|
||||
- `private_access`
|
||||
- `closure`
|
||||
- `dangerous_dogs`
|
||||
- `mud_sand_washout`
|
||||
- `bridge_out`
|
||||
- `ferry_issue`
|
||||
- `sleep_option_good`
|
||||
- `sleep_option_bad`
|
||||
- `shop_closed_or_missing`
|
||||
- `repair_available`
|
||||
- `reroute_recommended`
|
||||
|
||||
## Report payload example
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "water_unavailable",
|
||||
"location": { "lat": 47.1234, "lon": 11.5678 },
|
||||
"route_id": "route_123",
|
||||
"meters_from_start": 84231,
|
||||
"observed_at": "2026-06-30T14:30:00Z",
|
||||
"details": "Tap locked; no public access after 18:00.",
|
||||
"photo_optional": true
|
||||
}
|
||||
```
|
||||
|
||||
## Trust model
|
||||
|
||||
Signals:
|
||||
|
||||
- account age,
|
||||
- number of confirmed reports,
|
||||
- local status,
|
||||
- photo/GPS evidence,
|
||||
- agreement with other riders,
|
||||
- recency,
|
||||
- conflict count.
|
||||
|
||||
## Expiry model
|
||||
|
||||
Reports should not live forever.
|
||||
|
||||
Default expiration:
|
||||
|
||||
- water status: 60 days,
|
||||
- closures: 30 days,
|
||||
- mud/surface condition: 30 days,
|
||||
- locked gate: 90 days,
|
||||
- shop status: 90 days,
|
||||
- legal/access reports: manual review,
|
||||
- local hints: 180 days.
|
||||
|
||||
## Moderation
|
||||
|
||||
Moderate:
|
||||
|
||||
- illegal camping instructions,
|
||||
- private property entry hints,
|
||||
- sensitive locations,
|
||||
- abuse/harassment,
|
||||
- unsafe advice,
|
||||
- business defamation.
|
||||
|
||||
## OSM feedback loop
|
||||
|
||||
Objective, verifiable corrections can be contributed back to OSM by trained editors following OSM community norms. Subjective suitability, safety, and camping hints should remain in the app layer unless they map to accepted OSM tags and are verifiable.
|
||||
54
docs/13_security_privacy.md
Normal file
54
docs/13_security_privacy.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Security and Privacy
|
||||
|
||||
## Sensitive data
|
||||
|
||||
The app may process:
|
||||
|
||||
- location history,
|
||||
- planned travel dates,
|
||||
- sleep locations,
|
||||
- emergency contacts,
|
||||
- account identity,
|
||||
- API tokens,
|
||||
- community reports,
|
||||
- booking/deep-link actions.
|
||||
|
||||
## Privacy requirements
|
||||
|
||||
- Default trips to private.
|
||||
- Let users delete trips and reports where legally possible.
|
||||
- Do not share exact sleep plans publicly by default.
|
||||
- Minimize continuous location collection.
|
||||
- Allow offline use without constant tracking.
|
||||
- Separate analytics from precise route history.
|
||||
- Hash/anonymize telemetry where possible.
|
||||
|
||||
## Security requirements
|
||||
|
||||
- Store secrets in environment variables or secret manager.
|
||||
- Use short-lived signed URLs for offline-pack downloads.
|
||||
- Validate GPX uploads.
|
||||
- Sanitize user-generated content.
|
||||
- Rate-limit report submission.
|
||||
- Protect against route/POI scraping if commercial data is included.
|
||||
- Keep provider API keys server-side.
|
||||
|
||||
## Compliance notes
|
||||
|
||||
For EU users, assume GDPR obligations:
|
||||
|
||||
- explicit consent for location tracking,
|
||||
- data export/deletion,
|
||||
- purpose limitation,
|
||||
- retention policy,
|
||||
- processor agreements for analytics/cloud vendors.
|
||||
|
||||
## Safety disclaimers
|
||||
|
||||
Show concise warnings:
|
||||
|
||||
- route data may be wrong,
|
||||
- access rules can change,
|
||||
- weather forecasts are uncertain,
|
||||
- users must obey signs and local laws,
|
||||
- remote riding requires judgment and preparation.
|
||||
63
docs/14_testing_plan.md
Normal file
63
docs/14_testing_plan.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Testing Plan
|
||||
|
||||
## Unit tests
|
||||
|
||||
### Scoring
|
||||
|
||||
- penalizes steep loose climbs for loaded gravel profile;
|
||||
- allows rougher segments for hardtail profile;
|
||||
- penalizes unknown access;
|
||||
- rewards official cycle routes;
|
||||
- increases severity for long water gaps;
|
||||
- detects no sleep options near target endpoint.
|
||||
|
||||
### Staging
|
||||
|
||||
- splits route into daily ranges;
|
||||
- avoids impossible climbing days;
|
||||
- chooses endpoints with sleep candidates;
|
||||
- produces Plan B endpoints;
|
||||
- rebalances when one day exceeds constraints.
|
||||
|
||||
### POI corridor
|
||||
|
||||
- finds POIs within buffer;
|
||||
- sorts by distance along route;
|
||||
- deduplicates overlapping sources;
|
||||
- computes detour distance;
|
||||
- respects category filters.
|
||||
|
||||
### Offline manifest
|
||||
|
||||
- includes required layers;
|
||||
- records source versions;
|
||||
- flags stale data;
|
||||
- supports report sync queue.
|
||||
|
||||
## Integration tests
|
||||
|
||||
- route planning provider returns normalized geometry;
|
||||
- stage planner consumes route geometry and POIs;
|
||||
- GPX export round-trips with common viewers;
|
||||
- API returns consistent warnings from fixtures;
|
||||
- map renders route/stages/POIs.
|
||||
|
||||
## Fixture tests
|
||||
|
||||
Use sample fixtures in `examples/`:
|
||||
|
||||
- simple 2-day route,
|
||||
- remote route with water gap,
|
||||
- route with uncertain access,
|
||||
- route with sleep endpoints,
|
||||
- route with steep rough segment.
|
||||
|
||||
## Manual QA scenarios
|
||||
|
||||
1. Plan 3-day trip with campsites only.
|
||||
2. Plan 5-day trip with indoor sleep every third night.
|
||||
3. Import GPX and ask app to stage it.
|
||||
4. Download offline pack and simulate airplane mode.
|
||||
5. Submit water-unavailable report offline and sync later.
|
||||
6. Export GPX and import into Garmin Connect or another route viewer.
|
||||
7. Check that legal/rule warnings show source and uncertainty.
|
||||
58
docs/15_roadmap.md
Normal file
58
docs/15_roadmap.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Roadmap
|
||||
|
||||
## Phase 0 — Repository and design foundation
|
||||
|
||||
- Create monorepo.
|
||||
- Implement shared domain types.
|
||||
- Add mock data fixtures.
|
||||
- Add scoring/staging unit tests.
|
||||
|
||||
## Phase 1 — Web planner MVP
|
||||
|
||||
- Map UI.
|
||||
- Trip request form.
|
||||
- Route rendering.
|
||||
- Stage cards.
|
||||
- POI corridor display.
|
||||
- Critical gap warnings.
|
||||
- GPX export.
|
||||
|
||||
## Phase 2 — Backend MVP
|
||||
|
||||
- Route planning endpoint.
|
||||
- Provider abstraction.
|
||||
- Stage planner endpoint.
|
||||
- POI corridor endpoint.
|
||||
- Offline manifest endpoint.
|
||||
- Report endpoint.
|
||||
|
||||
## Phase 3 — Real data integration
|
||||
|
||||
- OSM extract import.
|
||||
- POI normalization.
|
||||
- Elevation enrichment.
|
||||
- Hosted/self-hosted routing engine adapter.
|
||||
- Rule card ingestion for one pilot region.
|
||||
|
||||
## Phase 4 — Mobile/offline MVP
|
||||
|
||||
- Mobile route/stage viewer.
|
||||
- Offline trip cache.
|
||||
- Next-critical-thing panel.
|
||||
- Offline report queue.
|
||||
- Basic reroute to cached POI or external navigation deep link.
|
||||
|
||||
## Phase 5 — Advanced bikepacking intelligence
|
||||
|
||||
- Segment-level suitability scoring from ridden data.
|
||||
- Trusted local reports.
|
||||
- Weather-aware warnings.
|
||||
- Sleep reliability score.
|
||||
- Adaptive route planning based on actual rider speed.
|
||||
|
||||
## Phase 6 — Partnerships and device sync
|
||||
|
||||
- Accommodation partner APIs/deep links.
|
||||
- Garmin/Wahoo integration subject to approval.
|
||||
- Hut/campsite partner feeds.
|
||||
- Regional tourism board routes and rules.
|
||||
91
docs/source_references.md
Normal file
91
docs/source_references.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Source References
|
||||
|
||||
Verified on 2026-06-30. Check terms again before production use.
|
||||
|
||||
## Baseline map/routing data
|
||||
|
||||
- Komoot support: the Komoot map is based on OpenStreetMap data for map display, route planning, and POIs.
|
||||
https://support.komoot.com/hc/en-us/articles/10194668255258-Improve-the-komoot-Map
|
||||
|
||||
- OpenStreetMap ODbL license information.
|
||||
https://wiki.openstreetmap.org/wiki/Open_Database_License
|
||||
https://osmfoundation.org/wiki/Licence/Licence_and_Legal_FAQ
|
||||
|
||||
- OSM tile usage policy: OSM data is free; public tile servers are capacity-limited and not for heavy production app traffic.
|
||||
https://operations.osmfoundation.org/policies/tiles/
|
||||
|
||||
- Geofabrik regional OSM extracts.
|
||||
https://download.geofabrik.de/
|
||||
https://www.geofabrik.de/data/download.html
|
||||
|
||||
- Planet OSM weekly full database dump.
|
||||
https://planet.openstreetmap.org/
|
||||
https://wiki.openstreetmap.org/wiki/Planet.osm
|
||||
|
||||
- Overpass API.
|
||||
https://wiki.openstreetmap.org/wiki/Overpass_API
|
||||
|
||||
## Routing engines
|
||||
|
||||
- openrouteservice API and backend docs.
|
||||
https://openrouteservice.org/
|
||||
https://giscience.github.io/openrouteservice/api-reference/endpoints/directions/
|
||||
|
||||
- GraphHopper routing engine.
|
||||
https://github.com/graphhopper/graphhopper
|
||||
|
||||
- Valhalla routing engine.
|
||||
https://github.com/valhalla/valhalla
|
||||
https://valhalla.github.io/valhalla/api/turn-by-turn/overview/
|
||||
|
||||
- BRouter offline bicycle routing.
|
||||
https://brouter.de/brouter/
|
||||
https://brouter.de/brouter/offline.html
|
||||
|
||||
## Elevation
|
||||
|
||||
- Copernicus DEM GLO-30/GLO-90.
|
||||
https://dataspace.copernicus.eu/explore-data/data-collections/copernicus-contributing-missions/collections-description/COP-DEM
|
||||
|
||||
- NASA SRTM.
|
||||
https://www.earthdata.nasa.gov/data/instruments/srtm
|
||||
|
||||
## Map display and offline map options
|
||||
|
||||
- MapLibre GL JS.
|
||||
https://maplibre.org/
|
||||
https://www.maplibre.org/maplibre-gl-js/docs/
|
||||
|
||||
- OpenMapTiles.
|
||||
https://openmaptiles.org/
|
||||
|
||||
- Protomaps / PMTiles.
|
||||
https://protomaps.com/
|
||||
https://docs.protomaps.com/guide/getting-started
|
||||
|
||||
## POIs, rules, weather, accommodation
|
||||
|
||||
- Overture Maps Places.
|
||||
https://docs.overturemaps.org/guides/places/
|
||||
|
||||
- Protected Planet / WDPA/WDPCA.
|
||||
https://www.protectedplanet.net/en/thematic-areas/wdpa
|
||||
https://www.protectedplanet.net/en
|
||||
|
||||
- Open-Meteo Forecast API.
|
||||
https://open-meteo.com/en/docs
|
||||
|
||||
- Booking.com Demand API.
|
||||
https://developers.booking.com/demand
|
||||
https://developers.booking.com/demand/docs/getting-started/overview
|
||||
|
||||
## Device integrations
|
||||
|
||||
- Garmin Courses API.
|
||||
https://developer.garmin.com/gc-developer-program/courses-api/
|
||||
|
||||
- Garmin Connect course file import.
|
||||
https://support.garmin.com/en-US/?faq=wKuZXCaZRP4mWPX5aRz5h5
|
||||
|
||||
- Wahoo Cloud API.
|
||||
https://developers.wahooligan.com/cloud
|
||||
45
examples/overpass_queries.md
Normal file
45
examples/overpass_queries.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Example Overpass Queries for Prototyping
|
||||
|
||||
Do not use public Overpass instances for production-scale app traffic. These queries are for small-area prototyping only.
|
||||
|
||||
## Drinking water in a bounding box
|
||||
|
||||
```overpass
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
node["amenity"="drinking_water"]({{bbox}});
|
||||
way["amenity"="drinking_water"]({{bbox}});
|
||||
relation["amenity"="drinking_water"]({{bbox}});
|
||||
node["drinking_water"="yes"]({{bbox}});
|
||||
node["natural"="spring"]({{bbox}});
|
||||
);
|
||||
out center tags;
|
||||
```
|
||||
|
||||
## Bike shops and repair stations
|
||||
|
||||
```overpass
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
node["shop"="bicycle"]({{bbox}});
|
||||
way["shop"="bicycle"]({{bbox}});
|
||||
node["amenity"="bicycle_repair_station"]({{bbox}});
|
||||
node["service:bicycle:repair"="yes"]({{bbox}});
|
||||
);
|
||||
out center tags;
|
||||
```
|
||||
|
||||
## Campsites, huts, and shelters
|
||||
|
||||
```overpass
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
node["tourism"="camp_site"]({{bbox}});
|
||||
way["tourism"="camp_site"]({{bbox}});
|
||||
node["tourism"="wilderness_hut"]({{bbox}});
|
||||
way["tourism"="wilderness_hut"]({{bbox}});
|
||||
node["amenity"="shelter"]({{bbox}});
|
||||
way["amenity"="shelter"]({{bbox}});
|
||||
);
|
||||
out center tags;
|
||||
```
|
||||
25
examples/sample_segment_scoring.json
Normal file
25
examples/sample_segment_scoring.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"profile": "loaded_gravel",
|
||||
"segment": {
|
||||
"distanceM": 1800,
|
||||
"avgGradePercent": 9.8,
|
||||
"maxGradePercent": 16.2,
|
||||
"surface": "gravel",
|
||||
"smoothness": "bad",
|
||||
"highway": "track",
|
||||
"bicycleAccess": "unknown",
|
||||
"legalAccessConfidence": "unknown",
|
||||
"trafficStress": 0.1,
|
||||
"officialCycleRoute": false,
|
||||
"protectedAreaOverlap": true,
|
||||
"sourceConfidence": "medium"
|
||||
},
|
||||
"expected": {
|
||||
"hikeABikeRisk": "high",
|
||||
"warnings": [
|
||||
"STEEP_LOADED_BIKE_CLIMB",
|
||||
"UNKNOWN_ACCESS",
|
||||
"PROTECTED_AREA_OVERLAP"
|
||||
]
|
||||
}
|
||||
}
|
||||
82
examples/sample_stage_plan.json
Normal file
82
examples/sample_stage_plan.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"routeId": "route_demo_001",
|
||||
"stages": [
|
||||
{
|
||||
"dayIndex": 1,
|
||||
"distanceKm": 72.4,
|
||||
"ascentM": 980,
|
||||
"surface": {
|
||||
"paved": 0.56,
|
||||
"gravel": 0.34,
|
||||
"dirt": 0.1
|
||||
},
|
||||
"sleep": [
|
||||
{
|
||||
"name": "Lakeside campsite",
|
||||
"confidence": "high",
|
||||
"detourKm": 0.8
|
||||
}
|
||||
],
|
||||
"water": [
|
||||
{
|
||||
"km": 18.2,
|
||||
"name": "Village fountain"
|
||||
},
|
||||
{
|
||||
"km": 50.9,
|
||||
"name": "Cemetery tap"
|
||||
}
|
||||
],
|
||||
"food": [
|
||||
{
|
||||
"km": 43.0,
|
||||
"name": "Supermarket"
|
||||
}
|
||||
],
|
||||
"warnings": [
|
||||
"Last reliable water before 11 km climb"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dayIndex": 2,
|
||||
"distanceKm": 81.7,
|
||||
"ascentM": 1420,
|
||||
"surface": {
|
||||
"paved": 0.38,
|
||||
"gravel": 0.52,
|
||||
"dirt": 0.1
|
||||
},
|
||||
"sleep": [
|
||||
{
|
||||
"name": "Mountain hostel",
|
||||
"confidence": "medium",
|
||||
"detourKm": 2.1
|
||||
}
|
||||
],
|
||||
"water": [
|
||||
{
|
||||
"km": 14.3,
|
||||
"name": "Spring"
|
||||
},
|
||||
{
|
||||
"km": 61.1,
|
||||
"name": "Public tap"
|
||||
}
|
||||
],
|
||||
"food": [
|
||||
{
|
||||
"km": 23.0,
|
||||
"name": "Bakery"
|
||||
},
|
||||
{
|
||||
"km": 65.4,
|
||||
"name": "Gas station"
|
||||
}
|
||||
],
|
||||
"warnings": [
|
||||
"One uncertain access segment",
|
||||
"Steep loose climb at km 57"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
31
examples/sample_trip_request.json
Normal file
31
examples/sample_trip_request.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"start": {
|
||||
"lat": 48.137,
|
||||
"lon": 11.575
|
||||
},
|
||||
"end": {
|
||||
"lat": 47.269,
|
||||
"lon": 11.404
|
||||
},
|
||||
"startDate": "2026-07-10",
|
||||
"endDate": "2026-07-14",
|
||||
"profile": "loaded_gravel",
|
||||
"sleepPreference": "mixed_budget",
|
||||
"dailyDistanceKm": {
|
||||
"min": 55,
|
||||
"target": 75,
|
||||
"max": 95
|
||||
},
|
||||
"dailyAscentM": {
|
||||
"target": 1100,
|
||||
"max": 1800
|
||||
},
|
||||
"maxLoadedGradePercent": 12,
|
||||
"preferredUnpavedPercent": {
|
||||
"min": 30,
|
||||
"max": 70
|
||||
},
|
||||
"waterGapMaxKm": 40,
|
||||
"foodGapMaxKm": 70,
|
||||
"requireBailoutEveryKm": 90
|
||||
}
|
||||
3734
package-lock.json
generated
Normal file
3734
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "pikebacker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"services/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm -w @pikebacker/shared run build && npm -w @pikebacker/api run build && npm -w @pikebacker/web run build && npm -w @pikebacker/mobile run build && npm -w @pikebacker/data-pipeline run build",
|
||||
"test": "npm -w @pikebacker/shared run test && npm -w @pikebacker/api run test",
|
||||
"typecheck": "npm -w @pikebacker/shared run build && npm -w @pikebacker/shared run typecheck && npm -w @pikebacker/api run typecheck && npm -w @pikebacker/web run typecheck && npm -w @pikebacker/mobile run typecheck && npm -w @pikebacker/data-pipeline run typecheck",
|
||||
"dev:api": "npm -w @pikebacker/api run dev",
|
||||
"dev:web": "npm -w @pikebacker/web run dev",
|
||||
"fixtures:pois": "npm -w @pikebacker/data-pipeline run normalize:sample"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
28
packages/shared/README.md
Normal file
28
packages/shared/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Shared Package
|
||||
|
||||
Purpose: shared domain types and pure logic.
|
||||
|
||||
Implement here:
|
||||
|
||||
- domain types,
|
||||
- profile thresholds,
|
||||
- segment scoring,
|
||||
- route scoring,
|
||||
- critical gap detection,
|
||||
- stage helper functions,
|
||||
- GPX serialization helpers if shared with API.
|
||||
|
||||
Pure functions should have unit tests and deterministic fixtures.
|
||||
|
||||
## Implemented functions
|
||||
|
||||
- `scoreSegment(segment, profile)`
|
||||
- `scoreRoute(route, profile)`
|
||||
- `estimateHikeABikeRisk(segment, profile)`
|
||||
- `filterPoisByCorridor(pois, route, filter)`
|
||||
- `detectCriticalGaps(route, pois, thresholds)`
|
||||
- `planStages(route, pois, tripRequest)`
|
||||
- `exportRouteToGpx(route, stages, pois, options)`
|
||||
- `createOfflinePackManifest(input)`
|
||||
|
||||
The mock trip request is kept aligned with `examples/sample_trip_request.json`; tests cover scoring, staging, POI corridor/gaps, and GPX export.
|
||||
22
packages/shared/package.json
Normal file
22
packages/shared/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@pikebacker/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"fast-xml-parser": "^5.9.3"
|
||||
}
|
||||
}
|
||||
7
packages/shared/src/fixtures.d.ts
vendored
Normal file
7
packages/shared/src/fixtures.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Poi, RoutePlan, RouteSegment, RuleCard, TripRequest } from './types.js';
|
||||
export declare const mockTripRequest: TripRequest;
|
||||
export declare const mockRouteSegments: RouteSegment[];
|
||||
export declare function createMockRoute(tripId?: string): RoutePlan;
|
||||
export declare const mockPois: Poi[];
|
||||
export declare const mockRules: RuleCard[];
|
||||
//# sourceMappingURL=fixtures.d.ts.map
|
||||
1
packages/shared/src/fixtures.d.ts.map
Normal file
1
packages/shared/src/fixtures.d.ts.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"fixtures.d.ts","sourceRoot":"","sources":["fixtures.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,YAAY,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAEtF,eAAO,MAAM,eAAe,EAAE,WAc7B,CAAC;AA6CF,eAAO,MAAM,iBAAiB,EAAE,YAAY,EAyB1C,CAAC;AAEH,wBAAgB,eAAe,CAAC,MAAM,SAAkB,GAAG,SAAS,CAyBnE;AAED,eAAO,MAAM,QAAQ,EAAE,GAAG,EA4BzB,CAAC;AAEF,eAAO,MAAM,SAAS,EAAE,QAAQ,EAqB/B,CAAC"}
|
||||
175
packages/shared/src/fixtures.js
Normal file
175
packages/shared/src/fixtures.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import { scoreRoute } from './scoring.js';
|
||||
export const mockTripRequest = {
|
||||
start: { lat: 48.137, lon: 11.575 },
|
||||
end: { lat: 47.269, lon: 11.404 },
|
||||
startDate: '2026-07-10',
|
||||
endDate: '2026-07-14',
|
||||
profile: 'loaded_gravel',
|
||||
sleepPreference: 'mixed_budget',
|
||||
dailyDistanceKm: { min: 55, target: 75, max: 95 },
|
||||
dailyAscentM: { target: 1100, max: 1800 },
|
||||
maxLoadedGradePercent: 12,
|
||||
preferredUnpavedPercent: { min: 30, max: 70 },
|
||||
waterGapMaxKm: 40,
|
||||
foodGapMaxKm: 70,
|
||||
requireBailoutEveryKm: 90
|
||||
};
|
||||
const routeCoordinates = [
|
||||
{ lat: 48.137, lon: 11.575 },
|
||||
{ lat: 48.045, lon: 11.49 },
|
||||
{ lat: 47.965, lon: 11.335 },
|
||||
{ lat: 47.83, lon: 11.19 },
|
||||
{ lat: 47.705, lon: 11.095 },
|
||||
{ lat: 47.612, lon: 11.23 },
|
||||
{ lat: 47.52, lon: 11.085 },
|
||||
{ lat: 47.45, lon: 11.245 },
|
||||
{ lat: 47.39, lon: 11.08 },
|
||||
{ lat: 47.315, lon: 11.205 },
|
||||
{ lat: 47.255, lon: 11.07 },
|
||||
{ lat: 47.18, lon: 11.215 },
|
||||
{ lat: 47.235, lon: 11.36 },
|
||||
{ lat: 47.13, lon: 11.47 },
|
||||
{ lat: 47.205, lon: 11.555 },
|
||||
{ lat: 47.269, lon: 11.404 }
|
||||
];
|
||||
const segmentDistances = [
|
||||
22000, 24000, 25000, 26000, 22000, 28000, 23000, 26000, 24000, 28000, 25000, 28000, 24000, 30000, 27000
|
||||
];
|
||||
const segmentAscents = [180, 260, 420, 650, 310, 520, 210, 430, 390, 620, 280, 510, 240, 480, 520];
|
||||
const segmentDescents = [120, 180, 260, 330, 420, 270, 250, 380, 290, 460, 420, 310, 350, 420, 540];
|
||||
const surfaces = [
|
||||
'asphalt',
|
||||
'gravel',
|
||||
'fine_gravel',
|
||||
'dirt',
|
||||
'gravel',
|
||||
'compacted',
|
||||
'asphalt',
|
||||
'gravel',
|
||||
'dirt',
|
||||
'gravel',
|
||||
'asphalt',
|
||||
'track',
|
||||
'fine_gravel',
|
||||
'gravel',
|
||||
'asphalt'
|
||||
];
|
||||
export const mockRouteSegments = segmentDistances.map((distanceM, index) => {
|
||||
const startMeters = segmentDistances.slice(0, index).reduce((sum, value) => sum + value, 0);
|
||||
const endMeters = startMeters + distanceM;
|
||||
const segmentId = `seg_demo_${String(index + 1).padStart(2, '0')}`;
|
||||
return {
|
||||
id: segmentId,
|
||||
geometry: [routeCoordinates[index] ?? routeCoordinates[0], routeCoordinates[index + 1] ?? routeCoordinates[routeCoordinates.length - 1]],
|
||||
distanceM,
|
||||
startMeters,
|
||||
endMeters,
|
||||
ascentM: segmentAscents[index] ?? 0,
|
||||
descentM: segmentDescents[index] ?? 0,
|
||||
avgGradePercent: index === 9 ? 8.4 : index === 3 ? 7.6 : 3 + (index % 4),
|
||||
maxGradePercent: index === 9 ? 16.4 : index === 3 ? 13.8 : 8 + (index % 4),
|
||||
surface: surfaces[index],
|
||||
smoothness: index === 9 ? 'bad' : index === 11 ? 'intermediate' : 'good',
|
||||
highway: index === 11 ? 'track' : index === 9 ? 'track' : index % 3 === 0 ? 'cycleway' : 'unclassified',
|
||||
bicycleAccess: index === 8 ? 'unknown' : 'yes',
|
||||
legalAccessConfidence: index === 8 ? 'unknown' : index === 10 ? 'low' : 'high',
|
||||
trafficStress: index === 10 ? 0.62 : index % 5 === 0 ? 0.34 : 0.18,
|
||||
officialCycleRoute: index === 1 || index === 6 || index === 14,
|
||||
protectedAreaOverlap: index === 9,
|
||||
communityScore: index === 6 ? 0.8 : undefined,
|
||||
sourceConfidence: index === 8 ? 'medium' : 'high'
|
||||
};
|
||||
});
|
||||
export function createMockRoute(tripId = 'trip_demo_001') {
|
||||
const baseRoute = {
|
||||
id: 'route_demo_001',
|
||||
tripId,
|
||||
geometry: routeCoordinates,
|
||||
distanceM: segmentDistances.reduce((sum, value) => sum + value, 0),
|
||||
ascentM: segmentAscents.reduce((sum, value) => sum + value, 0),
|
||||
descentM: segmentDescents.reduce((sum, value) => sum + value, 0),
|
||||
segments: mockRouteSegments,
|
||||
surfaceBreakdown: {},
|
||||
suitabilityScore: 0,
|
||||
warnings: [],
|
||||
provider: 'mock',
|
||||
attribution: [
|
||||
'Mock route fixture derived from OSM-like tags for MVP development.',
|
||||
'Map and POI attribution hooks preserve OpenStreetMap contributor requirements.'
|
||||
]
|
||||
};
|
||||
const routeScore = scoreRoute(baseRoute, mockTripRequest.profile);
|
||||
return {
|
||||
...baseRoute,
|
||||
surfaceBreakdown: routeScore.surfaceBreakdown,
|
||||
suitabilityScore: routeScore.suitabilityScore,
|
||||
warnings: routeScore.warnings
|
||||
};
|
||||
}
|
||||
export const mockPois = [
|
||||
poi('poi_water_001', 'water', 'Village fountain', 18200, 48.075, 11.512, 'known_good', 25),
|
||||
poi('poi_food_001', 'food', 'Bakery and mini market', 43000, 47.98, 11.37, 'high', 180),
|
||||
poi('poi_water_002', 'water', 'Cemetery tap', 50900, 47.92, 11.27, 'high', 90),
|
||||
poi('poi_sleep_001', 'sleep', 'Lakeside campsite', 72400, 47.805, 11.145, 'high', 800, { sleepType: 'campsite' }),
|
||||
poi('poi_bailout_001', 'bailout', 'Regional rail station', 85000, 47.75, 11.12, 'high', 600),
|
||||
poi('poi_water_003', 'water', 'Spring above forest road', 90000, 47.715, 11.12, 'medium', 120),
|
||||
poi('poi_food_002', 'food', 'Gas station shop', 113000, 47.62, 11.22, 'high', 250),
|
||||
poi('poi_repair_001', 'repair', 'Bike shop Murnau', 112000, 47.62, 11.235, 'high', 1300),
|
||||
poi('poi_water_004', 'water', 'Public tap', 126000, 47.53, 11.105, 'medium', 210),
|
||||
poi('poi_sleep_002', 'sleep', 'Mountain hostel', 154000, 47.505, 11.16, 'medium', 2100, { sleepType: 'hostel' }),
|
||||
poi('poi_food_003', 'food', 'Village supermarket', 165000, 47.47, 11.22, 'high', 650),
|
||||
poi('poi_water_005', 'water', 'Trailhead fountain', 174000, 47.43, 11.19, 'high', 80),
|
||||
poi('poi_bailout_002', 'bailout', 'Bus stop with bike carriage', 175000, 47.42, 11.2, 'medium', 400),
|
||||
poi('poi_water_006', 'water', 'Alpine hut tap', 225000, 47.29, 11.18, 'medium', 1400),
|
||||
poi('poi_sleep_003', 'sleep', 'Farm campground', 228000, 47.275, 11.16, 'high', 900, { sleepType: 'campsite' }),
|
||||
poi('poi_food_004', 'food', 'Bakery before pass', 236000, 47.25, 11.13, 'high', 500),
|
||||
poi('poi_repair_002', 'repair', 'Repair station at trail hub', 266000, 47.18, 11.24, 'medium', 250),
|
||||
poi('poi_bailout_003', 'bailout', 'Train station valley option', 265000, 47.18, 11.23, 'high', 1600),
|
||||
poi('poi_water_007', 'water', 'Village fountain after pass', 282000, 47.22, 11.33, 'high', 70),
|
||||
poi('poi_sleep_004', 'sleep', 'Budget inn', 305000, 47.16, 11.43, 'medium', 1700, { sleepType: 'hotel' }),
|
||||
poi('poi_food_005', 'food', 'Supermarket last resupply', 312000, 47.14, 11.47, 'high', 600),
|
||||
poi('poi_water_008', 'water', 'Town public tap', 339000, 47.19, 11.53, 'known_good', 45),
|
||||
poi('poi_bailout_004', 'bailout', 'Mainline rail station', 350000, 47.215, 11.55, 'high', 900),
|
||||
poi('poi_food_006', 'food', 'Innsbruck market hall', 370000, 47.25, 11.44, 'high', 400),
|
||||
poi('poi_repair_003', 'repair', 'Innsbruck bike workshop', 380000, 47.267, 11.41, 'high', 500),
|
||||
poi('poi_sleep_005', 'sleep', 'Finish hostel', 382000, 47.269, 11.404, 'high', 300, { sleepType: 'hostel' }),
|
||||
poi('poi_water_009', 'water', 'Finish drinking fountain', 382000, 47.269, 11.404, 'high', 30)
|
||||
];
|
||||
export const mockRules = [
|
||||
{
|
||||
id: 'rule_bavaria_access_001',
|
||||
ruleType: 'access',
|
||||
jurisdiction: 'Bavaria mock corridor',
|
||||
status: 'advisory',
|
||||
confidence: 'medium',
|
||||
summary: 'Forest and protected-area access can depend on local signage. Verify bicycle access at uncertain segments.',
|
||||
sourceUrl: 'https://www.openstreetmap.org/copyright',
|
||||
lastReviewedAt: '2026-07-02'
|
||||
},
|
||||
{
|
||||
id: 'rule_tyrol_camping_001',
|
||||
ruleType: 'camping',
|
||||
jurisdiction: 'Tyrol mock corridor',
|
||||
status: 'advisory',
|
||||
confidence: 'low',
|
||||
summary: 'Wild camping rules vary by municipality and protected status. Prefer official campsites or indoor lodging unless locally confirmed.',
|
||||
sourceUrl: 'https://www.openstreetmap.org/copyright',
|
||||
lastReviewedAt: '2026-07-02'
|
||||
}
|
||||
];
|
||||
function poi(id, category, name, metersFromStart, lat, lon, confidence, detourDistanceM, tags) {
|
||||
return {
|
||||
id,
|
||||
category,
|
||||
name,
|
||||
location: { lat, lon },
|
||||
source: 'mock_osm_fixture',
|
||||
confidence,
|
||||
metersFromStart,
|
||||
distanceFromRouteM: Math.min(detourDistanceM, 2000),
|
||||
detourDistanceM,
|
||||
tags,
|
||||
lastConfirmedAt: '2026-07-02T00:00:00.000Z'
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=fixtures.js.map
|
||||
1
packages/shared/src/fixtures.js.map
Normal file
1
packages/shared/src/fixtures.js.map
Normal file
File diff suppressed because one or more lines are too long
9
packages/shared/src/fixtures.test.ts
Normal file
9
packages/shared/src/fixtures.test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import sampleTripRequest from '../../../examples/sample_trip_request.json';
|
||||
import { mockTripRequest } from './fixtures.js';
|
||||
|
||||
describe('example fixtures', () => {
|
||||
it('keeps the mock trip request aligned with examples/sample_trip_request.json', () => {
|
||||
expect(mockTripRequest).toEqual(sampleTripRequest);
|
||||
});
|
||||
});
|
||||
194
packages/shared/src/fixtures.ts
Normal file
194
packages/shared/src/fixtures.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { scoreRoute } from './scoring.js';
|
||||
import type { Poi, RoutePlan, RouteSegment, RuleCard, TripRequest } from './types.js';
|
||||
|
||||
export const mockTripRequest: TripRequest = {
|
||||
start: { lat: 48.137, lon: 11.575 },
|
||||
end: { lat: 47.269, lon: 11.404 },
|
||||
startDate: '2026-07-10',
|
||||
endDate: '2026-07-14',
|
||||
profile: 'loaded_gravel',
|
||||
sleepPreference: 'mixed_budget',
|
||||
dailyDistanceKm: { min: 55, target: 75, max: 95 },
|
||||
dailyAscentM: { target: 1100, max: 1800 },
|
||||
maxLoadedGradePercent: 12,
|
||||
preferredUnpavedPercent: { min: 30, max: 70 },
|
||||
waterGapMaxKm: 40,
|
||||
foodGapMaxKm: 70,
|
||||
requireBailoutEveryKm: 90
|
||||
};
|
||||
|
||||
const routeCoordinates = [
|
||||
{ lat: 48.137, lon: 11.575, elevationM: 520 },
|
||||
{ lat: 48.045, lon: 11.49, elevationM: 575 },
|
||||
{ lat: 47.965, lon: 11.335, elevationM: 640 },
|
||||
{ lat: 47.83, lon: 11.19, elevationM: 860 },
|
||||
{ lat: 47.705, lon: 11.095, elevationM: 1160 },
|
||||
{ lat: 47.612, lon: 11.23, elevationM: 1030 },
|
||||
{ lat: 47.52, lon: 11.085, elevationM: 1210 },
|
||||
{ lat: 47.45, lon: 11.245, elevationM: 1115 },
|
||||
{ lat: 47.39, lon: 11.08, elevationM: 1300 },
|
||||
{ lat: 47.315, lon: 11.205, elevationM: 1475 },
|
||||
{ lat: 47.255, lon: 11.07, elevationM: 1320 },
|
||||
{ lat: 47.18, lon: 11.215, elevationM: 1505 },
|
||||
{ lat: 47.235, lon: 11.36, elevationM: 1380 },
|
||||
{ lat: 47.13, lon: 11.47, elevationM: 1625 },
|
||||
{ lat: 47.205, lon: 11.555, elevationM: 1450 },
|
||||
{ lat: 47.269, lon: 11.404, elevationM: 575 }
|
||||
];
|
||||
|
||||
const segmentDistances = [
|
||||
22000, 24000, 25000, 26000, 22000, 28000, 23000, 26000, 24000, 28000, 25000, 28000, 24000, 30000, 27000
|
||||
];
|
||||
|
||||
const segmentAscents = [180, 260, 420, 650, 310, 520, 210, 430, 390, 620, 280, 510, 240, 480, 520];
|
||||
const segmentDescents = [120, 180, 260, 330, 420, 270, 250, 380, 290, 460, 420, 310, 350, 420, 540];
|
||||
const surfaces = [
|
||||
'asphalt',
|
||||
'gravel',
|
||||
'fine_gravel',
|
||||
'dirt',
|
||||
'gravel',
|
||||
'compacted',
|
||||
'asphalt',
|
||||
'gravel',
|
||||
'dirt',
|
||||
'gravel',
|
||||
'asphalt',
|
||||
'track',
|
||||
'fine_gravel',
|
||||
'gravel',
|
||||
'asphalt'
|
||||
];
|
||||
|
||||
export const mockRouteSegments: RouteSegment[] = segmentDistances.map((distanceM, index) => {
|
||||
const startMeters = segmentDistances.slice(0, index).reduce((sum, value) => sum + value, 0);
|
||||
const endMeters = startMeters + distanceM;
|
||||
const segmentId = `seg_demo_${String(index + 1).padStart(2, '0')}`;
|
||||
return {
|
||||
id: segmentId,
|
||||
geometry: [routeCoordinates[index] ?? routeCoordinates[0]!, routeCoordinates[index + 1] ?? routeCoordinates[routeCoordinates.length - 1]!],
|
||||
distanceM,
|
||||
startMeters,
|
||||
endMeters,
|
||||
ascentM: segmentAscents[index] ?? 0,
|
||||
descentM: segmentDescents[index] ?? 0,
|
||||
avgGradePercent: index === 9 ? 8.4 : index === 3 ? 7.6 : 3 + (index % 4),
|
||||
maxGradePercent: index === 9 ? 16.4 : index === 3 ? 13.8 : 8 + (index % 4),
|
||||
surface: surfaces[index],
|
||||
smoothness: index === 9 ? 'bad' : index === 11 ? 'intermediate' : 'good',
|
||||
highway: index === 11 ? 'track' : index === 9 ? 'track' : index % 3 === 0 ? 'cycleway' : 'unclassified',
|
||||
bicycleAccess: index === 8 ? 'unknown' : 'yes',
|
||||
legalAccessConfidence: index === 8 ? 'unknown' : index === 10 ? 'low' : 'high',
|
||||
trafficStress: index === 10 ? 0.62 : index % 5 === 0 ? 0.34 : 0.18,
|
||||
officialCycleRoute: index === 1 || index === 6 || index === 14,
|
||||
protectedAreaOverlap: index === 9,
|
||||
communityScore: index === 6 ? 0.8 : undefined,
|
||||
sourceConfidence: index === 8 ? 'medium' : 'high'
|
||||
};
|
||||
});
|
||||
|
||||
export function createMockRoute(tripId = 'trip_demo_001'): RoutePlan {
|
||||
const baseRoute: RoutePlan = {
|
||||
id: 'route_demo_001',
|
||||
tripId,
|
||||
geometry: routeCoordinates,
|
||||
distanceM: segmentDistances.reduce((sum, value) => sum + value, 0),
|
||||
ascentM: segmentAscents.reduce((sum, value) => sum + value, 0),
|
||||
descentM: segmentDescents.reduce((sum, value) => sum + value, 0),
|
||||
segments: mockRouteSegments,
|
||||
surfaceBreakdown: {},
|
||||
suitabilityScore: 0,
|
||||
warnings: [],
|
||||
provider: 'mock',
|
||||
attribution: [
|
||||
'Mock route fixture derived from OSM-like tags for MVP development.',
|
||||
'Map and POI attribution hooks preserve OpenStreetMap contributor requirements.'
|
||||
]
|
||||
};
|
||||
const routeScore = scoreRoute(baseRoute, mockTripRequest.profile);
|
||||
return {
|
||||
...baseRoute,
|
||||
surfaceBreakdown: routeScore.surfaceBreakdown,
|
||||
suitabilityScore: routeScore.suitabilityScore,
|
||||
warnings: routeScore.warnings
|
||||
};
|
||||
}
|
||||
|
||||
export const mockPois: Poi[] = [
|
||||
poi('poi_water_001', 'water', 'Village fountain', 18200, 48.075, 11.512, 'known_good', 25),
|
||||
poi('poi_food_001', 'food', 'Bakery and mini market', 43000, 47.98, 11.37, 'high', 180),
|
||||
poi('poi_water_002', 'water', 'Cemetery tap', 50900, 47.92, 11.27, 'high', 90),
|
||||
poi('poi_sleep_001', 'sleep', 'Lakeside campsite', 72400, 47.805, 11.145, 'high', 800, { sleepType: 'campsite' }),
|
||||
poi('poi_bailout_001', 'bailout', 'Regional rail station', 85000, 47.75, 11.12, 'high', 600),
|
||||
poi('poi_water_003', 'water', 'Spring above forest road', 90000, 47.715, 11.12, 'medium', 120),
|
||||
poi('poi_food_002', 'food', 'Gas station shop', 113000, 47.62, 11.22, 'high', 250),
|
||||
poi('poi_repair_001', 'repair', 'Bike shop Murnau', 112000, 47.62, 11.235, 'high', 1300),
|
||||
poi('poi_water_004', 'water', 'Public tap', 126000, 47.53, 11.105, 'medium', 210),
|
||||
poi('poi_sleep_002', 'sleep', 'Mountain hostel', 154000, 47.505, 11.16, 'medium', 2100, { sleepType: 'hostel' }),
|
||||
poi('poi_food_003', 'food', 'Village supermarket', 165000, 47.47, 11.22, 'high', 650),
|
||||
poi('poi_water_005', 'water', 'Trailhead fountain', 174000, 47.43, 11.19, 'high', 80),
|
||||
poi('poi_bailout_002', 'bailout', 'Bus stop with bike carriage', 175000, 47.42, 11.2, 'medium', 400),
|
||||
poi('poi_water_006', 'water', 'Alpine hut tap', 225000, 47.29, 11.18, 'medium', 1400),
|
||||
poi('poi_sleep_003', 'sleep', 'Farm campground', 228000, 47.275, 11.16, 'high', 900, { sleepType: 'campsite' }),
|
||||
poi('poi_food_004', 'food', 'Bakery before pass', 236000, 47.25, 11.13, 'high', 500),
|
||||
poi('poi_repair_002', 'repair', 'Repair station at trail hub', 266000, 47.18, 11.24, 'medium', 250),
|
||||
poi('poi_bailout_003', 'bailout', 'Train station valley option', 265000, 47.18, 11.23, 'high', 1600),
|
||||
poi('poi_water_007', 'water', 'Village fountain after pass', 282000, 47.22, 11.33, 'high', 70),
|
||||
poi('poi_sleep_004', 'sleep', 'Budget inn', 305000, 47.16, 11.43, 'medium', 1700, { sleepType: 'hotel' }),
|
||||
poi('poi_food_005', 'food', 'Supermarket last resupply', 312000, 47.14, 11.47, 'high', 600),
|
||||
poi('poi_water_008', 'water', 'Town public tap', 339000, 47.19, 11.53, 'known_good', 45),
|
||||
poi('poi_bailout_004', 'bailout', 'Mainline rail station', 350000, 47.215, 11.55, 'high', 900),
|
||||
poi('poi_food_006', 'food', 'Innsbruck market hall', 370000, 47.25, 11.44, 'high', 400),
|
||||
poi('poi_repair_003', 'repair', 'Innsbruck bike workshop', 380000, 47.267, 11.41, 'high', 500),
|
||||
poi('poi_sleep_005', 'sleep', 'Finish hostel', 382000, 47.269, 11.404, 'high', 300, { sleepType: 'hostel' }),
|
||||
poi('poi_water_009', 'water', 'Finish drinking fountain', 382000, 47.269, 11.404, 'high', 30)
|
||||
];
|
||||
|
||||
export const mockRules: RuleCard[] = [
|
||||
{
|
||||
id: 'rule_bavaria_access_001',
|
||||
ruleType: 'access',
|
||||
jurisdiction: 'Bavaria mock corridor',
|
||||
status: 'advisory',
|
||||
confidence: 'medium',
|
||||
summary: 'Forest and protected-area access can depend on local signage. Verify bicycle access at uncertain segments.',
|
||||
sourceUrl: 'https://www.openstreetmap.org/copyright',
|
||||
lastReviewedAt: '2026-07-02'
|
||||
},
|
||||
{
|
||||
id: 'rule_tyrol_camping_001',
|
||||
ruleType: 'camping',
|
||||
jurisdiction: 'Tyrol mock corridor',
|
||||
status: 'advisory',
|
||||
confidence: 'low',
|
||||
summary: 'Wild camping rules vary by municipality and protected status. Prefer official campsites or indoor lodging unless locally confirmed.',
|
||||
sourceUrl: 'https://www.openstreetmap.org/copyright',
|
||||
lastReviewedAt: '2026-07-02'
|
||||
}
|
||||
];
|
||||
|
||||
function poi(
|
||||
id: string,
|
||||
category: Poi['category'],
|
||||
name: string,
|
||||
metersFromStart: number,
|
||||
lat: number,
|
||||
lon: number,
|
||||
confidence: Poi['confidence'],
|
||||
detourDistanceM: number,
|
||||
tags?: Record<string, string>
|
||||
): Poi {
|
||||
return {
|
||||
id,
|
||||
category,
|
||||
name,
|
||||
location: { lat, lon },
|
||||
source: 'mock_osm_fixture',
|
||||
confidence,
|
||||
metersFromStart,
|
||||
distanceFromRouteM: Math.min(detourDistanceM, 2000),
|
||||
detourDistanceM,
|
||||
tags,
|
||||
lastConfirmedAt: '2026-07-02T00:00:00.000Z'
|
||||
};
|
||||
}
|
||||
11
packages/shared/src/geo.d.ts
vendored
Normal file
11
packages/shared/src/geo.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Coordinate, RoutePlan } from './types.js';
|
||||
export declare function haversineDistanceM(a: Coordinate, b: Coordinate): number;
|
||||
export declare function polylineDistanceM(points: Coordinate[]): number;
|
||||
export declare function bboxForCoordinates(points: Coordinate[], paddingDegrees?: number): [number, number, number, number];
|
||||
export declare function routeMetersForSegments(route: RoutePlan): Array<{
|
||||
id: string;
|
||||
startM: number;
|
||||
endM: number;
|
||||
}>;
|
||||
export declare function coordinateAtDistance(route: RoutePlan, meters: number): Coordinate;
|
||||
//# sourceMappingURL=geo.d.ts.map
|
||||
1
packages/shared/src/geo.d.ts.map
Normal file
1
packages/shared/src/geo.d.ts.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"geo.d.ts","sourceRoot":"","sources":["geo.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAQxD,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,UAAU,GAAG,MAAM,CASvE;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,UAAU,EAAE,GAAG,MAAM,CAU9D;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,UAAU,EAAE,EAAE,cAAc,SAAI,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAkB7G;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,SAAS,GAAG,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAQ5G;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,UAAU,CAejF"}
|
||||
104
packages/shared/src/geo.js
Normal file
104
packages/shared/src/geo.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const EARTH_RADIUS_M = 6371008.8;
|
||||
function toRadians(value) {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
export function haversineDistanceM(a, b) {
|
||||
const lat1 = toRadians(a.lat);
|
||||
const lat2 = toRadians(b.lat);
|
||||
const deltaLat = toRadians(b.lat - a.lat);
|
||||
const deltaLon = toRadians(b.lon - a.lon);
|
||||
const sinLat = Math.sin(deltaLat / 2);
|
||||
const sinLon = Math.sin(deltaLon / 2);
|
||||
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon;
|
||||
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
export function polylineDistanceM(points) {
|
||||
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;
|
||||
}
|
||||
export function bboxForCoordinates(points, paddingDegrees = 0) {
|
||||
if (points.length === 0) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
let minLon = points[0]?.lon ?? 0;
|
||||
let minLat = points[0]?.lat ?? 0;
|
||||
let maxLon = minLon;
|
||||
let maxLat = minLat;
|
||||
for (const point of points) {
|
||||
minLon = Math.min(minLon, point.lon);
|
||||
minLat = Math.min(minLat, point.lat);
|
||||
maxLon = Math.max(maxLon, point.lon);
|
||||
maxLat = Math.max(maxLat, point.lat);
|
||||
}
|
||||
return [minLon - paddingDegrees, minLat - paddingDegrees, maxLon + paddingDegrees, maxLat + paddingDegrees];
|
||||
}
|
||||
export function routeMetersForSegments(route) {
|
||||
let cursor = 0;
|
||||
return route.segments.map((segment) => {
|
||||
const startM = segment.startMeters ?? cursor;
|
||||
const endM = segment.endMeters ?? startM + segment.distanceM;
|
||||
cursor = endM;
|
||||
return { id: segment.id, startM, endM };
|
||||
});
|
||||
}
|
||||
export function coordinateAtDistance(route, meters) {
|
||||
const clampedMeters = Math.max(0, Math.min(route.distanceM, meters));
|
||||
let cursor = 0;
|
||||
for (const segment of route.segments) {
|
||||
const startM = segment.startMeters ?? cursor;
|
||||
const endM = segment.endMeters ?? startM + segment.distanceM;
|
||||
if (clampedMeters >= startM && clampedMeters <= endM) {
|
||||
const segmentPoints = segment.geometry.length >= 2 ? segment.geometry : route.geometry;
|
||||
return interpolateAlongPolyline(segmentPoints, (clampedMeters - startM) / Math.max(1, endM - startM));
|
||||
}
|
||||
cursor = endM;
|
||||
}
|
||||
return route.geometry[route.geometry.length - 1] ?? { lat: 0, lon: 0 };
|
||||
}
|
||||
function interpolateAlongPolyline(points, fraction) {
|
||||
if (points.length === 0) {
|
||||
return { lat: 0, lon: 0 };
|
||||
}
|
||||
if (points.length === 1) {
|
||||
return points[0] ?? { lat: 0, lon: 0 };
|
||||
}
|
||||
const distances = [0];
|
||||
let total = 0;
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const previous = points[index - 1];
|
||||
const current = points[index];
|
||||
if (previous && current) {
|
||||
total += haversineDistanceM(previous, current);
|
||||
distances.push(total);
|
||||
}
|
||||
}
|
||||
if (total === 0) {
|
||||
return points[0] ?? { lat: 0, lon: 0 };
|
||||
}
|
||||
const target = Math.max(0, Math.min(1, fraction)) * total;
|
||||
for (let index = 1; index < distances.length; index += 1) {
|
||||
const previousDistance = distances[index - 1] ?? 0;
|
||||
const currentDistance = distances[index] ?? previousDistance;
|
||||
if (target <= currentDistance) {
|
||||
const previous = points[index - 1];
|
||||
const current = points[index];
|
||||
if (!previous || !current) {
|
||||
break;
|
||||
}
|
||||
const localFraction = (target - previousDistance) / Math.max(1, currentDistance - previousDistance);
|
||||
return {
|
||||
lat: previous.lat + (current.lat - previous.lat) * localFraction,
|
||||
lon: previous.lon + (current.lon - previous.lon) * localFraction
|
||||
};
|
||||
}
|
||||
}
|
||||
return points[points.length - 1] ?? { lat: 0, lon: 0 };
|
||||
}
|
||||
//# sourceMappingURL=geo.js.map
|
||||
1
packages/shared/src/geo.js.map
Normal file
1
packages/shared/src/geo.js.map
Normal file
File diff suppressed because one or more lines are too long
125
packages/shared/src/geo.ts
Normal file
125
packages/shared/src/geo.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { Coordinate, RoutePlan } from './types.js';
|
||||
|
||||
const EARTH_RADIUS_M = 6371008.8;
|
||||
|
||||
function toRadians(value: number): number {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
export function haversineDistanceM(a: Coordinate, b: Coordinate): number {
|
||||
const lat1 = toRadians(a.lat);
|
||||
const lat2 = toRadians(b.lat);
|
||||
const deltaLat = toRadians(b.lat - a.lat);
|
||||
const deltaLon = toRadians(b.lon - a.lon);
|
||||
const sinLat = Math.sin(deltaLat / 2);
|
||||
const sinLon = Math.sin(deltaLon / 2);
|
||||
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon;
|
||||
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function bboxForCoordinates(points: Coordinate[], paddingDegrees = 0): [number, number, number, number] {
|
||||
if (points.length === 0) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
|
||||
let minLon = points[0]?.lon ?? 0;
|
||||
let minLat = points[0]?.lat ?? 0;
|
||||
let maxLon = minLon;
|
||||
let maxLat = minLat;
|
||||
|
||||
for (const point of points) {
|
||||
minLon = Math.min(minLon, point.lon);
|
||||
minLat = Math.min(minLat, point.lat);
|
||||
maxLon = Math.max(maxLon, point.lon);
|
||||
maxLat = Math.max(maxLat, point.lat);
|
||||
}
|
||||
|
||||
return [minLon - paddingDegrees, minLat - paddingDegrees, maxLon + paddingDegrees, maxLat + paddingDegrees];
|
||||
}
|
||||
|
||||
export function routeMetersForSegments(route: RoutePlan): Array<{ id: string; startM: number; endM: number }> {
|
||||
let cursor = 0;
|
||||
return route.segments.map((segment) => {
|
||||
const startM = segment.startMeters ?? cursor;
|
||||
const endM = segment.endMeters ?? startM + segment.distanceM;
|
||||
cursor = endM;
|
||||
return { id: segment.id, startM, endM };
|
||||
});
|
||||
}
|
||||
|
||||
export function coordinateAtDistance(route: RoutePlan, meters: number): Coordinate {
|
||||
const clampedMeters = Math.max(0, Math.min(route.distanceM, meters));
|
||||
let cursor = 0;
|
||||
|
||||
for (const segment of route.segments) {
|
||||
const startM = segment.startMeters ?? cursor;
|
||||
const endM = segment.endMeters ?? startM + segment.distanceM;
|
||||
if (clampedMeters >= startM && clampedMeters <= endM) {
|
||||
const segmentPoints = segment.geometry.length >= 2 ? segment.geometry : route.geometry;
|
||||
return interpolateAlongPolyline(segmentPoints, (clampedMeters - startM) / Math.max(1, endM - startM));
|
||||
}
|
||||
cursor = endM;
|
||||
}
|
||||
|
||||
return route.geometry[route.geometry.length - 1] ?? { lat: 0, lon: 0 };
|
||||
}
|
||||
|
||||
function interpolateAlongPolyline(points: Coordinate[], fraction: number): Coordinate {
|
||||
if (points.length === 0) {
|
||||
return { lat: 0, lon: 0 };
|
||||
}
|
||||
if (points.length === 1) {
|
||||
return points[0] ?? { lat: 0, lon: 0 };
|
||||
}
|
||||
|
||||
const distances: number[] = [0];
|
||||
let total = 0;
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const previous = points[index - 1];
|
||||
const current = points[index];
|
||||
if (previous && current) {
|
||||
total += haversineDistanceM(previous, current);
|
||||
distances.push(total);
|
||||
}
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
return points[0] ?? { lat: 0, lon: 0 };
|
||||
}
|
||||
|
||||
const target = Math.max(0, Math.min(1, fraction)) * total;
|
||||
for (let index = 1; index < distances.length; index += 1) {
|
||||
const previousDistance = distances[index - 1] ?? 0;
|
||||
const currentDistance = distances[index] ?? previousDistance;
|
||||
if (target <= currentDistance) {
|
||||
const previous = points[index - 1];
|
||||
const current = points[index];
|
||||
if (!previous || !current) {
|
||||
break;
|
||||
}
|
||||
const localFraction = (target - previousDistance) / Math.max(1, currentDistance - previousDistance);
|
||||
const coordinate: Coordinate = {
|
||||
lat: previous.lat + (current.lat - previous.lat) * localFraction,
|
||||
lon: previous.lon + (current.lon - previous.lon) * localFraction
|
||||
};
|
||||
if (typeof previous.elevationM === 'number' && typeof current.elevationM === 'number') {
|
||||
coordinate.elevationM = previous.elevationM + (current.elevationM - previous.elevationM) * localFraction;
|
||||
}
|
||||
return coordinate;
|
||||
}
|
||||
}
|
||||
|
||||
return points[points.length - 1] ?? { lat: 0, lon: 0 };
|
||||
}
|
||||
9
packages/shared/src/gpx.d.ts
vendored
Normal file
9
packages/shared/src/gpx.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { Poi, RoutePlan, StagePlan } from './types.js';
|
||||
export type GpxOptions = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
includePois?: boolean;
|
||||
attribution?: string[];
|
||||
};
|
||||
export declare function exportRouteToGpx(route: RoutePlan, stages?: StagePlan[], pois?: Poi[], options?: GpxOptions): string;
|
||||
//# sourceMappingURL=gpx.d.ts.map
|
||||
1
packages/shared/src/gpx.d.ts.map
Normal file
1
packages/shared/src/gpx.d.ts.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"gpx.d.ts","sourceRoot":"","sources":["gpx.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5D,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB,CAAC;AAEF,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,GAAE,SAAS,EAAO,EAAE,IAAI,GAAE,GAAG,EAAO,EAAE,OAAO,GAAE,UAAe,GAAG,MAAM,CA8C/H"}
|
||||
70
packages/shared/src/gpx.js
Normal file
70
packages/shared/src/gpx.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { coordinateAtDistance } from './geo.js';
|
||||
export function exportRouteToGpx(route, stages = [], pois = [], options = {}) {
|
||||
const metadataName = options.name ?? `Pikebacker ${route.id}`;
|
||||
const metadataDescription = options.description ??
|
||||
'Bikepacking route export with stage endpoints and route-corridor logistics POIs. Advisory information is confidence-scored and not legal advice.';
|
||||
const attribution = options.attribution ?? route.attribution ?? ['Contains OSM-derived fixture data for MVP testing.'];
|
||||
const stageWaypoints = stages.map((stage) => {
|
||||
const coordinate = coordinateAtDistance(route, stage.endMeters);
|
||||
return waypointXml(coordinate.lat, coordinate.lon, `Stage ${stage.dayIndex} end`, 'stage-end', {
|
||||
routeMeters: String(stage.endMeters)
|
||||
});
|
||||
});
|
||||
const poiWaypoints = options.includePois
|
||||
? pois.map((poi) => waypointXml(poi.location.lat, poi.location.lon, poi.name, poi.category, {
|
||||
confidence: poi.confidence,
|
||||
metersFromStart: String(poi.metersFromStart ?? ''),
|
||||
source: poi.source
|
||||
}))
|
||||
: [];
|
||||
const routePoints = route.geometry
|
||||
.map((point) => ` <rtept lat="${number(point.lat)}" lon="${number(point.lon)}" />`)
|
||||
.join('\n');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="Pikebacker MVP" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 https://www.topografix.com/GPX/1/1/gpx.xsd">
|
||||
<metadata>
|
||||
<name>${escapeXml(metadataName)}</name>
|
||||
<desc>${escapeXml(metadataDescription)}</desc>
|
||||
<copyright author="OpenStreetMap contributors">
|
||||
<license>https://www.openstreetmap.org/copyright</license>
|
||||
</copyright>
|
||||
<extensions>
|
||||
<pikebacker:attribution xmlns:pikebacker="https://pikebacker.local/schema">${escapeXml(attribution.join(' | '))}</pikebacker:attribution>
|
||||
<pikebacker:suitabilityScore xmlns:pikebacker="https://pikebacker.local/schema">${route.suitabilityScore.toFixed(1)}</pikebacker:suitabilityScore>
|
||||
</extensions>
|
||||
</metadata>
|
||||
${[...stageWaypoints, ...poiWaypoints].join('\n')}
|
||||
<rte>
|
||||
<name>${escapeXml(metadataName)}</name>
|
||||
<desc>${escapeXml(`Distance ${(route.distanceM / 1000).toFixed(1)} km, ascent ${route.ascentM} m`)}</desc>
|
||||
${routePoints}
|
||||
</rte>
|
||||
</gpx>
|
||||
`;
|
||||
}
|
||||
function waypointXml(lat, lon, name, type, extensions) {
|
||||
const extensionEntries = Object.entries(extensions)
|
||||
.filter(([, value]) => value !== '')
|
||||
.map(([key, value]) => ` <pikebacker:${escapeXmlName(key)} xmlns:pikebacker="https://pikebacker.local/schema">${escapeXml(value)}</pikebacker:${escapeXmlName(key)}>`)
|
||||
.join('\n');
|
||||
const extensionsXml = extensionEntries ? `\n <extensions>\n${extensionEntries}\n </extensions>` : '';
|
||||
return ` <wpt lat="${number(lat)}" lon="${number(lon)}">
|
||||
<name>${escapeXml(name)}</name>
|
||||
<type>${escapeXml(type)}</type>${extensionsXml}
|
||||
</wpt>`;
|
||||
}
|
||||
function escapeXml(value) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
function escapeXmlName(value) {
|
||||
return value.replace(/[^A-Za-z0-9_.-]/g, '');
|
||||
}
|
||||
function number(value) {
|
||||
return value.toFixed(6);
|
||||
}
|
||||
//# sourceMappingURL=gpx.js.map
|
||||
1
packages/shared/src/gpx.js.map
Normal file
1
packages/shared/src/gpx.js.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"gpx.js","sourceRoot":"","sources":["gpx.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AAUhD,MAAM,UAAU,gBAAgB,CAAC,KAAgB,EAAE,SAAsB,EAAE,EAAE,OAAc,EAAE,EAAE,UAAsB,EAAE;IACrH,MAAM,YAAY,GAAG,OAAO,CAAC,IAAI,IAAI,cAAc,KAAK,CAAC,EAAE,EAAE,CAAC;IAC9D,MAAM,mBAAmB,GACvB,OAAO,CAAC,WAAW;QACnB,kJAAkJ,CAAC;IACrJ,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,IAAI,KAAK,CAAC,WAAW,IAAI,CAAC,oDAAoD,CAAC,CAAC;IACvH,MAAM,cAAc,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE;QAC1C,MAAM,UAAU,GAAG,oBAAoB,CAAC,KAAK,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;QAChE,OAAO,WAAW,CAAC,UAAU,CAAC,GAAG,EAAE,UAAU,CAAC,GAAG,EAAE,SAAS,KAAK,CAAC,QAAQ,MAAM,EAAE,WAAW,EAAE;YAC7F,WAAW,EAAE,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC;SACrC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IACH,MAAM,YAAY,GAAG,OAAO,CAAC,WAAW;QACtC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CACf,WAAW,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,QAAQ,EAAE;YACtE,UAAU,EAAE,GAAG,CAAC,UAAU;YAC1B,eAAe,EAAE,MAAM,CAAC,GAAG,CAAC,eAAe,IAAI,EAAE,CAAC;YAClD,MAAM,EAAE,GAAG,CAAC,MAAM;SACnB,CAAC,CACH;QACH,CAAC,CAAC,EAAE,CAAC;IACP,MAAM,WAAW,GAAG,KAAK,CAAC,QAAQ;SAC/B,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,qBAAqB,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,UAAU,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC;SACvF,IAAI,CAAC,IAAI,CAAC,CAAC;IAEd,OAAO;;;YAGG,SAAS,CAAC,YAAY,CAAC;YACvB,SAAS,CAAC,mBAAmB,CAAC;;;;;mFAKyC,SAAS,CAAC,WAAW,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;wFAC7B,KAAK,CAAC,gBAAgB,CAAC,OAAO,CAAC,CAAC,CAAC;;;EAGvH,CAAC,GAAG,cAAc,EAAE,GAAG,YAAY,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC;;YAErC,SAAS,CAAC,YAAY,CAAC;YACvB,SAAS,CAAC,YAAY,CAAC,KAAK,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,eAAe,KAAK,CAAC,OAAO,IAAI,CAAC;EACpG,WAAW;;;CAGZ,CAAC;AACF,CAAC;AAED,SAAS,WAAW,CAAC,GAAW,EAAE,GAAW,EAAE,IAAY,EAAE,IAAY,EAAE,UAAkC;IAC3G,MAAM,gBAAgB,GAAG,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC;SAChD,MAAM,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,KAAK,KAAK,EAAE,CAAC;SACnC,GAAG,CACF,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,EAAE,CACf,qBAAqB,aAAa,CAAC,GAAG,CAAC,uDAAuD,SAAS,CAAC,KAAK,CAAC,gBAAgB,aAAa,CAAC,GAAG,CAAC,GAAG,CACtJ;SACA,IAAI,CAAC,IAAI,CAAC,CAAC;IACd,MAAM,aAAa,GAAG,gBAAgB,CAAC,CAAC,CAAC,uBAAuB,gBAAgB,qBAAqB,CAAC,CAAC,CAAC,EAAE,CAAC;IAC3G,OAAO,eAAe,MAAM,CAAC,GAAG,CAAC,UAAU,MAAM,CAAC,GAAG,CAAC;YAC5C,SAAS,CAAC,IAAI,CAAC;YACf,SAAS,CAAC,IAAI,CAAC,UAAU,aAAa;SACzC,CAAC;AACV,CAAC;AAED,SAAS,SAAS,CAAC,KAAa;IAC9B,OAAO,KAAK;SACT,UAAU,CAAC,GAAG,EAAE,OAAO,CAAC;SACxB,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC;SACvB,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC;SACvB,UAAU,CAAC,GAAG,EAAE,QAAQ,CAAC;SACzB,UAAU,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;AAC/B,CAAC;AAED,SAAS,aAAa,CAAC,KAAa;IAClC,OAAO,KAAK,CAAC,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC,CAAC;AAC/C,CAAC;AAED,SAAS,MAAM,CAAC,KAAa;IAC3B,OAAO,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;AAC1B,CAAC"}
|
||||
20
packages/shared/src/gpx.test.ts
Normal file
20
packages/shared/src/gpx.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createMockRoute, mockPois, mockTripRequest } from './fixtures.js';
|
||||
import { exportRouteToGpx } from './gpx.js';
|
||||
import { planStages } from './staging.js';
|
||||
|
||||
describe('GPX export', () => {
|
||||
it('serializes valid GPX with route points, stage waypoints, POIs, and attribution', () => {
|
||||
const route = createMockRoute();
|
||||
const stages = planStages(route, mockPois, mockTripRequest);
|
||||
const gpx = exportRouteToGpx(route, stages, mockPois, { includePois: true, name: 'Fixture route' });
|
||||
const parsed = new XMLParser({ ignoreAttributes: false }).parse(gpx) as { gpx?: unknown };
|
||||
|
||||
expect(parsed.gpx).toBeTruthy();
|
||||
expect(gpx).toContain('<rtept lat="48.137000" lon="11.575000" />');
|
||||
expect(gpx).toContain('<name>Stage 1 end</name>');
|
||||
expect(gpx).toContain('OpenStreetMap contributors');
|
||||
expect(gpx).toContain('mock_osm_fixture');
|
||||
});
|
||||
});
|
||||
89
packages/shared/src/gpx.ts
Normal file
89
packages/shared/src/gpx.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { coordinateAtDistance } from './geo.js';
|
||||
import type { Poi, RoutePlan, StagePlan } from './types.js';
|
||||
|
||||
export type GpxOptions = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
includePois?: boolean;
|
||||
attribution?: string[];
|
||||
};
|
||||
|
||||
export function exportRouteToGpx(route: RoutePlan, stages: StagePlan[] = [], pois: Poi[] = [], options: GpxOptions = {}): string {
|
||||
const metadataName = options.name ?? `Pikebacker ${route.id}`;
|
||||
const metadataDescription =
|
||||
options.description ??
|
||||
'Bikepacking route export with stage endpoints and route-corridor logistics POIs. Advisory information is confidence-scored and not legal advice.';
|
||||
const attribution = options.attribution ?? route.attribution ?? ['Contains OSM-derived fixture data for MVP testing.'];
|
||||
const stageWaypoints = stages.map((stage) => {
|
||||
const coordinate = coordinateAtDistance(route, stage.endMeters);
|
||||
return waypointXml(coordinate.lat, coordinate.lon, `Stage ${stage.dayIndex} end`, 'stage-end', {
|
||||
routeMeters: String(stage.endMeters)
|
||||
});
|
||||
});
|
||||
const poiWaypoints = options.includePois
|
||||
? pois.map((poi) =>
|
||||
waypointXml(poi.location.lat, poi.location.lon, poi.name, poi.category, {
|
||||
confidence: poi.confidence,
|
||||
metersFromStart: String(poi.metersFromStart ?? ''),
|
||||
source: poi.source
|
||||
})
|
||||
)
|
||||
: [];
|
||||
const routePoints = route.geometry
|
||||
.map((point) => ` <rtept lat="${number(point.lat)}" lon="${number(point.lon)}" />`)
|
||||
.join('\n');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="Pikebacker MVP" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 https://www.topografix.com/GPX/1/1/gpx.xsd">
|
||||
<metadata>
|
||||
<name>${escapeXml(metadataName)}</name>
|
||||
<desc>${escapeXml(metadataDescription)}</desc>
|
||||
<copyright author="OpenStreetMap contributors">
|
||||
<license>https://www.openstreetmap.org/copyright</license>
|
||||
</copyright>
|
||||
<extensions>
|
||||
<pikebacker:attribution xmlns:pikebacker="https://pikebacker.local/schema">${escapeXml(attribution.join(' | '))}</pikebacker:attribution>
|
||||
<pikebacker:suitabilityScore xmlns:pikebacker="https://pikebacker.local/schema">${route.suitabilityScore.toFixed(1)}</pikebacker:suitabilityScore>
|
||||
</extensions>
|
||||
</metadata>
|
||||
${[...stageWaypoints, ...poiWaypoints].join('\n')}
|
||||
<rte>
|
||||
<name>${escapeXml(metadataName)}</name>
|
||||
<desc>${escapeXml(`Distance ${(route.distanceM / 1000).toFixed(1)} km, ascent ${route.ascentM} m`)}</desc>
|
||||
${routePoints}
|
||||
</rte>
|
||||
</gpx>
|
||||
`;
|
||||
}
|
||||
|
||||
function waypointXml(lat: number, lon: number, name: string, type: string, extensions: Record<string, string>): string {
|
||||
const extensionEntries = Object.entries(extensions)
|
||||
.filter(([, value]) => value !== '')
|
||||
.map(
|
||||
([key, value]) =>
|
||||
` <pikebacker:${escapeXmlName(key)} xmlns:pikebacker="https://pikebacker.local/schema">${escapeXml(value)}</pikebacker:${escapeXmlName(key)}>`
|
||||
)
|
||||
.join('\n');
|
||||
const extensionsXml = extensionEntries ? `\n <extensions>\n${extensionEntries}\n </extensions>` : '';
|
||||
return ` <wpt lat="${number(lat)}" lon="${number(lon)}">
|
||||
<name>${escapeXml(name)}</name>
|
||||
<type>${escapeXml(type)}</type>${extensionsXml}
|
||||
</wpt>`;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function escapeXmlName(value: string): string {
|
||||
return value.replace(/[^A-Za-z0-9_.-]/g, '');
|
||||
}
|
||||
|
||||
function number(value: number): string {
|
||||
return value.toFixed(6);
|
||||
}
|
||||
10
packages/shared/src/index.d.ts
vendored
Normal file
10
packages/shared/src/index.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from './types.js';
|
||||
export * from './profiles.js';
|
||||
export * from './geo.js';
|
||||
export * from './scoring.js';
|
||||
export * from './pois.js';
|
||||
export * from './staging.js';
|
||||
export * from './gpx.js';
|
||||
export * from './offline.js';
|
||||
export * from './fixtures.js';
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
1
packages/shared/src/index.d.ts.map
Normal file
1
packages/shared/src/index.d.ts.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC;AAC1B,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,eAAe,CAAC"}
|
||||
10
packages/shared/src/index.js
Normal file
10
packages/shared/src/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from './types.js';
|
||||
export * from './profiles.js';
|
||||
export * from './geo.js';
|
||||
export * from './scoring.js';
|
||||
export * from './pois.js';
|
||||
export * from './staging.js';
|
||||
export * from './gpx.js';
|
||||
export * from './offline.js';
|
||||
export * from './fixtures.js';
|
||||
//# sourceMappingURL=index.js.map
|
||||
1
packages/shared/src/index.js.map
Normal file
1
packages/shared/src/index.js.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC;AAC1B,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,eAAe,CAAC"}
|
||||
9
packages/shared/src/index.ts
Normal file
9
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './types.js';
|
||||
export * from './profiles.js';
|
||||
export * from './geo.js';
|
||||
export * from './scoring.js';
|
||||
export * from './pois.js';
|
||||
export * from './staging.js';
|
||||
export * from './gpx.js';
|
||||
export * from './offline.js';
|
||||
export * from './fixtures.js';
|
||||
13
packages/shared/src/offline.d.ts
vendored
Normal file
13
packages/shared/src/offline.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { OfflinePackManifest, Poi, RoutePlan, RuleCard, StagePlan } from './types.js';
|
||||
export type OfflinePackInput = {
|
||||
tripId: string;
|
||||
route: RoutePlan;
|
||||
stages: StagePlan[];
|
||||
pois: Poi[];
|
||||
rules: RuleCard[];
|
||||
routeBufferKm?: number;
|
||||
createdAt?: string;
|
||||
gpxBytes?: number;
|
||||
};
|
||||
export declare function createOfflinePackManifest(input: OfflinePackInput): OfflinePackManifest;
|
||||
//# sourceMappingURL=offline.d.ts.map
|
||||
1
packages/shared/src/offline.d.ts.map
Normal file
1
packages/shared/src/offline.d.ts.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"offline.d.ts","sourceRoot":"","sources":["offline.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAE,GAAG,EAAE,SAAS,EAAgB,QAAQ,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEzG,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,SAAS,CAAC;IACjB,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,IAAI,EAAE,GAAG,EAAE,CAAC;IACZ,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,gBAAgB,GAAG,mBAAmB,CAoDtF"}
|
||||
59
packages/shared/src/offline.js
Normal file
59
packages/shared/src/offline.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { bboxForCoordinates } from './geo.js';
|
||||
export function createOfflinePackManifest(input) {
|
||||
const createdAt = input.createdAt ?? '2026-07-02T00:00:00.000Z';
|
||||
const routeBufferKm = input.routeBufferKm ?? 5;
|
||||
const bboxPaddingDegrees = routeBufferKm / 111;
|
||||
const warnings = [
|
||||
{
|
||||
code: 'OFFLINE_TILES_UNAVAILABLE',
|
||||
severity: 'notice',
|
||||
title: 'Offline map tiles not bundled',
|
||||
message: 'MVP pack includes route, POIs, stages, rules, GPX, and report queue metadata. Tile files are represented as future references.'
|
||||
},
|
||||
{
|
||||
code: 'OFFLINE_REROUTING_UNAVAILABLE',
|
||||
severity: 'notice',
|
||||
title: 'Offline rerouting unavailable',
|
||||
message: 'Use the cached route and bailout POIs offline. Full offline routing graph support is not included in the MVP manifest.'
|
||||
}
|
||||
];
|
||||
return {
|
||||
packId: `pack_${input.tripId}_${input.route.id}`,
|
||||
tripId: input.tripId,
|
||||
routeId: input.route.id,
|
||||
createdAt,
|
||||
bbox: bboxForCoordinates(input.route.geometry, bboxPaddingDegrees),
|
||||
routeBufferKm,
|
||||
includedLayers: [
|
||||
'route_geometry',
|
||||
'stage_plan',
|
||||
'route_corridor_pois',
|
||||
'critical_gaps',
|
||||
'rule_cards',
|
||||
'gpx_export',
|
||||
'offline_report_queue'
|
||||
],
|
||||
dataVersions: {
|
||||
osm_extract: 'mock-osm-fixture-2026-07-02',
|
||||
poi_index: 'mock-poi-fixture-2026-07-02',
|
||||
rules: 'mock-rules-2026-07-02',
|
||||
scoring_model: 'pikebacker-mvp-0.1.0'
|
||||
},
|
||||
files: [
|
||||
{ type: 'route_json', path: `offline://${input.route.id}/route.json` },
|
||||
{ type: 'stages_json', path: `offline://${input.route.id}/stages.json` },
|
||||
{ type: 'pois_json', path: `offline://${input.route.id}/pois.json` },
|
||||
{ type: 'rules_json', path: `offline://${input.route.id}/rules.json` },
|
||||
{ type: 'gpx', path: `offline://${input.route.id}/route.gpx`, bytes: input.gpxBytes },
|
||||
{ type: 'tile_pack_placeholder', path: `offline://${input.route.id}/tiles.pmtiles`, bytes: 0 }
|
||||
],
|
||||
warnings,
|
||||
expiresAt: addDaysIso(createdAt, 14)
|
||||
};
|
||||
}
|
||||
function addDaysIso(isoDate, days) {
|
||||
const date = new Date(isoDate);
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return date.toISOString();
|
||||
}
|
||||
//# sourceMappingURL=offline.js.map
|
||||
1
packages/shared/src/offline.js.map
Normal file
1
packages/shared/src/offline.js.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"offline.js","sourceRoot":"","sources":["offline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAc9C,MAAM,UAAU,yBAAyB,CAAC,KAAuB;IAC/D,MAAM,SAAS,GAAG,KAAK,CAAC,SAAS,IAAI,0BAA0B,CAAC;IAChE,MAAM,aAAa,GAAG,KAAK,CAAC,aAAa,IAAI,CAAC,CAAC;IAC/C,MAAM,kBAAkB,GAAG,aAAa,GAAG,GAAG,CAAC;IAC/C,MAAM,QAAQ,GAAmB;QAC/B;YACE,IAAI,EAAE,2BAA2B;YACjC,QAAQ,EAAE,QAAQ;YAClB,KAAK,EAAE,+BAA+B;YACtC,OAAO,EAAE,gIAAgI;SAC1I;QACD;YACE,IAAI,EAAE,+BAA+B;YACrC,QAAQ,EAAE,QAAQ;YAClB,KAAK,EAAE,+BAA+B;YACtC,OAAO,EAAE,wHAAwH;SAClI;KACF,CAAC;IAEF,OAAO;QACL,MAAM,EAAE,QAAQ,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,KAAK,CAAC,EAAE,EAAE;QAChD,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,OAAO,EAAE,KAAK,CAAC,KAAK,CAAC,EAAE;QACvB,SAAS;QACT,IAAI,EAAE,kBAAkB,CAAC,KAAK,CAAC,KAAK,CAAC,QAAQ,EAAE,kBAAkB,CAAC;QAClE,aAAa;QACb,cAAc,EAAE;YACd,gBAAgB;YAChB,YAAY;YACZ,qBAAqB;YACrB,eAAe;YACf,YAAY;YACZ,YAAY;YACZ,sBAAsB;SACvB;QACD,YAAY,EAAE;YACZ,WAAW,EAAE,6BAA6B;YAC1C,SAAS,EAAE,6BAA6B;YACxC,KAAK,EAAE,uBAAuB;YAC9B,aAAa,EAAE,sBAAsB;SACtC;QACD,KAAK,EAAE;YACL,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,aAAa,KAAK,CAAC,KAAK,CAAC,EAAE,aAAa,EAAE;YACtE,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,aAAa,KAAK,CAAC,KAAK,CAAC,EAAE,cAAc,EAAE;YACxE,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,aAAa,KAAK,CAAC,KAAK,CAAC,EAAE,YAAY,EAAE;YACpE,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,aAAa,KAAK,CAAC,KAAK,CAAC,EAAE,aAAa,EAAE;YACtE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,aAAa,KAAK,CAAC,KAAK,CAAC,EAAE,YAAY,EAAE,KAAK,EAAE,KAAK,CAAC,QAAQ,EAAE;YACrF,EAAE,IAAI,EAAE,uBAAuB,EAAE,IAAI,EAAE,aAAa,KAAK,CAAC,KAAK,CAAC,EAAE,gBAAgB,EAAE,KAAK,EAAE,CAAC,EAAE;SAC/F;QACD,QAAQ;QACR,SAAS,EAAE,UAAU,CAAC,SAAS,EAAE,EAAE,CAAC;KACrC,CAAC;AACJ,CAAC;AAED,SAAS,UAAU,CAAC,OAAe,EAAE,IAAY;IAC/C,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,OAAO,CAAC,CAAC;IAC/B,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,UAAU,EAAE,GAAG,IAAI,CAAC,CAAC;IAC1C,OAAO,IAAI,CAAC,WAAW,EAAE,CAAC;AAC5B,CAAC"}
|
||||
73
packages/shared/src/offline.ts
Normal file
73
packages/shared/src/offline.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { bboxForCoordinates } from './geo.js';
|
||||
import type { OfflinePackManifest, Poi, RoutePlan, RouteWarning, RuleCard, StagePlan } from './types.js';
|
||||
|
||||
export type OfflinePackInput = {
|
||||
tripId: string;
|
||||
route: RoutePlan;
|
||||
stages: StagePlan[];
|
||||
pois: Poi[];
|
||||
rules: RuleCard[];
|
||||
routeBufferKm?: number;
|
||||
createdAt?: string;
|
||||
gpxBytes?: number;
|
||||
};
|
||||
|
||||
export function createOfflinePackManifest(input: OfflinePackInput): OfflinePackManifest {
|
||||
const createdAt = input.createdAt ?? '2026-07-02T00:00:00.000Z';
|
||||
const routeBufferKm = input.routeBufferKm ?? 5;
|
||||
const bboxPaddingDegrees = routeBufferKm / 111;
|
||||
const warnings: RouteWarning[] = [
|
||||
{
|
||||
code: 'OFFLINE_TILES_UNAVAILABLE',
|
||||
severity: 'notice',
|
||||
title: 'Offline map tiles not bundled',
|
||||
message: 'MVP pack includes route, POIs, stages, rules, GPX, and report queue metadata. Tile files are represented as future references.'
|
||||
},
|
||||
{
|
||||
code: 'OFFLINE_REROUTING_UNAVAILABLE',
|
||||
severity: 'notice',
|
||||
title: 'Offline rerouting unavailable',
|
||||
message: 'Use the cached route and bailout POIs offline. Full offline routing graph support is not included in the MVP manifest.'
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
packId: `pack_${input.tripId}_${input.route.id}`,
|
||||
tripId: input.tripId,
|
||||
routeId: input.route.id,
|
||||
createdAt,
|
||||
bbox: bboxForCoordinates(input.route.geometry, bboxPaddingDegrees),
|
||||
routeBufferKm,
|
||||
includedLayers: [
|
||||
'route_geometry',
|
||||
'stage_plan',
|
||||
'route_corridor_pois',
|
||||
'critical_gaps',
|
||||
'rule_cards',
|
||||
'gpx_export',
|
||||
'offline_report_queue'
|
||||
],
|
||||
dataVersions: {
|
||||
osm_extract: 'mock-osm-fixture-2026-07-02',
|
||||
poi_index: 'mock-poi-fixture-2026-07-02',
|
||||
rules: 'mock-rules-2026-07-02',
|
||||
scoring_model: 'pikebacker-mvp-0.1.0'
|
||||
},
|
||||
files: [
|
||||
{ type: 'route_json', path: `offline://${input.route.id}/route.json` },
|
||||
{ type: 'stages_json', path: `offline://${input.route.id}/stages.json` },
|
||||
{ type: 'pois_json', path: `offline://${input.route.id}/pois.json` },
|
||||
{ type: 'rules_json', path: `offline://${input.route.id}/rules.json` },
|
||||
{ type: 'gpx', path: `offline://${input.route.id}/route.gpx`, bytes: input.gpxBytes },
|
||||
{ type: 'tile_pack_placeholder', path: `offline://${input.route.id}/tiles.pmtiles`, bytes: 0 }
|
||||
],
|
||||
warnings,
|
||||
expiresAt: addDaysIso(createdAt, 14)
|
||||
};
|
||||
}
|
||||
|
||||
function addDaysIso(isoDate: string, days: number): string {
|
||||
const date = new Date(isoDate);
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return date.toISOString();
|
||||
}
|
||||
15
packages/shared/src/pois.d.ts
vendored
Normal file
15
packages/shared/src/pois.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { CriticalGapThresholds, Poi, PoiCategory, RoutePlan, ServiceGap, WarningSeverity } from './types.js';
|
||||
export type CorridorFilter = {
|
||||
category?: PoiCategory;
|
||||
bufferKm?: number;
|
||||
};
|
||||
export declare function filterPoisByCorridor(pois: Poi[], _route: RoutePlan, filter?: CorridorFilter): Poi[];
|
||||
export declare function detectCriticalGaps(route: RoutePlan, pois: Poi[], thresholds: CriticalGapThresholds): ServiceGap[];
|
||||
export declare function warningsFromGaps(gaps: ServiceGap[]): {
|
||||
code: string;
|
||||
severity: WarningSeverity;
|
||||
title: string;
|
||||
message: string;
|
||||
metersFromStart: number;
|
||||
}[];
|
||||
//# sourceMappingURL=pois.d.ts.map
|
||||
1
packages/shared/src/pois.d.ts.map
Normal file
1
packages/shared/src/pois.d.ts.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"pois.d.ts","sourceRoot":"","sources":["pois.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,GAAG,EAAE,WAAW,EAAE,SAAS,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAIlH,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,CAAC,EAAE,WAAW,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAE,cAAmB,GAAG,GAAG,EAAE,CAMvG;AAED,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE,GAAG,EAAE,EACX,UAAU,EAAE,qBAAqB,GAChC,UAAU,EAAE,CAoCd;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,UAAU,EAAE;;;;;;IAQlD"}
|
||||
72
packages/shared/src/pois.js
Normal file
72
packages/shared/src/pois.js
Normal file
@@ -0,0 +1,72 @@
|
||||
const LOGISTICS_CATEGORIES = ['water', 'food', 'sleep', 'repair', 'bailout', 'charging'];
|
||||
export function filterPoisByCorridor(pois, _route, filter = {}) {
|
||||
const bufferM = (filter.bufferKm ?? 5) * 1000;
|
||||
return pois
|
||||
.filter((poi) => (filter.category ? poi.category === filter.category : true))
|
||||
.filter((poi) => (poi.distanceFromRouteM ?? 0) <= bufferM)
|
||||
.sort(comparePoisByRoutePosition);
|
||||
}
|
||||
export function detectCriticalGaps(route, pois, thresholds) {
|
||||
const gaps = [];
|
||||
for (const category of LOGISTICS_CATEGORIES) {
|
||||
const threshold = thresholds[category];
|
||||
if (!threshold) {
|
||||
continue;
|
||||
}
|
||||
const routePositions = pois
|
||||
.filter((poi) => poi.category === category && typeof poi.metersFromStart === 'number')
|
||||
.map((poi) => Math.max(0, Math.min(route.distanceM, poi.metersFromStart ?? 0)))
|
||||
.sort((left, right) => left - right);
|
||||
const positions = [0, ...routePositions, route.distanceM];
|
||||
for (let index = 1; index < positions.length; index += 1) {
|
||||
const startM = positions[index - 1] ?? 0;
|
||||
const endM = positions[index] ?? route.distanceM;
|
||||
const distanceM = endM - startM;
|
||||
if (distanceM > threshold) {
|
||||
gaps.push({
|
||||
category,
|
||||
startM,
|
||||
endM,
|
||||
distanceM,
|
||||
severity: severityForGap(distanceM, threshold),
|
||||
explanation: explainGap(category, distanceM, threshold)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return gaps.sort((left, right) => {
|
||||
const severityRank = rankSeverity(right.severity) - rankSeverity(left.severity);
|
||||
return severityRank || left.startM - right.startM;
|
||||
});
|
||||
}
|
||||
export function warningsFromGaps(gaps) {
|
||||
return gaps.map((gap) => ({
|
||||
code: `${gap.category.toUpperCase()}_GAP`,
|
||||
severity: gap.severity,
|
||||
title: `Long ${gap.category} gap`,
|
||||
message: gap.explanation,
|
||||
metersFromStart: gap.startM
|
||||
}));
|
||||
}
|
||||
function comparePoisByRoutePosition(left, right) {
|
||||
return (left.metersFromStart ?? Number.MAX_SAFE_INTEGER) - (right.metersFromStart ?? Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
function severityForGap(distanceM, thresholdM) {
|
||||
const ratio = distanceM / thresholdM;
|
||||
if (ratio >= 1.5) {
|
||||
return 'critical';
|
||||
}
|
||||
if (ratio >= 1.0) {
|
||||
return 'warning';
|
||||
}
|
||||
return 'notice';
|
||||
}
|
||||
function rankSeverity(severity) {
|
||||
return severity === 'critical' ? 4 : severity === 'warning' ? 3 : severity === 'notice' ? 2 : 1;
|
||||
}
|
||||
function explainGap(category, distanceM, thresholdM) {
|
||||
const distanceKm = (distanceM / 1000).toFixed(1);
|
||||
const thresholdKm = (thresholdM / 1000).toFixed(0);
|
||||
return `${distanceKm} km without a confirmed ${category} POI, above the ${thresholdKm} km bikepacking threshold.`;
|
||||
}
|
||||
//# sourceMappingURL=pois.js.map
|
||||
1
packages/shared/src/pois.js.map
Normal file
1
packages/shared/src/pois.js.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"pois.js","sourceRoot":"","sources":["pois.ts"],"names":[],"mappings":"AAEA,MAAM,oBAAoB,GAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,UAAU,CAAC,CAAC;AAOxG,MAAM,UAAU,oBAAoB,CAAC,IAAW,EAAE,MAAiB,EAAE,SAAyB,EAAE;IAC9F,MAAM,OAAO,GAAG,CAAC,MAAM,CAAC,QAAQ,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC;IAC9C,OAAO,IAAI;SACR,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,KAAK,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;SAC5E,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,kBAAkB,IAAI,CAAC,CAAC,IAAI,OAAO,CAAC;SACzD,IAAI,CAAC,0BAA0B,CAAC,CAAC;AACtC,CAAC;AAED,MAAM,UAAU,kBAAkB,CAChC,KAAgB,EAChB,IAAW,EACX,UAAiC;IAEjC,MAAM,IAAI,GAAiB,EAAE,CAAC;IAE9B,KAAK,MAAM,QAAQ,IAAI,oBAAoB,EAAE,CAAC;QAC5C,MAAM,SAAS,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC;QACvC,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,SAAS;QACX,CAAC;QAED,MAAM,cAAc,GAAG,IAAI;aACxB,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,QAAQ,KAAK,QAAQ,IAAI,OAAO,GAAG,CAAC,eAAe,KAAK,QAAQ,CAAC;aACrF,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,SAAS,EAAE,GAAG,CAAC,eAAe,IAAI,CAAC,CAAC,CAAC,CAAC;aAC9E,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,IAAI,GAAG,KAAK,CAAC,CAAC;QAEvC,MAAM,SAAS,GAAG,CAAC,CAAC,EAAE,GAAG,cAAc,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;QAC1D,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,SAAS,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;YACzD,MAAM,MAAM,GAAG,SAAS,CAAC,KAAK,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC;YACzC,MAAM,IAAI,GAAG,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,SAAS,CAAC;YACjD,MAAM,SAAS,GAAG,IAAI,GAAG,MAAM,CAAC;YAChC,IAAI,SAAS,GAAG,SAAS,EAAE,CAAC;gBAC1B,IAAI,CAAC,IAAI,CAAC;oBACR,QAAQ;oBACR,MAAM;oBACN,IAAI;oBACJ,SAAS;oBACT,QAAQ,EAAE,cAAc,CAAC,SAAS,EAAE,SAAS,CAAC;oBAC9C,WAAW,EAAE,UAAU,CAAC,QAAQ,EAAE,SAAS,EAAE,SAAS,CAAC;iBACxD,CAAC,CAAC;YACL,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE;QAC/B,MAAM,YAAY,GAAG,YAAY,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,YAAY,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAChF,OAAO,YAAY,IAAI,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC;IACpD,CAAC,CAAC,CAAC;AACL,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,IAAkB;IACjD,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;QACxB,IAAI,EAAE,GAAG,GAAG,CAAC,QAAQ,CAAC,WAAW,EAAE,MAAM;QACzC,QAAQ,EAAE,GAAG,CAAC,QAAQ;QACtB,KAAK,EAAE,QAAQ,GAAG,CAAC,QAAQ,MAAM;QACjC,OAAO,EAAE,GAAG,CAAC,WAAW;QACxB,eAAe,EAAE,GAAG,CAAC,MAAM;KAC5B,CAAC,CAAC,CAAC;AACN,CAAC;AAED,SAAS,0BAA0B,CAAC,IAAS,EAAE,KAAU;IACvD,OAAO,CAAC,IAAI,CAAC,eAAe,IAAI,MAAM,CAAC,gBAAgB,CAAC,GAAG,CAAC,KAAK,CAAC,eAAe,IAAI,MAAM,CAAC,gBAAgB,CAAC,CAAC;AAChH,CAAC;AAED,SAAS,cAAc,CAAC,SAAiB,EAAE,UAAkB;IAC3D,MAAM,KAAK,GAAG,SAAS,GAAG,UAAU,CAAC;IACrC,IAAI,KAAK,IAAI,GAAG,EAAE,CAAC;QACjB,OAAO,UAAU,CAAC;IACpB,CAAC;IACD,IAAI,KAAK,IAAI,GAAG,EAAE,CAAC;QACjB,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,SAAS,YAAY,CAAC,QAAyB;IAC7C,OAAO,QAAQ,KAAK,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAClG,CAAC;AAED,SAAS,UAAU,CAAC,QAAqB,EAAE,SAAiB,EAAE,UAAkB;IAC9E,MAAM,UAAU,GAAG,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IACjD,MAAM,WAAW,GAAG,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IACnD,OAAO,GAAG,UAAU,2BAA2B,QAAQ,mBAAmB,WAAW,4BAA4B,CAAC;AACpH,CAAC"}
|
||||
24
packages/shared/src/pois.test.ts
Normal file
24
packages/shared/src/pois.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createMockRoute, mockPois, mockTripRequest } from './fixtures.js';
|
||||
import { thresholdsFromTripRequest } from './profiles.js';
|
||||
import { detectCriticalGaps, filterPoisByCorridor } from './pois.js';
|
||||
|
||||
describe('route corridor POIs and gaps', () => {
|
||||
it('filters route-corridor POIs by category and buffer', () => {
|
||||
const route = createMockRoute();
|
||||
const sleepPois = filterPoisByCorridor(mockPois, route, { category: 'sleep', bufferKm: 2 });
|
||||
|
||||
expect(sleepPois.map((poi) => poi.category)).toEqual(expect.arrayContaining(['sleep']));
|
||||
expect(sleepPois.every((poi) => (poi.distanceFromRouteM ?? 0) <= 2000)).toBe(true);
|
||||
expect(sleepPois[0]?.metersFromStart).toBeLessThan(sleepPois[sleepPois.length - 1]?.metersFromStart ?? 0);
|
||||
});
|
||||
|
||||
it('detects critical logistics gaps from deterministic fixtures', () => {
|
||||
const route = createMockRoute();
|
||||
const gaps = detectCriticalGaps(route, mockPois, thresholdsFromTripRequest(mockTripRequest));
|
||||
|
||||
expect(gaps.map((gap) => gap.category)).toEqual(expect.arrayContaining(['water', 'food']));
|
||||
expect(gaps.find((gap) => gap.category === 'water')?.severity).toBe('warning');
|
||||
expect(gaps.every((gap) => gap.distanceM > 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
93
packages/shared/src/pois.ts
Normal file
93
packages/shared/src/pois.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { CriticalGapThresholds, Poi, PoiCategory, RoutePlan, ServiceGap, WarningSeverity } from './types.js';
|
||||
|
||||
const LOGISTICS_CATEGORIES: PoiCategory[] = ['water', 'food', 'sleep', 'repair', 'bailout', 'charging'];
|
||||
|
||||
export type CorridorFilter = {
|
||||
category?: PoiCategory;
|
||||
bufferKm?: number;
|
||||
};
|
||||
|
||||
export function filterPoisByCorridor(pois: Poi[], _route: RoutePlan, filter: CorridorFilter = {}): Poi[] {
|
||||
const bufferM = (filter.bufferKm ?? 5) * 1000;
|
||||
return pois
|
||||
.filter((poi) => (filter.category ? poi.category === filter.category : true))
|
||||
.filter((poi) => (poi.distanceFromRouteM ?? 0) <= bufferM)
|
||||
.sort(comparePoisByRoutePosition);
|
||||
}
|
||||
|
||||
export function detectCriticalGaps(
|
||||
route: RoutePlan,
|
||||
pois: Poi[],
|
||||
thresholds: CriticalGapThresholds
|
||||
): ServiceGap[] {
|
||||
const gaps: ServiceGap[] = [];
|
||||
|
||||
for (const category of LOGISTICS_CATEGORIES) {
|
||||
const threshold = thresholds[category];
|
||||
if (!threshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const routePositions = pois
|
||||
.filter((poi) => poi.category === category && typeof poi.metersFromStart === 'number')
|
||||
.map((poi) => Math.max(0, Math.min(route.distanceM, poi.metersFromStart ?? 0)))
|
||||
.sort((left, right) => left - right);
|
||||
|
||||
const positions = [0, ...routePositions, route.distanceM];
|
||||
for (let index = 1; index < positions.length; index += 1) {
|
||||
const startM = positions[index - 1] ?? 0;
|
||||
const endM = positions[index] ?? route.distanceM;
|
||||
const distanceM = endM - startM;
|
||||
if (distanceM > threshold) {
|
||||
gaps.push({
|
||||
category,
|
||||
startM,
|
||||
endM,
|
||||
distanceM,
|
||||
severity: severityForGap(distanceM, threshold),
|
||||
explanation: explainGap(category, distanceM, threshold)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gaps.sort((left, right) => {
|
||||
const severityRank = rankSeverity(right.severity) - rankSeverity(left.severity);
|
||||
return severityRank || left.startM - right.startM;
|
||||
});
|
||||
}
|
||||
|
||||
export function warningsFromGaps(gaps: ServiceGap[]) {
|
||||
return gaps.map((gap) => ({
|
||||
code: `${gap.category.toUpperCase()}_GAP`,
|
||||
severity: gap.severity,
|
||||
title: `Long ${gap.category} gap`,
|
||||
message: gap.explanation,
|
||||
metersFromStart: gap.startM
|
||||
}));
|
||||
}
|
||||
|
||||
function comparePoisByRoutePosition(left: Poi, right: Poi): number {
|
||||
return (left.metersFromStart ?? Number.MAX_SAFE_INTEGER) - (right.metersFromStart ?? Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
|
||||
function severityForGap(distanceM: number, thresholdM: number): WarningSeverity {
|
||||
const ratio = distanceM / thresholdM;
|
||||
if (ratio >= 1.5) {
|
||||
return 'critical';
|
||||
}
|
||||
if (ratio >= 1.0) {
|
||||
return 'warning';
|
||||
}
|
||||
return 'notice';
|
||||
}
|
||||
|
||||
function rankSeverity(severity: WarningSeverity): number {
|
||||
return severity === 'critical' ? 4 : severity === 'warning' ? 3 : severity === 'notice' ? 2 : 1;
|
||||
}
|
||||
|
||||
function explainGap(category: PoiCategory, distanceM: number, thresholdM: number): string {
|
||||
const distanceKm = (distanceM / 1000).toFixed(1);
|
||||
const thresholdKm = (thresholdM / 1000).toFixed(0);
|
||||
return `${distanceKm} km without a confirmed ${category} POI, above the ${thresholdKm} km bikepacking threshold.`;
|
||||
}
|
||||
6
packages/shared/src/profiles.d.ts
vendored
Normal file
6
packages/shared/src/profiles.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { BikepackingProfile, BikepackingProfileId, CriticalGapThresholds, TripRequest } from './types.js';
|
||||
export declare const BIKEPACKING_PROFILES: Record<BikepackingProfileId, BikepackingProfile>;
|
||||
export declare function getProfile(profileId: BikepackingProfileId): BikepackingProfile;
|
||||
export declare function thresholdsFromTripRequest(request: TripRequest): CriticalGapThresholds;
|
||||
export declare function maxLoadedGradeForRequest(request: TripRequest): number;
|
||||
//# sourceMappingURL=profiles.d.ts.map
|
||||
1
packages/shared/src/profiles.d.ts.map
Normal file
1
packages/shared/src/profiles.d.ts.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"profiles.d.ts","sourceRoot":"","sources":["profiles.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE/G,eAAO,MAAM,oBAAoB,EAAE,MAAM,CAAC,oBAAoB,EAAE,kBAAkB,CA4EjF,CAAC;AAEF,wBAAgB,UAAU,CAAC,SAAS,EAAE,oBAAoB,GAAG,kBAAkB,CAE9E;AAED,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,WAAW,GAAG,qBAAqB,CAUrF;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAErE"}
|
||||
95
packages/shared/src/profiles.js
Normal file
95
packages/shared/src/profiles.js
Normal file
@@ -0,0 +1,95 @@
|
||||
export const BIKEPACKING_PROFILES = {
|
||||
beginner_safe: {
|
||||
id: 'beginner_safe',
|
||||
label: 'Beginner-safe',
|
||||
dailyDistanceKm: { min: 40, target: 55, max: 70 },
|
||||
maxGradeWarningPercent: 10,
|
||||
waterGapMaxKm: 25,
|
||||
foodGapMaxKm: 40,
|
||||
repairGapMaxKm: 80,
|
||||
bailoutGapMaxKm: 50,
|
||||
sleepGapMaxKm: 65,
|
||||
roughSurfaceTolerance: 0.35,
|
||||
trafficTolerance: 0.25,
|
||||
unknownAccessPenalty: 18,
|
||||
preferredSurfaces: ['paved', 'asphalt', 'fine_gravel', 'compacted']
|
||||
},
|
||||
loaded_gravel: {
|
||||
id: 'loaded_gravel',
|
||||
label: 'Loaded gravel',
|
||||
dailyDistanceKm: { min: 60, target: 80, max: 100 },
|
||||
maxGradeWarningPercent: 12,
|
||||
waterGapMaxKm: 40,
|
||||
foodGapMaxKm: 70,
|
||||
repairGapMaxKm: 110,
|
||||
bailoutGapMaxKm: 90,
|
||||
sleepGapMaxKm: 95,
|
||||
roughSurfaceTolerance: 0.6,
|
||||
trafficTolerance: 0.45,
|
||||
unknownAccessPenalty: 12,
|
||||
preferredSurfaces: ['gravel', 'fine_gravel', 'compacted', 'paved', 'asphalt', 'dirt']
|
||||
},
|
||||
hardtail_bikepacking: {
|
||||
id: 'hardtail_bikepacking',
|
||||
label: 'Hardtail bikepacking',
|
||||
dailyDistanceKm: { min: 50, target: 70, max: 90 },
|
||||
maxGradeWarningPercent: 15,
|
||||
waterGapMaxKm: 55,
|
||||
foodGapMaxKm: 90,
|
||||
repairGapMaxKm: 130,
|
||||
bailoutGapMaxKm: 110,
|
||||
sleepGapMaxKm: 100,
|
||||
roughSurfaceTolerance: 0.8,
|
||||
trafficTolerance: 0.35,
|
||||
unknownAccessPenalty: 10,
|
||||
preferredSurfaces: ['dirt', 'singletrack', 'gravel', 'track', 'compacted']
|
||||
},
|
||||
road_touring: {
|
||||
id: 'road_touring',
|
||||
label: 'Road touring',
|
||||
dailyDistanceKm: { min: 70, target: 100, max: 130 },
|
||||
maxGradeWarningPercent: 10,
|
||||
waterGapMaxKm: 40,
|
||||
foodGapMaxKm: 70,
|
||||
repairGapMaxKm: 120,
|
||||
bailoutGapMaxKm: 90,
|
||||
sleepGapMaxKm: 115,
|
||||
roughSurfaceTolerance: 0.2,
|
||||
trafficTolerance: 0.5,
|
||||
unknownAccessPenalty: 14,
|
||||
preferredSurfaces: ['paved', 'asphalt', 'concrete']
|
||||
},
|
||||
ebikepacking: {
|
||||
id: 'ebikepacking',
|
||||
label: 'E-bikepacking',
|
||||
dailyDistanceKm: { min: 50, target: 75, max: 100 },
|
||||
maxGradeWarningPercent: 12,
|
||||
waterGapMaxKm: 40,
|
||||
foodGapMaxKm: 60,
|
||||
repairGapMaxKm: 100,
|
||||
bailoutGapMaxKm: 80,
|
||||
sleepGapMaxKm: 90,
|
||||
roughSurfaceTolerance: 0.5,
|
||||
trafficTolerance: 0.4,
|
||||
unknownAccessPenalty: 15,
|
||||
preferredSurfaces: ['paved', 'asphalt', 'gravel', 'compacted']
|
||||
}
|
||||
};
|
||||
export function getProfile(profileId) {
|
||||
return BIKEPACKING_PROFILES[profileId];
|
||||
}
|
||||
export function thresholdsFromTripRequest(request) {
|
||||
const profile = getProfile(request.profile);
|
||||
return {
|
||||
water: (request.waterGapMaxKm ?? profile.waterGapMaxKm) * 1000,
|
||||
food: (request.foodGapMaxKm ?? profile.foodGapMaxKm) * 1000,
|
||||
repair: profile.repairGapMaxKm * 1000,
|
||||
bailout: (request.requireBailoutEveryKm ?? profile.bailoutGapMaxKm) * 1000,
|
||||
sleep: profile.sleepGapMaxKm * 1000,
|
||||
charging: request.profile === 'ebikepacking' ? 60000 : undefined
|
||||
};
|
||||
}
|
||||
export function maxLoadedGradeForRequest(request) {
|
||||
return request.maxLoadedGradePercent ?? getProfile(request.profile).maxGradeWarningPercent;
|
||||
}
|
||||
//# sourceMappingURL=profiles.js.map
|
||||
1
packages/shared/src/profiles.js.map
Normal file
1
packages/shared/src/profiles.js.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"profiles.js","sourceRoot":"","sources":["profiles.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,oBAAoB,GAAqD;IACpF,aAAa,EAAE;QACb,EAAE,EAAE,eAAe;QACnB,KAAK,EAAE,eAAe;QACtB,eAAe,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE;QACjD,sBAAsB,EAAE,EAAE;QAC1B,aAAa,EAAE,EAAE;QACjB,YAAY,EAAE,EAAE;QAChB,cAAc,EAAE,EAAE;QAClB,eAAe,EAAE,EAAE;QACnB,aAAa,EAAE,EAAE;QACjB,qBAAqB,EAAE,IAAI;QAC3B,gBAAgB,EAAE,IAAI;QACtB,oBAAoB,EAAE,EAAE;QACxB,iBAAiB,EAAE,CAAC,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,WAAW,CAAC;KACpE;IACD,aAAa,EAAE;QACb,EAAE,EAAE,eAAe;QACnB,KAAK,EAAE,eAAe;QACtB,eAAe,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE;QAClD,sBAAsB,EAAE,EAAE;QAC1B,aAAa,EAAE,EAAE;QACjB,YAAY,EAAE,EAAE;QAChB,cAAc,EAAE,GAAG;QACnB,eAAe,EAAE,EAAE;QACnB,aAAa,EAAE,EAAE;QACjB,qBAAqB,EAAE,GAAG;QAC1B,gBAAgB,EAAE,IAAI;QACtB,oBAAoB,EAAE,EAAE;QACxB,iBAAiB,EAAE,CAAC,QAAQ,EAAE,aAAa,EAAE,WAAW,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,CAAC;KACtF;IACD,oBAAoB,EAAE;QACpB,EAAE,EAAE,sBAAsB;QAC1B,KAAK,EAAE,sBAAsB;QAC7B,eAAe,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE;QACjD,sBAAsB,EAAE,EAAE;QAC1B,aAAa,EAAE,EAAE;QACjB,YAAY,EAAE,EAAE;QAChB,cAAc,EAAE,GAAG;QACnB,eAAe,EAAE,GAAG;QACpB,aAAa,EAAE,GAAG;QAClB,qBAAqB,EAAE,GAAG;QAC1B,gBAAgB,EAAE,IAAI;QACtB,oBAAoB,EAAE,EAAE;QACxB,iBAAiB,EAAE,CAAC,MAAM,EAAE,aAAa,EAAE,QAAQ,EAAE,OAAO,EAAE,WAAW,CAAC;KAC3E;IACD,YAAY,EAAE;QACZ,EAAE,EAAE,cAAc;QAClB,KAAK,EAAE,cAAc;QACrB,eAAe,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE;QACnD,sBAAsB,EAAE,EAAE;QAC1B,aAAa,EAAE,EAAE;QACjB,YAAY,EAAE,EAAE;QAChB,cAAc,EAAE,GAAG;QACnB,eAAe,EAAE,EAAE;QACnB,aAAa,EAAE,GAAG;QAClB,qBAAqB,EAAE,GAAG;QAC1B,gBAAgB,EAAE,GAAG;QACrB,oBAAoB,EAAE,EAAE;QACxB,iBAAiB,EAAE,CAAC,OAAO,EAAE,SAAS,EAAE,UAAU,CAAC;KACpD;IACD,YAAY,EAAE;QACZ,EAAE,EAAE,cAAc;QAClB,KAAK,EAAE,eAAe;QACtB,eAAe,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE;QAClD,sBAAsB,EAAE,EAAE;QAC1B,aAAa,EAAE,EAAE;QACjB,YAAY,EAAE,EAAE;QAChB,cAAc,EAAE,GAAG;QACnB,eAAe,EAAE,EAAE;QACnB,aAAa,EAAE,EAAE;QACjB,qBAAqB,EAAE,GAAG;QAC1B,gBAAgB,EAAE,GAAG;QACrB,oBAAoB,EAAE,EAAE;QACxB,iBAAiB,EAAE,CAAC,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,WAAW,CAAC;KAC/D;CACF,CAAC;AAEF,MAAM,UAAU,UAAU,CAAC,SAA+B;IACxD,OAAO,oBAAoB,CAAC,SAAS,CAAC,CAAC;AACzC,CAAC;AAED,MAAM,UAAU,yBAAyB,CAAC,OAAoB;IAC5D,MAAM,OAAO,GAAG,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5C,OAAO;QACL,KAAK,EAAE,CAAC,OAAO,CAAC,aAAa,IAAI,OAAO,CAAC,aAAa,CAAC,GAAG,IAAI;QAC9D,IAAI,EAAE,CAAC,OAAO,CAAC,YAAY,IAAI,OAAO,CAAC,YAAY,CAAC,GAAG,IAAI;QAC3D,MAAM,EAAE,OAAO,CAAC,cAAc,GAAG,IAAI;QACrC,OAAO,EAAE,CAAC,OAAO,CAAC,qBAAqB,IAAI,OAAO,CAAC,eAAe,CAAC,GAAG,IAAI;QAC1E,KAAK,EAAE,OAAO,CAAC,aAAa,GAAG,IAAI;QACnC,QAAQ,EAAE,OAAO,CAAC,OAAO,KAAK,cAAc,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS;KACjE,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,wBAAwB,CAAC,OAAoB;IAC3D,OAAO,OAAO,CAAC,qBAAqB,IAAI,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,sBAAsB,CAAC;AAC7F,CAAC"}
|
||||
99
packages/shared/src/profiles.ts
Normal file
99
packages/shared/src/profiles.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { BikepackingProfile, BikepackingProfileId, CriticalGapThresholds, TripRequest } from './types.js';
|
||||
|
||||
export const BIKEPACKING_PROFILES: Record<BikepackingProfileId, BikepackingProfile> = {
|
||||
beginner_safe: {
|
||||
id: 'beginner_safe',
|
||||
label: 'Beginner-safe',
|
||||
dailyDistanceKm: { min: 40, target: 55, max: 70 },
|
||||
maxGradeWarningPercent: 10,
|
||||
waterGapMaxKm: 25,
|
||||
foodGapMaxKm: 40,
|
||||
repairGapMaxKm: 80,
|
||||
bailoutGapMaxKm: 50,
|
||||
sleepGapMaxKm: 65,
|
||||
roughSurfaceTolerance: 0.35,
|
||||
trafficTolerance: 0.25,
|
||||
unknownAccessPenalty: 18,
|
||||
preferredSurfaces: ['paved', 'asphalt', 'fine_gravel', 'compacted']
|
||||
},
|
||||
loaded_gravel: {
|
||||
id: 'loaded_gravel',
|
||||
label: 'Loaded gravel',
|
||||
dailyDistanceKm: { min: 60, target: 80, max: 100 },
|
||||
maxGradeWarningPercent: 12,
|
||||
waterGapMaxKm: 40,
|
||||
foodGapMaxKm: 70,
|
||||
repairGapMaxKm: 110,
|
||||
bailoutGapMaxKm: 90,
|
||||
sleepGapMaxKm: 95,
|
||||
roughSurfaceTolerance: 0.6,
|
||||
trafficTolerance: 0.45,
|
||||
unknownAccessPenalty: 12,
|
||||
preferredSurfaces: ['gravel', 'fine_gravel', 'compacted', 'paved', 'asphalt', 'dirt']
|
||||
},
|
||||
hardtail_bikepacking: {
|
||||
id: 'hardtail_bikepacking',
|
||||
label: 'Hardtail bikepacking',
|
||||
dailyDistanceKm: { min: 50, target: 70, max: 90 },
|
||||
maxGradeWarningPercent: 15,
|
||||
waterGapMaxKm: 55,
|
||||
foodGapMaxKm: 90,
|
||||
repairGapMaxKm: 130,
|
||||
bailoutGapMaxKm: 110,
|
||||
sleepGapMaxKm: 100,
|
||||
roughSurfaceTolerance: 0.8,
|
||||
trafficTolerance: 0.35,
|
||||
unknownAccessPenalty: 10,
|
||||
preferredSurfaces: ['dirt', 'singletrack', 'gravel', 'track', 'compacted']
|
||||
},
|
||||
road_touring: {
|
||||
id: 'road_touring',
|
||||
label: 'Road touring',
|
||||
dailyDistanceKm: { min: 70, target: 100, max: 130 },
|
||||
maxGradeWarningPercent: 10,
|
||||
waterGapMaxKm: 40,
|
||||
foodGapMaxKm: 70,
|
||||
repairGapMaxKm: 120,
|
||||
bailoutGapMaxKm: 90,
|
||||
sleepGapMaxKm: 115,
|
||||
roughSurfaceTolerance: 0.2,
|
||||
trafficTolerance: 0.5,
|
||||
unknownAccessPenalty: 14,
|
||||
preferredSurfaces: ['paved', 'asphalt', 'concrete']
|
||||
},
|
||||
ebikepacking: {
|
||||
id: 'ebikepacking',
|
||||
label: 'E-bikepacking',
|
||||
dailyDistanceKm: { min: 50, target: 75, max: 100 },
|
||||
maxGradeWarningPercent: 12,
|
||||
waterGapMaxKm: 40,
|
||||
foodGapMaxKm: 60,
|
||||
repairGapMaxKm: 100,
|
||||
bailoutGapMaxKm: 80,
|
||||
sleepGapMaxKm: 90,
|
||||
roughSurfaceTolerance: 0.5,
|
||||
trafficTolerance: 0.4,
|
||||
unknownAccessPenalty: 15,
|
||||
preferredSurfaces: ['paved', 'asphalt', 'gravel', 'compacted']
|
||||
}
|
||||
};
|
||||
|
||||
export function getProfile(profileId: BikepackingProfileId): BikepackingProfile {
|
||||
return BIKEPACKING_PROFILES[profileId];
|
||||
}
|
||||
|
||||
export function thresholdsFromTripRequest(request: TripRequest): CriticalGapThresholds {
|
||||
const profile = getProfile(request.profile);
|
||||
return {
|
||||
water: (request.waterGapMaxKm ?? profile.waterGapMaxKm) * 1000,
|
||||
food: (request.foodGapMaxKm ?? profile.foodGapMaxKm) * 1000,
|
||||
repair: profile.repairGapMaxKm * 1000,
|
||||
bailout: (request.requireBailoutEveryKm ?? profile.bailoutGapMaxKm) * 1000,
|
||||
sleep: profile.sleepGapMaxKm * 1000,
|
||||
charging: request.profile === 'ebikepacking' ? 60000 : undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function maxLoadedGradeForRequest(request: TripRequest): number {
|
||||
return request.maxLoadedGradePercent ?? getProfile(request.profile).maxGradeWarningPercent;
|
||||
}
|
||||
6
packages/shared/src/scoring.d.ts
vendored
Normal file
6
packages/shared/src/scoring.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { BikepackingProfileId, HikeABikeRisk, RoutePlan, RouteScore, RouteSegment, SegmentScore } from './types.js';
|
||||
export declare function estimateHikeABikeRisk(segment: RouteSegment, profileId: BikepackingProfileId): HikeABikeRisk;
|
||||
export declare function scoreSegment(segment: RouteSegment, profileId: BikepackingProfileId): SegmentScore;
|
||||
export declare function scoreRoute(route: RoutePlan, profileId: BikepackingProfileId): RouteScore;
|
||||
export declare function calculateSurfaceBreakdown(segments: RouteSegment[]): Record<string, number>;
|
||||
//# sourceMappingURL=scoring.d.ts.map
|
||||
1
packages/shared/src/scoring.d.ts.map
Normal file
1
packages/shared/src/scoring.d.ts.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"scoring.d.ts","sourceRoot":"","sources":["scoring.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,oBAAoB,EACpB,aAAa,EACb,SAAS,EACT,UAAU,EACV,YAAY,EAEZ,YAAY,EACb,MAAM,YAAY,CAAC;AAMpB,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,oBAAoB,GAAG,aAAa,CAmC3G;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,oBAAoB,GAAG,YAAY,CAyFjG;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,oBAAoB,GAAG,UAAU,CAgBxF;AAED,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,YAAY,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAe1F"}
|
||||
158
packages/shared/src/scoring.js
Normal file
158
packages/shared/src/scoring.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import { getProfile } from './profiles.js';
|
||||
const ROUGH_SURFACES = new Set(['sand', 'mud', 'rock', 'roots', 'grass', 'ground', 'singletrack']);
|
||||
const ROUGH_SMOOTHNESS = new Set(['bad', 'very_bad', 'horrible', 'very_horrible', 'impassable']);
|
||||
const UNPAVED_SURFACES = new Set(['gravel', 'fine_gravel', 'compacted', 'dirt', 'ground', 'sand', 'mud', 'grass']);
|
||||
export function estimateHikeABikeRisk(segment, profileId) {
|
||||
const profile = getProfile(profileId);
|
||||
const maxGrade = segment.maxGradePercent ?? segment.avgGradePercent ?? 0;
|
||||
let risk = 0;
|
||||
if (maxGrade > profile.maxGradeWarningPercent) {
|
||||
risk += 2 + (maxGrade - profile.maxGradeWarningPercent) / 2;
|
||||
}
|
||||
if (segment.avgGradePercent && segment.avgGradePercent > profile.maxGradeWarningPercent - 2) {
|
||||
risk += 1.5;
|
||||
}
|
||||
if (segment.smoothness && ROUGH_SMOOTHNESS.has(segment.smoothness)) {
|
||||
risk += 2.2;
|
||||
}
|
||||
if (segment.surface && ROUGH_SURFACES.has(segment.surface)) {
|
||||
risk += 1.8;
|
||||
}
|
||||
if (segment.highway === 'path' || segment.highway === 'track') {
|
||||
risk += 0.8;
|
||||
}
|
||||
if (segment.legalAccessConfidence === 'unknown' || segment.bicycleAccess === 'unknown') {
|
||||
risk += 0.6;
|
||||
}
|
||||
risk -= profile.roughSurfaceTolerance;
|
||||
if (risk >= 8) {
|
||||
return 'critical';
|
||||
}
|
||||
if (risk >= 4.8) {
|
||||
return 'high';
|
||||
}
|
||||
if (risk >= 2.3) {
|
||||
return 'medium';
|
||||
}
|
||||
return 'low';
|
||||
}
|
||||
export function scoreSegment(segment, profileId) {
|
||||
const profile = getProfile(profileId);
|
||||
const hikeABikeRisk = estimateHikeABikeRisk(segment, profileId);
|
||||
const warnings = [];
|
||||
const costBreakdown = {};
|
||||
const maxGrade = segment.maxGradePercent ?? 0;
|
||||
costBreakdown.distance = Math.min(10, segment.distanceM / 10000);
|
||||
costBreakdown.climb = Math.min(18, (segment.ascentM ?? 0) / 70);
|
||||
if (maxGrade > profile.maxGradeWarningPercent) {
|
||||
costBreakdown.steepness = Math.min(28, (maxGrade - profile.maxGradeWarningPercent) * 3.5);
|
||||
warnings.push({
|
||||
code: 'STEEP_LOADED_BIKE_CLIMB',
|
||||
severity: maxGrade > profile.maxGradeWarningPercent + 5 ? 'critical' : 'warning',
|
||||
title: 'Steep loaded-bike climb',
|
||||
message: `Segment reaches ${maxGrade.toFixed(1)}%, above the ${profile.maxGradeWarningPercent}% ${profile.label} warning threshold.`,
|
||||
metersFromStart: segment.startMeters,
|
||||
segmentId: segment.id
|
||||
});
|
||||
}
|
||||
else {
|
||||
costBreakdown.steepness = 0;
|
||||
}
|
||||
if (segment.surface && !profile.preferredSurfaces.includes(segment.surface)) {
|
||||
costBreakdown.surface = ROUGH_SURFACES.has(segment.surface) ? 14 : 8;
|
||||
}
|
||||
else if (segment.smoothness && ROUGH_SMOOTHNESS.has(segment.smoothness)) {
|
||||
costBreakdown.surface = 8;
|
||||
}
|
||||
else {
|
||||
costBreakdown.surface = 0;
|
||||
}
|
||||
costBreakdown.traffic = Math.max(0, ((segment.trafficStress ?? 0) - profile.trafficTolerance) * 22);
|
||||
if (segment.legalAccessConfidence === 'unknown' || segment.bicycleAccess === 'unknown') {
|
||||
costBreakdown.access = profile.unknownAccessPenalty;
|
||||
warnings.push({
|
||||
code: 'UNKNOWN_ACCESS',
|
||||
severity: 'warning',
|
||||
title: 'Uncertain bicycle access',
|
||||
message: 'Bicycle access is not confirmed for this segment. Treat it as advisory and verify locally.',
|
||||
metersFromStart: segment.startMeters,
|
||||
segmentId: segment.id
|
||||
});
|
||||
}
|
||||
else if (segment.legalAccessConfidence === 'restricted' || segment.bicycleAccess === 'no') {
|
||||
costBreakdown.access = 35;
|
||||
warnings.push({
|
||||
code: 'RESTRICTED_ACCESS',
|
||||
severity: 'critical',
|
||||
title: 'Potential access restriction',
|
||||
message: 'This segment may be restricted for bicycles. Verify before relying on the route.',
|
||||
metersFromStart: segment.startMeters,
|
||||
segmentId: segment.id
|
||||
});
|
||||
}
|
||||
else {
|
||||
costBreakdown.access = 0;
|
||||
}
|
||||
if (segment.protectedAreaOverlap) {
|
||||
costBreakdown.protectedArea = 8;
|
||||
warnings.push({
|
||||
code: 'PROTECTED_AREA_OVERLAP',
|
||||
severity: 'notice',
|
||||
title: 'Protected-area overlap',
|
||||
message: 'Route crosses or borders a protected area. Local riding and camping rules may be stricter here.',
|
||||
metersFromStart: segment.startMeters,
|
||||
segmentId: segment.id
|
||||
});
|
||||
}
|
||||
else {
|
||||
costBreakdown.protectedArea = 0;
|
||||
}
|
||||
costBreakdown.hikeABike =
|
||||
hikeABikeRisk === 'critical' ? 24 : hikeABikeRisk === 'high' ? 14 : hikeABikeRisk === 'medium' ? 6 : 0;
|
||||
const bonus = (segment.officialCycleRoute ? 7 : 0) +
|
||||
(segment.communityScore && segment.communityScore > 0 ? Math.min(8, segment.communityScore * 8) : 0);
|
||||
const totalCost = Object.values(costBreakdown).reduce((sum, value) => sum + value, 0);
|
||||
const score = clamp(100 - totalCost + bonus, 0, 100);
|
||||
return {
|
||||
segmentId: segment.id,
|
||||
score: round(score, 1),
|
||||
hikeABikeRisk,
|
||||
warnings,
|
||||
costBreakdown
|
||||
};
|
||||
}
|
||||
export function scoreRoute(route, profileId) {
|
||||
const segmentScores = route.segments.map((segment) => scoreSegment(segment, profileId));
|
||||
const distanceTotal = Math.max(1, route.segments.reduce((sum, segment) => sum + segment.distanceM, 0));
|
||||
const weightedScore = route.segments.reduce((sum, segment, index) => {
|
||||
const segmentScore = segmentScores[index]?.score ?? 0;
|
||||
return sum + segmentScore * (segment.distanceM / distanceTotal);
|
||||
}, 0) || 0;
|
||||
const warnings = segmentScores.flatMap((score) => score.warnings);
|
||||
return {
|
||||
suitabilityScore: round(weightedScore, 1),
|
||||
warnings,
|
||||
segmentScores,
|
||||
surfaceBreakdown: calculateSurfaceBreakdown(route.segments)
|
||||
};
|
||||
}
|
||||
export function calculateSurfaceBreakdown(segments) {
|
||||
const totals = new Map();
|
||||
const totalDistance = Math.max(1, segments.reduce((sum, segment) => sum + segment.distanceM, 0));
|
||||
for (const segment of segments) {
|
||||
const surface = segment.surface ?? 'unknown';
|
||||
const normalized = UNPAVED_SURFACES.has(surface) ? surface : surface;
|
||||
totals.set(normalized, (totals.get(normalized) ?? 0) + segment.distanceM);
|
||||
}
|
||||
return Object.fromEntries([...totals.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([surface, meters]) => [surface, round(meters / totalDistance, 3)]));
|
||||
}
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
function round(value, precision) {
|
||||
const factor = 10 ** precision;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
//# sourceMappingURL=scoring.js.map
|
||||
1
packages/shared/src/scoring.js.map
Normal file
1
packages/shared/src/scoring.js.map
Normal file
File diff suppressed because one or more lines are too long
28
packages/shared/src/scoring.test.ts
Normal file
28
packages/shared/src/scoring.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import sampleScoring from '../../../examples/sample_segment_scoring.json';
|
||||
import { estimateHikeABikeRisk, scoreSegment } from './scoring.js';
|
||||
import type { RouteSegment } from './types.js';
|
||||
|
||||
describe('segment scoring', () => {
|
||||
it('matches the sample loaded-gravel warning fixture', () => {
|
||||
const fixture = sampleScoring as {
|
||||
profile: 'loaded_gravel';
|
||||
segment: Omit<RouteSegment, 'id' | 'geometry'>;
|
||||
expected: { hikeABikeRisk: string; warnings: string[] };
|
||||
};
|
||||
const segment: RouteSegment = {
|
||||
id: 'fixture_segment',
|
||||
geometry: [
|
||||
{ lat: 48.1, lon: 11.5 },
|
||||
{ lat: 48.0, lon: 11.4 }
|
||||
],
|
||||
...fixture.segment
|
||||
};
|
||||
|
||||
const result = scoreSegment(segment, fixture.profile);
|
||||
|
||||
expect(estimateHikeABikeRisk(segment, fixture.profile)).toBe(fixture.expected.hikeABikeRisk);
|
||||
expect(result.warnings.map((warning) => warning.code)).toEqual(expect.arrayContaining(fixture.expected.warnings));
|
||||
expect(result.score).toBeLessThan(80);
|
||||
});
|
||||
});
|
||||
186
packages/shared/src/scoring.ts
Normal file
186
packages/shared/src/scoring.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { getProfile } from './profiles.js';
|
||||
import type {
|
||||
BikepackingProfileId,
|
||||
HikeABikeRisk,
|
||||
RoutePlan,
|
||||
RouteScore,
|
||||
RouteSegment,
|
||||
RouteWarning,
|
||||
SegmentScore
|
||||
} from './types.js';
|
||||
|
||||
const ROUGH_SURFACES = new Set(['sand', 'mud', 'rock', 'roots', 'grass', 'ground', 'singletrack']);
|
||||
const ROUGH_SMOOTHNESS = new Set(['bad', 'very_bad', 'horrible', 'very_horrible', 'impassable']);
|
||||
const UNPAVED_SURFACES = new Set(['gravel', 'fine_gravel', 'compacted', 'dirt', 'ground', 'sand', 'mud', 'grass']);
|
||||
|
||||
export function estimateHikeABikeRisk(segment: RouteSegment, profileId: BikepackingProfileId): HikeABikeRisk {
|
||||
const profile = getProfile(profileId);
|
||||
const maxGrade = segment.maxGradePercent ?? segment.avgGradePercent ?? 0;
|
||||
let risk = 0;
|
||||
|
||||
if (maxGrade > profile.maxGradeWarningPercent) {
|
||||
risk += 2 + (maxGrade - profile.maxGradeWarningPercent) / 2;
|
||||
}
|
||||
if (segment.avgGradePercent && segment.avgGradePercent > profile.maxGradeWarningPercent - 2) {
|
||||
risk += 1.5;
|
||||
}
|
||||
if (segment.smoothness && ROUGH_SMOOTHNESS.has(segment.smoothness)) {
|
||||
risk += 2.2;
|
||||
}
|
||||
if (segment.surface && ROUGH_SURFACES.has(segment.surface)) {
|
||||
risk += 1.8;
|
||||
}
|
||||
if (segment.highway === 'path' || segment.highway === 'track') {
|
||||
risk += 0.8;
|
||||
}
|
||||
if (segment.legalAccessConfidence === 'unknown' || segment.bicycleAccess === 'unknown') {
|
||||
risk += 0.6;
|
||||
}
|
||||
risk -= profile.roughSurfaceTolerance;
|
||||
|
||||
if (risk >= 8) {
|
||||
return 'critical';
|
||||
}
|
||||
if (risk >= 4.8) {
|
||||
return 'high';
|
||||
}
|
||||
if (risk >= 2.3) {
|
||||
return 'medium';
|
||||
}
|
||||
return 'low';
|
||||
}
|
||||
|
||||
export function scoreSegment(segment: RouteSegment, profileId: BikepackingProfileId): SegmentScore {
|
||||
const profile = getProfile(profileId);
|
||||
const hikeABikeRisk = estimateHikeABikeRisk(segment, profileId);
|
||||
const warnings: RouteWarning[] = [];
|
||||
const costBreakdown: Record<string, number> = {};
|
||||
|
||||
const maxGrade = segment.maxGradePercent ?? 0;
|
||||
costBreakdown.distance = Math.min(10, segment.distanceM / 10000);
|
||||
costBreakdown.climb = Math.min(18, (segment.ascentM ?? 0) / 70);
|
||||
|
||||
if (maxGrade > profile.maxGradeWarningPercent) {
|
||||
costBreakdown.steepness = Math.min(28, (maxGrade - profile.maxGradeWarningPercent) * 3.5);
|
||||
warnings.push({
|
||||
code: 'STEEP_LOADED_BIKE_CLIMB',
|
||||
severity: maxGrade > profile.maxGradeWarningPercent + 5 ? 'critical' : 'warning',
|
||||
title: 'Steep loaded-bike climb',
|
||||
message: `Segment reaches ${maxGrade.toFixed(1)}%, above the ${profile.maxGradeWarningPercent}% ${profile.label} warning threshold.`,
|
||||
metersFromStart: segment.startMeters,
|
||||
segmentId: segment.id
|
||||
});
|
||||
} else {
|
||||
costBreakdown.steepness = 0;
|
||||
}
|
||||
|
||||
if (segment.surface && !profile.preferredSurfaces.includes(segment.surface)) {
|
||||
costBreakdown.surface = ROUGH_SURFACES.has(segment.surface) ? 14 : 8;
|
||||
} else if (segment.smoothness && ROUGH_SMOOTHNESS.has(segment.smoothness)) {
|
||||
costBreakdown.surface = 8;
|
||||
} else {
|
||||
costBreakdown.surface = 0;
|
||||
}
|
||||
|
||||
costBreakdown.traffic = Math.max(0, ((segment.trafficStress ?? 0) - profile.trafficTolerance) * 22);
|
||||
|
||||
if (segment.legalAccessConfidence === 'unknown' || segment.bicycleAccess === 'unknown') {
|
||||
costBreakdown.access = profile.unknownAccessPenalty;
|
||||
warnings.push({
|
||||
code: 'UNKNOWN_ACCESS',
|
||||
severity: 'warning',
|
||||
title: 'Uncertain bicycle access',
|
||||
message: 'Bicycle access is not confirmed for this segment. Treat it as advisory and verify locally.',
|
||||
metersFromStart: segment.startMeters,
|
||||
segmentId: segment.id
|
||||
});
|
||||
} else if (segment.legalAccessConfidence === 'restricted' || segment.bicycleAccess === 'no') {
|
||||
costBreakdown.access = 35;
|
||||
warnings.push({
|
||||
code: 'RESTRICTED_ACCESS',
|
||||
severity: 'critical',
|
||||
title: 'Potential access restriction',
|
||||
message: 'This segment may be restricted for bicycles. Verify before relying on the route.',
|
||||
metersFromStart: segment.startMeters,
|
||||
segmentId: segment.id
|
||||
});
|
||||
} else {
|
||||
costBreakdown.access = 0;
|
||||
}
|
||||
|
||||
if (segment.protectedAreaOverlap) {
|
||||
costBreakdown.protectedArea = 8;
|
||||
warnings.push({
|
||||
code: 'PROTECTED_AREA_OVERLAP',
|
||||
severity: 'notice',
|
||||
title: 'Protected-area overlap',
|
||||
message: 'Route crosses or borders a protected area. Local riding and camping rules may be stricter here.',
|
||||
metersFromStart: segment.startMeters,
|
||||
segmentId: segment.id
|
||||
});
|
||||
} else {
|
||||
costBreakdown.protectedArea = 0;
|
||||
}
|
||||
|
||||
costBreakdown.hikeABike =
|
||||
hikeABikeRisk === 'critical' ? 24 : hikeABikeRisk === 'high' ? 14 : hikeABikeRisk === 'medium' ? 6 : 0;
|
||||
|
||||
const bonus =
|
||||
(segment.officialCycleRoute ? 7 : 0) +
|
||||
(segment.communityScore && segment.communityScore > 0 ? Math.min(8, segment.communityScore * 8) : 0);
|
||||
|
||||
const totalCost = Object.values(costBreakdown).reduce((sum, value) => sum + value, 0);
|
||||
const score = clamp(100 - totalCost + bonus, 0, 100);
|
||||
|
||||
return {
|
||||
segmentId: segment.id,
|
||||
score: round(score, 1),
|
||||
hikeABikeRisk,
|
||||
warnings,
|
||||
costBreakdown
|
||||
};
|
||||
}
|
||||
|
||||
export function scoreRoute(route: RoutePlan, profileId: BikepackingProfileId): RouteScore {
|
||||
const segmentScores = route.segments.map((segment) => scoreSegment(segment, profileId));
|
||||
const distanceTotal = Math.max(1, route.segments.reduce((sum, segment) => sum + segment.distanceM, 0));
|
||||
const weightedScore =
|
||||
route.segments.reduce((sum, segment, index) => {
|
||||
const segmentScore = segmentScores[index]?.score ?? 0;
|
||||
return sum + segmentScore * (segment.distanceM / distanceTotal);
|
||||
}, 0) || 0;
|
||||
const warnings = segmentScores.flatMap((score) => score.warnings);
|
||||
|
||||
return {
|
||||
suitabilityScore: round(weightedScore, 1),
|
||||
warnings,
|
||||
segmentScores,
|
||||
surfaceBreakdown: calculateSurfaceBreakdown(route.segments)
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateSurfaceBreakdown(segments: RouteSegment[]): Record<string, number> {
|
||||
const totals = new Map<string, number>();
|
||||
const totalDistance = Math.max(1, segments.reduce((sum, segment) => sum + segment.distanceM, 0));
|
||||
|
||||
for (const segment of segments) {
|
||||
const surface = segment.surface ?? 'unknown';
|
||||
const normalized = UNPAVED_SURFACES.has(surface) ? surface : surface;
|
||||
totals.set(normalized, (totals.get(normalized) ?? 0) + segment.distanceM);
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
[...totals.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([surface, meters]) => [surface, round(meters / totalDistance, 3)])
|
||||
);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function round(value: number, precision: number): number {
|
||||
const factor = 10 ** precision;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
4
packages/shared/src/staging.d.ts
vendored
Normal file
4
packages/shared/src/staging.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { Poi, RoutePlan, StagePlan, TripRequest } from './types.js';
|
||||
export declare function planStages(route: RoutePlan, pois: Poi[], tripRequest: TripRequest): StagePlan[];
|
||||
export declare function estimateStageCount(distanceM: number, tripRequest: TripRequest): number;
|
||||
//# sourceMappingURL=staging.d.ts.map
|
||||
1
packages/shared/src/staging.d.ts.map
Normal file
1
packages/shared/src/staging.d.ts.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"staging.d.ts","sourceRoot":"","sources":["staging.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,GAAG,EAAE,SAAS,EAAgB,SAAS,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAIvF,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,GAAG,EAAE,EAAE,WAAW,EAAE,WAAW,GAAG,SAAS,EAAE,CA0D/F;AAED,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,WAAW,GAAG,MAAM,CAMtF"}
|
||||
218
packages/shared/src/staging.js
Normal file
218
packages/shared/src/staging.js
Normal file
@@ -0,0 +1,218 @@
|
||||
import { getProfile, thresholdsFromTripRequest } from './profiles.js';
|
||||
import { detectCriticalGaps, warningsFromGaps } from './pois.js';
|
||||
const ENDPOINT_TOLERANCE_M = 8000;
|
||||
export function planStages(route, pois, tripRequest) {
|
||||
const profile = getProfile(tripRequest.profile);
|
||||
const desiredStages = estimateStageCount(route.distanceM, tripRequest);
|
||||
const stages = [];
|
||||
let startMeters = 0;
|
||||
for (let dayIndex = 1; dayIndex <= desiredStages; dayIndex += 1) {
|
||||
const isLastStage = dayIndex === desiredStages;
|
||||
const remainingStages = desiredStages - dayIndex;
|
||||
const targetEnd = isLastStage ? route.distanceM : Math.round((route.distanceM / desiredStages) * dayIndex);
|
||||
const minEnd = startMeters + tripRequest.dailyDistanceKm.min * 1000;
|
||||
const maxEnd = Math.min(route.distanceM - remainingStages * tripRequest.dailyDistanceKm.min * 1000, startMeters + tripRequest.dailyDistanceKm.max * 1000);
|
||||
const boundedTarget = Math.max(minEnd, Math.min(maxEnd, targetEnd));
|
||||
const sleepCandidate = isLastStage
|
||||
? undefined
|
||||
: chooseSleepEndpoint(pois, boundedTarget, minEnd, maxEnd, tripRequest.sleepPreference);
|
||||
const endMeters = isLastStage ? route.distanceM : sleepCandidate?.metersFromStart ?? boundedTarget;
|
||||
const distanceM = Math.max(0, endMeters - startMeters);
|
||||
const stagePois = poisWithin(pois, startMeters, endMeters);
|
||||
const ascentM = estimateStageAscent(route, startMeters, endMeters);
|
||||
const descentM = estimateStageDescent(route, startMeters, endMeters);
|
||||
const stageWarnings = stageWarningsFor({
|
||||
route,
|
||||
stagePois,
|
||||
startMeters,
|
||||
endMeters,
|
||||
distanceM,
|
||||
ascentM,
|
||||
tripRequest,
|
||||
hasSleepEndpoint: isLastStage || Boolean(sleepCandidate)
|
||||
});
|
||||
stages.push({
|
||||
id: `stage_${route.id}_${dayIndex}`,
|
||||
routeId: route.id,
|
||||
dayIndex,
|
||||
startMeters,
|
||||
endMeters,
|
||||
distanceM,
|
||||
ascentM,
|
||||
descentM,
|
||||
expectedRideTimeMinutes: expectedRideTimeMinutes(distanceM, ascentM, profile.roughSurfaceTolerance),
|
||||
sleepCandidates: nearbyEndpointPois(stagePois, endMeters, 'sleep'),
|
||||
waterPois: stagePois.filter((poi) => poi.category === 'water'),
|
||||
foodPois: stagePois.filter((poi) => poi.category === 'food'),
|
||||
repairPois: stagePois.filter((poi) => poi.category === 'repair'),
|
||||
bailoutPois: stagePois.filter((poi) => poi.category === 'bailout'),
|
||||
warnings: stageWarnings,
|
||||
planB: planBForEndpoint(pois, endMeters, minEnd, maxEnd)
|
||||
});
|
||||
startMeters = endMeters;
|
||||
}
|
||||
return stages;
|
||||
}
|
||||
export function estimateStageCount(distanceM, tripRequest) {
|
||||
const dateDays = inclusiveDateDays(tripRequest.startDate, tripRequest.endDate);
|
||||
if (dateDays) {
|
||||
return dateDays;
|
||||
}
|
||||
return Math.max(1, Math.ceil(distanceM / Math.max(1, tripRequest.dailyDistanceKm.target * 1000)));
|
||||
}
|
||||
function inclusiveDateDays(startDate, endDate) {
|
||||
if (!startDate || !endDate) {
|
||||
return undefined;
|
||||
}
|
||||
const start = Date.parse(`${startDate}T00:00:00Z`);
|
||||
const end = Date.parse(`${endDate}T00:00:00Z`);
|
||||
if (Number.isNaN(start) || Number.isNaN(end) || end < start) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.floor((end - start) / 86400000) + 1;
|
||||
}
|
||||
function chooseSleepEndpoint(pois, targetM, minM, maxM, sleepPreference) {
|
||||
const candidates = pois
|
||||
.filter((poi) => poi.category === 'sleep')
|
||||
.filter((poi) => typeof poi.metersFromStart === 'number')
|
||||
.filter((poi) => {
|
||||
const meters = poi.metersFromStart ?? 0;
|
||||
return meters >= minM && meters <= maxM;
|
||||
})
|
||||
.filter((poi) => {
|
||||
if (sleepPreference === 'indoor_only') {
|
||||
return poi.tags?.sleepType === 'hostel' || poi.tags?.sleepType === 'hotel';
|
||||
}
|
||||
if (sleepPreference === 'official_campsites') {
|
||||
return poi.tags?.sleepType === 'campsite';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return candidates.sort((left, right) => {
|
||||
const leftDistance = Math.abs((left.metersFromStart ?? 0) - targetM);
|
||||
const rightDistance = Math.abs((right.metersFromStart ?? 0) - targetM);
|
||||
const confidenceDelta = confidenceRank(right.confidence) - confidenceRank(left.confidence);
|
||||
return leftDistance - rightDistance || confidenceDelta;
|
||||
})[0];
|
||||
}
|
||||
function poisWithin(pois, startM, endM) {
|
||||
return pois
|
||||
.filter((poi) => {
|
||||
const meters = poi.metersFromStart;
|
||||
return typeof meters === 'number' && meters >= startM && meters <= endM;
|
||||
})
|
||||
.sort((left, right) => (left.metersFromStart ?? 0) - (right.metersFromStart ?? 0));
|
||||
}
|
||||
function nearbyEndpointPois(pois, endM, category) {
|
||||
return pois
|
||||
.filter((poi) => poi.category === category)
|
||||
.filter((poi) => Math.abs((poi.metersFromStart ?? 0) - endM) <= ENDPOINT_TOLERANCE_M)
|
||||
.slice(0, 4);
|
||||
}
|
||||
function estimateStageAscent(route, startM, endM) {
|
||||
return Math.round(route.segments.reduce((sum, segment) => {
|
||||
const segmentStart = segment.startMeters ?? 0;
|
||||
const segmentEnd = segment.endMeters ?? segmentStart + segment.distanceM;
|
||||
const overlap = Math.max(0, Math.min(endM, segmentEnd) - Math.max(startM, segmentStart));
|
||||
if (overlap === 0) {
|
||||
return sum;
|
||||
}
|
||||
return sum + (segment.ascentM ?? 0) * (overlap / Math.max(1, segment.distanceM));
|
||||
}, 0));
|
||||
}
|
||||
function estimateStageDescent(route, startM, endM) {
|
||||
return Math.round(route.segments.reduce((sum, segment) => {
|
||||
const segmentStart = segment.startMeters ?? 0;
|
||||
const segmentEnd = segment.endMeters ?? segmentStart + segment.distanceM;
|
||||
const overlap = Math.max(0, Math.min(endM, segmentEnd) - Math.max(startM, segmentStart));
|
||||
if (overlap === 0) {
|
||||
return sum;
|
||||
}
|
||||
return sum + (segment.descentM ?? 0) * (overlap / Math.max(1, segment.distanceM));
|
||||
}, 0));
|
||||
}
|
||||
function expectedRideTimeMinutes(distanceM, ascentM, roughSurfaceTolerance) {
|
||||
const baseMovingKmh = 16 + roughSurfaceTolerance * 3;
|
||||
const low = Math.round((distanceM / 1000 / baseMovingKmh) * 60 + (ascentM / 450) * 45);
|
||||
const high = Math.round((distanceM / 1000 / Math.max(9, baseMovingKmh - 4)) * 60 + (ascentM / 350) * 55);
|
||||
return { low, high };
|
||||
}
|
||||
function stageWarningsFor(input) {
|
||||
const warnings = [];
|
||||
const maxDistanceM = input.tripRequest.dailyDistanceKm.max * 1000;
|
||||
if (input.distanceM > maxDistanceM) {
|
||||
warnings.push({
|
||||
code: 'STAGE_DISTANCE_OVER_MAX',
|
||||
severity: 'warning',
|
||||
title: 'Stage exceeds daily distance',
|
||||
message: `Stage is ${(input.distanceM / 1000).toFixed(1)} km, above the requested ${input.tripRequest.dailyDistanceKm.max} km maximum.`,
|
||||
metersFromStart: input.startMeters
|
||||
});
|
||||
}
|
||||
if (input.tripRequest.dailyAscentM?.max && input.ascentM > input.tripRequest.dailyAscentM.max) {
|
||||
warnings.push({
|
||||
code: 'STAGE_ASCENT_OVER_MAX',
|
||||
severity: 'warning',
|
||||
title: 'Stage exceeds climbing limit',
|
||||
message: `Stage climbs ${input.ascentM} m, above the requested ${input.tripRequest.dailyAscentM.max} m maximum.`,
|
||||
metersFromStart: input.startMeters
|
||||
});
|
||||
}
|
||||
if (!input.hasSleepEndpoint) {
|
||||
warnings.push({
|
||||
code: 'NO_CONFIDENT_SLEEP_ENDPOINT',
|
||||
severity: 'warning',
|
||||
title: 'No confident sleep endpoint',
|
||||
message: 'No sleep POI matched the stage endpoint window. Plan a fallback before committing to this day.',
|
||||
metersFromStart: input.endMeters
|
||||
});
|
||||
}
|
||||
const stageRoute = { ...input.route, distanceM: input.endMeters - input.startMeters };
|
||||
const shiftedPois = input.stagePois.map((poi) => ({
|
||||
...poi,
|
||||
metersFromStart: typeof poi.metersFromStart === 'number' ? poi.metersFromStart - input.startMeters : undefined
|
||||
}));
|
||||
const stageThresholds = thresholdsFromTripRequest(input.tripRequest);
|
||||
warnings.push(...warningsFromGaps(detectCriticalGaps(stageRoute, shiftedPois, stageThresholds)));
|
||||
return dedupeWarnings(warnings);
|
||||
}
|
||||
function planBForEndpoint(pois, endM, minM, maxM) {
|
||||
const sleepPois = pois
|
||||
.filter((poi) => poi.category === 'sleep' && typeof poi.metersFromStart === 'number')
|
||||
.sort((left, right) => (left.metersFromStart ?? 0) - (right.metersFromStart ?? 0));
|
||||
const earlier = [...sleepPois].reverse().find((poi) => (poi.metersFromStart ?? 0) < endM && (poi.metersFromStart ?? 0) >= minM);
|
||||
const later = sleepPois.find((poi) => (poi.metersFromStart ?? 0) > endM && (poi.metersFromStart ?? 0) <= maxM);
|
||||
if (!earlier && !later) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
earlierEndMeters: earlier?.metersFromStart,
|
||||
laterEndMeters: later?.metersFromStart,
|
||||
reason: 'Fallback sleep endpoints inside the daily distance window.'
|
||||
};
|
||||
}
|
||||
function confidenceRank(confidence) {
|
||||
return confidence === 'known_good'
|
||||
? 6
|
||||
: confidence === 'high'
|
||||
? 5
|
||||
: confidence === 'medium'
|
||||
? 4
|
||||
: confidence === 'low'
|
||||
? 3
|
||||
: confidence === 'unknown'
|
||||
? 2
|
||||
: 1;
|
||||
}
|
||||
function dedupeWarnings(warnings) {
|
||||
const seen = new Set();
|
||||
return warnings.filter((warning) => {
|
||||
const key = `${warning.code}:${warning.metersFromStart ?? 0}`;
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=staging.js.map
|
||||
1
packages/shared/src/staging.js.map
Normal file
1
packages/shared/src/staging.js.map
Normal file
File diff suppressed because one or more lines are too long
34
packages/shared/src/staging.test.ts
Normal file
34
packages/shared/src/staging.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createMockRoute, mockPois, mockTripRequest } from './fixtures.js';
|
||||
import { planStages } from './staging.js';
|
||||
|
||||
describe('stage planner', () => {
|
||||
it('splits the sample trip into five plausible bikepacking stages', () => {
|
||||
const route = createMockRoute();
|
||||
const stages = planStages(route, mockPois, mockTripRequest);
|
||||
|
||||
expect(stages).toHaveLength(5);
|
||||
expect(stages[0]?.distanceM).toBeGreaterThan(55000);
|
||||
expect(stages[0]?.sleepCandidates[0]?.name).toBe('Lakeside campsite');
|
||||
expect(stages.every((stage) => stage.distanceM <= mockTripRequest.dailyDistanceKm.max * 1000)).toBe(true);
|
||||
expect(stages.some((stage) => stage.waterPois.length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('creates a three-day plan without hiding hard-constraint warnings', () => {
|
||||
const route = createMockRoute();
|
||||
const stages = planStages(route, mockPois, {
|
||||
...mockTripRequest,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
dailyDistanceKm: { min: 100, target: 128, max: 138 },
|
||||
dailyAscentM: { max: 2600 }
|
||||
});
|
||||
|
||||
expect(stages).toHaveLength(3);
|
||||
for (const stage of stages) {
|
||||
if (stage.distanceM > 138000) {
|
||||
expect(stage.warnings.map((warning) => warning.code)).toContain('STAGE_DISTANCE_OVER_MAX');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user