Alpha stage commit
This commit is contained in:
32
services/data-pipeline/README.md
Normal file
32
services/data-pipeline/README.md
Normal 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`.
|
||||
47
services/data-pipeline/fixtures/osm-pois.sample.json
Normal file
47
services/data-pipeline/fixtures/osm-pois.sample.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
16
services/data-pipeline/package.json
Normal file
16
services/data-pipeline/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
116
services/data-pipeline/src/normalize-osm-pois.ts
Normal file
116
services/data-pipeline/src/normalize-osm-pois.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
11
services/data-pipeline/tsconfig.json
Normal file
11
services/data-pipeline/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user