initial commit
This commit is contained in:
commit
f56f3048b8
81 changed files with 11103 additions and 0 deletions
22
.env.example
Normal file
22
.env.example
Normal 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
33
.gitignore
vendored
Normal 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
100
Dockerfile
Normal 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
105
README.md
Normal 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): ~5–15 minutes
|
||||||
|
- Large city (1M+ pop): ~30–90 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.
|
||||||
151
apps/web/app/admin/cities/[slug]/page.tsx
Normal file
151
apps/web/app/admin/cities/[slug]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
801
apps/web/app/admin/cities/new/page.tsx
Normal file
801
apps/web/app/admin/cities/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
apps/web/app/admin/jobs/page.tsx
Normal file
140
apps/web/app/admin/jobs/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
apps/web/app/admin/layout.tsx
Normal file
40
apps/web/app/admin/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
apps/web/app/admin/login/page.tsx
Normal file
94
apps/web/app/admin/login/page.tsx
Normal 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
134
apps/web/app/admin/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
apps/web/app/api/admin/cities/[slug]/route.ts
Normal file
41
apps/web/app/api/admin/cities/[slug]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
144
apps/web/app/api/admin/cities/route.ts
Normal file
144
apps/web/app/api/admin/cities/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
38
apps/web/app/api/admin/geofabrik/route.ts
Normal file
38
apps/web/app/api/admin/geofabrik/route.ts
Normal 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" } });
|
||||||
|
}
|
||||||
36
apps/web/app/api/admin/ingest/[slug]/route.ts
Normal file
36
apps/web/app/api/admin/ingest/[slug]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
43
apps/web/app/api/admin/jobs/[id]/route.ts
Normal file
43
apps/web/app/api/admin/jobs/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
146
apps/web/app/api/admin/jobs/[id]/stream/route.ts
Normal file
146
apps/web/app/api/admin/jobs/[id]/stream/route.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
70
apps/web/app/api/admin/jobs/route.ts
Normal file
70
apps/web/app/api/admin/jobs/route.ts
Normal 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);
|
||||||
|
}
|
||||||
45
apps/web/app/api/admin/login/route.ts
Normal file
45
apps/web/app/api/admin/login/route.ts
Normal 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;
|
||||||
|
}
|
||||||
11
apps/web/app/api/admin/logout/route.ts
Normal file
11
apps/web/app/api/admin/logout/route.ts
Normal 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;
|
||||||
|
}
|
||||||
53
apps/web/app/api/cities/route.ts
Normal file
53
apps/web/app/api/cities/route.ts
Normal 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);
|
||||||
|
}
|
||||||
129
apps/web/app/api/grid/route.ts
Normal file
129
apps/web/app/api/grid/route.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
101
apps/web/app/api/isochrones/route.ts
Normal file
101
apps/web/app/api/isochrones/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
173
apps/web/app/api/location-score/route.ts
Normal file
173
apps/web/app/api/location-score/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
112
apps/web/app/api/pois/route.ts
Normal file
112
apps/web/app/api/pois/route.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
79
apps/web/app/api/stats/route.ts
Normal file
79
apps/web/app/api/stats/route.ts
Normal 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);
|
||||||
|
}
|
||||||
89
apps/web/app/api/tiles/grid/[...tile]/route.ts
Normal file
89
apps/web/app/api/tiles/grid/[...tile]/route.ts
Normal 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
26
apps/web/app/globals.css
Normal 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
20
apps/web/app/layout.tsx
Normal 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
249
apps/web/app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
apps/web/components/city-selector.tsx
Normal file
39
apps/web/components/city-selector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
apps/web/components/control-panel.tsx
Normal file
215
apps/web/components/control-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
apps/web/components/location-score-panel.tsx
Normal file
246
apps/web/components/location-score-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/web/components/logout-button.tsx
Normal file
18
apps/web/components/logout-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
apps/web/components/map-legend.tsx
Normal file
84
apps/web/components/map-legend.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
apps/web/components/map-view.tsx
Normal file
286
apps/web/components/map-view.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
48
apps/web/components/stats-bar.tsx
Normal file
48
apps/web/components/stats-bar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
apps/web/hooks/use-job-progress.ts
Normal file
122
apps/web/hooks/use-job-progress.ts
Normal 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;
|
||||||
|
}
|
||||||
72
apps/web/lib/admin-auth.ts
Normal file
72
apps/web/lib/admin-auth.ts
Normal 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
67
apps/web/lib/cache.ts
Normal 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
18
apps/web/lib/db.ts
Normal 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
66
apps/web/lib/queue.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
30
apps/web/lib/rate-limit.ts
Normal file
30
apps/web/lib/rate-limit.ts
Normal 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
42
apps/web/lib/redis.ts
Normal 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
50
apps/web/lib/scoring.ts
Normal 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
53
apps/web/lib/valhalla.ts
Normal 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
30
apps/web/middleware.ts
Normal 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
6
apps/web/next-env.d.ts
vendored
Normal 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
8
apps/web/next.config.ts
Normal 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
35
apps/web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
29
apps/web/public/tiles/style.json
Normal file
29
apps/web/public/tiles/style.json
Normal 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": ""
|
||||||
|
}
|
||||||
55
apps/web/public/tiles/style.pmtiles.json
Normal file
55
apps/web/public/tiles/style.pmtiles.json
Normal 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": ""
|
||||||
|
}
|
||||||
24
apps/web/tailwind.config.ts
Normal file
24
apps/web/tailwind.config.ts
Normal 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
23
apps/web/tsconfig.json
Normal 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
121
docker-compose.yml
Normal 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
153
infra/osm2pgsql.lua
Normal 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
125
infra/schema.sql
Normal 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
3455
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
package.json
Normal file
20
package.json
Normal 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
20
shared/package.json
Normal 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
4
shared/src/index.ts
Normal 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
181
shared/src/osm-tags.ts
Normal 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
276
shared/src/profiles.ts
Normal 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 0–2). */
|
||||||
|
categoryWeights: Record<CategoryId, number>;
|
||||||
|
/**
|
||||||
|
* Per-subcategory importance weights (0–1) 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
107
shared/src/queue.ts
Normal 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
144
shared/src/types.ts
Normal 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
8
shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
16
tsconfig.base.json
Normal file
16
tsconfig.base.json
Normal 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
22
worker/package.json
Normal 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
14
worker/src/db.ts
Normal 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
72
worker/src/index.ts
Normal 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)");
|
||||||
236
worker/src/jobs/build-valhalla.ts
Normal file
236
worker/src/jobs/build-valhalla.ts
Normal 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 (~5–10 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);
|
||||||
|
}
|
||||||
217
worker/src/jobs/compute-routing.ts
Normal file
217
worker/src/jobs/compute-routing.ts
Normal 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()
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
285
worker/src/jobs/compute-scores.ts
Normal file
285
worker/src/jobs/compute-scores.ts
Normal 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);
|
||||||
|
}
|
||||||
102
worker/src/jobs/download-pbf.ts
Normal file
102
worker/src/jobs/download-pbf.ts
Normal 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);
|
||||||
|
}
|
||||||
176
worker/src/jobs/extract-pois.ts
Normal file
176
worker/src/jobs/extract-pois.ts
Normal 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);
|
||||||
|
}
|
||||||
100
worker/src/jobs/generate-grid.ts
Normal file
100
worker/src/jobs/generate-grid.ts
Normal 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);
|
||||||
|
}
|
||||||
128
worker/src/jobs/refresh-city.ts
Normal file
128
worker/src/jobs/refresh-city.ts
Normal 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
14
worker/src/redis.ts
Normal 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
105
worker/src/valhalla-main.ts
Normal 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
95
worker/src/valhalla.ts
Normal 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
10
worker/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue