initial commit

This commit is contained in:
Jan-Henrik 2026-03-01 21:58:53 +01:00
commit 8046b774cf
81 changed files with 11103 additions and 0 deletions

22
.env.example Normal file
View file

@ -0,0 +1,22 @@
# Copy to .env and fill in values
# PostgreSQL
POSTGRES_PASSWORD=changeme-strong-password
# Valkey (Redis-compatible)
VALKEY_PASSWORD=changeme-strong-valkey-password
# Admin password hash (bcrypt)
# Generate with: node -e "require('bcryptjs').hash('yourpassword', 12).then(console.log)"
ADMIN_PASSWORD_HASH=$2b$12$placeholder
# Admin JWT secret (min 32 chars, used to sign session tokens)
# Generate with: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
ADMIN_JWT_SECRET=changeme-generate-a-long-random-string-here
# Optional: override service URLs for local dev
# DATABASE_URL=postgres://app:changeme@localhost:5432/fifteenmin
# REDIS_HOST=localhost
# REDIS_PORT=6379
# REDIS_PASSWORD=changeme-strong-valkey-password
# VALHALLA_URL=http://localhost:8002

33
.gitignore vendored Normal file
View file

@ -0,0 +1,33 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build outputs
.next/
dist/
out/
*.tsbuildinfo
# Environment
.env
.env.local
.env.*.local
# Data (never commit OSM PBF files or tiles)
/data/
*.osm.pbf
*.pmtiles
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp
# Logs
*.log
npm-debug.log*

100
Dockerfile Normal file
View file

@ -0,0 +1,100 @@
# ─── Build base (Alpine — small, used for npm install + tsc) ──────────────────
FROM node:22-alpine AS base
RUN apk add --no-cache libc6-compat
WORKDIR /app
# ─── Dependencies (include devDeps — needed for tsc, next build, etc.) ────────
FROM base AS deps
COPY package.json package-lock.json* tsconfig.base.json ./
COPY apps/web/package.json ./apps/web/
COPY worker/package.json ./worker/
COPY shared/package.json ./shared/
# NODE_ENV must NOT be production here — devDependencies (tsc, tsx, etc.) are required to build
RUN npm install --workspace=apps/web --workspace=worker --workspace=shared
# ─── Shared build ────────────────────────────────────────────────────────────
FROM deps AS shared-build
COPY shared/ ./shared/
RUN npm run build --workspace=shared
# ─── Next.js build ──────────────────────────────────────────────────────────
FROM shared-build AS web-build
COPY apps/web/ ./apps/web/
RUN npm run build --workspace=apps/web
# ─── Worker build ──────────────────────────────────────────────────────────
FROM shared-build AS worker-build
COPY worker/ ./worker/
RUN npm run build --workspace=worker
# ─── Web runtime (Alpine) ─────────────────────────────────────────────────────
FROM node:22-alpine AS web
RUN apk add --no-cache libc6-compat
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=web-build /app/apps/web/.next ./apps/web/.next
COPY --from=web-build /app/apps/web/public ./apps/web/public
COPY --from=shared-build /app/shared/dist ./shared/dist
COPY shared/package.json ./shared/
COPY apps/web/package.json ./apps/web/
USER nextjs
WORKDIR /app/apps/web
EXPOSE 3000
# Use absolute path — WORKDIR is /app/apps/web but node_modules are at /app/node_modules
CMD ["/app/node_modules/.bin/next", "start"]
# ─── Valhalla worker (gis-ops Valhalla image + Node.js 22) ───────────────────
# This container runs both a BullMQ worker (build-valhalla jobs) AND the
# valhalla_service HTTP server. It has valhalla_build_tiles and friends
# pre-installed from the base image. Node.js is added for the BullMQ consumer.
FROM ghcr.io/gis-ops/docker-valhalla/valhalla:latest AS valhalla-worker
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg osmium-tool \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production
# BullMQ and postgres are pure JS — no native add-ons — so Alpine-built
# node_modules from the deps stage work on this Debian/glibc base.
COPY --from=deps /app/node_modules ./node_modules
COPY --from=worker-build /app/worker/dist ./worker/dist
COPY --from=shared-build /app/shared/dist ./shared/dist
COPY shared/package.json ./shared/
COPY worker/package.json ./worker/
# /data/osm is shared with the pipeline worker (osm_data volume).
# Make it world-writable so the worker (UID 1001) can write PBF files here
# regardless of which container initialises the Docker volume first.
# valhalla mounts this volume :ro so it can never write here accidentally.
RUN mkdir -p /data/osm /data/valhalla && chmod 1777 /data/osm
ENTRYPOINT ["/bin/node"]
CMD ["worker/dist/valhalla-main.js"]
# ─── Worker runtime (Debian slim — osmium-tool + osm2pgsql are in apt) ────────
FROM node:22-slim AS worker
RUN apt-get update && apt-get install -y --no-install-recommends \
osmium-tool \
osm2pgsql \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 --gid nodejs workeruser
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=worker-build /app/worker/dist ./worker/dist
COPY --from=shared-build /app/shared/dist ./shared/dist
COPY shared/package.json ./shared/
COPY infra/ ./infra/
COPY worker/package.json ./worker/
# Create data directories owned by workeruser so Docker named volumes
# are initialized with the correct permissions on first run.
RUN mkdir -p /data/osm /data/valhalla && chown -R workeruser:nodejs /data
USER workeruser
CMD ["node", "worker/dist/index.js"]

105
README.md Normal file
View file

@ -0,0 +1,105 @@
# Transportationer — 15-Minute City Analyzer
A web application for analyzing urban accessibility through the lens of the 15-minute city concept. Shows a heatmap indicating distance to locations of interest across 5 categories: **Service & Trade**, **Transport**, **Work & School**, **Culture & Community**, and **Recreation**.
## Architecture
```
Browser (Next.js / React)
├── MapLibre GL JS (map + canvas heatmap)
└── API calls → Next.js API routes
Next.js App Server
├── Public API: /api/cities /api/pois /api/grid /api/stats /api/isochrones
├── Admin API: /api/admin/** (auth-protected)
├── PostgreSQL + PostGIS (POI data, grid scores, isochrone cache)
└── Valkey (API response cache, sessions, BullMQ queue)
BullMQ Worker (separate process)
├── download-pbf → streams OSM data from Geofabrik
├── extract-pois → osmium-tool + osm2pgsql → PostGIS
├── generate-grid → PostGIS SQL (200m grid)
├── compute-scores → KNN lateral join + sigmoid scoring
└── build-valhalla → Valhalla routing tile build
Valhalla → local routing (isochrones)
Protomaps → self-hosted map tiles (PMTiles)
```
## Quick Start
### 1. Configure environment
```bash
cp .env.example .env
# Edit .env with strong passwords
# Generate admin password hash
node -e "require('bcryptjs').hash('yourpassword', 12).then(console.log)"
# Paste result as ADMIN_PASSWORD_HASH in .env
```
### 2. Start services
```bash
docker compose up -d
```
### 3. Add a city
Open [http://localhost:3000/admin](http://localhost:3000/admin), log in, click **Add City**, browse Geofabrik regions (e.g. `europe/germany/berlin`), and start ingestion. Progress is shown live.
Processing time:
- Small city (< 100k pop): ~515 minutes
- Large city (1M+ pop): ~3090 minutes
### 4. Explore
Open [http://localhost:3000](http://localhost:3000) and select your city.
## Map Tiles
By default the app uses CartoDB Positron (CDN). For fully offline operation, download a PMTiles file for your region:
```bash
# Example: download Berlin region tiles
wget https://maps.protomaps.com/builds/berlin.pmtiles -O apps/web/public/tiles/region.pmtiles
# Then switch to the PMTiles style:
cp apps/web/public/tiles/style.pmtiles.json apps/web/public/tiles/style.json
```
## Development
```bash
npm install
npm run dev # Next.js dev server on :3000
npm run worker:dev # BullMQ worker with hot reload
```
Required local services: PostgreSQL+PostGIS, Valkey. Easiest via:
```bash
docker compose up postgres valkey -d
```
## Category Definitions
| Category | OSM Sources | Default Threshold |
|----------|-------------|-------------------|
| Service & Trade | shops, restaurants, pharmacies, banks | 10 min |
| Transport | bus stops, metro, train, bike share | 8 min |
| Work & School | offices, schools, universities | 20 min |
| Culture & Community | libraries, hospitals, museums, community centers | 15 min |
| Recreation | parks, sports, gyms, green spaces | 10 min |
## Scoring
For each grid point (200m spacing), the nearest POI in each category is found using a PostGIS KNN lateral join. The Euclidean distance is converted to travel time using mode speed assumptions (walking 5 km/h, cycling 15 km/h, driving 40 km/h). A sigmoid function converts travel time to a score in [0,1]:
```
score = 1 / (1 + exp(k * (travel_time - threshold)))
```
Where `k = 4/threshold`, giving score=0.5 exactly at the threshold.
The composite score is a weighted average of all 5 category scores, with user-adjustable weights.

View file

@ -0,0 +1,151 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { useJobProgress } from "@/hooks/use-job-progress";
interface CityDetail {
slug: string;
name: string;
country_code: string;
geofabrik_url: string;
status: string;
last_ingested: string | null;
poi_count: number;
grid_count: number;
error_message: string | null;
}
export default function CityDetailPage() {
const { slug } = useParams<{ slug: string }>();
const router = useRouter();
const [city, setCity] = useState<CityDetail | null>(null);
const [loading, setLoading] = useState(true);
const [jobId, setJobId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const { stages, overall } = useJobProgress(jobId);
useEffect(() => {
fetch(`/api/admin/cities`)
.then((r) => r.json())
.then((all: CityDetail[]) => {
setCity(all.find((c) => c.slug === slug) ?? null);
setLoading(false);
})
.catch(() => setLoading(false));
}, [slug]);
const handleReIngest = async () => {
const res = await fetch(`/api/admin/ingest/${slug}`, { method: "POST" });
if (res.ok) {
const data = await res.json();
setJobId(data.jobId);
}
};
const handleDelete = async () => {
if (!confirm(`Delete city "${city?.name}"? This will remove all POI and grid data.`)) return;
setDeleting(true);
const res = await fetch(`/api/admin/cities/${slug}`, { method: "DELETE" });
if (res.ok) router.push("/admin");
else setDeleting(false);
};
if (loading) return <div className="text-gray-500">Loading</div>;
if (!city)
return (
<div className="text-gray-500">
City not found.{" "}
<a href="/admin" className="text-brand-600 underline">
Back
</a>
</div>
);
return (
<div className="max-w-2xl mx-auto">
<div className="flex items-center gap-4 mb-6">
<a href="/admin" className="text-sm text-gray-500 hover:text-gray-700">
Back
</a>
<h1 className="text-2xl font-bold">{city.name}</h1>
<span className="badge bg-gray-100 text-gray-600 text-xs">
{city.slug}
</span>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-6">
{[
{ label: "POIs", value: city.poi_count.toLocaleString() },
{ label: "Grid Points", value: city.grid_count.toLocaleString() },
{
label: "Last Ingested",
value: city.last_ingested
? new Date(city.last_ingested).toLocaleDateString()
: "Never",
},
].map((s) => (
<div key={s.label} className="card text-center">
<p className="text-2xl font-bold text-gray-900">{s.value}</p>
<p className="text-sm text-gray-500 mt-1">{s.label}</p>
</div>
))}
</div>
{/* Source */}
<div className="card mb-6">
<h2 className="text-sm font-medium text-gray-700 mb-2">Data Source</h2>
<p className="text-xs text-gray-500 break-all">{city.geofabrik_url}</p>
</div>
{/* Live progress if ingesting */}
{jobId && (
<div className="card mb-6">
<h2 className="text-sm font-semibold mb-4">Ingestion Progress</h2>
<ol className="space-y-3">
{stages.map((s) => (
<li key={s.key} className="flex items-center gap-3 text-sm">
<span
className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${
s.status === "completed"
? "bg-green-200 text-green-700"
: s.status === "active"
? "bg-brand-100 text-brand-700"
: "bg-gray-100 text-gray-400"
}`}
>
{s.status === "completed" ? "✓" : s.status === "active" ? "…" : "○"}
</span>
<span className={s.status === "active" ? "font-medium" : "text-gray-500"}>
{s.label}
</span>
{s.status === "active" && (
<span className="text-xs text-gray-400">{s.pct}%</span>
)}
</li>
))}
</ol>
{overall === "completed" && (
<p className="text-sm text-green-700 mt-4"> Ingestion complete!</p>
)}
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button onClick={handleReIngest} className="btn-primary">
Re-ingest Data
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="btn-danger"
>
{deleting ? "Deleting…" : "Delete City"}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,801 @@
"use client";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import type { GeofabrikFeature, GeofabrikIndex } from "@transportationer/shared";
import { useJobProgress } from "@/hooks/use-job-progress";
type Step = "browse" | "confirm" | "ingest";
// ─── Step indicator ───────────────────────────────────────────────────────────
function StepIndicator({ current }: { current: Step }) {
const steps: { key: Step; label: string }[] = [
{ key: "browse", label: "Select Region" },
{ key: "confirm", label: "Confirm" },
{ key: "ingest", label: "Processing" },
];
const idx = steps.findIndex((s) => s.key === current);
return (
<nav className="flex items-center gap-4 mb-8">
{steps.map((s, i) => (
<div key={s.key} className="flex items-center gap-2">
<span
className={`w-6 h-6 rounded-full text-xs flex items-center justify-center font-medium ${
i < idx
? "bg-brand-600 text-white"
: i === idx
? "bg-brand-600 text-white ring-2 ring-brand-300"
: "bg-gray-200 text-gray-500"
}`}
>
{i < idx ? "✓" : i + 1}
</span>
<span
className={`text-sm font-medium ${i === idx ? "text-gray-900" : "text-gray-500"}`}
>
{s.label}
</span>
{i < steps.length - 1 && (
<span className="text-gray-300 mx-2"></span>
)}
</div>
))}
</nav>
);
}
// ─── Geofabrik browser ────────────────────────────────────────────────────────
function GeofabrikBrowser({
onSelect,
}: {
onSelect: (f: GeofabrikFeature) => void;
}) {
const [index, setIndex] = useState<GeofabrikIndex | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [parent, setParent] = useState<string | undefined>(undefined);
useEffect(() => {
fetch("/api/admin/geofabrik")
.then((r) => r.json())
.then(setIndex)
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}, []);
const features = useMemo(() => {
if (!index) return [];
return index.features.filter((f) => {
const matchesParent = parent
? f.properties.parent === parent
: !f.properties.parent;
const matchesQuery = query
? f.properties.name.toLowerCase().includes(query.toLowerCase()) ||
f.properties.id.toLowerCase().includes(query.toLowerCase())
: true;
return matchesParent && matchesQuery && f.properties.urls?.pbf;
});
}, [index, parent, query]);
const parentFeature = index?.features.find(
(f) => f.properties.id === parent,
);
const grandParent = parentFeature
? index?.features.find(
(f) => f.properties.id === parentFeature.properties.parent,
)
: null;
if (loading)
return (
<div className="card py-12 text-center text-gray-500">
Loading Geofabrik region index
</div>
);
if (error)
return (
<div className="card py-8 text-center text-red-600">
Error loading index: {error}
</div>
);
return (
<div className="card">
<h2 className="text-lg font-semibold mb-4">Select a Region</h2>
<div className="flex items-center gap-2 text-sm mb-4 flex-wrap">
<button
onClick={() => setParent(undefined)}
className="text-brand-600 hover:underline"
>
All regions
</button>
{grandParent && (
<>
<span className="text-gray-400"></span>
<button
onClick={() => setParent(grandParent.properties.id)}
className="text-brand-600 hover:underline"
>
{grandParent.properties.name}
</button>
</>
)}
{parentFeature && (
<>
<span className="text-gray-400"></span>
<span className="font-medium">{parentFeature.properties.name}</span>
</>
)}
</div>
<input
type="search"
placeholder="Search regions…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm mb-4 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
<div className="divide-y divide-gray-100 max-h-80 overflow-y-auto rounded border border-gray-200">
{features.length === 0 ? (
<p className="text-sm text-gray-500 py-4 px-3">No regions found.</p>
) : (
features.map((f) => {
const hasChildren = index!.features.some(
(c) => c.properties.parent === f.properties.id,
);
return (
<div
key={f.properties.id}
className="flex items-center justify-between px-3 py-2.5 hover:bg-gray-50"
>
<div>
<p className="text-sm font-medium text-gray-900">
{f.properties.name}
</p>
<p className="text-xs text-gray-400">{f.properties.id}</p>
</div>
<div className="flex gap-2">
{hasChildren && (
<button
onClick={() => {
setParent(f.properties.id);
setQuery("");
}}
className="btn-secondary text-xs py-1"
>
Browse
</button>
)}
<button
onClick={() => onSelect(f)}
className="btn-primary text-xs py-1"
>
Select
</button>
</div>
</div>
);
})
)}
</div>
</div>
);
}
// ─── Nominatim geocoder + radius selector + mini map ─────────────────────────
interface NominatimResult {
place_id: number;
display_name: string;
lat: string;
lon: string;
type: string;
class: string;
}
const RADIUS_OPTIONS = [2, 5, 10, 15, 20, 30];
function computeBbox(
lat: number,
lng: number,
radiusKm: number,
): [number, number, number, number] {
const latDelta = radiusKm / 111.0;
const lngDelta = radiusKm / (111.0 * Math.cos((lat * Math.PI) / 180));
return [lng - lngDelta, lat - latDelta, lng + lngDelta, lat + latDelta];
}
function bboxToGeoJSON(bbox: [number, number, number, number]) {
const [w, s, e, n] = bbox;
return {
type: "Feature" as const,
geometry: {
type: "Polygon" as const,
coordinates: [
[
[w, s],
[e, s],
[e, n],
[w, n],
[w, s],
],
],
},
properties: {},
};
}
function LocationSelector({
regionGeometry,
onBboxChange,
}: {
regionGeometry: GeofabrikFeature["geometry"];
onBboxChange: (bbox: [number, number, number, number] | null) => void;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<NominatimResult[]>([]);
const [selected, setSelected] = useState<NominatimResult | null>(null);
const [radius, setRadius] = useState(10);
const [showDropdown, setShowDropdown] = useState(false);
const [searching, setSearching] = useState(false);
const mapContainerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<import("maplibre-gl").Map | null>(null);
const mapReadyRef = useRef(false);
const onBboxChangeRef = useRef(onBboxChange);
onBboxChangeRef.current = onBboxChange;
const bbox = useMemo((): [number, number, number, number] | null => {
if (!selected) return null;
return computeBbox(parseFloat(selected.lat), parseFloat(selected.lon), radius);
}, [selected, radius]);
// Notify parent when bbox changes
useEffect(() => {
onBboxChangeRef.current(bbox);
}, [bbox]);
// Debounced Nominatim search
useEffect(() => {
if (query.length < 2) {
setResults([]);
return;
}
const timer = setTimeout(async () => {
setSearching(true);
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=6&addressdetails=0`,
{ headers: { "User-Agent": "Transportationer/1.0 (15-minute city analyzer)" } },
);
const data: NominatimResult[] = await res.json();
setResults(data);
setShowDropdown(true);
} catch {
setResults([]);
} finally {
setSearching(false);
}
}, 500);
return () => clearTimeout(timer);
}, [query]);
// Apply bbox to mini map
const applyBboxToMap = useCallback(
(map: import("maplibre-gl").Map, b: [number, number, number, number] | null) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const src = map.getSource("area-bbox") as any;
if (!src) return;
if (b) {
src.setData(bboxToGeoJSON(b));
map.fitBounds([b[0], b[1], b[2], b[3]], { padding: 40, duration: 500 });
} else {
src.setData({ type: "FeatureCollection", features: [] });
}
},
[],
);
// Update mini map when bbox changes
useEffect(() => {
const map = mapRef.current;
if (map && mapReadyRef.current) applyBboxToMap(map, bbox);
}, [bbox, applyBboxToMap]);
// Initialize mini map
useEffect(() => {
if (!mapContainerRef.current) return;
let cancelled = false;
(async () => {
const mgl = await import("maplibre-gl");
const { Protocol } = await import("pmtiles");
if (cancelled) return;
// Register pmtiles protocol (idempotent)
try {
const p = new Protocol();
mgl.addProtocol("pmtiles", p.tile);
} catch {
// Already registered
}
const map = new mgl.Map({
container: mapContainerRef.current!,
style: "/tiles/style.json",
center: [10, 51],
zoom: 3,
});
mapRef.current = map;
map.on("load", () => {
if (cancelled) return;
mapReadyRef.current = true;
// Region outline (Geofabrik polygon)
map.addSource("area-region", {
type: "geojson",
data: regionGeometry
? { type: "Feature", geometry: regionGeometry, properties: {} }
: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "area-region-fill",
type: "fill",
source: "area-region",
paint: { "fill-color": "#64748b", "fill-opacity": 0.1 },
});
map.addLayer({
id: "area-region-line",
type: "line",
source: "area-region",
paint: { "line-color": "#64748b", "line-width": 1.5, "line-dasharray": [3, 2] },
});
// Selected sub-area bbox
map.addSource("area-bbox", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "area-bbox-fill",
type: "fill",
source: "area-bbox",
paint: { "fill-color": "#2563eb", "fill-opacity": 0.15 },
});
map.addLayer({
id: "area-bbox-line",
type: "line",
source: "area-bbox",
paint: { "line-color": "#2563eb", "line-width": 2 },
});
// Fit to region if available
if (regionGeometry) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const src = map.getSource("area-region") as any;
if (src) {
// Fit map to the region outline
try {
const coords: number[][] =
regionGeometry.type === "Polygon"
? regionGeometry.coordinates[0]
: regionGeometry.coordinates[0][0];
const lngs = coords.map((c) => c[0]);
const lats = coords.map((c) => c[1]);
map.fitBounds(
[Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)],
{ padding: 20 },
);
} catch {
/* ignore */
}
}
}
// Apply bbox if already set (e.g. after component re-render)
if (bbox) applyBboxToMap(map, bbox);
});
})();
return () => {
cancelled = true;
mapReadyRef.current = false;
mapRef.current?.remove();
mapRef.current = null;
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="space-y-3">
{/* Geocoder */}
<div className="relative">
<div className="flex gap-2 items-center">
<input
type="search"
placeholder="Search for a city or location…"
value={query}
onChange={(e) => {
setQuery(e.target.value);
if (!e.target.value) { setSelected(null); setResults([]); }
}}
onFocus={() => results.length > 0 && setShowDropdown(true)}
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
{searching && (
<span className="text-xs text-gray-400 whitespace-nowrap">Searching</span>
)}
</div>
{showDropdown && results.length > 0 && (
<div className="absolute z-20 left-0 right-0 top-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-56 overflow-y-auto">
{results.map((r) => (
<button
key={r.place_id}
type="button"
onClick={() => {
setSelected(r);
setQuery(r.display_name.split(",").slice(0, 3).join(", "));
setShowDropdown(false);
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 border-b border-gray-100 last:border-0"
>
<span className="font-medium text-gray-800">
{r.display_name.split(",")[0]}
</span>
<span className="text-gray-400 ml-1 text-xs">
{r.display_name.split(",").slice(1, 3).join(",")}
</span>
</button>
))}
</div>
)}
</div>
{/* Radius selector */}
{selected && (
<div className="flex items-center gap-3">
<label className="text-sm text-gray-600 shrink-0">Radius:</label>
<div className="flex gap-1.5 flex-wrap">
{RADIUS_OPTIONS.map((r) => (
<button
key={r}
type="button"
onClick={() => setRadius(r)}
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition-colors ${
radius === r
? "bg-brand-600 text-white border-brand-600"
: "bg-white text-gray-600 border-gray-300 hover:border-brand-400"
}`}
>
{r} km
</button>
))}
</div>
</div>
)}
{/* Mini map */}
<div
ref={mapContainerRef}
className="w-full rounded-md border border-gray-200 overflow-hidden"
style={{ height: 220 }}
/>
{bbox && (
<p className="text-xs text-green-700">
Sub-region: {radius} km around {selected!.display_name.split(",")[0]} bbox [{bbox.map((v) => v.toFixed(4)).join(", ")}]
</p>
)}
{!selected && (
<p className="text-xs text-gray-400">
Search for a location to select a sub-region, or leave empty to use the entire dataset.
</p>
)}
</div>
);
}
// ─── Confirm step ─────────────────────────────────────────────────────────────
function ConfirmStep({
region,
onBack,
onConfirm,
}: {
region: GeofabrikFeature;
onBack: () => void;
onConfirm: (
slug: string,
name: string,
countryCode: string,
bbox: [number, number, number, number] | null,
) => Promise<void>;
}) {
const defaultSlug = region.properties.id.replace(/\//g, "-");
const [slug, setSlug] = useState(defaultSlug);
const [name, setName] = useState(region.properties.name);
const [countryCode, setCountryCode] = useState("");
const [bbox, setBbox] = useState<[number, number, number, number] | null>(null);
const [loading, setLoading] = useState(false);
const handleBboxChange = useCallback(
(b: [number, number, number, number] | null) => setBbox(b),
[],
);
return (
<div className="card max-w-2xl">
<h2 className="text-lg font-semibold mb-4">Confirm City Details</h2>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Display Name
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Slug <span className="text-gray-400">(URL identifier)</span>
</label>
<input
value={slug}
onChange={(e) =>
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-"))
}
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm font-mono focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
</div>
<div className="w-24">
<label className="block text-sm font-medium text-gray-700 mb-1">
Country Code <span className="text-gray-400">(2-letter)</span>
</label>
<input
value={countryCode}
maxLength={2}
onChange={(e) => setCountryCode(e.target.value.toUpperCase())}
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
placeholder="DE"
/>
</div>
{/* Sub-region selector */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sub-region{" "}
<span className="text-gray-400 font-normal">(optional clip to a city area)</span>
</label>
<p className="text-xs text-gray-500 mb-3">
Select a city center and radius to ingest only that sub-area of the dataset.
Recommended for large regions (e.g. entire states). Affects OSM import,
routing tiles, and grid analysis.
</p>
<LocationSelector
regionGeometry={region.geometry}
onBboxChange={handleBboxChange}
/>
</div>
<div className="bg-gray-50 rounded-md p-3">
<p className="text-xs text-gray-500 font-medium mb-1">Source URL</p>
<p className="text-xs text-gray-700 break-all">
{region.properties.urls.pbf}
</p>
</div>
</div>
<div className="flex gap-3 mt-6">
<button onClick={onBack} className="btn-secondary">
Back
</button>
<button
onClick={async () => {
setLoading(true);
await onConfirm(slug, name, countryCode, bbox);
setLoading(false);
}}
disabled={loading || !slug || !name}
className="btn-primary"
>
{loading ? "Starting…" : "Start Ingestion"}
</button>
</div>
</div>
);
}
// ─── Progress step ────────────────────────────────────────────────────────────
function ProgressStep({ jobId }: { jobId: string | null }) {
const { stages, overall, error } = useJobProgress(jobId);
return (
<div className="card max-w-lg">
<h2 className="text-lg font-semibold mb-6">Processing City Data</h2>
<ol className="space-y-4">
{stages.map((stage) => (
<li key={stage.key} className="flex items-start gap-3">
<StageIcon status={stage.status} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">{stage.label}</p>
{stage.status === "active" && (
<>
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-1.5">
<div
className="bg-brand-600 h-1.5 rounded-full transition-all duration-500"
style={{ width: `${stage.pct}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1 truncate">
{stage.message}
</p>
</>
)}
{stage.status === "failed" && error && (
<p className="text-xs text-red-600 mt-1">{error}</p>
)}
</div>
</li>
))}
</ol>
{overall === "completed" && (
<div className="mt-6 p-4 bg-green-50 rounded-lg text-green-800 text-sm">
City ingestion complete!{" "}
<a href="/admin" className="underline font-medium">
Return to dashboard
</a>{" "}
or{" "}
<a href="/" className="underline font-medium">
view on map
</a>
.
</div>
)}
{overall === "failed" && (
<div className="mt-6 p-4 bg-red-50 rounded-lg text-red-800 text-sm">
Ingestion failed: {error}.{" "}
<a href="/admin" className="underline">
Return to dashboard
</a>
.
</div>
)}
</div>
);
}
function StageIcon({ status }: { status: StageStatus["status"] }) {
if (status === "completed")
return (
<span className="w-5 h-5 flex items-center justify-center rounded-full bg-green-100 text-green-600 text-xs mt-0.5 shrink-0">
</span>
);
if (status === "failed")
return (
<span className="w-5 h-5 flex items-center justify-center rounded-full bg-red-100 text-red-600 text-xs mt-0.5 shrink-0">
</span>
);
if (status === "active")
return (
<span className="w-5 h-5 flex items-center justify-center mt-0.5 shrink-0">
<svg
className="animate-spin w-4 h-4 text-brand-600"
viewBox="0 0 24 24"
fill="none"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="2"
opacity="0.25"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</span>
);
return (
<span className="w-5 h-5 rounded-full border-2 border-gray-300 mt-0.5 shrink-0" />
);
}
import type { StageStatus } from "@/hooks/use-job-progress";
// ─── Main page ────────────────────────────────────────────────────────────────
export default function AddCityPage() {
const [step, setStep] = useState<Step>("browse");
const [selected, setSelected] = useState<GeofabrikFeature | null>(null);
const [jobId, setJobId] = useState<string | null>(null);
const [ingestError, setIngestError] = useState<string | null>(null);
const handleConfirm = async (
slug: string,
name: string,
countryCode: string,
bbox: [number, number, number, number] | null,
) => {
setIngestError(null);
try {
const res = await fetch("/api/admin/cities", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
slug,
name,
countryCode,
geofabrikUrl: selected!.properties.urls.pbf,
...(bbox ? { bbox } : {}),
}),
});
if (!res.ok) {
const data = await res.json();
setIngestError(data.error ?? "Failed to start ingestion");
return;
}
const data = await res.json();
setJobId(data.jobId);
setStep("ingest");
} catch (e) {
setIngestError(String(e));
}
};
return (
<div className="max-w-3xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Add City</h1>
<p className="text-sm text-gray-500 mb-6">
Select an OpenStreetMap region to import for 15-minute city analysis.
</p>
<StepIndicator current={step} />
{ingestError && (
<div className="mb-4 p-3 bg-red-50 rounded text-sm text-red-700">
{ingestError}
</div>
)}
{step === "browse" && (
<GeofabrikBrowser
onSelect={(f) => {
setSelected(f);
setStep("confirm");
}}
/>
)}
{step === "confirm" && selected && (
<ConfirmStep
region={selected}
onBack={() => setStep("browse")}
onConfirm={handleConfirm}
/>
)}
{step === "ingest" && <ProgressStep jobId={jobId} />}
</div>
);
}

View file

@ -0,0 +1,140 @@
"use client";
import { useEffect, useState } from "react";
import type { JobSummary } from "@transportationer/shared";
const STATE_STYLES: Record<string, string> = {
active: "bg-yellow-100 text-yellow-800",
waiting: "bg-blue-100 text-blue-800",
"waiting-children": "bg-purple-100 text-purple-800",
completed: "bg-green-100 text-green-800",
failed: "bg-red-100 text-red-800",
delayed: "bg-gray-100 text-gray-600",
};
function formatDuration(ms: number | null): string {
if (ms === null) return "—";
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60_000).toFixed(1)}m`;
}
export default function JobsPage() {
const [jobs, setJobs] = useState<JobSummary[]>([]);
const [loading, setLoading] = useState(true);
const refresh = () => {
fetch("/api/admin/jobs")
.then((r) => r.json())
.then(setJobs)
.finally(() => setLoading(false));
};
useEffect(() => {
refresh();
const id = setInterval(refresh, 5000);
return () => clearInterval(id);
}, []);
const handleDelete = async (jobId: string) => {
await fetch(`/api/admin/jobs/${jobId}`, { method: "DELETE" });
refresh();
};
return (
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Job Queue</h1>
<button onClick={refresh} className="btn-secondary text-sm">
Refresh
</button>
</div>
{loading ? (
<div className="text-gray-500">Loading</div>
) : jobs.length === 0 ? (
<div className="card text-center py-12 text-gray-500">
No jobs in the queue.
</div>
) : (
<div className="card p-0 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{["ID", "Type", "City", "State", "Progress", "Duration", "Created", "Actions"].map(
(h) => (
<th
key={h}
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide"
>
{h}
</th>
),
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{jobs.map((job) => (
<tr key={job.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-mono text-xs text-gray-500">
{job.id.slice(0, 8)}
</td>
<td className="px-4 py-3 font-medium">{job.type}</td>
<td className="px-4 py-3 text-gray-600">{job.citySlug}</td>
<td className="px-4 py-3">
<span
className={`badge ${STATE_STYLES[job.state] ?? "bg-gray-100 text-gray-600"}`}
>
{job.state}
</span>
</td>
<td className="px-4 py-3">
{job.progress ? (
<div className="flex items-center gap-2">
<div className="w-20 bg-gray-200 rounded-full h-1.5">
<div
className="bg-brand-600 h-1.5 rounded-full"
style={{ width: `${job.progress.pct}%` }}
/>
</div>
<span className="text-xs text-gray-500">
{job.progress.pct}%
</span>
</div>
) : (
"—"
)}
</td>
<td className="px-4 py-3 text-gray-500">
{formatDuration(job.duration)}
</td>
<td className="px-4 py-3 text-gray-500">
{new Date(job.createdAt).toLocaleTimeString()}
</td>
<td className="px-4 py-3">
{job.state !== "active" && (
<button
onClick={() => handleDelete(job.id)}
className="text-xs text-red-500 hover:underline"
>
Remove
</button>
)}
{job.failedReason && (
<p
className="text-xs text-red-500 mt-1 max-w-xs truncate"
title={job.failedReason}
>
{job.failedReason}
</p>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,40 @@
import Link from "next/link";
import { LogoutButton } from "@/components/logout-button";
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
<Link
href="/admin"
className="font-semibold text-gray-900 hover:text-brand-600"
>
Transportationer Admin
</Link>
<Link
href="/admin"
className="text-sm text-gray-600 hover:text-gray-900"
>
Cities
</Link>
<Link
href="/admin/jobs"
className="text-sm text-gray-600 hover:text-gray-900"
>
Jobs
</Link>
<div className="ml-auto flex items-center gap-4">
<Link href="/" className="text-sm text-gray-500 hover:text-gray-700">
Public Map
</Link>
<LogoutButton />
</div>
</nav>
<main className="p-6">{children}</main>
</div>
);
}

View file

@ -0,0 +1,94 @@
"use client";
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
function LoginForm() {
const router = useRouter();
const params = useSearchParams();
const from = params.get("from") ?? "/admin";
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await fetch("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (res.ok) {
router.push(from);
} else {
const data = await res.json();
setError(data.error ?? "Login failed");
}
} catch {
setError("Network error");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
placeholder="Enter admin password"
/>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 rounded px-3 py-2">
{error}
</p>
)}
<button
type="submit"
disabled={loading || !password}
className="btn-primary w-full justify-center"
>
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
);
}
export default function LoginPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-full max-w-sm">
<div className="card">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-gray-900">Admin Login</h1>
<p className="text-sm text-gray-500 mt-1">Transportationer</p>
</div>
<Suspense fallback={<div className="text-sm text-gray-500">Loading</div>}>
<LoginForm />
</Suspense>
</div>
</div>
</div>
);
}

134
apps/web/app/admin/page.tsx Normal file
View file

@ -0,0 +1,134 @@
import Link from "next/link";
import { sql } from "@/lib/db";
type CityRow = {
slug: string;
name: string;
country_code: string;
status: string;
last_ingested: string | null;
poi_count: number;
grid_count: number;
error_message: string | null;
};
const STATUS_STYLES: Record<string, { label: string; className: string }> = {
ready: { label: "Ready", className: "bg-green-100 text-green-800" },
processing: { label: "Processing", className: "bg-yellow-100 text-yellow-800" },
error: { label: "Error", className: "bg-red-100 text-red-800" },
pending: { label: "Pending", className: "bg-blue-100 text-blue-800" },
empty: { label: "Empty", className: "bg-gray-100 text-gray-600" },
};
async function getCities(): Promise<CityRow[]> {
return Promise.resolve(sql<CityRow[]>`
SELECT
c.slug, c.name, c.country_code, c.status,
c.last_ingested, c.error_message,
COALESCE(p.poi_count, 0)::int AS poi_count,
COALESCE(g.grid_count, 0)::int AS grid_count
FROM cities c
LEFT JOIN (
SELECT city_slug, COUNT(*) AS poi_count FROM raw_pois GROUP BY city_slug
) p ON p.city_slug = c.slug
LEFT JOIN (
SELECT city_slug, COUNT(*) AS grid_count FROM grid_points GROUP BY city_slug
) g ON g.city_slug = c.slug
ORDER BY c.name
`);
}
export const dynamic = "force-dynamic";
export default async function AdminDashboard() {
const cities = await getCities();
return (
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">City Management</h1>
<p className="text-sm text-gray-500 mt-1">
{cities.length} {cities.length === 1 ? "city" : "cities"} configured
</p>
</div>
<Link href="/admin/cities/new" className="btn-primary">
+ Add City
</Link>
</div>
{cities.length === 0 ? (
<div className="card text-center py-12">
<p className="text-gray-500 mb-4">No cities configured yet.</p>
<Link href="/admin/cities/new" className="btn-primary">
Add your first city
</Link>
</div>
) : (
<div className="card p-0 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{["Name", "Country", "POIs", "Grid Points", "Last Ingested", "Status", "Actions"].map(
(h) => (
<th
key={h}
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide"
>
{h}
</th>
),
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{cities.map((city) => {
const badge = STATUS_STYLES[city.status] ?? STATUS_STYLES.empty;
return (
<tr key={city.slug} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{city.name}</td>
<td className="px-4 py-3 text-gray-500">
{city.country_code || "—"}
</td>
<td className="px-4 py-3 text-gray-700">
{city.poi_count.toLocaleString()}
</td>
<td className="px-4 py-3 text-gray-700">
{city.grid_count.toLocaleString()}
</td>
<td className="px-4 py-3 text-gray-500">
{city.last_ingested
? new Date(city.last_ingested).toLocaleDateString()
: "Never"}
</td>
<td className="px-4 py-3">
<span className={`badge ${badge.className}`}>
{badge.label}
{city.status === "processing" && (
<span className="ml-1 inline-block animate-spin"></span>
)}
</span>
{city.error_message && (
<p className="text-xs text-red-500 mt-1 max-w-xs truncate" title={city.error_message}>
{city.error_message}
</p>
)}
</td>
<td className="px-4 py-3">
<Link
href={`/admin/cities/${city.slug}`}
className="text-brand-600 hover:underline text-sm"
>
Manage
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
import { cacheDel } from "@/lib/cache";
import { getValhallaQueue } from "@/lib/queue";
export const runtime = "nodejs";
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params;
const result = await Promise.resolve(sql`
DELETE FROM cities WHERE slug = ${slug} RETURNING slug
`);
if (result.length === 0) {
return NextResponse.json({ error: "City not found" }, { status: 404 });
}
// Invalidate all caches for this city
await Promise.all([
cacheDel("api:cities:*"),
cacheDel(`api:grid:*${slug}*`),
cacheDel(`api:pois:*${slug}*`),
cacheDel(`api:stats:*${slug}*`),
]);
// Remove city from the global Valhalla routing tile set.
// The valhalla-worker will delete the city's clipped PBF and rebuild
// tiles from all remaining cities' PBFs in one pass.
const valhallaQueue = getValhallaQueue();
await valhallaQueue.add(
"build-valhalla",
{ type: "build-valhalla", removeSlugs: [slug] },
{ attempts: 1, removeOnComplete: { age: 86400 } },
);
return NextResponse.json({ deleted: slug });
}

View file

@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
import { getPipelineQueue, JOB_OPTIONS } from "@/lib/queue";
import { cacheDel } from "@/lib/cache";
export const runtime = "nodejs";
const GEOFABRIK_URL_PATTERN =
/^https:\/\/download\.geofabrik\.de\/[\w][\w/-]+-latest\.osm\.pbf$/;
export async function GET() {
const rows = await Promise.resolve(sql<{
slug: string;
name: string;
country_code: string;
geofabrik_url: string;
status: string;
last_ingested: string | null;
poi_count: number;
grid_count: number;
error_message: string | null;
}[]>`
SELECT
c.slug,
c.name,
c.country_code,
c.geofabrik_url,
c.status,
c.last_ingested,
c.error_message,
COALESCE(p.poi_count, 0)::int AS poi_count,
COALESCE(g.grid_count, 0)::int AS grid_count
FROM cities c
LEFT JOIN (
SELECT city_slug, COUNT(*) AS poi_count FROM raw_pois GROUP BY city_slug
) p ON p.city_slug = c.slug
LEFT JOIN (
SELECT city_slug, COUNT(*) AS grid_count FROM grid_points GROUP BY city_slug
) g ON g.city_slug = c.slug
ORDER BY c.name
`);
return NextResponse.json(rows);
}
export async function POST(req: NextRequest) {
let body: Record<string, unknown>;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { name, slug, countryCode, geofabrikUrl, resolutionM = 200, bbox } = body;
if (typeof slug !== "string" || !/^[a-z0-9-]+$/.test(slug)) {
return NextResponse.json(
{ error: "slug must be lowercase alphanumeric with hyphens" },
{ status: 400 },
);
}
if (typeof geofabrikUrl !== "string" || !GEOFABRIK_URL_PATTERN.test(geofabrikUrl)) {
return NextResponse.json(
{ error: "geofabrikUrl must be a valid Geofabrik PBF URL" },
{ status: 400 },
);
}
// Validate optional bbox [minLng, minLat, maxLng, maxLat]
let validBbox: [number, number, number, number] | null = null;
if (bbox !== undefined) {
if (
!Array.isArray(bbox) ||
bbox.length !== 4 ||
!bbox.every((v: unknown) => typeof v === "number" && isFinite(v))
) {
return NextResponse.json(
{ error: "bbox must be [minLng, minLat, maxLng, maxLat] with numeric values" },
{ status: 400 },
);
}
const [minLng, minLat, maxLng, maxLat] = bbox as [number, number, number, number];
if (minLng >= maxLng || minLat >= maxLat) {
return NextResponse.json(
{ error: "bbox: minLng must be < maxLng and minLat must be < maxLat" },
{ status: 400 },
);
}
validBbox = [minLng, minLat, maxLng, maxLat];
}
if (validBbox) {
const [minLng, minLat, maxLng, maxLat] = validBbox;
await Promise.resolve(sql`
INSERT INTO cities (slug, name, country_code, geofabrik_url, bbox, status)
VALUES (
${slug as string},
${(name as string) ?? slug},
${(countryCode as string) ?? ""},
${geofabrikUrl},
ST_MakeEnvelope(${minLng}, ${minLat}, ${maxLng}, ${maxLat}, 4326),
'pending'
)
ON CONFLICT (slug) DO UPDATE
SET status = 'pending',
geofabrik_url = EXCLUDED.geofabrik_url,
bbox = EXCLUDED.bbox,
error_message = NULL
`);
} else {
await Promise.resolve(sql`
INSERT INTO cities (slug, name, country_code, geofabrik_url, status)
VALUES (
${slug as string},
${(name as string) ?? slug},
${(countryCode as string) ?? ""},
${geofabrikUrl},
'pending'
)
ON CONFLICT (slug) DO UPDATE
SET status = 'pending',
geofabrik_url = EXCLUDED.geofabrik_url,
error_message = NULL
`);
}
const queue = getPipelineQueue();
const job = await queue.add(
"refresh-city",
{
type: "refresh-city",
citySlug: slug as string,
geofabrikUrl,
resolutionM: resolutionM as number,
},
JOB_OPTIONS["refresh-city"],
);
// Invalidate city list cache
await cacheDel("api:cities:*");
return NextResponse.json({ citySlug: slug, jobId: job.id }, { status: 202 });
}

View file

@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { cacheGet, cacheSet } from "@/lib/cache";
import type { GeofabrikIndex } from "@transportationer/shared";
export const runtime = "nodejs";
const GEOFABRIK_INDEX_URL = "https://download.geofabrik.de/index-v1.json";
const CACHE_KEY = "geofabrik:index";
export async function GET() {
const cached = await cacheGet<GeofabrikIndex>(CACHE_KEY);
if (cached) {
return NextResponse.json(cached, { headers: { "X-Cache": "HIT" } });
}
let data: GeofabrikIndex;
try {
const res = await fetch(GEOFABRIK_INDEX_URL, {
headers: { "User-Agent": "Transportationer/1.0 15-min-city-analyzer" },
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
return NextResponse.json(
{ error: `Geofabrik returned ${res.status}` },
{ status: 502 },
);
}
data = await res.json();
} catch (err) {
return NextResponse.json(
{ error: "Failed to fetch Geofabrik index", detail: String(err) },
{ status: 502 },
);
}
await cacheSet(CACHE_KEY, data, "GEOFABRIK_INDEX");
return NextResponse.json(data, { headers: { "X-Cache": "MISS" } });
}

View file

@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
import { getPipelineQueue, JOB_OPTIONS } from "@/lib/queue";
export const runtime = "nodejs";
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params;
const rows = await Promise.resolve(sql<{ geofabrik_url: string }[]>`
SELECT geofabrik_url FROM cities WHERE slug = ${slug}
`);
if (rows.length === 0) {
return NextResponse.json({ error: "City not found" }, { status: 404 });
}
const { geofabrik_url: geofabrikUrl } = rows[0];
// Reset status
await Promise.resolve(sql`
UPDATE cities SET status = 'pending', error_message = NULL WHERE slug = ${slug}
`);
const queue = getPipelineQueue();
const job = await queue.add(
"refresh-city",
{ type: "refresh-city", citySlug: slug, geofabrikUrl },
JOB_OPTIONS["refresh-city"],
);
return NextResponse.json({ citySlug: slug, jobId: job.id }, { status: 202 });
}

View file

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { Job } from "bullmq";
import { getPipelineQueue } from "@/lib/queue";
import type { PipelineJobData } from "@/lib/queue";
export const runtime = "nodejs";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const queue = getPipelineQueue();
const job = await Job.fromId<PipelineJobData>(queue, id);
if (!job) {
return NextResponse.json({ error: "Job not found" }, { status: 404 });
}
const state = await job.getState();
return NextResponse.json({
id: job.id,
type: job.data.type,
citySlug: job.data.citySlug,
state,
progress: job.progress ?? null,
failedReason: job.failedReason ?? null,
createdAt: job.timestamp,
finishedAt: job.finishedOn ?? null,
});
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const queue = getPipelineQueue();
const job = await Job.fromId<PipelineJobData>(queue, id);
if (!job) {
return NextResponse.json({ error: "Job not found" }, { status: 404 });
}
await job.remove();
return NextResponse.json({ removed: id });
}

View file

@ -0,0 +1,146 @@
import { NextRequest } from "next/server";
import { Job } from "bullmq";
import { getPipelineQueue, getValhallaQueue } from "@/lib/queue";
import type { PipelineJobData, JobProgress } from "@/lib/queue";
import type { SSEEvent } from "@transportationer/shared";
export const runtime = "nodejs";
function fmt(event: SSEEvent): string {
return `data: ${JSON.stringify(event)}\n\n`;
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const queue = getPipelineQueue();
const encoder = new TextEncoder();
let timer: ReturnType<typeof setInterval> | null = null;
// Resolve citySlug from the refresh-city job that was returned to the UI.
// We track progress by citySlug across all pipeline stages because
// refresh-city itself completes almost immediately after enqueueing children.
let citySlug: string;
try {
const job = await Job.fromId<PipelineJobData>(queue, id);
if (!job) {
return new Response(fmt({ type: "failed", jobId: id, error: "Job not found" }), {
headers: { "Content-Type": "text/event-stream" },
});
}
citySlug = job.data.citySlug ?? "";
} catch {
return new Response(fmt({ type: "failed", jobId: id, error: "Queue unavailable" }), {
headers: { "Content-Type": "text/event-stream" },
});
}
const valhallaQueue = getValhallaQueue();
const stream = new ReadableStream({
start(ctrl) {
const enqueue = (e: SSEEvent) => {
try {
ctrl.enqueue(encoder.encode(fmt(e)));
} catch {
/* controller already closed */
}
};
const cleanup = () => {
if (timer) { clearInterval(timer); timer = null; }
try { ctrl.close(); } catch { /* already closed */ }
};
const poll = async () => {
try {
// 1. Find the currently active stage across both queues.
const [pipelineActive, valhallaActive] = await Promise.all([
queue.getActive(0, 100),
valhallaQueue.getActive(0, 100),
]);
const activeJob = [...pipelineActive, ...valhallaActive].find(
(j) => j.data.citySlug === citySlug && j.data.type !== "refresh-city",
);
if (activeJob) {
const p = activeJob.progress as JobProgress | undefined;
if (p?.stage) {
enqueue({
type: "progress",
stage: p.stage,
pct: p.pct,
message: p.message,
bytesDownloaded: p.bytesDownloaded,
totalBytes: p.totalBytes,
});
} else {
enqueue({ type: "heartbeat" });
}
return;
}
// 2. No active stage — check for a recent failure in either queue.
const [pipelineFailed, valhallaFailed] = await Promise.all([
queue.getFailed(0, 50),
valhallaQueue.getFailed(0, 50),
]);
const recentFail = [...pipelineFailed, ...valhallaFailed].find(
(j) =>
j.data.citySlug === citySlug &&
j.data.type !== "refresh-city" &&
Date.now() - (j.finishedOn ?? 0) < 600_000,
);
if (recentFail) {
enqueue({
type: "failed",
jobId: recentFail.id ?? "",
error: recentFail.failedReason ?? "Pipeline stage failed",
});
cleanup();
return;
}
// 3. Check if compute-scores completed recently → full pipeline done.
const completed = await queue.getCompleted(0, 100);
const finalDone = completed.find(
(j) =>
j.data.citySlug === citySlug &&
j.data.type === "compute-scores" &&
Date.now() - (j.finishedOn ?? 0) < 3_600_000,
);
if (finalDone) {
enqueue({ type: "completed", jobId: finalDone.id ?? "" });
cleanup();
return;
}
// 4. Still pending — heartbeat.
enqueue({ type: "heartbeat" });
} catch {
enqueue({ type: "heartbeat" });
}
};
poll();
timer = setInterval(poll, 1000);
req.signal.addEventListener("abort", cleanup);
},
cancel() {
if (timer) clearInterval(timer);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

View file

@ -0,0 +1,70 @@
import { NextResponse } from "next/server";
import { getPipelineQueue, getValhallaQueue } from "@/lib/queue";
import type { JobSummary } from "@transportationer/shared";
export const runtime = "nodejs";
export async function GET() {
const [pQueue, vQueue] = [getPipelineQueue(), getValhallaQueue()];
const [
pWaiting, pWaitingChildren, pActive, pCompleted, pFailed,
vWaiting, vActive, vCompleted, vFailed,
] = await Promise.all([
pQueue.getWaiting(0, 20),
pQueue.getWaitingChildren(0, 20),
pQueue.getActive(0, 20),
pQueue.getCompleted(0, 20),
pQueue.getFailed(0, 20),
vQueue.getWaiting(0, 20),
vQueue.getActive(0, 20),
vQueue.getCompleted(0, 20),
vQueue.getFailed(0, 20),
]);
const waitingChildren = [...pWaitingChildren];
const waiting = [...pWaiting, ...vWaiting];
const active = [...pActive, ...vActive];
const completed = [...pCompleted, ...vCompleted];
const failed = [...pFailed, ...vFailed];
const all = [...active, ...waitingChildren, ...waiting, ...completed, ...failed];
const jobs: JobSummary[] = all.map((job) => ({
id: job.id ?? "",
type: job.data.type,
citySlug: job.data.citySlug,
state: "waiting" as JobSummary["state"], // overridden below
progress: (job.progress as any) ?? null,
failedReason: job.failedReason ?? null,
createdAt: job.timestamp,
finishedAt: job.finishedOn ?? null,
duration:
job.finishedOn && job.processedOn
? job.finishedOn - job.processedOn
: null,
}));
// Tag states
for (const job of active) {
const found = jobs.find((j) => j.id === (job.id ?? ""));
if (found) found.state = "active";
}
for (const job of waitingChildren) {
const found = jobs.find((j) => j.id === (job.id ?? ""));
if (found) found.state = "waiting-children";
}
for (const job of completed) {
const found = jobs.find((j) => j.id === (job.id ?? ""));
if (found) found.state = "completed";
}
for (const job of failed) {
const found = jobs.find((j) => j.id === (job.id ?? ""));
if (found) found.state = "failed";
}
// Sort newest first
jobs.sort((a, b) => b.createdAt - a.createdAt);
return NextResponse.json(jobs);
}

View file

@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { createSession, makeSessionCookie } from "@/lib/admin-auth";
import { checkRateLimit, verifyPassword } from "@/lib/rate-limit";
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
const ip =
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const { allowed, remaining } = await checkRateLimit(ip);
if (!allowed) {
return NextResponse.json(
{ error: "Too many login attempts. Try again later." },
{
status: 429,
headers: { "Retry-After": "900", "X-RateLimit-Remaining": "0" },
},
);
}
let password: string;
try {
const body = await req.json();
password = body.password ?? "";
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const valid = await verifyPassword(password);
if (!valid) {
return NextResponse.json(
{ error: "Invalid password", remainingAttempts: remaining },
{ status: 401 },
);
}
const token = await createSession(ip);
const secure = req.nextUrl.protocol === "https:";
const cookie = makeSessionCookie(token, secure);
const res = NextResponse.json({ ok: true });
res.headers.set("Set-Cookie", cookie);
return res;
}

View file

@ -0,0 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { destroySession, clearSessionCookie } from "@/lib/admin-auth";
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
await destroySession(req);
const res = NextResponse.json({ ok: true });
res.headers.set("Set-Cookie", clearSessionCookie());
return res;
}

View file

@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { sql } from "@/lib/db";
import { cacheGet, cacheSet } from "@/lib/cache";
import type { City } from "@transportationer/shared";
export const runtime = "nodejs";
export async function GET() {
const cacheKey = "api:cities:all";
const cached = await cacheGet<City[]>(cacheKey);
if (cached) {
return NextResponse.json(cached, { headers: { "X-Cache": "HIT" } });
}
const rows = await Promise.resolve(sql<{
slug: string;
name: string;
country_code: string;
geofabrik_url: string;
bbox_arr: number[] | null;
status: string;
last_ingested: string | null;
}[]>`
SELECT
slug,
name,
country_code,
geofabrik_url,
CASE WHEN bbox IS NOT NULL THEN ARRAY[
ST_XMin(bbox)::float,
ST_YMin(bbox)::float,
ST_XMax(bbox)::float,
ST_YMax(bbox)::float
] END AS bbox_arr,
status,
last_ingested
FROM cities
ORDER BY name
`);
const cities: City[] = rows.map((r) => ({
slug: r.slug,
name: r.name,
countryCode: r.country_code,
geofabrikUrl: r.geofabrik_url,
bbox: (r.bbox_arr as [number, number, number, number]) ?? [0, 0, 0, 0],
status: r.status as City["status"],
lastIngested: r.last_ingested,
}));
await cacheSet(cacheKey, cities, "API_CITIES");
return NextResponse.json(cities);
}

View file

@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
import { cacheGet, cacheSet, hashParams } from "@/lib/cache";
import { compositeScore } from "@/lib/scoring";
import { CATEGORY_IDS, DEFAULT_WEIGHTS } from "@transportationer/shared";
import type { GridCell, HeatmapPayload, CategoryId } from "@transportationer/shared";
export const runtime = "nodejs";
const VALID_MODES = ["walking", "cycling", "driving"];
const VALID_THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30];
export async function GET(req: NextRequest) {
const p = req.nextUrl.searchParams;
const city = p.get("city") ?? "berlin";
const mode = p.get("mode") ?? "walking";
const threshold = parseInt(p.get("threshold") ?? "15", 10);
const bboxStr = p.get("bbox");
if (!VALID_MODES.includes(mode)) {
return NextResponse.json(
{ error: "Invalid mode", code: "INVALID_MODE" },
{ status: 400 },
);
}
// Find closest pre-computed threshold
const closestThreshold =
VALID_THRESHOLDS.reduce((prev, curr) =>
Math.abs(curr - threshold) < Math.abs(prev - threshold) ? curr : prev,
);
// Parse weights
let weights: Record<CategoryId, number>;
try {
const w = p.get("weights");
weights = w ? JSON.parse(w) : DEFAULT_WEIGHTS;
// Fill missing keys with defaults
for (const cat of CATEGORY_IDS) {
if (weights[cat] === undefined) weights[cat] = DEFAULT_WEIGHTS[cat];
}
} catch {
return NextResponse.json(
{ error: "Invalid weights JSON", code: "INVALID_WEIGHTS" },
{ status: 400 },
);
}
const cacheKey = `api:grid:${hashParams({ city, mode, threshold: closestThreshold, weights, bbox: bboxStr })}`;
const cached = await cacheGet<HeatmapPayload>(cacheKey);
if (cached) {
return NextResponse.json(cached, {
headers: { "X-Cache": "HIT", "Cache-Control": "public, s-maxage=60" },
});
}
let bboxFilter = sql`TRUE`;
if (bboxStr) {
const parts = bboxStr.split(",").map(Number);
if (parts.length === 4 && !parts.some(isNaN)) {
const [minLng, minLat, maxLng, maxLat] = parts;
bboxFilter = sql`ST_Within(gp.geom, ST_MakeEnvelope(${minLng}, ${minLat}, ${maxLng}, ${maxLat}, 4326))`;
}
}
const rows = await Promise.resolve(sql<{
grid_x: number;
grid_y: number;
lng: number;
lat: number;
score_service_trade: number | null;
score_transport: number | null;
score_work_school: number | null;
score_culture_community: number | null;
score_recreation: number | null;
}[]>`
SELECT
gp.grid_x,
gp.grid_y,
ST_X(gp.geom)::float AS lng,
ST_Y(gp.geom)::float AS lat,
MAX(gs.score) FILTER (WHERE gs.category = 'service_trade') AS score_service_trade,
MAX(gs.score) FILTER (WHERE gs.category = 'transport') AS score_transport,
MAX(gs.score) FILTER (WHERE gs.category = 'work_school') AS score_work_school,
MAX(gs.score) FILTER (WHERE gs.category = 'culture_community') AS score_culture_community,
MAX(gs.score) FILTER (WHERE gs.category = 'recreation') AS score_recreation
FROM grid_points gp
JOIN grid_scores gs ON gs.grid_point_id = gp.id
WHERE gp.city_slug = ${city}
AND gs.travel_mode = ${mode}
AND gs.threshold_min = ${closestThreshold}
AND ${bboxFilter}
GROUP BY gp.grid_x, gp.grid_y, gp.geom
ORDER BY gp.grid_y, gp.grid_x
`);
const cells: GridCell[] = rows.map((r) => {
const categoryScores: Partial<Record<CategoryId, number>> = {
service_trade: r.score_service_trade ?? 0,
transport: r.score_transport ?? 0,
work_school: r.score_work_school ?? 0,
culture_community: r.score_culture_community ?? 0,
recreation: r.score_recreation ?? 0,
};
return {
gridX: r.grid_x,
gridY: r.grid_y,
lng: r.lng,
lat: r.lat,
score: compositeScore(categoryScores, weights),
categoryScores,
};
});
const payload: HeatmapPayload = {
citySlug: city,
travelMode: mode as HeatmapPayload["travelMode"],
thresholdMin: closestThreshold,
weights,
gridSpacingM: 200,
cells,
generatedAt: new Date().toISOString(),
};
await cacheSet(cacheKey, payload, "API_GRID");
return NextResponse.json(payload, {
headers: { "X-Cache": "MISS", "Cache-Control": "public, s-maxage=60" },
});
}

View file

@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
import { fetchIsochrone } from "@/lib/valhalla";
import { getValhallaQueue } from "@/lib/queue";
export const runtime = "nodejs";
const CACHE_TOLERANCE_M = 50;
export async function POST(req: NextRequest) {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json(
{ error: "Invalid JSON body", code: "INVALID_BODY" },
{ status: 400 },
);
}
const { lng, lat, travelMode, contourMinutes } = body as Record<
string,
unknown
>;
if (typeof lng !== "number" || typeof lat !== "number") {
return NextResponse.json(
{ error: "lng and lat must be numbers", code: "INVALID_COORDS" },
{ status: 400 },
);
}
const contours: number[] = Array.isArray(contourMinutes)
? (contourMinutes as number[])
: [5, 10, 15];
const mode = typeof travelMode === "string" ? travelMode : "walking";
// Check PostGIS isochrone cache
const cached = await Promise.resolve(sql<{ result: object }[]>`
SELECT result
FROM isochrone_cache
WHERE travel_mode = ${mode}
AND contours_min = ${contours}
AND ST_DWithin(
origin_geom::geography,
ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography,
${CACHE_TOLERANCE_M}
)
ORDER BY created_at DESC
LIMIT 1
`);
if (cached.length > 0) {
return NextResponse.json({ ...cached[0].result, cached: true });
}
// Refuse to call valhalla_service while tiles are being rebuilt —
// the service is stopped during the build and requests would hang or fail.
const activeValhalla = await getValhallaQueue().getActiveCount();
if (activeValhalla > 0) {
return NextResponse.json(
{ error: "Routing engine is rebuilding, please try again shortly.", code: "VALHALLA_REBUILDING" },
{ status: 503, headers: { "Retry-After": "60" } },
);
}
// Fetch from local Valhalla
let geojson: object;
try {
geojson = await fetchIsochrone({
lng,
lat,
travelMode: mode,
contourMinutes: contours,
polygons: true,
});
} catch (err) {
return NextResponse.json(
{
error: "Routing engine unavailable",
code: "VALHALLA_ERROR",
detail: err instanceof Error ? err.message : "unknown",
},
{ status: 503 },
);
}
// Store in PostGIS cache
await Promise.resolve(sql`
INSERT INTO isochrone_cache (origin_geom, travel_mode, contours_min, result)
VALUES (
ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326),
${mode},
${contours},
${JSON.stringify(geojson)}
)
`);
return NextResponse.json({ ...geojson, cached: false });
}

View file

@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
import type { CategoryId, ProfileId } from "@transportationer/shared";
import {
CATEGORY_IDS,
PROFILE_IDS,
} from "@transportationer/shared";
export const runtime = "nodejs";
const VALID_MODES = ["walking", "cycling", "driving"];
const VALID_THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30];
export async function GET(req: NextRequest) {
const p = req.nextUrl.searchParams;
const lat = parseFloat(p.get("lat") ?? "");
const lng = parseFloat(p.get("lng") ?? "");
const city = p.get("city") ?? "";
const mode = p.get("mode") ?? "walking";
const threshold = parseInt(p.get("threshold") ?? "15", 10);
const profileId = (PROFILE_IDS as readonly string[]).includes(p.get("profile") ?? "")
? (p.get("profile") as ProfileId)
: "universal" as ProfileId;
if (isNaN(lat) || isNaN(lng)) {
return NextResponse.json({ error: "Invalid lat/lng" }, { status: 400 });
}
if (!city) return NextResponse.json({ error: "Missing city" }, { status: 400 });
if (!VALID_MODES.includes(mode)) {
return NextResponse.json({ error: "Invalid mode" }, { status: 400 });
}
const closestThreshold = VALID_THRESHOLDS.reduce((prev, curr) =>
Math.abs(curr - threshold) < Math.abs(prev - threshold) ? curr : prev,
);
const rows = await Promise.resolve(sql<{
grid_point_id: string;
grid_lat: number;
grid_lng: number;
score_service_trade: number | null;
score_transport: number | null;
score_work_school: number | null;
score_culture_community: number | null;
score_recreation: number | null;
dist_service_trade: number | null;
dist_transport: number | null;
dist_work_school: number | null;
dist_culture_community: number | null;
dist_recreation: number | null;
time_service_trade: number | null;
time_transport: number | null;
time_work_school: number | null;
time_culture_community: number | null;
time_recreation: number | null;
}[]>`
WITH nearest AS (
SELECT gp.id,
gp.id::text AS grid_point_id,
ST_Y(gp.geom)::float AS grid_lat,
ST_X(gp.geom)::float AS grid_lng
FROM grid_points gp
WHERE gp.city_slug = ${city}
ORDER BY gp.geom <-> ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)
LIMIT 1
)
SELECT
n.grid_point_id,
n.grid_lat, n.grid_lng,
MAX(gs.score) FILTER (WHERE gs.category = 'service_trade') AS score_service_trade,
MAX(gs.score) FILTER (WHERE gs.category = 'transport') AS score_transport,
MAX(gs.score) FILTER (WHERE gs.category = 'work_school') AS score_work_school,
MAX(gs.score) FILTER (WHERE gs.category = 'culture_community') AS score_culture_community,
MAX(gs.score) FILTER (WHERE gs.category = 'recreation') AS score_recreation,
MAX(gs.distance_m) FILTER (WHERE gs.category = 'service_trade') AS dist_service_trade,
MAX(gs.distance_m) FILTER (WHERE gs.category = 'transport') AS dist_transport,
MAX(gs.distance_m) FILTER (WHERE gs.category = 'work_school') AS dist_work_school,
MAX(gs.distance_m) FILTER (WHERE gs.category = 'culture_community') AS dist_culture_community,
MAX(gs.distance_m) FILTER (WHERE gs.category = 'recreation') AS dist_recreation,
MAX(gs.travel_time_s) FILTER (WHERE gs.category = 'service_trade') AS time_service_trade,
MAX(gs.travel_time_s) FILTER (WHERE gs.category = 'transport') AS time_transport,
MAX(gs.travel_time_s) FILTER (WHERE gs.category = 'work_school') AS time_work_school,
MAX(gs.travel_time_s) FILTER (WHERE gs.category = 'culture_community') AS time_culture_community,
MAX(gs.travel_time_s) FILTER (WHERE gs.category = 'recreation') AS time_recreation
FROM nearest n
JOIN grid_scores gs ON gs.grid_point_id = n.id
WHERE gs.travel_mode = ${mode}
AND gs.threshold_min = ${closestThreshold}
AND gs.profile = ${profileId}
GROUP BY n.grid_point_id, n.grid_lat, n.grid_lng
`);
if (rows.length === 0) {
return NextResponse.json(
{ error: "No grid data for this location" },
{ status: 404 },
);
}
const r = rows[0];
const categoryScores: Record<CategoryId, number> = {
service_trade: r.score_service_trade ?? 0,
transport: r.score_transport ?? 0,
work_school: r.score_work_school ?? 0,
culture_community: r.score_culture_community ?? 0,
recreation: r.score_recreation ?? 0,
};
const distancesM: Partial<Record<CategoryId, number>> = {
service_trade: r.dist_service_trade ?? undefined,
transport: r.dist_transport ?? undefined,
work_school: r.dist_work_school ?? undefined,
culture_community: r.dist_culture_community ?? undefined,
recreation: r.dist_recreation ?? undefined,
};
const travelTimesS: Partial<Record<CategoryId, number>> = {
service_trade: r.time_service_trade ?? undefined,
transport: r.time_transport ?? undefined,
work_school: r.time_work_school ?? undefined,
culture_community: r.time_culture_community ?? undefined,
recreation: r.time_recreation ?? undefined,
};
// Fetch nearest POI per subcategory for this grid point and mode.
const detailRows = await Promise.resolve(sql<{
category: string;
subcategory: string;
nearest_poi_name: string | null;
distance_m: number | null;
travel_time_s: number | null;
}[]>`
SELECT category, subcategory, nearest_poi_name, distance_m, travel_time_s
FROM grid_poi_details
WHERE grid_point_id = ${r.grid_point_id}::bigint
AND travel_mode = ${mode}
ORDER BY category, distance_m
`);
type SubcategoryDetail = {
subcategory: string;
name: string | null;
distanceM: number | null;
travelTimeS: number | null;
};
const subcategoryDetails: Partial<Record<CategoryId, SubcategoryDetail[]>> = {};
for (const cat of CATEGORY_IDS) {
subcategoryDetails[cat] = [];
}
for (const row of detailRows) {
const cat = row.category as CategoryId;
if (subcategoryDetails[cat]) {
subcategoryDetails[cat]!.push({
subcategory: row.subcategory,
name: row.nearest_poi_name,
distanceM: row.distance_m,
travelTimeS: row.travel_time_s,
});
}
}
return NextResponse.json({
lat: r.grid_lat,
lng: r.grid_lng,
categoryScores,
distancesM,
travelTimesS,
subcategoryDetails,
profile: profileId,
});
}

View file

@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
import { cacheGet, cacheSet, hashParams } from "@/lib/cache";
import { CATEGORY_IDS } from "@transportationer/shared";
import type { Poi } from "@transportationer/shared";
export const runtime = "nodejs";
export async function GET(req: NextRequest) {
const p = req.nextUrl.searchParams;
const city = p.get("city") ?? "berlin";
const category = p.get("category");
const bboxStr = p.get("bbox");
const cluster = p.get("cluster") === "true";
const zoom = parseInt(p.get("zoom") ?? "12", 10);
if (category && !CATEGORY_IDS.includes(category as any)) {
return NextResponse.json(
{ error: "Invalid category", code: "INVALID_CATEGORY" },
{ status: 400 },
);
}
const cacheKey = `api:pois:${hashParams({ city, category, bbox: bboxStr, cluster, zoom })}`;
const cached = await cacheGet<Poi[]>(cacheKey);
if (cached) {
return NextResponse.json(cached, { headers: { "X-Cache": "HIT" } });
}
let bboxFilter = sql`TRUE`;
if (bboxStr) {
const parts = bboxStr.split(",").map(Number);
if (parts.length !== 4 || parts.some(isNaN)) {
return NextResponse.json(
{ error: "Invalid bbox", code: "INVALID_BBOX" },
{ status: 400 },
);
}
const [minLng, minLat, maxLng, maxLat] = parts;
bboxFilter = sql`ST_Within(p.geom, ST_MakeEnvelope(${minLng}, ${minLat}, ${maxLng}, ${maxLat}, 4326))`;
}
let catFilter = sql`TRUE`;
if (category) {
catFilter = sql`p.category = ${category}`;
}
if (cluster && zoom < 14) {
const rows = await Promise.resolve(sql<{
count: number;
lng: number;
lat: number;
category: string;
}[]>`
SELECT
COUNT(*)::int AS count,
ST_X(ST_Centroid(ST_Collect(p.geom)))::float AS lng,
ST_Y(ST_Centroid(ST_Collect(p.geom)))::float AS lat,
p.category
FROM raw_pois p
WHERE p.city_slug = ${city}
AND ${catFilter}
AND ${bboxFilter}
GROUP BY
ROUND(ST_X(p.geom)::numeric, 2),
ROUND(ST_Y(p.geom)::numeric, 2),
p.category
LIMIT 2000
`);
await cacheSet(cacheKey, rows, "API_POIS");
return NextResponse.json(rows);
}
const rows = await Promise.resolve(sql<{
osm_id: string;
osm_type: string;
category: string;
subcategory: string;
name: string | null;
lng: number;
lat: number;
}[]>`
SELECT
p.osm_id::text,
p.osm_type,
p.category,
p.subcategory,
p.name,
ST_X(p.geom)::float AS lng,
ST_Y(p.geom)::float AS lat
FROM raw_pois p
WHERE p.city_slug = ${city}
AND ${catFilter}
AND ${bboxFilter}
LIMIT 5000
`);
const pois: Poi[] = rows.map((r) => ({
osmId: r.osm_id,
osmType: r.osm_type as "N" | "W" | "R",
category: r.category as Poi["category"],
subcategory: r.subcategory,
name: r.name,
lng: r.lng,
lat: r.lat,
}));
await cacheSet(cacheKey, pois, "API_POIS");
return NextResponse.json(pois, {
headers: { "Cache-Control": "public, s-maxage=300" },
});
}

View file

@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
import { cacheGet, cacheSet, hashParams } from "@/lib/cache";
import type { CityStats } from "@transportationer/shared";
export const runtime = "nodejs";
export async function GET(req: NextRequest) {
const city = req.nextUrl.searchParams.get("city") ?? "berlin";
const mode = req.nextUrl.searchParams.get("mode") ?? "walking";
const threshold = parseInt(
req.nextUrl.searchParams.get("threshold") ?? "15",
10,
);
const profile = req.nextUrl.searchParams.get("profile") ?? "universal";
const cacheKey = `api:stats:${hashParams({ city, mode, threshold, profile })}`;
const cached = await cacheGet<CityStats>(cacheKey);
if (cached) {
return NextResponse.json(cached, { headers: { "X-Cache": "HIT" } });
}
const [poiCounts, gridCount, percentiles] = await Promise.all([
Promise.resolve(sql<{ category: string; count: number }[]>`
SELECT category, COUNT(*)::int AS count
FROM raw_pois
WHERE city_slug = ${city}
GROUP BY category
`),
Promise.resolve(sql<{ count: number }[]>`
SELECT COUNT(*)::int AS count FROM grid_points WHERE city_slug = ${city}
`),
Promise.resolve(sql<{
category: string;
p10: number; p25: number; p50: number; p75: number; p90: number;
coverage_pct: number;
}[]>`
SELECT
gs.category,
PERCENTILE_CONT(0.10) WITHIN GROUP (ORDER BY gs.score)::float AS p10,
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY gs.score)::float AS p25,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY gs.score)::float AS p50,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY gs.score)::float AS p75,
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY gs.score)::float AS p90,
(COUNT(*) FILTER (WHERE gs.score >= 0.5) * 100.0 / COUNT(*))::float AS coverage_pct
FROM grid_scores gs
JOIN grid_points gp ON gp.id = gs.grid_point_id
WHERE gp.city_slug = ${city}
AND gs.travel_mode = ${mode}
AND gs.threshold_min = ${threshold}
AND gs.profile = ${profile}
GROUP BY gs.category
`),
]);
const poisByCategory = Object.fromEntries(
poiCounts.map((r) => [r.category, r.count]),
);
const stats: CityStats = {
citySlug: city,
totalPois: Object.values(poisByCategory).reduce((a, b) => a + b, 0),
gridPointCount: gridCount[0]?.count ?? 0,
categoryStats: percentiles.map((r) => ({
category: r.category as any,
poiCount: poisByCategory[r.category] ?? 0,
p10: r.p10,
p25: r.p25,
p50: r.p50,
p75: r.p75,
p90: r.p90,
coveragePct: r.coverage_pct,
})),
};
await cacheSet(cacheKey, stats, "API_STATS");
return NextResponse.json(stats);
}

View file

@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
import { PROFILE_IDS } from "@transportationer/shared";
export const runtime = "nodejs";
const VALID_MODES = ["walking", "cycling", "driving"];
const VALID_THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30];
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ tile: string[] }> },
) {
const { tile } = await params;
if (tile.length !== 3) {
return new NextResponse("Invalid tile path", { status: 400 });
}
const z = parseInt(tile[0], 10);
const x = parseInt(tile[1], 10);
const y = parseInt(tile[2], 10);
if ([z, x, y].some(isNaN)) {
return new NextResponse("Invalid tile coordinates", { status: 400 });
}
const p = req.nextUrl.searchParams;
const city = p.get("city") ?? "";
const mode = p.get("mode") ?? "walking";
const threshold = parseInt(p.get("threshold") ?? "15", 10);
const profile = p.get("profile") ?? "universal";
if (!city) return new NextResponse("Missing city", { status: 400 });
if (!VALID_MODES.includes(mode)) return new NextResponse("Invalid mode", { status: 400 });
const validProfile = (PROFILE_IDS as readonly string[]).includes(profile) ? profile : "universal";
const closestThreshold = VALID_THRESHOLDS.reduce((prev, curr) =>
Math.abs(curr - threshold) < Math.abs(prev - threshold) ? curr : prev,
);
try {
const rows = await Promise.resolve(sql<{ mvt: Uint8Array }[]>`
WITH
envelope AS (SELECT ST_TileEnvelope(${z}, ${x}, ${y}) AS env),
city_info AS (SELECT COALESCE(resolution_m, 200) AS resolution_m FROM cities WHERE slug = ${city})
SELECT ST_AsMVT(t, 'grid', 4096, 'geom') AS mvt
FROM (
SELECT
ST_AsMVTGeom(
ST_Expand(ST_Transform(gp.geom, 3857), ci.resolution_m::float / 2),
e.env,
4096, 0, true
) AS geom,
MAX(gs.score) FILTER (WHERE gs.category = 'service_trade') AS score_service_trade,
MAX(gs.score) FILTER (WHERE gs.category = 'transport') AS score_transport,
MAX(gs.score) FILTER (WHERE gs.category = 'work_school') AS score_work_school,
MAX(gs.score) FILTER (WHERE gs.category = 'culture_community') AS score_culture_community,
MAX(gs.score) FILTER (WHERE gs.category = 'recreation') AS score_recreation
FROM grid_points gp
JOIN grid_scores gs ON gs.grid_point_id = gp.id
CROSS JOIN envelope e
CROSS JOIN city_info ci
WHERE gp.city_slug = ${city}
AND gs.travel_mode = ${mode}
AND gs.threshold_min = ${closestThreshold}
AND gs.profile = ${validProfile}
AND ST_Intersects(
ST_Transform(gp.geom, 3857),
ST_Expand(e.env, ci.resolution_m::float / 2)
)
GROUP BY gp.id, gp.geom, e.env, ci.resolution_m
) t
WHERE t.geom IS NOT NULL
`);
const buf = rows[0]?.mvt;
const data = buf ? new Uint8Array(buf) : new Uint8Array(0);
return new NextResponse(data, {
headers: {
"Content-Type": "application/x-protobuf",
"Cache-Control": "public, max-age=300",
"Access-Control-Allow-Origin": "*",
},
});
} catch (err) {
console.error("[tiles/grid] Error:", err);
return new NextResponse("Internal Server Error", { status: 500 });
}
}

26
apps/web/app/globals.css Normal file
View file

@ -0,0 +1,26 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* MapLibre GL reset */
.maplibregl-map {
font-family: inherit;
}
@layer components {
.btn-primary {
@apply inline-flex items-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors;
}
.btn-secondary {
@apply inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 disabled:opacity-50 transition-colors;
}
.btn-danger {
@apply inline-flex items-center gap-2 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 transition-colors;
}
.card {
@apply rounded-lg border border-gray-200 bg-white p-6 shadow-sm;
}
.badge {
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
}
}

20
apps/web/app/layout.tsx Normal file
View file

@ -0,0 +1,20 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Transportationer — 15-Minute City Analyzer",
description:
"Analyze urban accessibility across service, transport, work, culture, and recreation categories.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="bg-gray-50 text-gray-900 antialiased">{children}</body>
</html>
);
}

249
apps/web/app/page.tsx Normal file
View file

@ -0,0 +1,249 @@
"use client";
import { useState, useEffect } from "react";
import dynamic from "next/dynamic";
import type {
City,
CityStats,
CategoryId,
TravelMode,
ProfileId,
} from "@transportationer/shared";
import { PROFILES } from "@transportationer/shared";
import { ControlPanel } from "@/components/control-panel";
import { StatsBar } from "@/components/stats-bar";
import { CitySelector } from "@/components/city-selector";
import {
LocationScorePanel,
type LocationScoreData,
type OverlayMode,
} from "@/components/location-score-panel";
import { MapLegend } from "@/components/map-legend";
const MapView = dynamic(
() => import("@/components/map-view").then((m) => m.MapView),
{ ssr: false, loading: () => <div className="flex-1 bg-gray-200 animate-pulse" /> },
);
/** Compute 3 evenly-spaced contour values up to the threshold (deduped, min 1). */
function isochroneContours(threshold: number): number[] {
const raw = [
Math.max(1, Math.round(threshold / 3)),
Math.max(2, Math.round((threshold * 2) / 3)),
threshold,
];
return [...new Set(raw)];
}
export default function HomePage() {
const [cities, setCities] = useState<City[]>([]);
const [selectedCity, setSelectedCity] = useState<string | null>(null);
const [profile, setProfile] = useState<ProfileId>("universal");
const [mode, setMode] = useState<TravelMode>("walking");
const [threshold, setThreshold] = useState(15);
const [weights, setWeights] = useState({ ...PROFILES["universal"].categoryWeights });
const [activeCategory, setActiveCategory] = useState<CategoryId | "composite">("composite");
const [stats, setStats] = useState<CityStats | null>(null);
// Pin / location rating
const [pinLocation, setPinLocation] = useState<{ lat: number; lng: number } | null>(null);
const [pinData, setPinData] = useState<LocationScoreData | null>(null);
const [pinAddress, setPinAddress] = useState<string | undefined>(undefined);
// Overlay mode: isochrone (new default) or relative heatmap
const [overlayMode, setOverlayMode] = useState<OverlayMode>("isochrone");
const [isochroneData, setIsochroneData] = useState<object | null>(null);
const [isochroneLoading, setIsochroneLoading] = useState(false);
// Load city list
useEffect(() => {
fetch("/api/cities")
.then((r) => r.json())
.then((data: City[]) => {
setCities(data);
const firstReady = data.find((c) => c.status === "ready");
if (firstReady) setSelectedCity(firstReady.slug);
})
.catch(console.error);
}, []);
// Load stats when city/mode/threshold change
useEffect(() => {
if (!selectedCity) return;
const params = new URLSearchParams({ city: selectedCity, mode, threshold: String(threshold), profile });
fetch(`/api/stats?${params}`)
.then((r) => r.json())
.then(setStats)
.catch(console.error);
}, [selectedCity, mode, threshold, profile]);
// Fetch location score + reverse geocode when pin changes
useEffect(() => {
if (!pinLocation || !selectedCity) return;
const params = new URLSearchParams({
lat: String(pinLocation.lat),
lng: String(pinLocation.lng),
city: selectedCity,
mode,
threshold: String(threshold),
profile,
});
Promise.all([
fetch(`/api/location-score?${params}`).then((r) => r.json()),
fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${pinLocation.lat}&lon=${pinLocation.lng}&format=json`,
{ headers: { "Accept-Language": "en" } },
)
.then((r) => r.json())
.then((d) => d.display_name as string)
.catch(() => undefined),
])
.then(([scoreData, address]) => {
if (scoreData?.error) return;
setPinData(scoreData as LocationScoreData);
setPinAddress(address);
})
.catch(console.error);
}, [pinLocation, selectedCity, mode, threshold, profile]);
// Fetch isochrone when in isochrone mode with an active pin
useEffect(() => {
if (!pinLocation || overlayMode !== "isochrone") {
setIsochroneData(null);
return;
}
setIsochroneLoading(true);
setIsochroneData(null);
fetch("/api/isochrones", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
lng: pinLocation.lng,
lat: pinLocation.lat,
travelMode: mode,
contourMinutes: isochroneContours(threshold),
}),
})
.then((r) => r.json())
.then((data) => {
// Valhalla may return 200 OK with error_code (not error) for unroutable locations.
// Only accept valid FeatureCollections.
if (!data.error && !data.error_code && Array.isArray(data.features)) {
setIsochroneData(data);
}
})
.catch(console.error)
.finally(() => setIsochroneLoading(false));
}, [pinLocation, overlayMode, mode, threshold]);
function handleProfileChange(newProfile: ProfileId) {
setProfile(newProfile);
setWeights({ ...PROFILES[newProfile].categoryWeights });
// Clear pin when profile changes so scores are re-fetched with new profile
setPinLocation(null);
setPinData(null);
setPinAddress(undefined);
setIsochroneData(null);
}
function handleLocationClick(lat: number, lng: number) {
setPinLocation({ lat, lng });
setPinData(null);
setPinAddress(undefined);
setIsochroneData(null);
}
function handlePinClose() {
setPinLocation(null);
setPinData(null);
setPinAddress(undefined);
setIsochroneData(null);
}
const selectedCityData = cities.find((c) => c.slug === selectedCity);
return (
<div className="flex flex-col h-screen overflow-hidden">
<header className="bg-white border-b border-gray-200 px-4 py-2.5 flex items-center gap-4 shrink-0 z-10">
<h1 className="font-bold text-gray-900 hidden sm:block">Transportationer</h1>
<div className="w-px h-5 bg-gray-200 hidden sm:block" />
<CitySelector cities={cities} selected={selectedCity} onSelect={setSelectedCity} />
<div className="ml-auto">
<a href="/admin" className="text-xs text-gray-400 hover:text-gray-600">Admin</a>
</div>
</header>
<div className="flex flex-1 overflow-hidden">
<ControlPanel
profile={profile}
mode={mode}
threshold={threshold}
weights={weights}
activeCategory={activeCategory}
onProfileChange={handleProfileChange}
onModeChange={setMode}
onThresholdChange={setThreshold}
onWeightChange={(cat, w) => setWeights((prev) => ({ ...prev, [cat]: w }))}
onCategoryChange={setActiveCategory}
/>
<div className="flex-1 relative">
{!selectedCity ? (
<div className="flex items-center justify-center h-full text-gray-400">
<div className="text-center">
<p className="text-xl mb-2">Select a city to begin</p>
<p className="text-sm">
Or{" "}
<a href="/admin/cities/new" className="text-brand-600 underline">
add a new city
</a>{" "}
in the admin area.
</p>
</div>
</div>
) : (
<MapView
citySlug={selectedCity}
cityBbox={selectedCityData?.bbox ?? [-180, -90, 180, 90]}
profile={profile}
mode={mode}
threshold={threshold}
activeCategory={activeCategory}
weights={weights}
pinLocation={pinLocation}
pinCategoryScores={
overlayMode === "relative" ? (pinData?.categoryScores ?? null) : null
}
isochrones={overlayMode === "isochrone" ? isochroneData : null}
onLocationClick={handleLocationClick}
/>
)}
<MapLegend
overlayMode={overlayMode}
threshold={threshold}
hasPinData={!!pinData}
/>
{pinData && (
<LocationScorePanel
data={pinData}
weights={weights}
address={pinAddress}
overlayMode={overlayMode}
isochroneLoading={isochroneLoading}
onOverlayModeChange={setOverlayMode}
onClose={handlePinClose}
/>
)}
</div>
</div>
<StatsBar stats={stats} activeCategory={activeCategory} />
</div>
);
}

View file

@ -0,0 +1,39 @@
"use client";
import type { City } from "@transportationer/shared";
interface CitySelectorProps {
cities: City[];
selected: string | null;
onSelect: (slug: string) => void;
}
export function CitySelector({ cities, selected, onSelect }: CitySelectorProps) {
const ready = cities.filter((c) => c.status === "ready");
if (ready.length === 0) {
return (
<div className="flex items-center gap-3 px-4 py-2 bg-yellow-50 border border-yellow-200 rounded-md text-sm text-yellow-800">
<span>No cities available.</span>
<a href="/admin/cities/new" className="underline font-medium">
Add one in Admin
</a>
</div>
);
}
return (
<select
value={selected ?? ""}
onChange={(e) => onSelect(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 text-sm bg-white focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
>
{!selected && <option value="">Select a city</option>}
{ready.map((c) => (
<option key={c.slug} value={c.slug}>
{c.name}
</option>
))}
</select>
);
}

View file

@ -0,0 +1,215 @@
"use client";
import { CATEGORIES, PROFILES, PROFILE_IDS } from "@transportationer/shared";
import type { CategoryId, TravelMode, ProfileId } from "@transportationer/shared";
const TRAVEL_MODES: Array<{ value: TravelMode; label: string; icon: string }> =
[
{ value: "walking", label: "Walking", icon: "🚶" },
{ value: "cycling", label: "Cycling", icon: "🚲" },
{ value: "driving", label: "Driving", icon: "🚗" },
];
const THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30];
interface ControlPanelProps {
profile: ProfileId;
mode: TravelMode;
threshold: number;
weights: Record<CategoryId, number>;
activeCategory: CategoryId | "composite";
onProfileChange: (p: ProfileId) => void;
onModeChange: (m: TravelMode) => void;
onThresholdChange: (t: number) => void;
onWeightChange: (cat: CategoryId, w: number) => void;
onCategoryChange: (cat: CategoryId | "composite") => void;
}
export function ControlPanel({
profile,
mode,
threshold,
weights,
activeCategory,
onProfileChange,
onModeChange,
onThresholdChange,
onWeightChange,
onCategoryChange,
}: ControlPanelProps) {
return (
<aside className="w-72 shrink-0 bg-white border-r border-gray-200 flex flex-col overflow-y-auto">
<div className="p-4 border-b border-gray-100">
<h1 className="font-bold text-gray-900 text-sm">
15-Minute City Analyzer
</h1>
<p className="text-xs text-gray-500 mt-0.5">
Transportationer
</p>
</div>
{/* Resident profile */}
<div className="p-4 border-b border-gray-100">
<p className="text-xs font-medium text-gray-600 mb-2 uppercase tracking-wide">
Resident Profile
</p>
<div className="grid grid-cols-1 gap-1">
{PROFILE_IDS.map((pid) => {
const p = PROFILES[pid];
return (
<button
key={pid}
onClick={() => onProfileChange(pid)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-xs transition-colors text-left ${
profile === pid
? "bg-gray-900 text-white font-medium"
: "text-gray-600 hover:bg-gray-100"
}`}
>
<span>{p.emoji}</span>
<span className="font-medium">{p.label}</span>
<span className={`ml-auto text-[10px] truncate max-w-[110px] ${profile === pid ? "text-gray-300" : "text-gray-400"}`}>
{p.description}
</span>
</button>
);
})}
</div>
</div>
{/* Travel mode */}
<div className="p-4 border-b border-gray-100">
<p className="text-xs font-medium text-gray-600 mb-2 uppercase tracking-wide">
Travel Mode
</p>
<div className="flex gap-1">
{TRAVEL_MODES.map((m) => (
<button
key={m.value}
onClick={() => onModeChange(m.value)}
className={`flex-1 flex flex-col items-center gap-1 py-2 rounded-md text-xs border transition-colors ${
mode === m.value
? "border-brand-500 bg-brand-50 text-brand-700 font-medium"
: "border-gray-200 text-gray-600 hover:border-gray-300"
}`}
>
<span className="text-lg">{m.icon}</span>
{m.label}
</button>
))}
</div>
</div>
{/* Threshold */}
<div className="p-4 border-b border-gray-100">
<p className="text-xs font-medium text-gray-600 mb-2 uppercase tracking-wide">
Target Threshold
</p>
<div className="grid grid-cols-4 gap-1">
{THRESHOLDS.map((t) => (
<button
key={t}
onClick={() => onThresholdChange(t)}
className={`py-1.5 rounded text-xs font-medium transition-colors ${
threshold === t
? "bg-brand-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{t}m
</button>
))}
</div>
</div>
{/* Active heatmap */}
<div className="p-4 border-b border-gray-100">
<p className="text-xs font-medium text-gray-600 mb-2 uppercase tracking-wide">
Display Layer
</p>
<div className="space-y-1">
<button
onClick={() => onCategoryChange("composite")}
className={`w-full text-left px-3 py-2 rounded-md text-sm flex items-center gap-2 transition-colors ${
activeCategory === "composite"
? "bg-gray-900 text-white"
: "text-gray-700 hover:bg-gray-100"
}`}
>
<span
className="w-2.5 h-2.5 rounded-full bg-gradient-to-r from-red-400 to-green-400 shrink-0"
/>
Composite Score
</button>
{CATEGORIES.map((cat) => (
<button
key={cat.id}
onClick={() => onCategoryChange(cat.id)}
className={`w-full text-left px-3 py-2 rounded-md text-sm flex items-center gap-2 transition-colors ${
activeCategory === cat.id
? "bg-gray-100 text-gray-900 font-medium"
: "text-gray-600 hover:bg-gray-50"
}`}
>
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: cat.color }}
/>
{cat.label}
</button>
))}
</div>
</div>
{/* Category weights */}
<div className="p-4 flex-1">
<p className="text-xs font-medium text-gray-600 mb-3 uppercase tracking-wide">
Category Weights
</p>
<div className="space-y-3">
{CATEGORIES.map((cat) => (
<div key={cat.id}>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-gray-600 flex items-center gap-1.5">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: cat.color }}
/>
{cat.label}
</label>
<span className="text-xs font-medium text-gray-700 w-6 text-right">
{weights[cat.id].toFixed(1)}
</span>
</div>
<input
type="range"
min="0"
max="2"
step="0.1"
value={weights[cat.id]}
onChange={(e) =>
onWeightChange(cat.id, parseFloat(e.target.value))
}
className="w-full accent-brand-600"
style={{ accentColor: cat.color }}
/>
</div>
))}
</div>
</div>
{/* Legend */}
<div className="p-4 border-t border-gray-100">
<p className="text-xs font-medium text-gray-600 mb-2">Score Legend</p>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Low</span>
<div className="flex-1 h-3 rounded-full bg-gradient-to-r from-red-400 via-yellow-400 to-green-500" />
<span className="text-xs text-gray-500">High</span>
</div>
<p className="text-xs text-gray-400 mt-1 text-center">
Midpoint = {threshold} min threshold
</p>
</div>
</aside>
);
}

View file

@ -0,0 +1,246 @@
"use client";
import { useState } from "react";
import type { CategoryId } from "@transportationer/shared";
import { CATEGORIES } from "@transportationer/shared";
type Weights = Record<CategoryId, number>;
export type OverlayMode = "isochrone" | "relative";
export interface SubcategoryDetail {
subcategory: string;
name: string | null;
distanceM: number | null;
travelTimeS: number | null;
}
export interface LocationScoreData {
lat: number;
lng: number;
categoryScores: Record<CategoryId, number>;
distancesM: Partial<Record<CategoryId, number>>;
travelTimesS: Partial<Record<CategoryId, number>>;
subcategoryDetails?: Partial<Record<CategoryId, SubcategoryDetail[]>>;
}
const SUBCATEGORY_LABELS: Record<string, string> = {
// service_trade
supermarket: "Supermarket",
pharmacy: "Pharmacy",
convenience: "Convenience store",
restaurant: "Restaurant",
cafe: "Café",
bank: "Bank",
atm: "ATM",
market: "Market",
laundry: "Laundry",
post_office: "Post office",
// transport
train_station: "Train station",
metro: "Metro",
tram_stop: "Tram stop",
bus_stop: "Bus stop",
stop: "Transit stop",
ferry: "Ferry",
bike_share: "Bike share",
car_share: "Car share",
// work_school
school: "School",
driving_school: "Driving school",
kindergarten: "Kindergarten",
university: "University",
coworking: "Coworking",
office: "Office",
// culture_community
hospital: "Hospital",
clinic: "Clinic",
library: "Library",
community_center: "Community center",
social_services: "Social services",
theatre: "Theatre",
place_of_worship: "Place of worship",
government: "Government",
museum: "Museum",
// recreation
park: "Park",
playground: "Playground",
sports_facility: "Sports facility",
gym: "Gym",
green_space: "Green space",
swimming_pool: "Swimming pool",
};
export function compositeScore(
scores: Record<CategoryId, number>,
weights: Weights,
): number {
const entries = Object.entries(weights) as [CategoryId, number][];
const total = entries.reduce((s, [, w]) => s + w, 0);
if (total === 0) return 0;
return entries.reduce((s, [cat, w]) => s + (scores[cat] ?? 0) * w, 0) / total;
}
function grade(score: number): string {
if (score >= 0.8) return "A";
if (score >= 0.65) return "B";
if (score >= 0.5) return "C";
if (score >= 0.35) return "D";
return "F";
}
function gradeColor(g: string): string {
const map: Record<string, string> = {
A: "text-green-600",
B: "text-green-500",
C: "text-yellow-500",
D: "text-orange-500",
F: "text-red-600",
};
return map[g] ?? "text-gray-600";
}
function formatDist(m: number): string {
return m >= 1000 ? `${(m / 1000).toFixed(1)} km` : `${Math.round(m)} m`;
}
function formatTime(s: number): string {
const min = Math.round(s / 60);
return min < 1 ? "<1 min" : `${min} min`;
}
export function LocationScorePanel({
data,
weights,
address,
overlayMode,
isochroneLoading,
onOverlayModeChange,
onClose,
}: {
data: LocationScoreData;
weights: Weights;
address?: string;
overlayMode: OverlayMode;
isochroneLoading: boolean;
onOverlayModeChange: (mode: OverlayMode) => void;
onClose: () => void;
}) {
const [expandedCategory, setExpandedCategory] = useState<CategoryId | null>(null);
const composite = compositeScore(data.categoryScores, weights);
const g = grade(composite);
return (
<div className="absolute bottom-8 right-4 z-20 bg-white rounded-xl shadow-lg border border-gray-100 w-72 p-4">
{/* Header: grade + address + close */}
<div className="flex items-start justify-between mb-3">
<div>
<div className={`text-5xl font-bold leading-none ${gradeColor(g)}`}>{g}</div>
<div className="text-xs text-gray-400 mt-1">{Math.round(composite * 100)} / 100</div>
{address && (
<div className="text-xs text-gray-400 mt-1 truncate max-w-[200px]" title={address}>
{address}
</div>
)}
</div>
<button
onClick={onClose}
className="text-gray-300 hover:text-gray-500 text-xl leading-none mt-0.5"
aria-label="Close"
>
×
</button>
</div>
{/* Overlay mode toggle */}
<div className="flex rounded-lg border border-gray-200 overflow-hidden text-xs mb-3.5">
<button
className={`flex-1 py-1.5 transition-colors ${
overlayMode === "isochrone"
? "bg-blue-600 text-white font-medium"
: "text-gray-500 hover:bg-gray-50"
}`}
onClick={() => onOverlayModeChange("isochrone")}
>
{isochroneLoading && overlayMode === "isochrone" ? "Loading…" : "Isochrone"}
</button>
<button
className={`flex-1 py-1.5 transition-colors ${
overlayMode === "relative"
? "bg-blue-600 text-white font-medium"
: "text-gray-500 hover:bg-gray-50"
}`}
onClick={() => onOverlayModeChange("relative")}
>
Relative
</button>
</div>
{/* Per-category score bars */}
<div className="space-y-2.5">
{CATEGORIES.map((cat) => {
const score = data.categoryScores[cat.id] ?? 0;
const dist = data.distancesM[cat.id];
const time = data.travelTimesS[cat.id];
const barColor =
score >= 0.65 ? "#22c55e" : score >= 0.4 ? "#eab308" : "#ef4444";
const subcats = data.subcategoryDetails?.[cat.id];
const isExpanded = expandedCategory === cat.id;
const hasDetails = subcats && subcats.length > 0;
return (
<div key={cat.id}>
<button
className="w-full text-left"
onClick={() => setExpandedCategory(isExpanded ? null : cat.id)}
disabled={!hasDetails}
>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-600 font-medium flex items-center gap-1">
{cat.label}
{hasDetails && (
<span className="text-gray-300 text-[9px]">{isExpanded ? "▲" : "▼"}</span>
)}
</span>
<span className="text-gray-400 text-[10px]">
{dist != null && formatDist(dist)}
{dist != null && time != null && " · "}
{time != null && formatTime(time)}
</span>
</div>
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{ width: `${Math.round(score * 100)}%`, backgroundColor: barColor }}
/>
</div>
</button>
{isExpanded && hasDetails && (
<div className="mt-1.5 space-y-0.5 pl-1">
{subcats.map((d) => (
<div
key={d.subcategory}
className="flex items-center justify-between text-[10px] text-gray-500 py-0.5"
>
<span className="truncate max-w-[130px]" title={d.name ?? undefined}>
<span className="text-gray-400 mr-1">
{SUBCATEGORY_LABELS[d.subcategory] ?? d.subcategory}
</span>
{d.name && (
<span className="text-gray-600 font-medium">{d.name}</span>
)}
</span>
<span className="text-gray-400 shrink-0 ml-1">
{d.distanceM != null && formatDist(d.distanceM)}
{d.distanceM != null && d.travelTimeS != null && " · "}
{d.travelTimeS != null && formatTime(d.travelTimeS)}
</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,18 @@
"use client";
export function LogoutButton() {
const handleLogout = async () => {
await fetch("/api/admin/logout", { method: "POST" });
window.location.href = "/admin/login";
};
return (
<button
type="button"
onClick={handleLogout}
className="text-sm text-gray-500 hover:text-gray-700"
>
Logout
</button>
);
}

View file

@ -0,0 +1,84 @@
"use client";
import type { OverlayMode } from "./location-score-panel";
interface MapLegendProps {
overlayMode: OverlayMode;
threshold: number;
hasPinData: boolean;
}
// Shared score ramp stops — matches makeColorExpr in map-view.tsx
const SCORE_STOPS: [number, string][] = [
[0, "#d73027"],
[0.25, "#fc8d59"],
[0.5, "#fee08b"],
[0.75, "#d9ef8b"],
[1, "#1a9850"],
];
function gradientCss(stops: [number, string][]): string {
return `linear-gradient(to right, ${stops.map(([p, c]) => `${c} ${p * 100}%`).join(", ")})`;
}
export function MapLegend({ overlayMode, threshold, hasPinData }: MapLegendProps) {
if (overlayMode === "isochrone" && hasPinData) {
// Travel-time legend: green (near) → red (far)
const stops: [number, string][] = [
[0, "#1a9850"],
[0.5, "#fee08b"],
[1, "#d73027"],
];
return (
<div className="absolute bottom-8 left-4 z-20 bg-white/90 backdrop-blur-sm rounded-lg shadow border border-gray-100 px-3 py-2 text-xs text-gray-600">
<div className="font-medium mb-1.5">Travel time</div>
<div className="flex items-center gap-2">
<span className="shrink-0">0 min</span>
<div
className="h-2.5 w-28 rounded-full"
style={{ background: gradientCss(stops) }}
/>
<span className="shrink-0">{threshold} min</span>
</div>
</div>
);
}
if (overlayMode === "relative" && hasPinData) {
const stops: [number, string][] = [
[0, "#d73027"],
[0.25, "#fc8d59"],
[0.5, "#ffffbf"],
[0.75, "#91cf60"],
[1, "#1a9850"],
];
return (
<div className="absolute bottom-8 left-4 z-20 bg-white/90 backdrop-blur-sm rounded-lg shadow border border-gray-100 px-3 py-2 text-xs text-gray-600">
<div className="font-medium mb-1.5">vs. pin</div>
<div className="flex items-center gap-2">
<span className="shrink-0">Worse</span>
<div
className="h-2.5 w-28 rounded-full"
style={{ background: gradientCss(stops) }}
/>
<span className="shrink-0">Better</span>
</div>
</div>
);
}
// Default: absolute accessibility score
return (
<div className="absolute bottom-8 left-4 z-20 bg-white/90 backdrop-blur-sm rounded-lg shadow border border-gray-100 px-3 py-2 text-xs text-gray-600">
<div className="font-medium mb-1.5">Accessibility</div>
<div className="flex items-center gap-2">
<span className="shrink-0">Low</span>
<div
className="h-2.5 w-28 rounded-full"
style={{ background: gradientCss(SCORE_STOPS) }}
/>
<span className="shrink-0">High</span>
</div>
</div>
);
}

View file

@ -0,0 +1,286 @@
"use client";
import { useEffect, useRef } from "react";
import type { CategoryId, TravelMode, ProfileId } from "@transportationer/shared";
type Weights = Record<CategoryId, number>;
export interface MapViewProps {
citySlug: string;
cityBbox: [number, number, number, number];
profile: ProfileId;
mode: TravelMode;
threshold: number;
activeCategory: CategoryId | "composite";
weights: Weights;
pinLocation?: { lat: number; lng: number } | null;
/** Set in relative mode: each grid cell is colored vs. this reference score. */
pinCategoryScores?: Record<CategoryId, number> | null;
/** Set in isochrone mode: GeoJSON FeatureCollection from Valhalla. */
isochrones?: object | null;
onLocationClick?: (lat: number, lng: number) => void;
}
// Red → yellow → green score ramp
const SCORE_RAMP = [0, "#d73027", 0.25, "#fc8d59", 0.5, "#fee08b", 0.75, "#d9ef8b", 1, "#1a9850"];
function makeColorExpr(cat: CategoryId | "composite", weights: Weights): unknown[] {
const scoreExpr =
cat === "composite"
? makeCompositeExpr(weights)
: ["coalesce", ["get", `score_${cat}`], 0];
return ["interpolate", ["linear"], scoreExpr, ...SCORE_RAMP];
}
function makeCompositeExpr(weights: Weights): unknown[] {
const entries = Object.entries(weights) as [CategoryId, number][];
const total = entries.reduce((s, [, w]) => s + w, 0);
if (total === 0) return [0];
const terms = entries
.filter(([, w]) => w > 0)
.map(([cat, w]) => ["*", ["coalesce", ["get", `score_${cat}`], 0], w]);
if (terms.length === 0) return [0];
const sumExpr = terms.length === 1 ? terms[0] : ["+", ...terms];
return ["/", sumExpr, total];
}
function makeRelativeColorExpr(
cat: CategoryId | "composite",
weights: Weights,
pinCategoryScores: Record<CategoryId, number>,
): unknown[] {
const scoreExpr =
cat === "composite"
? makeCompositeExpr(weights)
: ["coalesce", ["get", `score_${cat}`], 0];
let pinScore: number;
if (cat === "composite") {
const entries = Object.entries(weights) as [CategoryId, number][];
const total = entries.reduce((s, [, w]) => s + w, 0);
pinScore =
total === 0
? 0
: entries.reduce((s, [c, w]) => s + (pinCategoryScores[c] ?? 0) * w, 0) / total;
} else {
pinScore = pinCategoryScores[cat] ?? 0;
}
// Diverging: negative = worse than pin (red), positive = better (green)
return [
"interpolate", ["linear"], ["-", scoreExpr, pinScore],
-0.5, "#d73027",
-0.15, "#fc8d59",
0, "#ffffbf",
0.15, "#91cf60",
0.5, "#1a9850",
];
}
function tileUrl(city: string, mode: string, threshold: number, profile: string) {
const origin = typeof window !== "undefined" ? window.location.origin : "";
return `${origin}/api/tiles/grid/{z}/{x}/{y}?city=${encodeURIComponent(city)}&mode=${mode}&threshold=${threshold}&profile=${profile}`;
}
/** Remove isochrone layer/source if they exist. */
function removeIsochroneLayers(map: import("maplibre-gl").Map) {
if (map.getLayer("isochrone-fill")) map.removeLayer("isochrone-fill");
if (map.getSource("isochrone")) map.removeSource("isochrone");
}
export function MapView({
citySlug,
cityBbox,
profile,
mode,
threshold,
activeCategory,
weights,
pinLocation,
pinCategoryScores,
isochrones,
onLocationClick,
}: MapViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<import("maplibre-gl").Map | null>(null);
const markerRef = useRef<import("maplibre-gl").Marker | null>(null);
const mountedRef = useRef(false);
const stateRef = useRef({
citySlug, profile, mode, threshold, activeCategory, weights, onLocationClick,
});
stateRef.current = { citySlug, profile, mode, threshold, activeCategory, weights, onLocationClick };
// Update heatmap paint when category, weights, or pin scores change
useEffect(() => {
const map = mapRef.current;
if (!map?.isStyleLoaded() || !map.getLayer("grid-fill")) return;
const colorExpr = pinCategoryScores
? makeRelativeColorExpr(activeCategory, weights, pinCategoryScores)
: makeColorExpr(activeCategory, weights);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map.setPaintProperty("grid-fill", "fill-color", colorExpr as any);
}, [activeCategory, weights, pinCategoryScores]);
// Update tile source when city/mode/threshold/profile change
useEffect(() => {
const map = mapRef.current;
if (!map?.isStyleLoaded()) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const src = map.getSource("grid-tiles") as any;
if (src?.setTiles) src.setTiles([tileUrl(citySlug, mode, threshold, profile)]);
}, [citySlug, mode, threshold, profile]);
// Add/remove pin marker when pin location changes
useEffect(() => {
const map = mapRef.current;
if (!map) return;
markerRef.current?.remove();
markerRef.current = null;
if (pinLocation) {
import("maplibre-gl").then(({ Marker }) => {
const marker = new Marker({ color: "#2563eb" })
.setLngLat([pinLocation.lng, pinLocation.lat])
.addTo(map);
markerRef.current = marker;
});
}
}, [pinLocation]);
// Add/remove isochrone layer when isochrones data changes.
// The grid-fill layer is hidden while isochrones are shown so only one
// overlay is visible at a time.
useEffect(() => {
const map = mapRef.current;
if (!map?.isStyleLoaded()) return;
removeIsochroneLayers(map);
if (!isochrones) {
// Restore grid when leaving isochrone mode.
if (map.getLayer("grid-fill")) {
map.setLayoutProperty("grid-fill", "visibility", "visible");
}
return;
}
// Hide the grid heatmap — the isochrone replaces it visually.
if (map.getLayer("grid-fill")) {
map.setLayoutProperty("grid-fill", "visibility", "none");
}
// Sort largest contour first — smaller (inner, more accessible) polygons
// are drawn on top, so each pixel shows the color of the smallest contour
// that covers it (i.e. the fastest reachable zone wins visually).
const geojson = isochrones as { type: string; features: { properties: { contour: number } }[] };
if (!Array.isArray(geojson.features) || geojson.features.length === 0) {
// Malformed response (e.g. Valhalla error body with no features) — restore grid.
if (map.getLayer("grid-fill")) map.setLayoutProperty("grid-fill", "visibility", "visible");
return;
}
const contourValues = geojson.features.map((f) => f.properties.contour);
const maxContour = Math.max(...contourValues);
const sorted = {
...geojson,
features: [...geojson.features].sort(
(a, b) => b.properties.contour - a.properties.contour,
),
};
try {
map.addSource("isochrone", { type: "geojson", data: sorted as never });
// Color each zone using the same green→red ramp:
// small contour (close) = green, large contour (far) = red.
map.addLayer({
id: "isochrone-fill",
type: "fill",
source: "isochrone",
paint: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
"fill-color": ["interpolate", ["linear"], ["get", "contour"],
0, "#1a9850",
maxContour * 0.5, "#fee08b",
maxContour, "#d73027",
] as any,
"fill-opacity": 0.65,
"fill-outline-color": "rgba(0,0,0,0.15)",
},
});
} catch (err) {
console.warn("[map-view] Error adding isochrone layer:", err);
}
return () => {
const m = mapRef.current;
if (!m?.isStyleLoaded()) return;
removeIsochroneLayers(m);
if (m.getLayer("grid-fill")) {
m.setLayoutProperty("grid-fill", "visibility", "visible");
}
};
}, [isochrones]);
// Initialize map once on mount
useEffect(() => {
if (mountedRef.current || !containerRef.current) return;
mountedRef.current = true;
(async () => {
const mgl = await import("maplibre-gl");
const { Protocol } = await import("pmtiles");
const protocol = new Protocol();
mgl.addProtocol("pmtiles", protocol.tile);
const map = new mgl.Map({
container: containerRef.current!,
style: "/tiles/style.json",
bounds: cityBbox,
fitBoundsOptions: { padding: 40 },
});
mapRef.current = map;
map.on("load", () => {
const { citySlug: city, profile: prof, mode: m, threshold: t, activeCategory: cat, weights: w } = stateRef.current;
map.addSource("grid-tiles", {
type: "vector",
tiles: [tileUrl(city, m, t, prof)],
minzoom: 0,
maxzoom: 16,
});
map.addLayer({
id: "grid-fill",
type: "fill",
source: "grid-tiles",
"source-layer": "grid",
paint: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
"fill-color": makeColorExpr(cat, w) as any,
"fill-opacity": 0.8,
"fill-outline-color": "rgba(0,0,0,0.06)",
},
});
map.on("click", (e) => {
stateRef.current.onLocationClick?.(e.lngLat.lat, e.lngLat.lng);
});
map.getCanvas().style.cursor = "crosshair";
});
})();
return () => {
markerRef.current?.remove();
markerRef.current = null;
mapRef.current?.remove();
mapRef.current = null;
mountedRef.current = false;
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return <div ref={containerRef} className="w-full h-full" />;
}

View file

@ -0,0 +1,48 @@
"use client";
import type { CityStats, CategoryId } from "@transportationer/shared";
import { CATEGORIES } from "@transportationer/shared";
interface StatsBarProps {
stats: CityStats | null;
activeCategory: CategoryId | "composite";
}
export function StatsBar({ stats, activeCategory }: StatsBarProps) {
if (!stats) return null;
const displayStats =
activeCategory === "composite"
? stats.categoryStats
: stats.categoryStats.filter((s) => s.category === activeCategory);
return (
<div className="bg-white border-t border-gray-200 px-6 py-3 flex items-center gap-6 text-sm overflow-x-auto">
<div className="shrink-0">
<span className="text-gray-500">POIs: </span>
<span className="font-semibold">{stats.totalPois.toLocaleString()}</span>
</div>
<div className="shrink-0">
<span className="text-gray-500">Grid: </span>
<span className="font-semibold">{stats.gridPointCount.toLocaleString()}</span>
</div>
<div className="w-px h-4 bg-gray-200 shrink-0" />
{displayStats.map((s) => {
const cat = CATEGORIES.find((c) => c.id === s.category);
return (
<div key={s.category} className="shrink-0 flex items-center gap-2">
{cat && (
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: cat.color }}
/>
)}
<span className="text-gray-600">{cat?.label ?? s.category}: </span>
<span className="font-semibold">{s.coveragePct.toFixed(0)}%</span>
<span className="text-gray-400 text-xs">within threshold</span>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,122 @@
"use client";
import { useEffect, useReducer, useRef } from "react";
import type { SSEEvent } from "@transportationer/shared";
export type PipelineStageKey =
| "download-pbf"
| "extract-pois"
| "generate-grid"
| "build-valhalla"
| "compute-scores"
| "refresh-city";
export interface StageStatus {
key: string;
label: string;
status: "pending" | "active" | "completed" | "failed";
pct: number;
message: string;
}
const STAGE_ORDER: Array<{ key: string; label: string }> = [
{ key: "Downloading PBF", label: "Download OSM data" },
{ key: "Filtering OSM tags", label: "Filter & extract POIs" },
{ key: "Importing to PostGIS", label: "Import to database" },
{ key: "Building routing graph", label: "Build routing graph" },
{ key: "Generating grid", label: "Generate analysis grid" },
{ key: "Computing scores", label: "Compute accessibility scores" },
];
export type OverallStatus = "pending" | "active" | "completed" | "failed";
interface ProgressState {
stages: StageStatus[];
overall: OverallStatus;
error?: string;
}
type Action =
| { type: "progress"; stage: string; pct: number; message: string }
| { type: "completed" }
| { type: "failed"; error: string };
function initialState(): ProgressState {
return {
stages: STAGE_ORDER.map((s) => ({
key: s.key,
label: s.label,
status: "pending",
pct: 0,
message: "",
})),
overall: "pending",
};
}
function reducer(state: ProgressState, action: Action): ProgressState {
switch (action.type) {
case "progress": {
let found = false;
const stages = state.stages.map((s) => {
if (s.key === action.stage) {
found = true;
return {
...s,
status: "active" as const,
pct: action.pct,
message: action.message,
};
}
// Mark prior stages completed once a later stage is active
if (!found) return { ...s, status: "completed" as const, pct: 100 };
return s;
});
return { ...state, stages, overall: "active" };
}
case "completed":
return {
...state,
overall: "completed",
stages: state.stages.map((s) => ({
...s,
status: "completed",
pct: 100,
})),
};
case "failed":
return { ...state, overall: "failed", error: action.error };
default:
return state;
}
}
export function useJobProgress(jobId: string | null): ProgressState {
const [state, dispatch] = useReducer(reducer, undefined, initialState);
const esRef = useRef<EventSource | null>(null);
useEffect(() => {
if (!jobId) return;
const es = new EventSource(`/api/admin/jobs/${jobId}/stream`);
esRef.current = es;
es.onmessage = (event) => {
const payload = JSON.parse(event.data) as SSEEvent;
if (payload.type === "heartbeat") return;
dispatch(payload as Action);
};
es.onerror = () => {
dispatch({ type: "failed", error: "Lost connection to job stream" });
es.close();
};
return () => {
es.close();
esRef.current = null;
};
}, [jobId]);
return state;
}

View file

@ -0,0 +1,72 @@
import { SignJWT, jwtVerify, type JWTPayload } from "jose";
import type { NextRequest } from "next/server";
const SESSION_COOKIE = "admin_session";
const SESSION_TTL = 28_800; // 8 hours in seconds
function getJwtSecret(): Uint8Array {
const secret = process.env.ADMIN_JWT_SECRET;
if (!secret || secret.length < 32) {
throw new Error(
"ADMIN_JWT_SECRET env var must be set to a string of at least 32 characters",
);
}
return new TextEncoder().encode(secret);
}
export interface AdminSession extends JWTPayload {
ip: string;
}
/** Creates a signed JWT and returns it as the cookie value. */
export async function createSession(ip: string): Promise<string> {
const token = await new SignJWT({ ip } satisfies Partial<AdminSession>)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${SESSION_TTL}s`)
.sign(getJwtSecret());
return token;
}
/**
* Verifies the session JWT from the request cookie.
* Works in both Edge and Node.js runtimes (uses Web Crypto).
*/
export async function getSession(
req: NextRequest,
): Promise<AdminSession | null> {
const token = req.cookies.get(SESSION_COOKIE)?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify<AdminSession>(token, getJwtSecret());
return payload;
} catch {
return null;
}
}
/**
* Destroys a session with JWT sessions the cookie is cleared client-side.
* No server-side state to remove.
*/
export async function destroySession(_req: NextRequest): Promise<void> {
// JWT sessions are stateless — clearing the cookie in the response is sufficient.
}
export const SESSION_COOKIE_NAME = SESSION_COOKIE;
export function makeSessionCookie(token: string, secure: boolean): string {
const parts = [
`${SESSION_COOKIE}=${token}`,
"HttpOnly",
"SameSite=Strict",
`Max-Age=${SESSION_TTL}`,
"Path=/",
];
if (secure) parts.push("Secure");
return parts.join("; ");
}
export function clearSessionCookie(): string {
return `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/`;
}

67
apps/web/lib/cache.ts Normal file
View file

@ -0,0 +1,67 @@
import { getRedis } from "./redis";
/** TTL in seconds for each cache category */
const TTL = {
API_CITIES: 3600, // 1 hour
API_POIS: 300, // 5 minutes
API_GRID: 600, // 10 minutes
API_STATS: 120, // 2 minutes
API_ISOCHRONES: 3600, // 1 hour
GEOFABRIK_INDEX: 86400, // 24 hours
SESSION: 28800, // 8 hours
} as const;
export type CacheTTLKey = keyof typeof TTL;
export async function cacheGet<T>(key: string): Promise<T | null> {
const redis = getRedis();
try {
const raw = await redis.get(key);
if (!raw) return null;
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export async function cacheSet<T>(
key: string,
value: T,
ttlKey: CacheTTLKey,
): Promise<void> {
const redis = getRedis();
try {
await redis.set(key, JSON.stringify(value), "EX", TTL[ttlKey]);
} catch (err) {
console.error("[cache] set error:", err);
}
}
export async function cacheDel(pattern: string): Promise<void> {
const redis = getRedis();
try {
const stream = redis.scanStream({ match: pattern, count: 100 });
const keys: string[] = [];
for await (const batch of stream as AsyncIterable<string[]>) {
keys.push(...batch);
}
if (keys.length > 0) {
await redis.del(...keys);
}
} catch (err) {
console.error("[cache] del error:", err);
}
}
/** Stable hash of query params for cache keys (djb2, not cryptographic) */
export function hashParams(params: Record<string, unknown>): string {
const sorted = Object.keys(params)
.sort()
.map((k) => `${k}=${JSON.stringify(params[k])}`)
.join("&");
let hash = 5381;
for (let i = 0; i < sorted.length; i++) {
hash = ((hash << 5) + hash) ^ sorted.charCodeAt(i);
}
return (hash >>> 0).toString(16);
}

18
apps/web/lib/db.ts Normal file
View file

@ -0,0 +1,18 @@
import postgres from "postgres";
declare global {
// eslint-disable-next-line no-var
var __pgPool: ReturnType<typeof postgres> | undefined;
}
function createPool() {
return postgres(process.env.DATABASE_URL!, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
ssl: process.env.DATABASE_SSL === "true" ? { rejectUnauthorized: false } : false,
});
}
export const sql =
globalThis.__pgPool ?? (globalThis.__pgPool = createPool());

66
apps/web/lib/queue.ts Normal file
View file

@ -0,0 +1,66 @@
import { Queue, QueueEvents } from "bullmq";
import { createBullMQConnection } from "./redis";
// Re-export shared job types so web code can import from one place
export type {
PipelineJobData,
DownloadPbfJobData,
ExtractPoisJobData,
GenerateGridJobData,
ComputeScoresJobData,
BuildValhallaJobData,
RefreshCityJobData,
JobProgress,
} from "@transportationer/shared";
export { JOB_OPTIONS } from "@transportationer/shared";
import type { PipelineJobData } from "@transportationer/shared";
// ─── Queue singleton ──────────────────────────────────────────────────────────
declare global {
// eslint-disable-next-line no-var
// eslint-disable-next-line @typescript-eslint/no-explicit-any
var __pipelineQueue: Queue<any> | undefined;
// eslint-disable-next-line no-var
// eslint-disable-next-line @typescript-eslint/no-explicit-any
var __valhallaQueue: Queue<any> | undefined;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getPipelineQueue(): Queue<any> {
if (!globalThis.__pipelineQueue) {
globalThis.__pipelineQueue = new Queue("pipeline", {
connection: createBullMQConnection(),
defaultJobOptions: {
attempts: 1,
removeOnComplete: { age: 86400 * 7 },
removeOnFail: { age: 86400 * 30 },
},
});
}
return globalThis.__pipelineQueue;
}
/** Queue for build-valhalla jobs, processed by the valhalla-worker container. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getValhallaQueue(): Queue<any> {
if (!globalThis.__valhallaQueue) {
globalThis.__valhallaQueue = new Queue("valhalla", {
connection: createBullMQConnection(),
defaultJobOptions: {
attempts: 1,
removeOnComplete: { age: 86400 * 7 },
removeOnFail: { age: 86400 * 30 },
},
});
}
return globalThis.__valhallaQueue;
}
export function createQueueEvents(): QueueEvents {
return new QueueEvents("pipeline", {
connection: createBullMQConnection(),
});
}

View file

@ -0,0 +1,30 @@
import bcrypt from "bcryptjs";
import { getRedis } from "./redis";
const RATE_LIMIT_PREFIX = "ratelimit:login:";
const RATE_LIMIT_WINDOW = 900; // 15 minutes
const RATE_LIMIT_MAX = 10;
export async function verifyPassword(plaintext: string): Promise<boolean> {
const hash = process.env.ADMIN_PASSWORD_HASH;
if (!hash) {
console.error("ADMIN_PASSWORD_HASH env var not set — admin login disabled");
return false;
}
return bcrypt.compare(plaintext, hash);
}
export async function checkRateLimit(
ip: string,
): Promise<{ allowed: boolean; remaining: number }> {
const redis = getRedis();
const key = `${RATE_LIMIT_PREFIX}${ip}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, RATE_LIMIT_WINDOW);
}
return {
allowed: count <= RATE_LIMIT_MAX,
remaining: Math.max(0, RATE_LIMIT_MAX - count),
};
}

42
apps/web/lib/redis.ts Normal file
View file

@ -0,0 +1,42 @@
import Redis from "ioredis";
declare global {
// eslint-disable-next-line no-var
var __redisClient: Redis | undefined;
}
export function getRedis(): Redis {
if (!globalThis.__redisClient) {
globalThis.__redisClient = new Redis({
host: process.env.REDIS_HOST ?? "localhost",
port: parseInt(process.env.REDIS_PORT ?? "6379", 10),
password: process.env.REDIS_PASSWORD,
retryStrategy: (times) => Math.min(times * 200, 30_000),
lazyConnect: false,
enableOfflineQueue: true,
maxRetriesPerRequest: 3,
});
globalThis.__redisClient.on("error", (err) => {
console.error("[redis] connection error:", err.message);
});
}
return globalThis.__redisClient;
}
/**
* BullMQ requires a dedicated connection with maxRetriesPerRequest: null.
* Returns plain ConnectionOptions (not an ioredis instance) to avoid the
* dual-ioredis type conflict between the top-level ioredis and BullMQ's
* bundled copy.
*/
export function createBullMQConnection() {
return {
host: process.env.REDIS_HOST ?? "localhost",
port: parseInt(process.env.REDIS_PORT ?? "6379", 10),
password: process.env.REDIS_PASSWORD,
maxRetriesPerRequest: null as null,
enableOfflineQueue: true,
};
}

50
apps/web/lib/scoring.ts Normal file
View file

@ -0,0 +1,50 @@
import type { CategoryId } from "@transportationer/shared";
import type { GridCell } from "@transportationer/shared";
export interface SigmoidParams {
thresholdSec: number;
k: number;
}
/**
* Sigmoid decay: 1 / (1 + exp(k * (t - threshold)))
* score = 1.0 when t = 0 (POI at doorstep)
* score = 0.5 when t = threshold
* score 0.02 when t = 2 * threshold
*/
export function sigmoid(travelTimeSec: number, params: SigmoidParams): number {
return 1 / (1 + Math.exp(params.k * (travelTimeSec - params.thresholdSec)));
}
export function defaultSigmoidParams(thresholdMin: number): SigmoidParams {
const thresholdSec = thresholdMin * 60;
return { thresholdSec, k: 4 / thresholdSec };
}
/**
* Weighted composite score, normalized to [0, 1].
* Missing categories contribute 0 to the numerator and their weight to the denominator.
*/
export function compositeScore(
categoryScores: Partial<Record<CategoryId, number>>,
weights: Record<CategoryId, number>,
): number {
const totalWeight = Object.values(weights).reduce((a, b) => a + b, 0);
if (totalWeight === 0) return 0;
let sum = 0;
for (const [cat, w] of Object.entries(weights) as [CategoryId, number][]) {
sum += (w / totalWeight) * (categoryScores[cat] ?? 0);
}
return Math.min(1, Math.max(0, sum));
}
/** Re-apply weights to rows already fetched from PostGIS */
export function reweightCells(
rows: Omit<GridCell, "score">[],
weights: Record<CategoryId, number>,
): GridCell[] {
return rows.map((row) => ({
...row,
score: compositeScore(row.categoryScores, weights),
}));
}

53
apps/web/lib/valhalla.ts Normal file
View file

@ -0,0 +1,53 @@
const VALHALLA_BASE = process.env.VALHALLA_URL ?? "http://valhalla:8002";
export type ValhallaCosting = "pedestrian" | "bicycle" | "auto";
const COSTING_MAP: Record<string, ValhallaCosting> = {
walking: "pedestrian",
cycling: "bicycle",
driving: "auto",
};
export interface IsochroneOpts {
lng: number;
lat: number;
travelMode: string;
contourMinutes: number[];
polygons?: boolean;
}
export async function fetchIsochrone(opts: IsochroneOpts): Promise<object> {
const costing = COSTING_MAP[opts.travelMode] ?? "pedestrian";
const body = {
locations: [{ lon: opts.lng, lat: opts.lat }],
costing,
contours: opts.contourMinutes.map((time) => ({ time })),
polygons: opts.polygons ?? true,
show_locations: false,
};
const res = await fetch(`${VALHALLA_BASE}/isochrone`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Valhalla error ${res.status}: ${text}`);
}
return res.json();
}
export async function checkValhalla(): Promise<boolean> {
try {
const res = await fetch(`${VALHALLA_BASE}/status`, {
signal: AbortSignal.timeout(3000),
});
return res.ok;
} catch {
return false;
}
}

30
apps/web/middleware.ts Normal file
View file

@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "./lib/admin-auth";
export const config = {
matcher: ["/admin/:path*", "/api/admin/:path*"],
};
const PUBLIC_PATHS = ["/admin/login", "/api/admin/login"];
export async function middleware(req: NextRequest): Promise<NextResponse> {
const { pathname } = req.nextUrl;
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
const session = await getSession(req);
if (!session) {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = req.nextUrl.clone();
url.pathname = "/admin/login";
url.searchParams.set("from", pathname);
return NextResponse.redirect(url);
}
return NextResponse.next();
}

6
apps/web/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

8
apps/web/next.config.ts Normal file
View file

@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@transportationer/shared"],
serverExternalPackages: ["ioredis", "bullmq", "postgres", "bcryptjs"],
};
export default nextConfig;

35
apps/web/package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "@transportationer/web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@transportationer/shared": "*",
"bcryptjs": "^2.4.3",
"bullmq": "^5.13.0",
"ioredis": "^5.4.1",
"jose": "^5.9.6",
"maplibre-gl": "^4.7.1",
"next": "^15.1.0",
"pmtiles": "^3.2.1",
"postgres": "^3.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.9",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.0"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -0,0 +1,29 @@
{
"version": 8,
"name": "Transportationer Base",
"sources": {
"carto-light": {
"type": "raster",
"tiles": [
"https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png",
"https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png",
"https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png"
],
"tileSize": 256,
"attribution": "© <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors © <a href='https://carto.com/attributions'>CARTO</a>",
"maxzoom": 19
}
},
"layers": [
{
"id": "background",
"type": "raster",
"source": "carto-light",
"paint": {
"raster-opacity": 1
}
}
],
"glyphs": "https://fonts.openmaptiles.org/{fontstack}/{range}.pbf",
"sprite": ""
}

View file

@ -0,0 +1,55 @@
{
"version": 8,
"name": "Transportationer (PMTiles)",
"comment": "Use this style when serving tiles locally from the tiles/ container. Update the pmtiles URL to match your region file.",
"sources": {
"protomaps": {
"type": "vector",
"url": "pmtiles://http://tiles:8080/region.pmtiles",
"attribution": "© <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors"
}
},
"layers": [
{
"id": "background",
"type": "background",
"paint": { "background-color": "#f8f8f4" }
},
{
"id": "water",
"type": "fill",
"source": "protomaps",
"source-layer": "water",
"paint": { "fill-color": "#c9d9e5" }
},
{
"id": "roads",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"paint": { "line-color": "#ffffff", "line-width": 1 }
},
{
"id": "buildings",
"type": "fill",
"source": "protomaps",
"source-layer": "buildings",
"minzoom": 14,
"paint": { "fill-color": "#e8e4e0", "fill-opacity": 0.8 }
},
{
"id": "labels",
"type": "symbol",
"source": "protomaps",
"source-layer": "places",
"layout": {
"text-field": ["get", "name"],
"text-size": 12,
"text-font": ["Noto Sans Regular"]
},
"paint": { "text-color": "#555555" }
}
],
"glyphs": "https://fonts.openmaptiles.org/{fontstack}/{range}.pbf",
"sprite": ""
}

View file

@ -0,0 +1,24 @@
import type { Config } from "tailwindcss";
import forms from "@tailwindcss/forms";
const config: Config = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
brand: {
50: "#f0fdf4",
500: "#22c55e",
600: "#16a34a",
700: "#15803d",
},
},
},
},
plugins: [forms],
};
export default config;

23
apps/web/tsconfig.json Normal file
View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

121
docker-compose.yml Normal file
View file

@ -0,0 +1,121 @@
services:
# ─── PostgreSQL + PostGIS ──────────────────────────────────────────────────
postgres:
image: postgis/postgis:16-3.4
restart: unless-stopped
environment:
POSTGRES_DB: fifteenmin
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./infra/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d fifteenmin"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
# ─── Valkey ───────────────────────────────────────────────────────────────
valkey:
image: valkey/valkey:8-alpine
restart: unless-stopped
command: valkey-server --appendonly yes --requirepass ${VALKEY_PASSWORD}
volumes:
- valkey_data:/data
healthcheck:
test: ["CMD", "valkey-cli", "-a", "${VALKEY_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
# ─── Valhalla routing engine + BullMQ worker ──────────────────────────────
# Built from the gis-ops Valhalla image + Node.js. This container does two
# things: processes build-valhalla BullMQ jobs (running valhalla_build_tiles
# natively) and serves the resulting tiles via valhalla_service on port 8002.
valhalla:
build:
context: .
target: valhalla-worker
restart: unless-stopped
volumes:
- osm_data:/data/osm:ro # PBF files downloaded by the main worker
- valhalla_tiles:/data/valhalla # Valhalla config and routing tiles
environment:
REDIS_HOST: valkey
REDIS_PORT: "6379"
REDIS_PASSWORD: ${VALKEY_PASSWORD}
OSM_DATA_DIR: /data/osm
VALHALLA_CONFIG: /data/valhalla/valhalla.json
VALHALLA_TILES_DIR: /data/valhalla/valhalla_tiles
NODE_ENV: production
ports:
- "127.0.0.1:8002:8002" # Valhalla HTTP API
depends_on:
valkey:
condition: service_healthy
# ─── Protomaps tile server ─────────────────────────────────────────────────
tiles:
image: ghcr.io/protomaps/go-pmtiles:latest
restart: unless-stopped
volumes:
- pmtiles_data:/data
command: serve /data --cors "*"
ports:
- "127.0.0.1:8080:8080"
# ─── Next.js web application ───────────────────────────────────────────────
web:
build:
context: .
target: web
restart: unless-stopped
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@postgres:5432/fifteenmin
REDIS_HOST: valkey
REDIS_PORT: "6379"
REDIS_PASSWORD: ${VALKEY_PASSWORD}
VALHALLA_URL: http://valhalla:8002
ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH}
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET}
NODE_ENV: production
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
# ─── BullMQ pipeline worker ───────────────────────────────────────────────
worker:
build:
context: .
target: worker
restart: unless-stopped
environment:
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@postgres:5432/fifteenmin
REDIS_HOST: valkey
REDIS_PORT: "6379"
REDIS_PASSWORD: ${VALKEY_PASSWORD}
OSM_DATA_DIR: /data/osm
LUA_SCRIPT: /app/infra/osm2pgsql.lua
VALHALLA_URL: http://valhalla:8002
NODE_ENV: production
volumes:
- osm_data:/data/osm # Worker downloads PBF here
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
volumes:
postgres_data:
valkey_data:
osm_data: # Shared: worker writes, valhalla reads
valhalla_tiles:
pmtiles_data:

153
infra/osm2pgsql.lua Normal file
View file

@ -0,0 +1,153 @@
-- osm2pgsql Flex output script for Transportationer
-- Maps OSM tags to the 5 POI categories
-- Write to a per-city staging table so osm2pgsql can freely drop/recreate it
-- in create mode without touching other cities' rows in raw_pois.
-- extract-pois.ts merges the staging data into raw_pois after the import.
local city_slug = os.getenv('CITY_SLUG') or 'unknown'
local staging_name = 'raw_pois_import_' .. city_slug:gsub('[^%w]', '_')
local pois = osm2pgsql.define_table({
name = staging_name,
ids = { type = 'any', id_column = 'osm_id', type_column = 'osm_type' },
columns = {
{ column = 'city_slug', type = 'text' },
{ column = 'category', type = 'text' },
{ column = 'subcategory', type = 'text' },
{ column = 'name', type = 'text' },
{ column = 'tags', type = 'jsonb' },
{ column = 'geom', type = 'point', projection = 4326 },
}
})
-- Tag → {category, subcategory} mapping
local tag_map = {
amenity = {
-- service_trade
pharmacy = { 'service_trade', 'pharmacy' },
bank = { 'service_trade', 'bank' },
atm = { 'service_trade', 'atm' },
cafe = { 'service_trade', 'cafe' },
restaurant = { 'service_trade', 'restaurant' },
fast_food = { 'service_trade', 'restaurant' },
post_office = { 'service_trade', 'post_office' },
marketplace = { 'service_trade', 'market' },
-- transport
bicycle_rental = { 'transport', 'bike_share' },
car_sharing = { 'transport', 'car_share' },
ferry_terminal = { 'transport', 'ferry' },
-- work_school
kindergarten = { 'work_school', 'kindergarten' },
school = { 'work_school', 'school' },
university = { 'work_school', 'university' },
college = { 'work_school', 'university' },
driving_school = { 'work_school', 'driving_school' },
-- culture_community
library = { 'culture_community', 'library' },
theatre = { 'culture_community', 'theatre' },
cinema = { 'culture_community', 'theatre' },
community_centre= { 'culture_community', 'community_center' },
place_of_worship= { 'culture_community', 'place_of_worship' },
hospital = { 'culture_community', 'hospital' },
clinic = { 'culture_community', 'clinic' },
doctors = { 'culture_community', 'clinic' },
social_facility = { 'culture_community', 'social_services' },
townhall = { 'culture_community', 'government' },
police = { 'culture_community', 'government' },
-- recreation
swimming_pool = { 'recreation', 'swimming_pool' },
},
shop = {
supermarket = { 'service_trade', 'supermarket' },
convenience = { 'service_trade', 'convenience' },
bakery = { 'service_trade', 'cafe' },
pharmacy = { 'service_trade', 'pharmacy' },
laundry = { 'service_trade', 'laundry' },
dry_cleaning = { 'service_trade', 'laundry' },
greengrocer = { 'service_trade', 'market' },
butcher = { 'service_trade', 'market' },
},
highway = {
bus_stop = { 'transport', 'bus_stop' },
},
railway = {
station = { 'transport', 'train_station' },
halt = { 'transport', 'train_station' },
tram_stop = { 'transport', 'tram_stop' },
subway_entrance = { 'transport', 'metro' },
},
-- public_transport stop_position/platform intentionally excluded:
-- they duplicate highway=bus_stop / railway=tram_stop nodes at the same
-- physical location and would double-count those stops in scoring.
office = {
coworking = { 'work_school', 'coworking' },
company = { 'work_school', 'office' },
government = { 'work_school', 'office' },
},
tourism = {
museum = { 'culture_community', 'museum' },
},
leisure = {
park = { 'recreation', 'park' },
playground = { 'recreation', 'playground' },
sports_centre = { 'recreation', 'sports_facility' },
fitness_centre = { 'recreation', 'gym' },
swimming_pool = { 'recreation', 'swimming_pool' },
garden = { 'recreation', 'park' },
nature_reserve = { 'recreation', 'green_space' },
pitch = { 'recreation', 'sports_facility' },
arts_centre = { 'culture_community', 'community_center' },
},
landuse = {
commercial = { 'work_school', 'office' },
office = { 'work_school', 'office' },
recreation_ground = { 'recreation', 'green_space' },
grass = { 'recreation', 'green_space' },
meadow = { 'recreation', 'green_space' },
forest = { 'recreation', 'green_space' },
},
}
local CITY_SLUG = city_slug
local function classify(tags)
for key, values in pairs(tag_map) do
local val = tags[key]
if val and values[val] then
return values[val][1], values[val][2]
end
end
return nil, nil
end
local function get_centroid(object)
if object.type == 'node' then
return object:as_point()
elseif object.type == 'way' then
local geom = object:as_linestring()
if geom then return geom:centroid() end
else
local geom = object:as_multipolygon()
if geom then return geom:centroid() end
end
return nil
end
local function process(object)
local category, subcategory = classify(object.tags)
if not category then return end
local geom = get_centroid(object)
if not geom then return end
pois:insert({
city_slug = CITY_SLUG,
category = category,
subcategory = subcategory,
name = object.tags.name,
tags = object.tags,
geom = geom,
})
end
function osm2pgsql.process_node(object) process(object) end
function osm2pgsql.process_way(object) process(object) end
function osm2pgsql.process_relation(object) process(object) end

125
infra/schema.sql Normal file
View file

@ -0,0 +1,125 @@
-- Enable PostGIS
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS postgis_topology;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- ─── Cities ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cities (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL,
country_code CHAR(2) NOT NULL DEFAULT '',
geofabrik_url TEXT NOT NULL,
bbox geometry(Polygon, 4326),
resolution_m INTEGER NOT NULL DEFAULT 200,
status TEXT NOT NULL DEFAULT 'empty'
CHECK (status IN ('empty','pending','processing','ready','error')),
error_message TEXT,
last_ingested TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Migration for existing databases
ALTER TABLE cities ADD COLUMN IF NOT EXISTS resolution_m INTEGER NOT NULL DEFAULT 200;
CREATE INDEX IF NOT EXISTS idx_cities_bbox ON cities USING GIST (bbox);
-- ─── Raw POIs (created and managed by osm2pgsql flex output) ─────────────────
-- osm2pgsql --drop recreates this table on each ingest using the Lua script.
-- Columns: osm_id (bigint), osm_type (char), city_slug, category, subcategory,
-- name, tags, geom — no auto-generated id column.
-- This CREATE TABLE IF NOT EXISTS is a no-op after the first osm2pgsql run.
CREATE TABLE IF NOT EXISTS raw_pois (
osm_id BIGINT NOT NULL,
osm_type CHAR(1) NOT NULL,
city_slug TEXT NOT NULL,
category TEXT NOT NULL,
subcategory TEXT NOT NULL,
name TEXT,
tags JSONB,
geom geometry(Point, 4326) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_raw_pois_city_cat
ON raw_pois (city_slug, category);
CREATE INDEX IF NOT EXISTS idx_raw_pois_geom
ON raw_pois USING GIST (geom);
CREATE INDEX IF NOT EXISTS idx_raw_pois_name
ON raw_pois USING GIN (name gin_trgm_ops)
WHERE name IS NOT NULL;
-- ─── Grid points ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS grid_points (
id BIGSERIAL PRIMARY KEY,
city_slug TEXT NOT NULL REFERENCES cities(slug) ON DELETE CASCADE,
geom geometry(Point, 4326) NOT NULL,
grid_x INTEGER NOT NULL,
grid_y INTEGER NOT NULL,
UNIQUE (city_slug, grid_x, grid_y)
);
CREATE INDEX IF NOT EXISTS idx_grid_city ON grid_points (city_slug);
CREATE INDEX IF NOT EXISTS idx_grid_geom ON grid_points USING GIST (geom);
-- ─── Pre-computed accessibility scores ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS grid_scores (
grid_point_id BIGINT NOT NULL REFERENCES grid_points(id) ON DELETE CASCADE,
category TEXT NOT NULL,
travel_mode TEXT NOT NULL CHECK (travel_mode IN ('walking','cycling','driving')),
threshold_min INTEGER NOT NULL,
profile TEXT NOT NULL DEFAULT 'universal',
nearest_poi_id BIGINT,
distance_m FLOAT,
travel_time_s FLOAT,
score FLOAT NOT NULL CHECK (score >= 0 AND score <= 1),
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (grid_point_id, category, travel_mode, threshold_min, profile)
);
-- Migration for existing databases (adds profile column and rebuilds PK)
ALTER TABLE grid_scores ADD COLUMN IF NOT EXISTS profile TEXT NOT NULL DEFAULT 'universal';
CREATE INDEX IF NOT EXISTS idx_grid_scores_lookup
ON grid_scores (grid_point_id, travel_mode, threshold_min, profile);
-- ─── Nearest POI per subcategory per grid point ───────────────────────────────
-- Populated by compute-scores job. Stores the nearest (by routing time) POI for
-- each subcategory at each grid point, for each travel mode. Threshold-independent.
CREATE TABLE IF NOT EXISTS grid_poi_details (
grid_point_id BIGINT NOT NULL REFERENCES grid_points(id) ON DELETE CASCADE,
category TEXT NOT NULL,
subcategory TEXT NOT NULL,
travel_mode TEXT NOT NULL,
nearest_poi_id BIGINT,
nearest_poi_name TEXT,
distance_m FLOAT,
travel_time_s FLOAT,
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (grid_point_id, category, subcategory, travel_mode)
);
CREATE INDEX IF NOT EXISTS idx_grid_poi_details_lookup
ON grid_poi_details (grid_point_id, travel_mode);
-- ─── Isochrone cache ──────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS isochrone_cache (
id BIGSERIAL PRIMARY KEY,
origin_geom geometry(Point, 4326) NOT NULL,
travel_mode TEXT NOT NULL,
contours_min INTEGER[] NOT NULL,
result JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_isochrone_origin
ON isochrone_cache USING GIST (origin_geom);
CREATE INDEX IF NOT EXISTS idx_isochrone_created
ON isochrone_cache (created_at);
-- Auto-expire isochrone cache entries older than 30 days
-- (handled by periodic cleanup or TTL logic in app)

3455
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

20
package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "transportationer",
"version": "0.1.0",
"private": true,
"workspaces": [
"apps/web",
"worker",
"shared"
],
"scripts": {
"dev": "npm run build --workspace=shared && npm run dev --workspace=apps/web",
"build": "npm run build --workspace=shared && npm run build --workspace=apps/web && npm run build --workspace=worker",
"worker:dev": "npm run build --workspace=shared && npm run dev --workspace=worker",
"lint": "npm run lint --workspace=apps/web"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}

20
shared/package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "@transportationer/shared",
"version": "0.1.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"devDependencies": {
"typescript": "^5.6.0"
}
}

4
shared/src/index.ts Normal file
View file

@ -0,0 +1,4 @@
export * from "./osm-tags.js";
export * from "./types.js";
export * from "./queue.js";
export * from "./profiles.js";

181
shared/src/osm-tags.ts Normal file
View file

@ -0,0 +1,181 @@
export type CategoryId =
| "service_trade"
| "transport"
| "work_school"
| "culture_community"
| "recreation";
export type TravelMode = "walking" | "cycling" | "driving";
export interface TagFilter {
key: string;
values: string[];
}
export interface CategoryDefinition {
id: CategoryId;
label: string;
defaultWeight: number;
defaultThresholdMinutes: number;
color: string;
tags: TagFilter[];
}
export const CATEGORIES: CategoryDefinition[] = [
{
id: "service_trade",
label: "Service & Trade",
defaultWeight: 0.2,
defaultThresholdMinutes: 10,
color: "#e63946",
tags: [
{
key: "shop",
values: [
"supermarket",
"convenience",
"bakery",
"pharmacy",
"laundry",
"dry_cleaning",
"greengrocer",
"butcher",
],
},
{
key: "amenity",
values: [
"pharmacy",
"bank",
"atm",
"cafe",
"restaurant",
"fast_food",
"post_office",
"marketplace",
],
},
],
},
{
id: "transport",
label: "Transport",
defaultWeight: 0.2,
defaultThresholdMinutes: 8,
color: "#457b9d",
tags: [
{ key: "highway", values: ["bus_stop"] },
{
key: "railway",
values: ["station", "halt", "tram_stop", "subway_entrance"],
},
{
key: "amenity",
values: ["bicycle_rental", "car_sharing", "ferry_terminal"],
},
{
key: "public_transport",
values: ["stop_position", "platform"],
},
],
},
{
id: "work_school",
label: "Work & School",
defaultWeight: 0.2,
defaultThresholdMinutes: 20,
color: "#2a9d8f",
tags: [
{ key: "office", values: ["coworking", "company", "government"] },
{
key: "amenity",
values: [
"kindergarten",
"school",
"university",
"college",
"driving_school",
],
},
{ key: "landuse", values: ["commercial", "office"] },
],
},
{
id: "culture_community",
label: "Culture & Community",
defaultWeight: 0.2,
defaultThresholdMinutes: 15,
color: "#e9c46a",
tags: [
{
key: "amenity",
values: [
"library",
"theatre",
"cinema",
"community_centre",
"place_of_worship",
"hospital",
"clinic",
"doctors",
"social_facility",
"townhall",
"police",
],
},
{ key: "tourism", values: ["museum"] },
{ key: "leisure", values: ["arts_centre"] },
],
},
{
id: "recreation",
label: "Recreation",
defaultWeight: 0.2,
defaultThresholdMinutes: 10,
color: "#06d6a0",
tags: [
{
key: "leisure",
values: [
"park",
"playground",
"sports_centre",
"fitness_centre",
"swimming_pool",
"garden",
"nature_reserve",
"pitch",
"golf_course",
],
},
{
key: "landuse",
values: ["recreation_ground", "grass", "meadow", "forest"],
},
{ key: "amenity", values: ["swimming_pool"] },
],
},
];
export const CATEGORY_MAP = new Map<CategoryId, CategoryDefinition>(
CATEGORIES.map((c) => [c.id, c]),
);
export const CATEGORY_IDS = CATEGORIES.map((c) => c.id);
export const DEFAULT_WEIGHTS: Record<CategoryId, number> = Object.fromEntries(
CATEGORIES.map((c) => [c.id, c.defaultWeight]),
) as Record<CategoryId, number>;
/** Generate osmium tags-filter expression lines */
export function toOsmiumExpressions(categories: CategoryDefinition[]): string[] {
const lines: string[] = [];
for (const cat of categories) {
for (const filter of cat.tags) {
for (const value of filter.values) {
lines.push(`nwr/${filter.key}=${value}`);
}
}
}
return [...new Set(lines)];
}

276
shared/src/profiles.ts Normal file
View file

@ -0,0 +1,276 @@
import type { CategoryId } from "./osm-tags.js";
export const PROFILE_IDS = [
"universal",
"family",
"senior",
"professional",
"student",
] as const;
export type ProfileId = (typeof PROFILE_IDS)[number];
export interface Profile {
id: ProfileId;
label: string;
description: string;
emoji: string;
/** Suggested category weight presets (shown in sliders, range 02). */
categoryWeights: Record<CategoryId, number>;
/**
* Per-subcategory importance weights (01) used when computing grid_scores
* for this profile. Any subcategory not listed falls back to DEFAULT_SUBCATEGORY_WEIGHT.
*/
subcategoryWeights: Record<string, number>;
}
/** Fallback weight for any subcategory not explicitly listed in a profile. */
export const DEFAULT_SUBCATEGORY_WEIGHT = 0.5;
/**
* Universal subcategory weights corrected baseline used by the "universal" profile
* and as the fallback for profile-specific overrides.
*/
export const UNIVERSAL_SUBCATEGORY_WEIGHTS: Record<string, number> = {
// ── service_trade ─────────────────────────────────────────────────────────
supermarket: 1.0, // primary grocery source — essential daily
convenience: 0.65, // corner shop fallback, often open late
pharmacy: 1.0, // health necessity
restaurant: 0.55, // daily eating / social
cafe: 0.4, // social / work spot
bank: 0.35, // branch banking — increasingly digital, visited rarely
atm: 0.2, // cash access — separate from bank branch
market: 0.4, // greengrocer, butcher, marketplace — specialty food
laundry: 0.3, // occasional need
post_office: 0.4, // administrative, rarely visited
// ── transport ─────────────────────────────────────────────────────────────
train_station: 1.0, // high-speed / intercity rail
metro: 1.0, // rapid urban transit
tram_stop: 0.75,// reliable fixed-route urban
bus_stop: 0.55,// basic PT — variable frequency/quality
ferry: 0.5, // situational but important where present
bike_share: 0.5, // active mobility option
car_share: 0.5, // car access without ownership
// ── work_school ───────────────────────────────────────────────────────────
school: 0.7, // compulsory education — essential for families, not universal
driving_school: 0.2, // niche, occasional — rarely a walkability factor
kindergarten: 0.75, // essential for young families
university: 0.55, // higher education — relevant to students/young adults
coworking: 0.55, // remote / flexible work
office: 0.1, // landuse centroid — not a meaningful walkable destination
// ── culture_community ─────────────────────────────────────────────────────
hospital: 1.0, // critical healthcare
clinic: 0.8, // routine healthcare
library: 0.7, // education + community hub
community_center: 0.6, // social infrastructure
social_services: 0.6, // vital for those who need it
theatre: 0.5, // cultural enrichment
place_of_worship: 0.25,// highly variable relevance
government: 0.4, // administrative
museum: 0.4, // cultural enrichment
// ── recreation ────────────────────────────────────────────────────────────
park: 1.0, // daily green space
playground: 0.85,// family essential
sports_facility: 0.65,// active recreation
gym: 0.65,// fitness
green_space: 0.6, // passive nature (forest, meadow, grass)
swimming_pool: 0.55,// seasonal / activity-specific
};
export const PROFILES: Record<ProfileId, Profile> = {
universal: {
id: "universal",
label: "Universal",
description: "Balanced across all resident types",
emoji: "⚖️",
categoryWeights: {
service_trade: 1.0,
transport: 1.0,
work_school: 1.0,
culture_community: 1.0,
recreation: 1.0,
},
subcategoryWeights: UNIVERSAL_SUBCATEGORY_WEIGHTS,
},
family: {
id: "family",
label: "Young Family",
description: "Families with young children — schools, playgrounds, healthcare",
emoji: "👨‍👩‍👧",
categoryWeights: {
service_trade: 1.2,
transport: 1.0,
work_school: 1.5,
culture_community: 0.9,
recreation: 1.4,
},
subcategoryWeights: {
...UNIVERSAL_SUBCATEGORY_WEIGHTS,
// school & childcare — essential for this group
school: 1.0,
kindergarten: 1.0,
// daily shopping
supermarket: 1.0,
convenience: 0.7,
pharmacy: 1.0,
// healthcare for children
clinic: 1.0,
hospital: 0.9,
// outdoor play
playground: 1.0,
park: 1.0,
green_space: 0.75,
sports_facility: 0.7,
// less relevant to families with young children
cafe: 0.3,
restaurant: 0.45,
gym: 0.5,
university: 0.2,
coworking: 0.25,
office: 0.05,
theatre: 0.3,
museum: 0.35,
bar: 0.0,
},
},
senior: {
id: "senior",
label: "Senior",
description: "Older residents — healthcare, local services, green space",
emoji: "🧓",
categoryWeights: {
service_trade: 1.4,
transport: 1.1,
work_school: 0.3,
culture_community: 1.5,
recreation: 1.0,
},
subcategoryWeights: {
...UNIVERSAL_SUBCATEGORY_WEIGHTS,
// healthcare — top priority
hospital: 1.0,
clinic: 1.0,
pharmacy: 1.0,
social_services: 0.9,
// daily essentials
supermarket: 1.0,
convenience: 0.8,
// social & community
community_center: 0.9,
library: 0.8,
cafe: 0.55, // social outings matter
restaurant: 0.45,
// accessible green space
park: 1.0,
green_space: 0.75,
// transit (many don't drive)
bus_stop: 0.75,
tram_stop: 0.8,
metro: 0.8,
train_station: 0.75,
// not relevant
school: 0.05,
kindergarten: 0.05,
university: 0.15,
coworking: 0.05,
office: 0.0,
playground: 0.3,
gym: 0.5,
swimming_pool: 0.5,
},
},
professional: {
id: "professional",
label: "Young Professional",
description: "Working adults without children — transit, fitness, dining",
emoji: "💼",
categoryWeights: {
service_trade: 1.0,
transport: 1.5,
work_school: 0.7,
culture_community: 0.9,
recreation: 1.1,
},
subcategoryWeights: {
...UNIVERSAL_SUBCATEGORY_WEIGHTS,
// transit — high priority for commuters
metro: 1.0,
train_station: 1.0,
tram_stop: 0.85,
bus_stop: 0.65,
bike_share: 0.7,
// fitness & dining
gym: 0.9,
sports_facility: 0.7,
swimming_pool: 0.65,
restaurant: 0.75,
cafe: 0.7,
// daily essentials
supermarket: 1.0,
pharmacy: 0.85,
// work
coworking: 0.85,
// less relevant
school: 0.1,
kindergarten: 0.05,
playground: 0.15,
university: 0.35,
office: 0.05,
social_services: 0.4,
},
},
student: {
id: "student",
label: "Student",
description: "Students — university, library, cafés, transit, budget services",
emoji: "🎓",
categoryWeights: {
service_trade: 0.9,
transport: 1.4,
work_school: 1.5,
culture_community: 1.2,
recreation: 0.8,
},
subcategoryWeights: {
...UNIVERSAL_SUBCATEGORY_WEIGHTS,
// education
university: 1.0,
library: 1.0,
coworking: 0.9,
// social
cafe: 0.9,
restaurant: 0.65,
// transit
metro: 1.0,
train_station: 0.9,
tram_stop: 0.8,
bus_stop: 0.7,
bike_share: 0.85,
// budget shopping
supermarket: 0.9,
convenience: 0.75,
pharmacy: 0.75,
// culture
museum: 0.55,
theatre: 0.5,
community_center: 0.6,
// recreation
gym: 0.75,
sports_facility: 0.65,
park: 0.75,
// not relevant
school: 0.05,
kindergarten: 0.05,
office: 0.05,
playground: 0.2,
},
},
};

107
shared/src/queue.ts Normal file
View file

@ -0,0 +1,107 @@
// ─── Job data types ───────────────────────────────────────────────────────────
export interface DownloadPbfJobData {
type: "download-pbf";
citySlug: string;
geofabrikUrl: string;
expectedBytes?: number;
}
export interface ExtractPoisJobData {
type: "extract-pois";
citySlug: string;
pbfPath: string;
/** Optional bounding box [minLng, minLat, maxLng, maxLat] to clip PBF before tag-filtering */
bbox?: [number, number, number, number];
}
export interface GenerateGridJobData {
type: "generate-grid";
citySlug: string;
resolutionM: number;
}
export interface ComputeScoresJobData {
type: "compute-scores";
citySlug: string;
modes: Array<"walking" | "cycling" | "driving">;
thresholds: number[];
/** Set after compute-routing children are dispatched (internal two-phase state). */
routingDispatched?: boolean;
}
export interface ComputeRoutingJobData {
type: "compute-routing";
citySlug: string;
mode: "walking" | "cycling" | "driving";
category: string;
}
export interface BuildValhallaJobData {
type: "build-valhalla";
/** City being added/updated. Absent for removal-only rebuilds. */
citySlug?: string;
pbfPath?: string;
/** Optional bounding box [minLng, minLat, maxLng, maxLat] to clip PBF before building routing tiles */
bbox?: [number, number, number, number];
/** Slugs to drop from the global routing tile set before rebuilding */
removeSlugs?: string[];
}
export interface RefreshCityJobData {
type: "refresh-city";
citySlug: string;
geofabrikUrl: string;
resolutionM?: number;
}
export type PipelineJobData =
| DownloadPbfJobData
| ExtractPoisJobData
| GenerateGridJobData
| ComputeScoresJobData
| ComputeRoutingJobData
| BuildValhallaJobData
| RefreshCityJobData;
// ─── Job options (BullMQ-compatible plain objects) ────────────────────────────
export const JOB_OPTIONS: Record<PipelineJobData["type"], object> = {
"compute-routing": {
attempts: 2,
backoff: { type: "fixed", delay: 3000 },
removeOnComplete: { age: 86400 * 7 },
removeOnFail: { age: 86400 * 30 },
},
"download-pbf": {
attempts: 2,
backoff: { type: "fixed", delay: 5000 },
removeOnComplete: { age: 86400 * 7 },
removeOnFail: { age: 86400 * 30 },
},
"extract-pois": {
attempts: 1,
removeOnComplete: { age: 86400 * 7 },
removeOnFail: { age: 86400 * 30 },
},
"generate-grid": {
attempts: 1,
removeOnComplete: { age: 86400 * 7 },
removeOnFail: { age: 86400 * 30 },
},
"compute-scores": {
attempts: 1,
removeOnComplete: { age: 86400 * 7 },
removeOnFail: { age: 86400 * 30 },
},
"build-valhalla": {
attempts: 1,
removeOnComplete: { age: 86400 * 7 },
removeOnFail: { age: 86400 * 30 },
},
"refresh-city": {
attempts: 1,
removeOnComplete: { age: 86400 * 7 },
removeOnFail: { age: 86400 * 30 },
},
};

144
shared/src/types.ts Normal file
View file

@ -0,0 +1,144 @@
import type { CategoryId, TravelMode } from "./osm-tags.js";
// ─── Cities ──────────────────────────────────────────────────────────────────
export type CityStatus = "pending" | "processing" | "ready" | "error" | "empty";
export interface City {
slug: string;
name: string;
countryCode: string;
geofabrikUrl: string;
bbox: [number, number, number, number]; // [minLng, minLat, maxLng, maxLat]
status: CityStatus;
lastIngested: string | null;
}
// ─── POIs ────────────────────────────────────────────────────────────────────
export interface Poi {
osmId: string;
osmType: "N" | "W" | "R";
category: CategoryId;
subcategory: string;
name: string | null;
lng: number;
lat: number;
}
// ─── Grid / Heatmap ──────────────────────────────────────────────────────────
export interface GridCell {
gridX: number;
gridY: number;
lng: number;
lat: number;
score: number;
categoryScores: Partial<Record<CategoryId, number>>;
}
export interface HeatmapPayload {
citySlug: string;
travelMode: TravelMode;
thresholdMin: number;
weights: Record<CategoryId, number>;
gridSpacingM: number;
cells: GridCell[];
generatedAt: string;
}
// ─── Stats ───────────────────────────────────────────────────────────────────
export interface CategoryStats {
category: CategoryId;
poiCount: number;
p10: number;
p25: number;
p50: number;
p75: number;
p90: number;
coveragePct: number;
}
export interface CityStats {
citySlug: string;
totalPois: number;
gridPointCount: number;
categoryStats: CategoryStats[];
}
// ─── Isochrones ──────────────────────────────────────────────────────────────
export interface IsochroneRequest {
lng: number;
lat: number;
travelMode: TravelMode;
contourMinutes: number[];
}
// ─── Job / Pipeline ──────────────────────────────────────────────────────────
export interface JobProgress {
stage: string;
pct: number;
message: string;
bytesDownloaded?: number;
totalBytes?: number;
}
export type JobState =
| "waiting"
| "active"
| "completed"
| "failed"
| "delayed"
| "waiting-children";
export interface JobSummary {
id: string;
type: string;
citySlug: string;
state: JobState;
progress: JobProgress | null;
failedReason: string | null;
createdAt: number;
finishedAt: number | null;
duration: number | null;
}
// ─── SSE Events ──────────────────────────────────────────────────────────────
export type SSEEvent =
| { type: "progress"; stage: string; pct: number; message: string; bytesDownloaded?: number; totalBytes?: number }
| { type: "completed"; jobId: string }
| { type: "failed"; jobId: string; error: string }
| { type: "heartbeat" };
// ─── Geofabrik ───────────────────────────────────────────────────────────────
export interface GeofabrikFeature {
type: "Feature";
properties: {
id: string;
parent?: string;
name: string;
urls: {
pbf: string;
"pbf.md5": string;
};
poly?: string;
};
geometry: GeoJSON.Polygon | GeoJSON.MultiPolygon | null;
}
export interface GeofabrikIndex {
type: "FeatureCollection";
features: GeofabrikFeature[];
}
// ─── API helpers ─────────────────────────────────────────────────────────────
export interface ApiError {
error: string;
code: string;
}

8
shared/tsconfig.json Normal file
View file

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

16
tsconfig.base.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}

22
worker/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "@transportationer/worker",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@transportationer/shared": "*",
"bullmq": "^5.13.0",
"postgres": "^3.4.4"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}

14
worker/src/db.ts Normal file
View file

@ -0,0 +1,14 @@
import postgres from "postgres";
let _sql: ReturnType<typeof postgres> | null = null;
export function getSql(): ReturnType<typeof postgres> {
if (!_sql) {
_sql = postgres(process.env.DATABASE_URL!, {
max: 20,
idle_timeout: 30,
connect_timeout: 15,
});
}
return _sql;
}

72
worker/src/index.ts Normal file
View file

@ -0,0 +1,72 @@
import { Worker, type Job } from "bullmq";
import { createBullMQConnection } from "./redis.js";
import type { PipelineJobData } from "@transportationer/shared";
import { handleDownloadPbf } from "./jobs/download-pbf.js";
import { handleExtractPois } from "./jobs/extract-pois.js";
import { handleGenerateGrid } from "./jobs/generate-grid.js";
import { handleComputeScores } from "./jobs/compute-scores.js";
import { handleComputeRouting } from "./jobs/compute-routing.js";
import { handleRefreshCity } from "./jobs/refresh-city.js";
console.log("[worker] Starting Transportationer pipeline worker…");
const worker = new Worker<PipelineJobData>(
"pipeline",
async (job: Job<PipelineJobData>, token?: string) => {
console.log(`[worker] Processing job ${job.id} type=${job.data.type} city=${job.data.citySlug}`);
switch (job.data.type) {
case "download-pbf":
return handleDownloadPbf(job as Job<any>);
case "extract-pois":
return handleExtractPois(job as Job<any>);
case "generate-grid":
return handleGenerateGrid(job as Job<any>);
case "compute-scores":
return handleComputeScores(job as Job<any>, token);
case "compute-routing":
return handleComputeRouting(job as Job<any>);
case "refresh-city":
return handleRefreshCity(job as Job<any>);
default:
throw new Error(`Unknown job type: ${(job.data as any).type}`);
}
},
{
connection: createBullMQConnection(),
// Higher concurrency lets multiple compute-routing jobs run in parallel.
// The 15 routing jobs per city (3 modes × 5 categories) will fill these
// slots; sequential jobs like download-pbf/extract-pois still run one at a time
// because they're gated by the FlowProducer dependency chain.
concurrency: 8,
lockDuration: 300_000, // 5 minutes — download jobs can be slow
lockRenewTime: 15_000, // Renew every 15s
},
);
worker.on("completed", (job) => {
console.log(`[worker] ✓ Job ${job.id} (${job.data.type}) completed`);
});
worker.on("failed", (job, err) => {
console.error(`[worker] ✗ Job ${job?.id} (${job?.data?.type}) failed:`, err.message);
});
worker.on("active", (job) => {
console.log(`[worker] → Job ${job.id} (${job.data.type}) started`);
});
worker.on("error", (err) => {
console.error("[worker] Worker error:", err.message);
});
const shutdown = async () => {
console.log("[worker] Shutting down gracefully…");
await worker.close();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
console.log("[worker] Ready — waiting for jobs (concurrency=8)");

View file

@ -0,0 +1,236 @@
import type { Job } from "bullmq";
import { execSync, spawn } from "child_process";
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
import type { JobProgress } from "@transportationer/shared";
export type BuildValhallaData = {
type: "build-valhalla";
/** City being added/updated. Absent for removal-only rebuilds. */
citySlug?: string;
pbfPath?: string;
bbox?: [number, number, number, number];
/** Slugs to drop from the global routing tile set before rebuilding */
removeSlugs?: string[];
};
const OSM_DATA_DIR = process.env.OSM_DATA_DIR ?? "/data/osm";
const VALHALLA_CONFIG = process.env.VALHALLA_CONFIG ?? "/data/valhalla/valhalla.json";
const VALHALLA_TILES_DIR = process.env.VALHALLA_TILES_DIR ?? "/data/valhalla/valhalla_tiles";
const VALHALLA_DATA_DIR = "/data/valhalla";
/**
* Manifest file: maps citySlug absolute path of its routing PBF.
* Persists in the valhalla_tiles Docker volume across restarts.
*
* For bbox-clipped cities the path is /data/valhalla/{slug}-routing.osm.pbf.
* For whole-region cities (no bbox) the path is /data/osm/{slug}-latest.osm.pbf
* (accessible via the osm_data volume mounted read-only in this container).
*/
const ROUTING_MANIFEST = `${VALHALLA_DATA_DIR}/routing-sources.json`;
function readManifest(): Record<string, string> {
try {
return JSON.parse(readFileSync(ROUTING_MANIFEST, "utf8")) as Record<string, string>;
} catch {
return {};
}
}
function writeManifest(manifest: Record<string, string>): void {
writeFileSync(ROUTING_MANIFEST, JSON.stringify(manifest, null, 2));
}
function runProcess(cmd: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
console.log(`[build-valhalla] Running: ${cmd} ${args.join(" ")}`);
const child = spawn(cmd, args, { stdio: "inherit" });
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`${cmd} exited with code ${code}`));
});
});
}
type JsonObject = Record<string, unknown>;
/** Deep-merge override into base. Objects are merged recursively; arrays and
* scalars in override replace the corresponding base value entirely. */
function deepMerge(base: JsonObject, override: JsonObject): JsonObject {
const result: JsonObject = { ...base };
for (const [key, val] of Object.entries(override)) {
const baseVal = base[key];
if (
val !== null && typeof val === "object" && !Array.isArray(val) &&
baseVal !== null && typeof baseVal === "object" && !Array.isArray(baseVal)
) {
result[key] = deepMerge(baseVal as JsonObject, val as JsonObject);
} else {
result[key] = val;
}
}
return result;
}
/**
* Generate valhalla.json by starting from the canonical defaults produced by
* valhalla_build_config, then overlaying only the deployment-specific settings.
* This ensures every required field for the installed Valhalla version is present
* without us having to maintain a manual list.
*/
function generateConfig(): void {
mkdirSync(VALHALLA_TILES_DIR, { recursive: true });
// Get the full default config for this exact Valhalla build.
let base: JsonObject = {};
try {
const out = execSync("valhalla_build_config", {
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
});
base = JSON.parse(out) as JsonObject;
console.log("[build-valhalla] Loaded defaults from valhalla_build_config");
} catch (err) {
console.warn("[build-valhalla] valhalla_build_config failed, using empty base:", err);
}
// Only override settings specific to this deployment.
const overrides: JsonObject = {
mjolnir: {
tile_dir: VALHALLA_TILES_DIR,
tile_extract: `${VALHALLA_TILES_DIR}.tar`,
timezone: `${VALHALLA_TILES_DIR}/timezone.sqlite`,
admin: `${VALHALLA_TILES_DIR}/admins.sqlite`,
},
additional_data: {
elevation: "/data/elevation/",
},
httpd: {
service: {
listen: "tcp://*:8002",
timeout_seconds: 26,
},
},
};
const config = deepMerge(base, overrides);
writeFileSync(VALHALLA_CONFIG, JSON.stringify(config, null, 2));
console.log(`[build-valhalla] Config written to ${VALHALLA_CONFIG}`);
}
export async function handleBuildValhalla(
job: Job<BuildValhallaData>,
restartService: () => Promise<void>,
): Promise<void> {
const { citySlug, pbfPath, bbox, removeSlugs = [] } = job.data;
// Always regenerate config to ensure it's valid JSON (not stale/corrupted).
await job.updateProgress({
stage: "Building routing graph",
pct: 2,
message: "Writing Valhalla configuration…",
} satisfies JobProgress);
generateConfig();
// ── Step 1: update the routing manifest ──────────────────────────────────
// The manifest maps citySlug → pbfPath for every city that should be
// included in the global tile set. It persists across container restarts.
const manifest = readManifest();
// Remove requested cities
for (const slug of removeSlugs) {
const clippedPbf = `${VALHALLA_DATA_DIR}/${slug}-routing.osm.pbf`;
if (existsSync(clippedPbf)) {
unlinkSync(clippedPbf);
console.log(`[build-valhalla] Removed clipped PBF for ${slug}`);
}
delete manifest[slug];
}
// Add/update the city being ingested (absent for removal-only jobs)
if (citySlug && pbfPath) {
await job.updateProgress({
stage: "Building routing graph",
pct: 5,
message: bbox
? `Clipping PBF to bbox [${bbox.map((v) => v.toFixed(3)).join(", ")}]…`
: `Registering full PBF for ${citySlug}`,
} satisfies JobProgress);
let routingPbf: string;
if (bbox) {
const [minLng, minLat, maxLng, maxLat] = bbox;
const clippedPbf = `${VALHALLA_DATA_DIR}/${citySlug}-routing.osm.pbf`;
if (!existsSync(pbfPath)) throw new Error(`PBF file not found: ${pbfPath}`);
await runProcess("osmium", [
"extract",
`--bbox=${minLng},${minLat},${maxLng},${maxLat}`,
pbfPath,
"-o", clippedPbf,
"--overwrite",
]);
routingPbf = clippedPbf;
} else {
// No bbox: use the full PBF from the osm_data volume (mounted :ro here)
if (existsSync(pbfPath)) {
routingPbf = pbfPath;
} else {
const { readdirSync } = await import("fs");
const found = readdirSync(OSM_DATA_DIR)
.filter((f) => f.endsWith("-latest.osm.pbf"))
.map((f) => `${OSM_DATA_DIR}/${f}`);
if (found.length === 0) throw new Error(`No PBF files found in ${OSM_DATA_DIR}`);
routingPbf = found[0];
}
}
manifest[citySlug] = routingPbf;
}
writeManifest(manifest);
// ── Step 2: build tiles from ALL registered cities ────────────────────────
const allPbfs = Object.values(manifest).filter(existsSync);
const allSlugs = Object.keys(manifest);
if (allPbfs.length === 0) {
console.log("[build-valhalla] Manifest is empty — no cities to build routing tiles for.");
await job.updateProgress({
stage: "Building routing graph",
pct: 100,
message: "No cities in manifest, skipping tile build.",
} satisfies JobProgress);
return;
}
await job.updateProgress({
stage: "Building routing graph",
pct: 10,
message: `Building global routing tiles for: ${allSlugs.join(", ")}`,
} satisfies JobProgress);
// valhalla_build_tiles accepts multiple PBF files as positional arguments,
// so we get one combined tile set covering all cities in a single pass.
await runProcess("valhalla_build_tiles", ["-c", VALHALLA_CONFIG, ...allPbfs]);
// Tiles are fully built — restart the service to pick them up.
// compute-routing jobs will transparently retry their in-flight matrix calls
// across the brief restart window (~510 s).
await job.updateProgress({
stage: "Building routing graph",
pct: 95,
message: "Tiles built — restarting Valhalla service…",
} satisfies JobProgress);
await restartService();
await job.updateProgress({
stage: "Building routing graph",
pct: 100,
message: `Routing graph ready — covers: ${allSlugs.join(", ")}`,
} satisfies JobProgress);
}

View file

@ -0,0 +1,217 @@
import type { Job } from "bullmq";
import { getSql } from "../db.js";
import type { JobProgress } from "@transportationer/shared";
import { fetchMatrix } from "../valhalla.js";
export type ComputeRoutingData = {
type: "compute-routing";
citySlug: string;
mode: "walking" | "cycling" | "driving";
category: string;
};
/** Number of nearest POI candidates per grid point. */
const K = 6;
/** Grid points per Valhalla matrix call. */
const BATCH_SIZE = 20;
/** Concurrent Valhalla calls within this job. */
const BATCH_CONCURRENCY = 4;
/** Rows per INSERT. */
const INSERT_CHUNK = 2000;
async function asyncPool<T>(
concurrency: number,
items: T[],
fn: (item: T) => Promise<void>,
): Promise<void> {
const queue = [...items];
async function worker(): Promise<void> {
while (queue.length > 0) await fn(queue.shift()!);
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
}
/**
* Fetch Valhalla routing times from every grid point to the K nearest POIs
* in the given category, then persist nearest-per-subcategory into grid_poi_details.
*/
export async function handleComputeRouting(job: Job<ComputeRoutingData>): Promise<void> {
const { citySlug, mode, category } = job.data;
const sql = getSql();
const gridPoints = await Promise.resolve(sql<{ id: string; lat: number; lng: number }[]>`
SELECT id::text AS id, ST_Y(geom) AS lat, ST_X(geom) AS lng
FROM grid_points
WHERE city_slug = ${citySlug}
ORDER BY id
`);
if (gridPoints.length === 0) return;
const [{ count }] = await Promise.resolve(sql<{ count: number }[]>`
SELECT COUNT(*)::int AS count
FROM raw_pois
WHERE city_slug = ${citySlug} AND category = ${category}
`);
if (count === 0) return;
// Nearest POI per (gridPointId, subcategory).
const result = new Map<string, Map<string, {
poiId: string;
poiName: string | null;
distM: number;
timeS: number | null;
}>>();
for (const gp of gridPoints) result.set(gp.id, new Map());
const batches: Array<{ id: string; lat: number; lng: number }[]> = [];
for (let i = 0; i < gridPoints.length; i += BATCH_SIZE) {
batches.push(gridPoints.slice(i, i + BATCH_SIZE));
}
let batchesDone = 0;
await asyncPool(BATCH_CONCURRENCY, batches, async (batch) => {
const batchIds = batch.map((gp) => gp.id);
const knnRows = await Promise.resolve(sql<{
grid_point_id: string;
poi_id: string;
poi_lat: number;
poi_lng: number;
poi_name: string | null;
dist_m: number;
subcategory: string;
}[]>`
SELECT
gp.id::text AS grid_point_id,
p.osm_id::text AS poi_id,
ST_Y(p.geom) AS poi_lat,
ST_X(p.geom) AS poi_lng,
p.poi_name,
ST_Distance(gp.geom::geography, p.geom::geography) AS dist_m,
p.subcategory
FROM grid_points gp
CROSS JOIN LATERAL (
SELECT p.osm_id, p.geom, p.subcategory, p.name AS poi_name
FROM raw_pois p
WHERE p.city_slug = ${citySlug} AND p.category = ${category}
ORDER BY gp.geom <-> p.geom
LIMIT ${K}
) p
WHERE gp.id = ANY(${batchIds}::bigint[])
ORDER BY gp.id, dist_m
`);
batchesDone++;
if (knnRows.length === 0) return;
const targetIdx = new Map<string, number>();
const targets: { lat: number; lng: number }[] = [];
for (const row of knnRows) {
if (!targetIdx.has(row.poi_id)) {
targetIdx.set(row.poi_id, targets.length);
targets.push({ lat: row.poi_lat, lng: row.poi_lng });
}
}
const sources = batch.map((gp) => ({ lat: gp.lat, lng: gp.lng }));
let matrix: (number | null)[][];
try {
matrix = await fetchMatrix(sources, targets, mode);
} catch (err) {
console.error(
`[compute-routing] Valhalla failed (${mode}/${category}, batch ${batchesDone}):`,
(err as Error).message,
);
return;
}
type KnnRow = (typeof knnRows)[number];
const gpKnn = new Map<string, KnnRow[]>();
for (const row of knnRows) {
const list = gpKnn.get(row.grid_point_id) ?? [];
list.push(row);
gpKnn.set(row.grid_point_id, list);
}
for (let bi = 0; bi < batch.length; bi++) {
const gp = batch[bi];
const knn = gpKnn.get(gp.id);
if (!knn || knn.length === 0) continue;
const subcatMap = result.get(gp.id)!;
for (const row of knn) {
if (!subcatMap.has(row.subcategory)) {
const idx = targetIdx.get(row.poi_id);
subcatMap.set(row.subcategory, {
poiId: row.poi_id,
poiName: row.poi_name,
distM: row.dist_m,
timeS: idx !== undefined ? (matrix[bi]?.[idx] ?? null) : null,
});
}
}
}
await job.updateProgress({
stage: `Routing ${mode}/${category}`,
pct: Math.round((batchesDone / batches.length) * 100),
message: `Batch ${batchesDone}/${batches.length}`,
} satisfies JobProgress);
});
// Bulk-insert nearest POI per subcategory into grid_poi_details.
const gpIdArr: string[] = [];
const subcatArr: string[] = [];
const poiIdArr: (string | null)[] = [];
const poiNameArr: (string | null)[] = [];
const distArr: (number | null)[] = [];
const timeArr: (number | null)[] = [];
for (const [gpId, subcatMap] of result) {
for (const [subcategory, detail] of subcatMap) {
gpIdArr.push(gpId);
subcatArr.push(subcategory);
poiIdArr.push(detail.poiId);
poiNameArr.push(detail.poiName);
distArr.push(detail.distM);
timeArr.push(detail.timeS);
}
}
for (let i = 0; i < gpIdArr.length; i += INSERT_CHUNK) {
const end = Math.min(i + INSERT_CHUNK, gpIdArr.length);
await Promise.resolve(sql`
INSERT INTO grid_poi_details (
grid_point_id, category, subcategory, travel_mode,
nearest_poi_id, nearest_poi_name, distance_m, travel_time_s
)
SELECT
gp_id::bigint,
${category},
subcat,
${mode},
CASE WHEN poi_id IS NULL THEN NULL ELSE poi_id::bigint END,
poi_name,
dist,
time_s
FROM unnest(
${gpIdArr.slice(i, end)}::text[],
${subcatArr.slice(i, end)}::text[],
${poiIdArr.slice(i, end)}::text[],
${poiNameArr.slice(i, end)}::text[],
${distArr.slice(i, end)}::float8[],
${timeArr.slice(i, end)}::float8[]
) AS t(gp_id, subcat, poi_id, poi_name, dist, time_s)
ON CONFLICT (grid_point_id, category, subcategory, travel_mode)
DO UPDATE SET
nearest_poi_id = EXCLUDED.nearest_poi_id,
nearest_poi_name = EXCLUDED.nearest_poi_name,
distance_m = EXCLUDED.distance_m,
travel_time_s = EXCLUDED.travel_time_s,
computed_at = now()
`);
}
}

View file

@ -0,0 +1,285 @@
import type { Job } from "bullmq";
import { Queue, WaitingChildrenError } from "bullmq";
import { getSql } from "../db.js";
import { createBullMQConnection } from "../redis.js";
import type { JobProgress } from "@transportationer/shared";
import {
CATEGORY_IDS,
PROFILES,
PROFILE_IDS,
DEFAULT_SUBCATEGORY_WEIGHT,
} from "@transportationer/shared";
export type ComputeScoresData = {
type: "compute-scores";
citySlug: string;
modes: Array<"walking" | "cycling" | "driving">;
thresholds: number[];
/** Persisted after routing children are dispatched to distinguish phase 1 from phase 2. */
routingDispatched?: boolean;
};
const INSERT_CHUNK = 2000;
function subcategoryWeight(profileId: string, subcategory: string): number {
const weights = PROFILES[profileId as keyof typeof PROFILES]?.subcategoryWeights;
if (!weights) return DEFAULT_SUBCATEGORY_WEIGHT;
return weights[subcategory] ?? DEFAULT_SUBCATEGORY_WEIGHT;
}
function sigmoid(t_s: number, threshold_s: number): number {
return 1 / (1 + Math.exp(4 * (t_s - threshold_s) / threshold_s));
}
function complementProduct(
subcategoryTimes: Array<{ subcategory: string; timeS: number | null }>,
threshold_s: number,
profileId: string,
): number {
let logProd = 0;
let hasAny = false;
for (const { subcategory, timeS } of subcategoryTimes) {
const weight = subcategoryWeight(profileId, subcategory);
if (timeS === null || weight <= 0) continue;
hasAny = true;
logProd += Math.log(Math.max(1 - weight * sigmoid(timeS, threshold_s), 1e-10));
}
return hasAny ? 1 - Math.exp(logProd) : 0;
}
/**
* Two-phase orchestrator for accessibility score computation.
*
* Phase 1 (first activation, after generate-grid completes):
* Clears stale data.
* Enqueues one `compute-routing` child job per (mode × category) pair.
* Suspends itself via moveToWaitingChildren; BullMQ re-queues it when
* all routing children finish.
*
* Phase 2 (re-activation after all routing children complete):
* Reads grid_poi_details (populated by the routing jobs).
* Computes weighted complement-product scores for every
* (grid_point × mode × category × threshold × profile) combination.
* Bulk-inserts into grid_scores and marks the city ready.
*/
export async function handleComputeScores(
job: Job<ComputeScoresData>,
token?: string,
): Promise<void> {
const { citySlug, modes, thresholds } = job.data;
const sql = getSql();
// ── Phase 1: dispatch compute-routing children ────────────────────────────
if (!job.data.routingDispatched) {
const totalRoutingJobs = modes.length * CATEGORY_IDS.length;
await job.updateProgress({
stage: "Computing scores",
pct: 2,
message: `Dispatching ${totalRoutingJobs} routing jobs for ${citySlug}`,
} satisfies JobProgress);
// Clear any stale scores from a previous run.
await Promise.resolve(sql`
DELETE FROM grid_scores
USING grid_points gp
WHERE grid_scores.grid_point_id = gp.id
AND gp.city_slug = ${citySlug}
`);
await Promise.resolve(sql`
DELETE FROM grid_poi_details
USING grid_points gp
WHERE grid_poi_details.grid_point_id = gp.id
AND gp.city_slug = ${citySlug}
`);
// Enqueue one routing child per (mode, category). Each child registers
// itself to this parent job via opts.parent, so BullMQ tracks completion.
const queue = new Queue("pipeline", { connection: createBullMQConnection() });
try {
for (const mode of modes) {
for (const category of CATEGORY_IDS) {
await queue.add(
"compute-routing",
{ type: "compute-routing", citySlug, mode, category },
{
attempts: 2,
backoff: { type: "fixed", delay: 3000 },
removeOnComplete: { age: 86400 * 7 },
removeOnFail: { age: 86400 * 30 },
parent: {
id: job.id!,
// qualifiedName = "bull:pipeline" — the Redis key BullMQ uses
// to track parent/child relationships.
queue: queue.qualifiedName,
},
},
);
}
}
} finally {
await queue.close();
}
// Persist the dispatched flag so phase 2 is triggered on re-activation.
await job.updateData({ ...job.data, routingDispatched: true });
// Suspend until all routing children complete.
// Throwing WaitingChildrenError tells the worker not to mark the job
// completed — BullMQ will re-activate it once all children finish.
await job.moveToWaitingChildren(token!);
throw new WaitingChildrenError();
}
// ── Phase 2: aggregate scores from grid_poi_details ──────────────────────
await job.updateProgress({
stage: "Computing scores",
pct: 70,
message: `All routing complete — computing profile scores…`,
} satisfies JobProgress);
// Load all per-subcategory routing results for this city in one query.
// Ordered by distance so the first row per (gpId, mode, category) is nearest.
const detailRows = await Promise.resolve(sql<{
grid_point_id: string;
category: string;
subcategory: string;
travel_mode: string;
nearest_poi_id: string | null;
distance_m: number | null;
travel_time_s: number | null;
}[]>`
SELECT
gpd.grid_point_id::text,
gpd.category,
gpd.subcategory,
gpd.travel_mode,
gpd.nearest_poi_id::text,
gpd.distance_m,
gpd.travel_time_s
FROM grid_poi_details gpd
JOIN grid_points gp ON gp.id = gpd.grid_point_id
WHERE gp.city_slug = ${citySlug}
ORDER BY gpd.grid_point_id, gpd.travel_mode, gpd.category, gpd.distance_m
`);
// Build in-memory structure keyed by "gpId:mode:category".
type GroupEntry = {
gpId: string;
mode: string;
category: string;
subcategoryTimes: Array<{ subcategory: string; timeS: number | null }>;
nearestPoiId: string | null;
nearestDistM: number | null;
nearestTimeS: number | null;
};
const groups = new Map<string, GroupEntry>();
for (const row of detailRows) {
const key = `${row.grid_point_id}:${row.travel_mode}:${row.category}`;
let entry = groups.get(key);
if (!entry) {
entry = {
gpId: row.grid_point_id,
mode: row.travel_mode,
category: row.category,
subcategoryTimes: [],
nearestPoiId: null,
nearestDistM: null,
nearestTimeS: null,
};
groups.set(key, entry);
}
entry.subcategoryTimes.push({ subcategory: row.subcategory, timeS: row.travel_time_s });
// Track the overall nearest POI for this category (minimum distance).
if (
row.distance_m !== null &&
(entry.nearestDistM === null || row.distance_m < entry.nearestDistM)
) {
entry.nearestPoiId = row.nearest_poi_id;
entry.nearestDistM = row.distance_m;
entry.nearestTimeS = row.travel_time_s;
}
}
// Compute and insert scores for every threshold × profile combination.
for (let ti = 0; ti < thresholds.length; ti++) {
const thresholdMin = thresholds[ti];
const threshold_s = thresholdMin * 60;
await job.updateProgress({
stage: "Computing scores",
pct: 70 + Math.round(((ti + 1) / thresholds.length) * 28),
message: `${thresholdMin}min — inserting scores for all profiles…`,
} satisfies JobProgress);
const gpIdArr: string[] = [];
const catArr: string[] = [];
const modeArr: string[] = [];
const profileArr: string[] = [];
const poiIdArr: (string | null)[] = [];
const distArr: (number | null)[] = [];
const timeArr: (number | null)[] = [];
const scoreArr: number[] = [];
for (const entry of groups.values()) {
for (const profileId of PROFILE_IDS) {
gpIdArr.push(entry.gpId);
catArr.push(entry.category);
modeArr.push(entry.mode);
profileArr.push(profileId);
poiIdArr.push(entry.nearestPoiId);
distArr.push(entry.nearestDistM);
timeArr.push(entry.nearestTimeS);
scoreArr.push(complementProduct(entry.subcategoryTimes, threshold_s, profileId));
}
}
for (let i = 0; i < gpIdArr.length; i += INSERT_CHUNK) {
const end = Math.min(i + INSERT_CHUNK, gpIdArr.length);
await Promise.resolve(sql`
INSERT INTO grid_scores (
grid_point_id, category, travel_mode, threshold_min, profile,
nearest_poi_id, distance_m, travel_time_s, score
)
SELECT
gp_id::bigint,
cat,
mode_val,
${thresholdMin}::int,
prof,
CASE WHEN poi_id IS NULL THEN NULL ELSE poi_id::bigint END,
dist,
time_s,
score_val
FROM unnest(
${gpIdArr.slice(i, end)}::text[],
${catArr.slice(i, end)}::text[],
${modeArr.slice(i, end)}::text[],
${profileArr.slice(i, end)}::text[],
${poiIdArr.slice(i, end)}::text[],
${distArr.slice(i, end)}::float8[],
${timeArr.slice(i, end)}::float8[],
${scoreArr.slice(i, end)}::float8[]
) AS t(gp_id, cat, mode_val, prof, poi_id, dist, time_s, score_val)
ON CONFLICT (grid_point_id, category, travel_mode, threshold_min, profile)
DO UPDATE SET
nearest_poi_id = EXCLUDED.nearest_poi_id,
distance_m = EXCLUDED.distance_m,
travel_time_s = EXCLUDED.travel_time_s,
score = EXCLUDED.score,
computed_at = now()
`);
}
}
await Promise.resolve(sql`
UPDATE cities SET status = 'ready', last_ingested = now()
WHERE slug = ${citySlug}
`);
await job.updateProgress({
stage: "Computing scores",
pct: 100,
message: `All scores computed for ${citySlug}`,
} satisfies JobProgress);
}

View file

@ -0,0 +1,102 @@
import type { Job } from "bullmq";
import { createWriteStream, mkdirSync } from "fs";
import { pipeline } from "stream/promises";
import { Writable } from "stream";
import type { JobProgress } from "@transportationer/shared";
export type DownloadPbfData = {
type: "download-pbf";
citySlug: string;
geofabrikUrl: string;
expectedBytes?: number;
};
const ALLOWED_PATTERN =
/^https:\/\/download\.geofabrik\.de\/[\w][\w/-]+-latest\.osm\.pbf$/;
const OSM_DATA_DIR = process.env.OSM_DATA_DIR ?? "/data/osm";
export async function handleDownloadPbf(
job: Job<DownloadPbfData>,
): Promise<void> {
const { citySlug, geofabrikUrl, expectedBytes } = job.data;
if (!ALLOWED_PATTERN.test(geofabrikUrl)) {
throw new Error(`Rejected URL (must be a Geofabrik PBF): ${geofabrikUrl}`);
}
mkdirSync(OSM_DATA_DIR, { recursive: true });
const outputPath = `${OSM_DATA_DIR}/${citySlug}-latest.osm.pbf`;
await job.updateProgress({
stage: "Downloading PBF",
pct: 0,
message: `Starting download from Geofabrik…`,
} satisfies JobProgress);
const response = await fetch(geofabrikUrl, {
headers: { "User-Agent": "Transportationer/1.0" },
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status} from ${geofabrikUrl}`);
}
const totalBytes =
expectedBytes ??
parseInt(response.headers.get("content-length") ?? "0", 10);
let downloaded = 0;
let lastPct = -1;
const fileStream = createWriteStream(outputPath);
// Count bytes through a transform, then write to file
const reader = response.body.getReader();
const writable = new Writable({
write(chunk, _enc, cb) {
fileStream.write(chunk, cb);
},
final(cb) {
fileStream.end(cb);
},
});
// Propagate fileStream errors (e.g. EACCES, ENOSPC) to the writable
// so they surface as a rejected promise rather than an unhandled event.
fileStream.on("error", (err) => writable.destroy(err));
await (async () => {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
downloaded += value.byteLength;
const pct =
totalBytes > 0 ? Math.floor((downloaded / totalBytes) * 100) : 0;
if (pct !== lastPct) {
lastPct = pct;
job
.updateProgress({
stage: "Downloading PBF",
pct,
message: `${(downloaded / 1_048_576).toFixed(1)} MB${totalBytes ? ` / ${(totalBytes / 1_048_576).toFixed(1)} MB` : ""}`,
bytesDownloaded: downloaded,
totalBytes,
} satisfies JobProgress)
.catch(() => {});
}
writable.write(value);
}
}
writable.end();
await new Promise((res, rej) =>
writable.on("finish", res).on("error", rej),
);
})();
await job.updateProgress({
stage: "Downloading PBF",
pct: 100,
message: `Download complete: ${outputPath}`,
} satisfies JobProgress);
}

View file

@ -0,0 +1,176 @@
import type { Job } from "bullmq";
import { spawn } from "child_process";
import { existsSync } from "fs";
import path from "path";
import type { JobProgress } from "@transportationer/shared";
import { getSql } from "../db.js";
export type ExtractPoisData = {
type: "extract-pois";
citySlug: string;
pbfPath: string;
bbox?: [number, number, number, number]; // [minLng, minLat, maxLng, maxLat]
};
const OSM_DATA_DIR = process.env.OSM_DATA_DIR ?? "/data/osm";
const LUA_SCRIPT = process.env.LUA_SCRIPT ?? "/app/infra/osm2pgsql.lua";
const DATABASE_URL = process.env.DATABASE_URL!;
function runProcess(
cmd: string,
args: string[],
env: Record<string, string> = {},
onLine?: (line: string) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, ...env },
});
const handleLine = (data: Buffer) => {
const lines = data.toString().split("\n").filter(Boolean);
lines.forEach((l) => {
process.stdout.write(`[${cmd}] ${l}\n`);
onLine?.(l);
});
};
child.stdout.on("data", handleLine);
child.stderr.on("data", handleLine);
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`${cmd} exited with code ${code}`));
});
});
}
export async function handleExtractPois(
job: Job<ExtractPoisData>,
): Promise<void> {
const { citySlug, pbfPath, bbox } = job.data;
const filteredPbf = path.join(OSM_DATA_DIR, `${citySlug}-filtered.osm.pbf`);
if (!existsSync(pbfPath)) {
throw new Error(`PBF file not found: ${pbfPath}`);
}
// Stage 0 (optional): osmium extract --bbox to clip to the area of interest.
// This dramatically reduces memory and processing time for large geofabrik regions.
let sourcePbf = pbfPath;
if (bbox) {
const [minLng, minLat, maxLng, maxLat] = bbox;
const bboxPbf = path.join(OSM_DATA_DIR, `${citySlug}-bbox.osm.pbf`);
await job.updateProgress({
stage: "Clipping to bounding box",
pct: 2,
message: `Clipping region to [${minLng},${minLat},${maxLng},${maxLat}]…`,
} satisfies JobProgress);
await runProcess(
"osmium",
[
"extract",
`--bbox=${minLng},${minLat},${maxLng},${maxLat}`,
pbfPath,
"-o", bboxPbf,
"--overwrite",
],
{},
);
sourcePbf = bboxPbf;
}
// Stage 1: osmium tags-filter
await job.updateProgress({
stage: "Filtering OSM tags",
pct: 5,
message: "Running osmium tags-filter…",
} satisfies JobProgress);
await runProcess(
"osmium",
[
"tags-filter",
sourcePbf,
// Include all relevant tag groups
"nwr/amenity=pharmacy,bank,atm,cafe,restaurant,fast_food,post_office,marketplace",
"nwr/amenity=bicycle_rental,car_sharing,ferry_terminal",
"nwr/amenity=kindergarten,school,university,college",
"nwr/amenity=library,theatre,cinema,community_centre,place_of_worship",
"nwr/amenity=hospital,clinic,doctors,social_facility,townhall,police",
"nwr/amenity=swimming_pool",
"nwr/shop=supermarket,convenience,bakery,pharmacy,laundry,dry_cleaning,greengrocer,butcher",
"nwr/highway=bus_stop",
"nwr/railway=station,halt,tram_stop,subway_entrance",
"nwr/public_transport=stop_position,platform",
"nwr/office=coworking,company,government",
"nwr/tourism=museum",
"nwr/leisure=park,playground,sports_centre,fitness_centre,swimming_pool,garden,nature_reserve,pitch,arts_centre",
"-o",
filteredPbf,
"--overwrite",
],
{},
);
await job.updateProgress({
stage: "Filtering OSM tags",
pct: 30,
message: "osmium complete, starting osm2pgsql…",
} satisfies JobProgress);
// Stage 2: osm2pgsql with flex output
// The Lua script writes to a per-city staging table (raw_pois_import_{slug})
// in create mode, so osm2pgsql can drop/recreate it freely without touching
// other cities' rows in raw_pois. We merge afterwards.
let nodeCount = 0;
await runProcess(
"osm2pgsql",
[
"--output=flex",
`--style=${LUA_SCRIPT}`,
`--database=${DATABASE_URL}`,
"--slim",
"--drop",
filteredPbf,
],
{ CITY_SLUG: citySlug },
(line) => {
const match = line.match(/(\d+)\s+nodes?/i);
if (match) {
nodeCount = parseInt(match[1]);
const pct = Math.min(30 + Math.floor(nodeCount / 10_000), 95);
job
.updateProgress({
stage: "Importing to PostGIS",
pct,
message: line.trim(),
} satisfies JobProgress)
.catch(() => {});
}
},
);
// Merge staging table into raw_pois, replacing only this city's rows.
// The staging table name mirrors what the Lua script uses.
const stagingTable = `raw_pois_import_${citySlug.replace(/[^a-z0-9]/gi, "_")}`;
const sql = getSql();
await Promise.resolve(sql`DELETE FROM raw_pois WHERE city_slug = ${citySlug}`);
await Promise.resolve(sql`
INSERT INTO raw_pois (osm_id, osm_type, city_slug, category, subcategory, name, tags, geom)
SELECT osm_id, osm_type, city_slug, category, subcategory, name, tags, geom
FROM ${sql(stagingTable)}
`);
await Promise.resolve(sql`DROP TABLE IF EXISTS ${sql(stagingTable)}`);
await job.updateProgress({
stage: "Importing to PostGIS",
pct: 100,
message: `POI extraction complete for ${citySlug}`,
} satisfies JobProgress);
}

View file

@ -0,0 +1,100 @@
import type { Job } from "bullmq";
import { getSql } from "../db.js";
import type { JobProgress } from "@transportationer/shared";
export type GenerateGridData = {
type: "generate-grid";
citySlug: string;
resolutionM: number;
};
export async function handleGenerateGrid(
job: Job<GenerateGridData>,
): Promise<void> {
const { citySlug, resolutionM } = job.data;
const sql = getSql();
await job.updateProgress({
stage: "Generating grid",
pct: 5,
message: `Generating ${resolutionM}m grid for ${citySlug}`,
} satisfies JobProgress);
// Update city bbox from POI extents if not set.
// ST_Extent is a streaming aggregate (O(1) memory) unlike ST_Collect which
// materializes all geometries — safe even for millions of POIs.
await Promise.resolve(sql`
UPDATE cities
SET bbox = (
SELECT ST_Envelope(ST_Buffer(ST_Extent(geom)::geometry, 0.01))
FROM raw_pois WHERE city_slug = ${citySlug}
)
WHERE slug = ${citySlug} AND bbox IS NULL
`);
// Delete existing grid for this city (re-ingest)
await Promise.resolve(sql`DELETE FROM grid_points WHERE city_slug = ${citySlug}`);
// Generate grid in Web Mercator (metric), project back to WGS84
await Promise.resolve(sql`
WITH
city AS (
SELECT
ST_XMin(ST_Transform(bbox, 3857)) AS xmin,
ST_XMax(ST_Transform(bbox, 3857)) AS xmax,
ST_YMin(ST_Transform(bbox, 3857)) AS ymin,
ST_YMax(ST_Transform(bbox, 3857)) AS ymax,
bbox
FROM cities WHERE slug = ${citySlug}
),
xs AS (
SELECT generate_series(0, CEIL((xmax - xmin) / ${resolutionM}::float)::int) AS gx,
xmin, ymin, bbox
FROM city
),
ys AS (
SELECT generate_series(0, CEIL((ymax - ymin) / ${resolutionM}::float)::int) AS gy,
xmin, ymin, bbox
FROM city
),
grid AS (
SELECT
xs.gx,
ys.gy,
ST_Transform(
ST_SetSRID(
ST_MakePoint(
xs.xmin + xs.gx * ${resolutionM}::float,
xs.ymin + ys.gy * ${resolutionM}::float
), 3857
), 4326
) AS pt,
xs.bbox
FROM xs CROSS JOIN ys
)
INSERT INTO grid_points (city_slug, geom, grid_x, grid_y)
SELECT
${citySlug},
grid.pt,
grid.gx,
grid.gy
FROM grid
WHERE ST_Within(grid.pt, grid.bbox)
ON CONFLICT (city_slug, grid_x, grid_y) DO NOTHING
`);
// Persist the grid resolution so the tile endpoint can size the squares correctly.
await Promise.resolve(sql`
UPDATE cities SET resolution_m = ${resolutionM} WHERE slug = ${citySlug}
`);
const result = await Promise.resolve(sql<{ count: number }[]>`
SELECT COUNT(*)::int AS count FROM grid_points WHERE city_slug = ${citySlug}
`);
await job.updateProgress({
stage: "Generating grid",
pct: 100,
message: `Grid complete: ${result[0].count} points`,
} satisfies JobProgress);
}

View file

@ -0,0 +1,128 @@
import type { Job } from "bullmq";
import { FlowProducer } from "bullmq";
import { createBullMQConnection } from "../redis.js";
import { getSql } from "../db.js";
import { JOB_OPTIONS } from "@transportationer/shared";
import type { JobProgress } from "@transportationer/shared";
export type RefreshCityData = {
type: "refresh-city";
citySlug: string;
geofabrikUrl: string;
resolutionM?: number;
};
const OSM_DATA_DIR = process.env.OSM_DATA_DIR ?? "/data/osm";
export async function handleRefreshCity(
job: Job<RefreshCityData>,
): Promise<void> {
const { citySlug, geofabrikUrl, resolutionM = 200 } = job.data;
const sql = getSql();
const pbfPath = `${OSM_DATA_DIR}/${citySlug}-latest.osm.pbf`;
// Read the user-specified bbox from the database (set at city creation time).
// If present, it will be passed to extract-pois to clip the PBF before import.
const bboxRows = await Promise.resolve(sql<{
minlng: number; minlat: number; maxlng: number; maxlat: number;
}[]>`
SELECT
ST_XMin(bbox)::float AS minlng,
ST_YMin(bbox)::float AS minlat,
ST_XMax(bbox)::float AS maxlng,
ST_YMax(bbox)::float AS maxlat
FROM cities WHERE slug = ${citySlug} AND bbox IS NOT NULL
`);
const bbox: [number, number, number, number] | undefined =
bboxRows.length > 0
? [bboxRows[0].minlng, bboxRows[0].minlat, bboxRows[0].maxlng, bboxRows[0].maxlat]
: undefined;
await job.updateProgress({
stage: "Orchestrating pipeline",
pct: 0,
message: `Starting full ingest for ${citySlug}`,
} satisfies JobProgress);
// Mark city as processing
await Promise.resolve(sql`
UPDATE cities SET status = 'processing' WHERE slug = ${citySlug}
`);
// FlowProducer creates a dependency chain evaluated bottom-up:
// download → extract → generate-grid → build-valhalla → compute-scores
const flow = new FlowProducer({ connection: createBullMQConnection() });
try {
await flow.add({
name: "compute-scores",
queueName: "pipeline",
data: {
type: "compute-scores",
citySlug,
modes: ["walking", "cycling", "driving"],
thresholds: [5, 10, 15, 20, 30],
},
opts: JOB_OPTIONS["compute-scores"],
children: [
{
name: "generate-grid",
queueName: "pipeline",
data: {
type: "generate-grid",
citySlug,
resolutionM,
},
opts: JOB_OPTIONS["generate-grid"],
children: [
{
name: "build-valhalla",
queueName: "valhalla", // handled by the dedicated valhalla-worker
data: {
type: "build-valhalla",
citySlug,
pbfPath,
...(bbox ? { bbox } : {}),
},
opts: JOB_OPTIONS["build-valhalla"],
children: [
{
name: "extract-pois",
queueName: "pipeline",
data: {
type: "extract-pois",
citySlug,
pbfPath,
...(bbox ? { bbox } : {}),
},
opts: JOB_OPTIONS["extract-pois"],
children: [
{
name: "download-pbf",
queueName: "pipeline",
data: {
type: "download-pbf",
citySlug,
geofabrikUrl,
},
opts: JOB_OPTIONS["download-pbf"],
},
],
},
],
},
],
},
],
});
} finally {
await flow.close();
}
await job.updateProgress({
stage: "Orchestrating pipeline",
pct: 100,
message: "All pipeline jobs enqueued. Processing will begin shortly.",
} satisfies JobProgress);
}

14
worker/src/redis.ts Normal file
View file

@ -0,0 +1,14 @@
// Return plain connection options objects instead of Redis instances.
// BullMQ accepts ConnectionOptions (plain object) directly — this avoids
// importing ioredis and the duplicate-install type conflict with BullMQ's
// own bundled ioredis.
export function createBullMQConnection() {
return {
host: process.env.REDIS_HOST ?? "localhost",
port: parseInt(process.env.REDIS_PORT ?? "6379", 10),
password: process.env.REDIS_PASSWORD,
maxRetriesPerRequest: null as null, // required by BullMQ workers
enableOfflineQueue: true,
};
}

105
worker/src/valhalla-main.ts Normal file
View file

@ -0,0 +1,105 @@
import { Worker, type Job } from "bullmq";
import { spawn, type ChildProcess } from "child_process";
import { existsSync } from "fs";
import { createBullMQConnection } from "./redis.js";
import { handleBuildValhalla } from "./jobs/build-valhalla.js";
const VALHALLA_CONFIG = process.env.VALHALLA_CONFIG ?? "/data/valhalla/valhalla.json";
console.log("[valhalla-worker] Starting Transportationer Valhalla worker…");
// ─── Valhalla service process manager ─────────────────────────────────────────
// The valhalla_service HTTP server runs as a child process alongside this
// BullMQ worker. When a build-valhalla job arrives, we stop the server, rebuild
// the routing tiles (using the Valhalla tools installed in this container),
// then restart the server.
let valhallaProc: ChildProcess | null = null;
function startValhallaService(): void {
if (!existsSync(VALHALLA_CONFIG)) {
console.log("[valhalla-worker] No config yet — will start after first tile build");
return;
}
console.log("[valhalla-worker] Starting valhalla_service…");
// valhalla_service <config_file> [concurrency] — positional arg, not -c flag
valhallaProc = spawn("valhalla_service", [VALHALLA_CONFIG], {
stdio: "inherit",
});
valhallaProc.on("exit", (code, signal) => {
console.log(`[valhalla-worker] valhalla_service exited (code=${code}, signal=${signal})`);
valhallaProc = null;
});
}
function stopValhallaService(): Promise<void> {
return new Promise((resolve) => {
if (!valhallaProc) { resolve(); return; }
const proc = valhallaProc;
proc.once("exit", () => resolve());
proc.kill("SIGTERM");
// Force kill after 10 s if it doesn't exit cleanly
setTimeout(() => {
if (valhallaProc === proc) proc.kill("SIGKILL");
}, 10_000);
});
}
// ─── BullMQ worker ────────────────────────────────────────────────────────────
const worker = new Worker(
"valhalla",
async (job: Job) => {
console.log(`[valhalla-worker] Processing job ${job.id} type=${job.data.type} city=${job.data.citySlug ?? "(rebuild)"}`);
// Valhalla keeps serving old tiles while the new tiles are being built.
// restartService is called from inside handleBuildValhalla only after the
// tile build completes — the service is only down for the few seconds it
// takes to restart, and compute-routing jobs retry transparently across that
// window via fetchMatrix's built-in retry logic.
async function restartService(): Promise<void> {
await stopValhallaService();
startValhallaService();
}
await handleBuildValhalla(job as any, restartService);
},
{
connection: createBullMQConnection(),
concurrency: 1,
lockDuration: 1_800_000, // 30 min — large-region tile builds can be very slow
lockRenewTime: 60_000,
},
);
worker.on("completed", (job) => {
console.log(`[valhalla-worker] ✓ Job ${job.id} (${job.data.type}) completed`);
});
worker.on("failed", (job, err) => {
console.error(`[valhalla-worker] ✗ Job ${job?.id} (${job?.data?.type}) failed:`, err.message);
});
worker.on("active", (job) => {
const city = job.data.citySlug ?? job.data.removeSlugs?.join(",") ?? "rebuild";
console.log(`[valhalla-worker] → Job ${job.id} (${job.data.type}) started city=${city}`);
});
worker.on("error", (err) => {
console.error("[valhalla-worker] Worker error:", err.message);
});
const shutdown = async () => {
console.log("[valhalla-worker] Shutting down gracefully…");
await worker.close();
await stopValhallaService();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
// Start serving if tiles already exist from a previous run
startValhallaService();
console.log("[valhalla-worker] Ready — waiting for build-valhalla jobs on 'valhalla' queue");

95
worker/src/valhalla.ts Normal file
View file

@ -0,0 +1,95 @@
const VALHALLA_URL = process.env.VALHALLA_URL ?? "http://localhost:8002";
const COSTING: Record<"walking" | "cycling" | "driving", string> = {
walking: "pedestrian",
cycling: "bicycle",
driving: "auto",
};
export interface LatLng {
lat: number;
lng: number;
}
interface MatrixCell {
time: number | null;
distance: number | null;
}
interface MatrixResponse {
sources_to_targets: MatrixCell[][];
}
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
/** Max attempts for retrying transient Valhalla failures (e.g. service restart). */
const MATRIX_MAX_ATTEMPTS = 8;
/** Exponential backoff: delay = min(BASE * 2^attempt, MAX) ms. */
const MATRIX_RETRY_BASE_MS = 1_000;
const MATRIX_RETRY_MAX_MS = 15_000;
/** Per-request timeout — prevents hanging indefinitely if the service is down. */
const MATRIX_TIMEOUT_MS = 30_000;
/**
* Call Valhalla's sources_to_targets matrix endpoint.
* Returns an M×N matrix where [i][j] is travel time in seconds
* from sources[i] to targets[j], or null if unreachable.
*
* Retries automatically on connection errors and 5xx responses to survive
* brief Valhalla service restarts (tile rebuilds). After MATRIX_MAX_ATTEMPTS
* the last error is rethrown.
*/
export async function fetchMatrix(
sources: LatLng[],
targets: LatLng[],
mode: "walking" | "cycling" | "driving",
): Promise<(number | null)[][]> {
const body = {
sources: sources.map(({ lat, lng }) => ({ lat, lon: lng })),
targets: targets.map(({ lat, lng }) => ({ lat, lon: lng })),
costing: COSTING[mode],
};
const bodyJson = JSON.stringify(body);
let lastErr: unknown;
for (let attempt = 1; attempt <= MATRIX_MAX_ATTEMPTS; attempt++) {
try {
const resp = await fetch(`${VALHALLA_URL}/sources_to_targets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: bodyJson,
signal: AbortSignal.timeout(MATRIX_TIMEOUT_MS),
});
if (!resp.ok) {
// 5xx: service may be restarting — retry
if (resp.status >= 500 && attempt < MATRIX_MAX_ATTEMPTS) {
const delay = Math.min(MATRIX_RETRY_BASE_MS * 2 ** (attempt - 1), MATRIX_RETRY_MAX_MS);
await sleep(delay);
continue;
}
const text = await resp.text();
throw new Error(`Valhalla matrix ${resp.status}: ${text.slice(0, 300)}`);
}
const data = (await resp.json()) as MatrixResponse;
return data.sources_to_targets.map((row) => row.map((cell) => cell.time ?? null));
} catch (err) {
lastErr = err;
if (attempt >= MATRIX_MAX_ATTEMPTS) break;
// TypeError from fetch = network-level failure (ECONNREFUSED, reset, timeout)
// AbortError = our per-request timeout fired
// Both are transient during a service restart.
const isTransient =
err instanceof TypeError ||
(err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError"));
if (!isTransient) throw err;
const delay = Math.min(MATRIX_RETRY_BASE_MS * 2 ** (attempt - 1), MATRIX_RETRY_MAX_MS);
console.warn(
`[valhalla] fetchMatrix attempt ${attempt}/${MATRIX_MAX_ATTEMPTS} failed (${(err as Error).message}) — retrying in ${delay / 1000}s…`,
);
await sleep(delay);
}
}
throw lastErr;
}

10
worker/tsconfig.json Normal file
View file

@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}