Alpha stage commit
This commit is contained in:
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":"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"}
|
||||
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":"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"}
|
||||
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":"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"}
|
||||
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');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
269
packages/shared/src/staging.ts
Normal file
269
packages/shared/src/staging.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { coordinateAtDistance } from './geo.js';
|
||||
import { getProfile, thresholdsFromTripRequest } from './profiles.js';
|
||||
import { detectCriticalGaps, warningsFromGaps } from './pois.js';
|
||||
import type { Poi, RoutePlan, RouteWarning, StagePlan, TripRequest } from './types.js';
|
||||
|
||||
const ENDPOINT_TOLERANCE_M = 8000;
|
||||
|
||||
export function planStages(route: RoutePlan, pois: Poi[], tripRequest: TripRequest): StagePlan[] {
|
||||
const profile = getProfile(tripRequest.profile);
|
||||
const desiredStages = estimateStageCount(route.distanceM, tripRequest);
|
||||
const stages: StagePlan[] = [];
|
||||
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: number, tripRequest: TripRequest): number {
|
||||
const dateDays = inclusiveDateDays(tripRequest.startDate, tripRequest.endDate);
|
||||
const targetBasedStages = Math.max(1, Math.ceil(distanceM / Math.max(1, tripRequest.dailyDistanceKm.target * 1000)));
|
||||
if (dateDays) {
|
||||
const averageStageM = distanceM / dateDays;
|
||||
if (averageStageM < tripRequest.dailyDistanceKm.min * 1000) {
|
||||
return targetBasedStages;
|
||||
}
|
||||
if (averageStageM > tripRequest.dailyDistanceKm.max * 1000) {
|
||||
return Math.max(dateDays, Math.ceil(distanceM / Math.max(1, tripRequest.dailyDistanceKm.max * 1000)));
|
||||
}
|
||||
return dateDays;
|
||||
}
|
||||
return targetBasedStages;
|
||||
}
|
||||
|
||||
function inclusiveDateDays(startDate?: string, endDate?: string): number | undefined {
|
||||
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: Poi[],
|
||||
targetM: number,
|
||||
minM: number,
|
||||
maxM: number,
|
||||
sleepPreference: TripRequest['sleepPreference']
|
||||
): Poi | undefined {
|
||||
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: Poi[], startM: number, endM: number): Poi[] {
|
||||
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: Poi[], endM: number, category: Poi['category']): Poi[] {
|
||||
return pois
|
||||
.filter((poi) => poi.category === category)
|
||||
.filter((poi) => Math.abs((poi.metersFromStart ?? 0) - endM) <= ENDPOINT_TOLERANCE_M)
|
||||
.slice(0, 4);
|
||||
}
|
||||
|
||||
function estimateStageAscent(route: RoutePlan, startM: number, endM: number): number {
|
||||
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: RoutePlan, startM: number, endM: number): number {
|
||||
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: number, ascentM: number, roughSurfaceTolerance: number) {
|
||||
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: {
|
||||
route: RoutePlan;
|
||||
stagePois: Poi[];
|
||||
startMeters: number;
|
||||
endMeters: number;
|
||||
distanceM: number;
|
||||
ascentM: number;
|
||||
tripRequest: TripRequest;
|
||||
hasSleepEndpoint: boolean;
|
||||
}): RouteWarning[] {
|
||||
const warnings: RouteWarning[] = [];
|
||||
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: Poi[], endM: number, minM: number, maxM: number): StagePlan['planB'] {
|
||||
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: Poi['confidence']): number {
|
||||
return confidence === 'known_good'
|
||||
? 6
|
||||
: confidence === 'high'
|
||||
? 5
|
||||
: confidence === 'medium'
|
||||
? 4
|
||||
: confidence === 'low'
|
||||
? 3
|
||||
: confidence === 'unknown'
|
||||
? 2
|
||||
: 1;
|
||||
}
|
||||
|
||||
function dedupeWarnings(warnings: RouteWarning[]): RouteWarning[] {
|
||||
const seen = new Set<string>();
|
||||
return warnings.filter((warning) => {
|
||||
const key = `${warning.code}:${warning.metersFromStart ?? 0}`;
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
216
packages/shared/src/types.d.ts
vendored
Normal file
216
packages/shared/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,216 @@
|
||||
export type Coordinate = {
|
||||
lat: number;
|
||||
lon: number;
|
||||
};
|
||||
export type BikepackingProfileId = 'loaded_gravel' | 'hardtail_bikepacking' | 'road_touring' | 'ebikepacking' | 'beginner_safe';
|
||||
export type SleepPreference = 'official_campsites' | 'mixed_budget' | 'indoor_only' | 'wild_where_legal' | 'any_safe_option';
|
||||
export type PoiCategory = 'water' | 'food' | 'sleep' | 'repair' | 'bailout' | 'charging' | 'emergency';
|
||||
export type Confidence = 'known_good' | 'high' | 'medium' | 'low' | 'unknown' | 'restricted' | 'conflicting';
|
||||
export type WarningSeverity = 'info' | 'notice' | 'warning' | 'critical';
|
||||
export type TripRequest = {
|
||||
start: Coordinate;
|
||||
end: Coordinate;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
profile: BikepackingProfileId;
|
||||
sleepPreference: SleepPreference;
|
||||
dailyDistanceKm: {
|
||||
min: number;
|
||||
target: number;
|
||||
max: number;
|
||||
};
|
||||
dailyAscentM?: {
|
||||
target?: number;
|
||||
max?: number;
|
||||
};
|
||||
maxLoadedGradePercent?: number;
|
||||
preferredUnpavedPercent?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
};
|
||||
waterGapMaxKm?: number;
|
||||
foodGapMaxKm?: number;
|
||||
requireBailoutEveryKm?: number;
|
||||
};
|
||||
export type Trip = {
|
||||
id: string;
|
||||
title: string;
|
||||
request: TripRequest;
|
||||
};
|
||||
export type RouteSegment = {
|
||||
id: string;
|
||||
geometry: Coordinate[];
|
||||
distanceM: number;
|
||||
startMeters?: number;
|
||||
endMeters?: number;
|
||||
ascentM?: number;
|
||||
descentM?: number;
|
||||
avgGradePercent?: number;
|
||||
maxGradePercent?: number;
|
||||
surface?: string;
|
||||
smoothness?: string;
|
||||
highway?: string;
|
||||
bicycleAccess?: string;
|
||||
legalAccessConfidence: Confidence;
|
||||
trafficStress?: number;
|
||||
officialCycleRoute?: boolean;
|
||||
protectedAreaOverlap?: boolean;
|
||||
communityScore?: number;
|
||||
sourceConfidence: Confidence;
|
||||
};
|
||||
export type RouteWarning = {
|
||||
code: string;
|
||||
severity: WarningSeverity;
|
||||
title: string;
|
||||
message: string;
|
||||
metersFromStart?: number;
|
||||
segmentId?: string;
|
||||
};
|
||||
export type RoutePlan = {
|
||||
id: string;
|
||||
tripId: string;
|
||||
geometry: Coordinate[];
|
||||
distanceM: number;
|
||||
ascentM: number;
|
||||
descentM: number;
|
||||
segments: RouteSegment[];
|
||||
surfaceBreakdown: Record<string, number>;
|
||||
suitabilityScore: number;
|
||||
warnings: RouteWarning[];
|
||||
provider: string;
|
||||
attribution?: string[];
|
||||
};
|
||||
export type RoutePlanRequest = {
|
||||
tripRequest: TripRequest;
|
||||
provider?: string;
|
||||
};
|
||||
export type Poi = {
|
||||
id: string;
|
||||
category: PoiCategory;
|
||||
name: string;
|
||||
location: Coordinate;
|
||||
source: string;
|
||||
confidence: Confidence;
|
||||
distanceFromRouteM?: number;
|
||||
metersFromStart?: number;
|
||||
detourDistanceM?: number;
|
||||
openingHours?: string;
|
||||
lastConfirmedAt?: string;
|
||||
tags?: Record<string, string>;
|
||||
};
|
||||
export type StagePlanRequest = {
|
||||
routeId: string;
|
||||
tripRequest: TripRequest;
|
||||
};
|
||||
export type StagePlan = {
|
||||
id: string;
|
||||
routeId: string;
|
||||
dayIndex: number;
|
||||
startMeters: number;
|
||||
endMeters: number;
|
||||
distanceM: number;
|
||||
ascentM?: number;
|
||||
descentM?: number;
|
||||
expectedRideTimeMinutes?: {
|
||||
low: number;
|
||||
high: number;
|
||||
};
|
||||
sleepCandidates: Poi[];
|
||||
waterPois: Poi[];
|
||||
foodPois: Poi[];
|
||||
repairPois: Poi[];
|
||||
bailoutPois: Poi[];
|
||||
warnings: RouteWarning[];
|
||||
planB?: {
|
||||
earlierEndMeters?: number;
|
||||
laterEndMeters?: number;
|
||||
reason: string;
|
||||
};
|
||||
};
|
||||
export type ServiceGap = {
|
||||
category: PoiCategory;
|
||||
startM: number;
|
||||
endM: number;
|
||||
distanceM: number;
|
||||
severity: WarningSeverity;
|
||||
explanation: string;
|
||||
};
|
||||
export type RuleCard = {
|
||||
id: string;
|
||||
ruleType: string;
|
||||
jurisdiction: string;
|
||||
status: string;
|
||||
confidence: Confidence;
|
||||
summary: string;
|
||||
sourceUrl?: string;
|
||||
lastReviewedAt?: string;
|
||||
};
|
||||
export type OfflinePackManifest = {
|
||||
packId: string;
|
||||
tripId: string;
|
||||
routeId: string;
|
||||
createdAt: string;
|
||||
bbox: [number, number, number, number];
|
||||
routeBufferKm: number;
|
||||
includedLayers: string[];
|
||||
dataVersions: Record<string, string>;
|
||||
files: Array<{
|
||||
type: string;
|
||||
url?: string;
|
||||
path?: string;
|
||||
bytes?: number;
|
||||
}>;
|
||||
warnings: RouteWarning[];
|
||||
expiresAt?: string;
|
||||
};
|
||||
export type LocalReportCreate = {
|
||||
reportType: string;
|
||||
location: Coordinate;
|
||||
routeId?: string;
|
||||
metersFromStart?: number;
|
||||
observedAt: string;
|
||||
details?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
offlineClientId?: string;
|
||||
};
|
||||
export type LocalReport = LocalReportCreate & {
|
||||
id: string;
|
||||
moderationStatus: 'pending' | 'approved' | 'rejected';
|
||||
trustScore: number;
|
||||
queuedOffline?: boolean;
|
||||
};
|
||||
export type CriticalGapThresholds = Partial<Record<PoiCategory, number>>;
|
||||
export type HikeABikeRisk = 'low' | 'medium' | 'high' | 'critical';
|
||||
export type SegmentScore = {
|
||||
segmentId?: string;
|
||||
score: number;
|
||||
hikeABikeRisk: HikeABikeRisk;
|
||||
warnings: RouteWarning[];
|
||||
costBreakdown: Record<string, number>;
|
||||
};
|
||||
export type RouteScore = {
|
||||
suitabilityScore: number;
|
||||
warnings: RouteWarning[];
|
||||
segmentScores: SegmentScore[];
|
||||
surfaceBreakdown: Record<string, number>;
|
||||
};
|
||||
export type BikepackingProfile = {
|
||||
id: BikepackingProfileId;
|
||||
label: string;
|
||||
dailyDistanceKm: {
|
||||
min: number;
|
||||
target: number;
|
||||
max: number;
|
||||
};
|
||||
maxGradeWarningPercent: number;
|
||||
waterGapMaxKm: number;
|
||||
foodGapMaxKm: number;
|
||||
repairGapMaxKm: number;
|
||||
bailoutGapMaxKm: number;
|
||||
sleepGapMaxKm: number;
|
||||
roughSurfaceTolerance: number;
|
||||
trafficTolerance: number;
|
||||
unknownAccessPenalty: number;
|
||||
preferredSurfaces: string[];
|
||||
};
|
||||
//# sourceMappingURL=types.d.ts.map
|
||||
1
packages/shared/src/types.d.ts.map
Normal file
1
packages/shared/src/types.d.ts.map
Normal file
File diff suppressed because one or more lines are too long
2
packages/shared/src/types.js
Normal file
2
packages/shared/src/types.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=types.js.map
|
||||
1
packages/shared/src/types.js.map
Normal file
1
packages/shared/src/types.js.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":""}
|
||||
243
packages/shared/src/types.ts
Normal file
243
packages/shared/src/types.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
export type Coordinate = {
|
||||
lat: number;
|
||||
lon: number;
|
||||
elevationM?: number;
|
||||
};
|
||||
|
||||
export type BikepackingProfileId =
|
||||
| 'loaded_gravel'
|
||||
| 'hardtail_bikepacking'
|
||||
| 'road_touring'
|
||||
| 'ebikepacking'
|
||||
| 'beginner_safe';
|
||||
|
||||
export type SleepPreference =
|
||||
| 'official_campsites'
|
||||
| 'mixed_budget'
|
||||
| 'indoor_only'
|
||||
| 'wild_where_legal'
|
||||
| 'any_safe_option';
|
||||
|
||||
export type PoiCategory =
|
||||
| 'water'
|
||||
| 'food'
|
||||
| 'sleep'
|
||||
| 'repair'
|
||||
| 'bailout'
|
||||
| 'charging'
|
||||
| 'emergency';
|
||||
|
||||
export type Confidence =
|
||||
| 'known_good'
|
||||
| 'high'
|
||||
| 'medium'
|
||||
| 'low'
|
||||
| 'unknown'
|
||||
| 'restricted'
|
||||
| 'conflicting';
|
||||
|
||||
export type WarningSeverity = 'info' | 'notice' | 'warning' | 'critical';
|
||||
|
||||
export type TripRequest = {
|
||||
start: Coordinate;
|
||||
end: Coordinate;
|
||||
viaPoints?: Coordinate[];
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
profile: BikepackingProfileId;
|
||||
sleepPreference: SleepPreference;
|
||||
dailyDistanceKm: { min: number; target: number; max: number };
|
||||
dailyAscentM?: { target?: number; max?: number };
|
||||
maxLoadedGradePercent?: number;
|
||||
preferredUnpavedPercent?: { min?: number; max?: number };
|
||||
waterGapMaxKm?: number;
|
||||
foodGapMaxKm?: number;
|
||||
requireBailoutEveryKm?: number;
|
||||
};
|
||||
|
||||
export type Trip = {
|
||||
id: string;
|
||||
title: string;
|
||||
request: TripRequest;
|
||||
};
|
||||
|
||||
export type RouteSegment = {
|
||||
id: string;
|
||||
geometry: Coordinate[];
|
||||
distanceM: number;
|
||||
startMeters?: number;
|
||||
endMeters?: number;
|
||||
ascentM?: number;
|
||||
descentM?: number;
|
||||
avgGradePercent?: number;
|
||||
maxGradePercent?: number;
|
||||
surface?: string;
|
||||
smoothness?: string;
|
||||
highway?: string;
|
||||
bicycleAccess?: string;
|
||||
legalAccessConfidence: Confidence;
|
||||
trafficStress?: number;
|
||||
officialCycleRoute?: boolean;
|
||||
protectedAreaOverlap?: boolean;
|
||||
communityScore?: number;
|
||||
sourceConfidence: Confidence;
|
||||
};
|
||||
|
||||
export type RouteWarning = {
|
||||
code: string;
|
||||
severity: WarningSeverity;
|
||||
title: string;
|
||||
message: string;
|
||||
metersFromStart?: number;
|
||||
segmentId?: string;
|
||||
};
|
||||
|
||||
export type RoutePlan = {
|
||||
id: string;
|
||||
tripId: string;
|
||||
geometry: Coordinate[];
|
||||
distanceM: number;
|
||||
ascentM: number;
|
||||
descentM: number;
|
||||
segments: RouteSegment[];
|
||||
surfaceBreakdown: Record<string, number>;
|
||||
suitabilityScore: number;
|
||||
warnings: RouteWarning[];
|
||||
provider: string;
|
||||
attribution?: string[];
|
||||
};
|
||||
|
||||
export type RoutePlanRequest = {
|
||||
tripRequest: TripRequest;
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
export type Poi = {
|
||||
id: string;
|
||||
category: PoiCategory;
|
||||
name: string;
|
||||
location: Coordinate;
|
||||
source: string;
|
||||
confidence: Confidence;
|
||||
distanceFromRouteM?: number;
|
||||
metersFromStart?: number;
|
||||
detourDistanceM?: number;
|
||||
openingHours?: string;
|
||||
lastConfirmedAt?: string;
|
||||
tags?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type StagePlanRequest = {
|
||||
routeId: string;
|
||||
tripRequest: TripRequest;
|
||||
};
|
||||
|
||||
export type StagePlan = {
|
||||
id: string;
|
||||
routeId: string;
|
||||
dayIndex: number;
|
||||
startMeters: number;
|
||||
endMeters: number;
|
||||
distanceM: number;
|
||||
ascentM?: number;
|
||||
descentM?: number;
|
||||
expectedRideTimeMinutes?: { low: number; high: number };
|
||||
sleepCandidates: Poi[];
|
||||
waterPois: Poi[];
|
||||
foodPois: Poi[];
|
||||
repairPois: Poi[];
|
||||
bailoutPois: Poi[];
|
||||
warnings: RouteWarning[];
|
||||
planB?: {
|
||||
earlierEndMeters?: number;
|
||||
laterEndMeters?: number;
|
||||
reason: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ServiceGap = {
|
||||
category: PoiCategory;
|
||||
startM: number;
|
||||
endM: number;
|
||||
distanceM: number;
|
||||
severity: WarningSeverity;
|
||||
explanation: string;
|
||||
};
|
||||
|
||||
export type RuleCard = {
|
||||
id: string;
|
||||
ruleType: string;
|
||||
jurisdiction: string;
|
||||
status: string;
|
||||
confidence: Confidence;
|
||||
summary: string;
|
||||
sourceUrl?: string;
|
||||
lastReviewedAt?: string;
|
||||
};
|
||||
|
||||
export type OfflinePackManifest = {
|
||||
packId: string;
|
||||
tripId: string;
|
||||
routeId: string;
|
||||
createdAt: string;
|
||||
bbox: [number, number, number, number];
|
||||
routeBufferKm: number;
|
||||
includedLayers: string[];
|
||||
dataVersions: Record<string, string>;
|
||||
files: Array<{ type: string; url?: string; path?: string; bytes?: number }>;
|
||||
warnings: RouteWarning[];
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
export type LocalReportCreate = {
|
||||
reportType: string;
|
||||
location: Coordinate;
|
||||
routeId?: string;
|
||||
metersFromStart?: number;
|
||||
observedAt: string;
|
||||
details?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
offlineClientId?: string;
|
||||
};
|
||||
|
||||
export type LocalReport = LocalReportCreate & {
|
||||
id: string;
|
||||
moderationStatus: 'pending' | 'approved' | 'rejected';
|
||||
trustScore: number;
|
||||
queuedOffline?: boolean;
|
||||
};
|
||||
|
||||
export type CriticalGapThresholds = Partial<Record<PoiCategory, number>>;
|
||||
|
||||
export type HikeABikeRisk = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
export type SegmentScore = {
|
||||
segmentId?: string;
|
||||
score: number;
|
||||
hikeABikeRisk: HikeABikeRisk;
|
||||
warnings: RouteWarning[];
|
||||
costBreakdown: Record<string, number>;
|
||||
};
|
||||
|
||||
export type RouteScore = {
|
||||
suitabilityScore: number;
|
||||
warnings: RouteWarning[];
|
||||
segmentScores: SegmentScore[];
|
||||
surfaceBreakdown: Record<string, number>;
|
||||
};
|
||||
|
||||
export type BikepackingProfile = {
|
||||
id: BikepackingProfileId;
|
||||
label: string;
|
||||
dailyDistanceKm: { min: number; target: number; max: number };
|
||||
maxGradeWarningPercent: number;
|
||||
waterGapMaxKm: number;
|
||||
foodGapMaxKm: number;
|
||||
repairGapMaxKm: number;
|
||||
bailoutGapMaxKm: number;
|
||||
sleepGapMaxKm: number;
|
||||
roughSurfaceTolerance: number;
|
||||
trafficTolerance: number;
|
||||
unknownAccessPenalty: number;
|
||||
preferredSurfaces: string[];
|
||||
};
|
||||
13
packages/shared/tsconfig.json
Normal file
13
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
8
packages/shared/vitest.config.ts
Normal file
8
packages/shared/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts']
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user