Alpha stage commit

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

7
packages/shared/src/fixtures.d.ts vendored Normal file
View 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

View 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"}

View 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

File diff suppressed because one or more lines are too long

View 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);
});
});

View 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
View 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

View 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
View 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

File diff suppressed because one or more lines are too long

125
packages/shared/src/geo.ts Normal file
View 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
View 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

View 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"}

View 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
function escapeXmlName(value) {
return value.replace(/[^A-Za-z0-9_.-]/g, '');
}
function number(value) {
return value.toFixed(6);
}
//# sourceMappingURL=gpx.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"gpx.js","sourceRoot":"","sources":["gpx.ts"],"names":[],"mappings":"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"}

View 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');
});
});

View 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
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
View 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

View 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"}

View 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

View 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"}

View 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
View 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

View 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"}

View 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

View 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"}

View 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
View 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

View 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"}

View 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

View File

@@ -0,0 +1 @@
{"version":3,"file":"pois.js","sourceRoot":"","sources":["pois.ts"],"names":[],"mappings":"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"}

View 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);
});
});

View 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
View 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

View 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"}

View 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

View File

@@ -0,0 +1 @@
{"version":3,"file":"profiles.js","sourceRoot":"","sources":["profiles.ts"],"names":[],"mappings":"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"}

View 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
View 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

View 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"}

View 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

File diff suppressed because one or more lines are too long

View 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);
});
});

View 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
View 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

View 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"}

View 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

File diff suppressed because one or more lines are too long

View 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');
}
}
});
});

View 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
View 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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=types.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":""}

View 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[];
};