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

View File

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

View File

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

View File

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

View File

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

View File

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