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