commit f56f3048b89c249c97aebe0869f9315a0a4ba10c Author: Jan-Henrik Bruhn Date: Sun Mar 1 21:58:53 2026 +0100 initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..922a0b1 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20122b8 --- /dev/null +++ b/.gitignore @@ -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* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d5a229a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d130dc0 --- /dev/null +++ b/README.md @@ -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. diff --git a/apps/web/app/admin/cities/[slug]/page.tsx b/apps/web/app/admin/cities/[slug]/page.tsx new file mode 100644 index 0000000..d197d1a --- /dev/null +++ b/apps/web/app/admin/cities/[slug]/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [jobId, setJobId] = useState(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
Loading…
; + if (!city) + return ( +
+ City not found.{" "} + + Back + +
+ ); + + return ( +
+
+ + ← Back + +

{city.name}

+ + {city.slug} + +
+ + {/* Stats */} +
+ {[ + { 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) => ( +
+

{s.value}

+

{s.label}

+
+ ))} +
+ + {/* Source */} +
+

Data Source

+

{city.geofabrik_url}

+
+ + {/* Live progress if ingesting */} + {jobId && ( +
+

Ingestion Progress

+
    + {stages.map((s) => ( +
  1. + + {s.status === "completed" ? "✓" : s.status === "active" ? "…" : "○"} + + + {s.label} + + {s.status === "active" && ( + {s.pct}% + )} +
  2. + ))} +
+ {overall === "completed" && ( +

✓ Ingestion complete!

+ )} +
+ )} + + {/* Actions */} +
+ + +
+
+ ); +} diff --git a/apps/web/app/admin/cities/new/page.tsx b/apps/web/app/admin/cities/new/page.tsx new file mode 100644 index 0000000..10e7a7f --- /dev/null +++ b/apps/web/app/admin/cities/new/page.tsx @@ -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 ( + + ); +} + +// ─── Geofabrik browser ──────────────────────────────────────────────────────── + +function GeofabrikBrowser({ + onSelect, +}: { + onSelect: (f: GeofabrikFeature) => void; +}) { + const [index, setIndex] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [query, setQuery] = useState(""); + const [parent, setParent] = useState(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 ( +
+ Loading Geofabrik region index… +
+ ); + if (error) + return ( +
+ Error loading index: {error} +
+ ); + + return ( +
+

Select a Region

+ +
+ + {grandParent && ( + <> + + + + )} + {parentFeature && ( + <> + + {parentFeature.properties.name} + + )} +
+ + 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" + /> + +
+ {features.length === 0 ? ( +

No regions found.

+ ) : ( + features.map((f) => { + const hasChildren = index!.features.some( + (c) => c.properties.parent === f.properties.id, + ); + return ( +
+
+

+ {f.properties.name} +

+

{f.properties.id}

+
+
+ {hasChildren && ( + + )} + +
+
+ ); + }) + )} +
+
+ ); +} + +// ─── 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([]); + const [selected, setSelected] = useState(null); + const [radius, setRadius] = useState(10); + const [showDropdown, setShowDropdown] = useState(false); + const [searching, setSearching] = useState(false); + const mapContainerRef = useRef(null); + const mapRef = useRef(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 ( +
+ {/* Geocoder */} +
+
+ { + 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 && ( + Searching… + )} +
+ + {showDropdown && results.length > 0 && ( +
+ {results.map((r) => ( + + ))} +
+ )} +
+ + {/* Radius selector */} + {selected && ( +
+ +
+ {RADIUS_OPTIONS.map((r) => ( + + ))} +
+
+ )} + + {/* Mini map */} +
+ + {bbox && ( +

+ ✓ Sub-region: {radius} km around {selected!.display_name.split(",")[0]} — bbox [{bbox.map((v) => v.toFixed(4)).join(", ")}] +

+ )} + {!selected && ( +

+ Search for a location to select a sub-region, or leave empty to use the entire dataset. +

+ )} +
+ ); +} + +// ─── 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; +}) { + 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 ( +
+

Confirm City Details

+ +
+
+
+ + 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" + /> +
+
+ + + 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" + /> +
+
+ +
+ + 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" + /> +
+ + {/* Sub-region selector */} +
+ +

+ 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. +

+ +
+ +
+

Source URL

+

+ {region.properties.urls.pbf} +

+
+
+ +
+ + +
+
+ ); +} + +// ─── Progress step ──────────────────────────────────────────────────────────── + +function ProgressStep({ jobId }: { jobId: string | null }) { + const { stages, overall, error } = useJobProgress(jobId); + + return ( +
+

Processing City Data

+ +
    + {stages.map((stage) => ( +
  1. + +
    +

    {stage.label}

    + {stage.status === "active" && ( + <> +
    +
    +
    +

    + {stage.message} +

    + + )} + {stage.status === "failed" && error && ( +

    {error}

    + )} +
    +
  2. + ))} +
+ + {overall === "completed" && ( +
+ ✓ City ingestion complete!{" "} + + Return to dashboard + {" "} + or{" "} + + view on map + + . +
+ )} + + {overall === "failed" && ( +
+ ✗ Ingestion failed: {error}.{" "} + + Return to dashboard + + . +
+ )} +
+ ); +} + +function StageIcon({ status }: { status: StageStatus["status"] }) { + if (status === "completed") + return ( + + ✓ + + ); + if (status === "failed") + return ( + + ✗ + + ); + if (status === "active") + return ( + + + + + + + ); + return ( + + ); +} + +import type { StageStatus } from "@/hooks/use-job-progress"; + +// ─── Main page ──────────────────────────────────────────────────────────────── + +export default function AddCityPage() { + const [step, setStep] = useState("browse"); + const [selected, setSelected] = useState(null); + const [jobId, setJobId] = useState(null); + const [ingestError, setIngestError] = useState(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 ( +
+

Add City

+

+ Select an OpenStreetMap region to import for 15-minute city analysis. +

+ + + + {ingestError && ( +
+ {ingestError} +
+ )} + + {step === "browse" && ( + { + setSelected(f); + setStep("confirm"); + }} + /> + )} + {step === "confirm" && selected && ( + setStep("browse")} + onConfirm={handleConfirm} + /> + )} + {step === "ingest" && } +
+ ); +} diff --git a/apps/web/app/admin/jobs/page.tsx b/apps/web/app/admin/jobs/page.tsx new file mode 100644 index 0000000..e6fec0f --- /dev/null +++ b/apps/web/app/admin/jobs/page.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { JobSummary } from "@transportationer/shared"; + +const STATE_STYLES: Record = { + 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([]); + 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 ( +
+
+

Job Queue

+ +
+ + {loading ? ( +
Loading…
+ ) : jobs.length === 0 ? ( +
+ No jobs in the queue. +
+ ) : ( +
+ + + + {["ID", "Type", "City", "State", "Progress", "Duration", "Created", "Actions"].map( + (h) => ( + + ), + )} + + + + {jobs.map((job) => ( + + + + + + + + + + + ))} + +
+ {h} +
+ {job.id.slice(0, 8)}… + {job.type}{job.citySlug} + + {job.state} + + + {job.progress ? ( +
+
+
+
+ + {job.progress.pct}% + +
+ ) : ( + "—" + )} +
+ {formatDuration(job.duration)} + + {new Date(job.createdAt).toLocaleTimeString()} + + {job.state !== "active" && ( + + )} + {job.failedReason && ( +

+ {job.failedReason} +

+ )} +
+
+ )} +
+ ); +} diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx new file mode 100644 index 0000000..7bf15cf --- /dev/null +++ b/apps/web/app/admin/layout.tsx @@ -0,0 +1,40 @@ +import Link from "next/link"; +import { LogoutButton } from "@/components/logout-button"; + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/apps/web/app/admin/login/page.tsx b/apps/web/app/admin/login/page.tsx new file mode 100644 index 0000000..27a53ea --- /dev/null +++ b/apps/web/app/admin/login/page.tsx @@ -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(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 ( +
+
+ + 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" + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+ ); +} + +export default function LoginPage() { + return ( +
+
+
+
+

Admin Login

+

Transportationer

+
+ Loading…
}> + + +
+
+
+ ); +} diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx new file mode 100644 index 0000000..2da1aed --- /dev/null +++ b/apps/web/app/admin/page.tsx @@ -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 = { + 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 { + return Promise.resolve(sql` + 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 ( +
+
+
+

City Management

+

+ {cities.length} {cities.length === 1 ? "city" : "cities"} configured +

+
+ + + Add City + +
+ + {cities.length === 0 ? ( +
+

No cities configured yet.

+ + Add your first city + +
+ ) : ( +
+ + + + {["Name", "Country", "POIs", "Grid Points", "Last Ingested", "Status", "Actions"].map( + (h) => ( + + ), + )} + + + + {cities.map((city) => { + const badge = STATUS_STYLES[city.status] ?? STATUS_STYLES.empty; + return ( + + + + + + + + + + ); + })} + +
+ {h} +
{city.name} + {city.country_code || "—"} + + {city.poi_count.toLocaleString()} + + {city.grid_count.toLocaleString()} + + {city.last_ingested + ? new Date(city.last_ingested).toLocaleDateString() + : "Never"} + + + {badge.label} + {city.status === "processing" && ( + + )} + + {city.error_message && ( +

+ {city.error_message} +

+ )} +
+ + Manage + +
+
+ )} +
+ ); +} diff --git a/apps/web/app/api/admin/cities/[slug]/route.ts b/apps/web/app/api/admin/cities/[slug]/route.ts new file mode 100644 index 0000000..f7a3456 --- /dev/null +++ b/apps/web/app/api/admin/cities/[slug]/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/admin/cities/route.ts b/apps/web/app/api/admin/cities/route.ts new file mode 100644 index 0000000..c565643 --- /dev/null +++ b/apps/web/app/api/admin/cities/route.ts @@ -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; + 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 }); +} diff --git a/apps/web/app/api/admin/geofabrik/route.ts b/apps/web/app/api/admin/geofabrik/route.ts new file mode 100644 index 0000000..284ced9 --- /dev/null +++ b/apps/web/app/api/admin/geofabrik/route.ts @@ -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(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" } }); +} diff --git a/apps/web/app/api/admin/ingest/[slug]/route.ts b/apps/web/app/api/admin/ingest/[slug]/route.ts new file mode 100644 index 0000000..52122d0 --- /dev/null +++ b/apps/web/app/api/admin/ingest/[slug]/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/admin/jobs/[id]/route.ts b/apps/web/app/api/admin/jobs/[id]/route.ts new file mode 100644 index 0000000..3d94993 --- /dev/null +++ b/apps/web/app/api/admin/jobs/[id]/route.ts @@ -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(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(queue, id); + if (!job) { + return NextResponse.json({ error: "Job not found" }, { status: 404 }); + } + await job.remove(); + return NextResponse.json({ removed: id }); +} diff --git a/apps/web/app/api/admin/jobs/[id]/stream/route.ts b/apps/web/app/api/admin/jobs/[id]/stream/route.ts new file mode 100644 index 0000000..3ffa5be --- /dev/null +++ b/apps/web/app/api/admin/jobs/[id]/stream/route.ts @@ -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 { + const { id } = await params; + const queue = getPipelineQueue(); + const encoder = new TextEncoder(); + let timer: ReturnType | 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(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", + }, + }); +} diff --git a/apps/web/app/api/admin/jobs/route.ts b/apps/web/app/api/admin/jobs/route.ts new file mode 100644 index 0000000..2999e3b --- /dev/null +++ b/apps/web/app/api/admin/jobs/route.ts @@ -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); +} diff --git a/apps/web/app/api/admin/login/route.ts b/apps/web/app/api/admin/login/route.ts new file mode 100644 index 0000000..14b3a45 --- /dev/null +++ b/apps/web/app/api/admin/login/route.ts @@ -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; +} diff --git a/apps/web/app/api/admin/logout/route.ts b/apps/web/app/api/admin/logout/route.ts new file mode 100644 index 0000000..e1dcc6f --- /dev/null +++ b/apps/web/app/api/admin/logout/route.ts @@ -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; +} diff --git a/apps/web/app/api/cities/route.ts b/apps/web/app/api/cities/route.ts new file mode 100644 index 0000000..6dcc765 --- /dev/null +++ b/apps/web/app/api/cities/route.ts @@ -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(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); +} diff --git a/apps/web/app/api/grid/route.ts b/apps/web/app/api/grid/route.ts new file mode 100644 index 0000000..314c869 --- /dev/null +++ b/apps/web/app/api/grid/route.ts @@ -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; + 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(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> = { + 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" }, + }); +} diff --git a/apps/web/app/api/isochrones/route.ts b/apps/web/app/api/isochrones/route.ts new file mode 100644 index 0000000..19c580b --- /dev/null +++ b/apps/web/app/api/isochrones/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/location-score/route.ts b/apps/web/app/api/location-score/route.ts new file mode 100644 index 0000000..62c77af --- /dev/null +++ b/apps/web/app/api/location-score/route.ts @@ -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 = { + 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> = { + 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> = { + 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> = {}; + 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, + }); +} diff --git a/apps/web/app/api/pois/route.ts b/apps/web/app/api/pois/route.ts new file mode 100644 index 0000000..4856ea2 --- /dev/null +++ b/apps/web/app/api/pois/route.ts @@ -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(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" }, + }); +} diff --git a/apps/web/app/api/stats/route.ts b/apps/web/app/api/stats/route.ts new file mode 100644 index 0000000..86c68ef --- /dev/null +++ b/apps/web/app/api/stats/route.ts @@ -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(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); +} diff --git a/apps/web/app/api/tiles/grid/[...tile]/route.ts b/apps/web/app/api/tiles/grid/[...tile]/route.ts new file mode 100644 index 0000000..f834e11 --- /dev/null +++ b/apps/web/app/api/tiles/grid/[...tile]/route.ts @@ -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 }); + } +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..b2a0529 --- /dev/null +++ b/apps/web/app/globals.css @@ -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; + } +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..bca0b55 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 0000000..82d36f3 --- /dev/null +++ b/apps/web/app/page.tsx @@ -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: () =>
}, +); + +/** 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([]); + const [selectedCity, setSelectedCity] = useState(null); + const [profile, setProfile] = useState("universal"); + const [mode, setMode] = useState("walking"); + const [threshold, setThreshold] = useState(15); + const [weights, setWeights] = useState({ ...PROFILES["universal"].categoryWeights }); + const [activeCategory, setActiveCategory] = useState("composite"); + const [stats, setStats] = useState(null); + + // Pin / location rating + const [pinLocation, setPinLocation] = useState<{ lat: number; lng: number } | null>(null); + const [pinData, setPinData] = useState(null); + const [pinAddress, setPinAddress] = useState(undefined); + + // Overlay mode: isochrone (new default) or relative heatmap + const [overlayMode, setOverlayMode] = useState("isochrone"); + const [isochroneData, setIsochroneData] = useState(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 ( +
+
+

Transportationer

+
+ +
+ Admin +
+
+ +
+ setWeights((prev) => ({ ...prev, [cat]: w }))} + onCategoryChange={setActiveCategory} + /> + +
+ {!selectedCity ? ( +
+
+

Select a city to begin

+

+ Or{" "} + + add a new city + {" "} + in the admin area. +

+
+
+ ) : ( + + )} + + + + {pinData && ( + + )} +
+
+ + +
+ ); +} diff --git a/apps/web/components/city-selector.tsx b/apps/web/components/city-selector.tsx new file mode 100644 index 0000000..3bdc0a0 --- /dev/null +++ b/apps/web/components/city-selector.tsx @@ -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 ( +
+ No cities available. + + Add one in Admin → + +
+ ); + } + + return ( + + ); +} diff --git a/apps/web/components/control-panel.tsx b/apps/web/components/control-panel.tsx new file mode 100644 index 0000000..82ea9b9 --- /dev/null +++ b/apps/web/components/control-panel.tsx @@ -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; + 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 ( + + ); +} diff --git a/apps/web/components/location-score-panel.tsx b/apps/web/components/location-score-panel.tsx new file mode 100644 index 0000000..7172b65 --- /dev/null +++ b/apps/web/components/location-score-panel.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { useState } from "react"; +import type { CategoryId } from "@transportationer/shared"; +import { CATEGORIES } from "@transportationer/shared"; + +type Weights = Record; +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; + distancesM: Partial>; + travelTimesS: Partial>; + subcategoryDetails?: Partial>; +} + +const SUBCATEGORY_LABELS: Record = { + // 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, + 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 = { + 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(null); + const composite = compositeScore(data.categoryScores, weights); + const g = grade(composite); + + return ( +
+ {/* Header: grade + address + close */} +
+
+
{g}
+
{Math.round(composite * 100)} / 100
+ {address && ( +
+ {address} +
+ )} +
+ +
+ + {/* Overlay mode toggle */} +
+ + +
+ + {/* Per-category score bars */} +
+ {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 ( +
+ + {isExpanded && hasDetails && ( +
+ {subcats.map((d) => ( +
+ + + {SUBCATEGORY_LABELS[d.subcategory] ?? d.subcategory} + + {d.name && ( + {d.name} + )} + + + {d.distanceM != null && formatDist(d.distanceM)} + {d.distanceM != null && d.travelTimeS != null && " · "} + {d.travelTimeS != null && formatTime(d.travelTimeS)} + +
+ ))} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/components/logout-button.tsx b/apps/web/components/logout-button.tsx new file mode 100644 index 0000000..f4c3f75 --- /dev/null +++ b/apps/web/components/logout-button.tsx @@ -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 ( + + ); +} diff --git a/apps/web/components/map-legend.tsx b/apps/web/components/map-legend.tsx new file mode 100644 index 0000000..d43db8e --- /dev/null +++ b/apps/web/components/map-legend.tsx @@ -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 ( +
+
Travel time
+
+ 0 min +
+ {threshold} min +
+
+ ); + } + + if (overlayMode === "relative" && hasPinData) { + const stops: [number, string][] = [ + [0, "#d73027"], + [0.25, "#fc8d59"], + [0.5, "#ffffbf"], + [0.75, "#91cf60"], + [1, "#1a9850"], + ]; + return ( +
+
vs. pin
+
+ Worse +
+ Better +
+
+ ); + } + + // Default: absolute accessibility score + return ( +
+
Accessibility
+
+ Low +
+ High +
+
+ ); +} diff --git a/apps/web/components/map-view.tsx b/apps/web/components/map-view.tsx new file mode 100644 index 0000000..9dd6746 --- /dev/null +++ b/apps/web/components/map-view.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import type { CategoryId, TravelMode, ProfileId } from "@transportationer/shared"; + +type Weights = Record; + +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 | 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, +): 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(null); + const mapRef = useRef(null); + const markerRef = useRef(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
; +} diff --git a/apps/web/components/stats-bar.tsx b/apps/web/components/stats-bar.tsx new file mode 100644 index 0000000..02f094d --- /dev/null +++ b/apps/web/components/stats-bar.tsx @@ -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 ( +
+
+ POIs: + {stats.totalPois.toLocaleString()} +
+
+ Grid: + {stats.gridPointCount.toLocaleString()} +
+
+ {displayStats.map((s) => { + const cat = CATEGORIES.find((c) => c.id === s.category); + return ( +
+ {cat && ( + + )} + {cat?.label ?? s.category}: + {s.coveragePct.toFixed(0)}% + within threshold +
+ ); + })} +
+ ); +} diff --git a/apps/web/hooks/use-job-progress.ts b/apps/web/hooks/use-job-progress.ts new file mode 100644 index 0000000..9fc7e4c --- /dev/null +++ b/apps/web/hooks/use-job-progress.ts @@ -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(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; +} diff --git a/apps/web/lib/admin-auth.ts b/apps/web/lib/admin-auth.ts new file mode 100644 index 0000000..cec9d50 --- /dev/null +++ b/apps/web/lib/admin-auth.ts @@ -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 { + const token = await new SignJWT({ ip } satisfies Partial) + .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 { + const token = req.cookies.get(SESSION_COOKIE)?.value; + if (!token) return null; + try { + const { payload } = await jwtVerify(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 { + // 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=/`; +} diff --git a/apps/web/lib/cache.ts b/apps/web/lib/cache.ts new file mode 100644 index 0000000..0ba582e --- /dev/null +++ b/apps/web/lib/cache.ts @@ -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(key: string): Promise { + 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( + key: string, + value: T, + ttlKey: CacheTTLKey, +): Promise { + 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 { + const redis = getRedis(); + try { + const stream = redis.scanStream({ match: pattern, count: 100 }); + const keys: string[] = []; + for await (const batch of stream as AsyncIterable) { + 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 { + 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); +} diff --git a/apps/web/lib/db.ts b/apps/web/lib/db.ts new file mode 100644 index 0000000..e49c152 --- /dev/null +++ b/apps/web/lib/db.ts @@ -0,0 +1,18 @@ +import postgres from "postgres"; + +declare global { + // eslint-disable-next-line no-var + var __pgPool: ReturnType | 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()); diff --git a/apps/web/lib/queue.ts b/apps/web/lib/queue.ts new file mode 100644 index 0000000..ee9ed61 --- /dev/null +++ b/apps/web/lib/queue.ts @@ -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 | undefined; + // eslint-disable-next-line no-var + // eslint-disable-next-line @typescript-eslint/no-explicit-any + var __valhallaQueue: Queue | undefined; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getPipelineQueue(): Queue { + 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 { + 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(), + }); +} diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts new file mode 100644 index 0000000..8917cd3 --- /dev/null +++ b/apps/web/lib/rate-limit.ts @@ -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 { + 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), + }; +} diff --git a/apps/web/lib/redis.ts b/apps/web/lib/redis.ts new file mode 100644 index 0000000..82f108b --- /dev/null +++ b/apps/web/lib/redis.ts @@ -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, + }; +} diff --git a/apps/web/lib/scoring.ts b/apps/web/lib/scoring.ts new file mode 100644 index 0000000..933536a --- /dev/null +++ b/apps/web/lib/scoring.ts @@ -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>, + weights: Record, +): 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[], + weights: Record, +): GridCell[] { + return rows.map((row) => ({ + ...row, + score: compositeScore(row.categoryScores, weights), + })); +} diff --git a/apps/web/lib/valhalla.ts b/apps/web/lib/valhalla.ts new file mode 100644 index 0000000..d3b5945 --- /dev/null +++ b/apps/web/lib/valhalla.ts @@ -0,0 +1,53 @@ +const VALHALLA_BASE = process.env.VALHALLA_URL ?? "http://valhalla:8002"; + +export type ValhallaCosting = "pedestrian" | "bicycle" | "auto"; + +const COSTING_MAP: Record = { + 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 { + 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 { + try { + const res = await fetch(`${VALHALLA_BASE}/status`, { + signal: AbortSignal.timeout(3000), + }); + return res.ok; + } catch { + return false; + } +} diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts new file mode 100644 index 0000000..c04be26 --- /dev/null +++ b/apps/web/middleware.ts @@ -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 { + 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(); +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..0113bdf --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + transpilePackages: ["@transportationer/shared"], + serverExternalPackages: ["ioredis", "bullmq", "postgres", "bcryptjs"], +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..6a2793c --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/public/tiles/style.json b/apps/web/public/tiles/style.json new file mode 100644 index 0000000..93c782a --- /dev/null +++ b/apps/web/public/tiles/style.json @@ -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": "© OpenStreetMap contributors © CARTO", + "maxzoom": 19 + } + }, + "layers": [ + { + "id": "background", + "type": "raster", + "source": "carto-light", + "paint": { + "raster-opacity": 1 + } + } + ], + "glyphs": "https://fonts.openmaptiles.org/{fontstack}/{range}.pbf", + "sprite": "" +} diff --git a/apps/web/public/tiles/style.pmtiles.json b/apps/web/public/tiles/style.pmtiles.json new file mode 100644 index 0000000..29f8451 --- /dev/null +++ b/apps/web/public/tiles/style.pmtiles.json @@ -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": "© OpenStreetMap 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": "" +} diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts new file mode 100644 index 0000000..eb304df --- /dev/null +++ b/apps/web/tailwind.config.ts @@ -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; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..5e716a7 --- /dev/null +++ b/apps/web/tsconfig.json @@ -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"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7c5e971 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/infra/osm2pgsql.lua b/infra/osm2pgsql.lua new file mode 100644 index 0000000..eac061c --- /dev/null +++ b/infra/osm2pgsql.lua @@ -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 diff --git a/infra/schema.sql b/infra/schema.sql new file mode 100644 index 0000000..c280a5b --- /dev/null +++ b/infra/schema.sql @@ -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) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c99382c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3455 @@ +{ + "name": "transportationer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "transportationer", + "version": "0.1.0", + "workspaces": [ + "apps/web", + "worker", + "shared" + ], + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } + }, + "apps/web": { + "name": "@transportationer/web", + "version": "0.1.0", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", + "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@next/env": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.12.tgz", + "integrity": "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz", + "integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz", + "integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz", + "integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz", + "integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz", + "integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz", + "integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz", + "integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.12.tgz", + "integrity": "sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@transportationer/shared": { + "resolved": "shared", + "link": true + }, + "node_modules/@transportationer/web": { + "resolved": "apps/web", + "link": true + }, + "node_modules/@transportationer/worker": { + "resolved": "worker", + "link": true + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/mapbox__point-geometry": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", + "license": "MIT" + }, + "node_modules/@types/mapbox__vector-tile": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/mapbox__point-geometry": "*", + "@types/pbf": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", + "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bullmq": { + "version": "5.70.1", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.70.1.tgz", + "integrity": "sha512-HjfGHfICkAClrFL0Y07qNbWcmiOCv1l+nusupXUjrvTPuDEyPEJ23MP0lUwUs/QEy1a3pWt/P/sCsSZ1RjRK+w==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.9.3", + "msgpackr": "1.11.5", + "node-abort-controller": "3.1.1", + "semver": "7.7.4", + "tslib": "2.8.1", + "uuid": "11.1.0" + } + }, + "node_modules/bullmq/node_modules/@ioredis/commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "license": "MIT" + }, + "node_modules/bullmq/node_modules/ioredis": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.3.tgz", + "integrity": "sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001775", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", + "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", + "license": "ISC" + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global-prefix": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", + "license": "MIT", + "dependencies": { + "ini": "^4.1.3", + "kind-of": "^6.0.3", + "which": "^4.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ioredis": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz", + "integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/maplibre-gl": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", + "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^20.3.1", + "@types/geojson": "^7946.0.14", + "@types/geojson-vt": "3.2.5", + "@types/mapbox__point-geometry": "^0.1.4", + "@types/mapbox__vector-tile": "^1.3.4", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.0", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.3", + "global-prefix": "^4.0.0", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^3.3.0", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0", + "vt-pbf": "^3.1.3" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz", + "integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.12", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.12", + "@next/swc-darwin-x64": "15.5.12", + "@next/swc-linux-arm64-gnu": "15.5.12", + "@next/swc-linux-arm64-musl": "15.5.12", + "@next/swc-linux-x64-gnu": "15.5.12", + "@next/swc-linux-x64-musl": "15.5.12", + "@next/swc-win32-arm64-msvc": "15.5.12", + "@next/swc-win32-x64-msvc": "15.5.12", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pmtiles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-3.2.1.tgz", + "integrity": "sha512-3R4fBwwoli5mw7a6t1IGwOtfmcSAODq6Okz0zkXhS1zi9sz1ssjjIfslwPvcWw5TNhdjNBUg9fgfPLeqZlH6ng==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/leaflet": "^1.9.8", + "fflate": "^0.8.0" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/postgres": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.8.tgz", + "integrity": "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" + } + }, + "node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "shared": { + "name": "@transportationer/shared", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.6.0" + } + }, + "worker": { + "name": "@transportationer/worker", + "version": "0.1.0", + "dependencies": { + "@transportationer/shared": "*", + "bullmq": "^5.13.0", + "ioredis": "^5.4.1", + "postgres": "^3.4.4" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4cdf366 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..311c042 --- /dev/null +++ b/shared/package.json @@ -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" + } +} diff --git a/shared/src/index.ts b/shared/src/index.ts new file mode 100644 index 0000000..f93602b --- /dev/null +++ b/shared/src/index.ts @@ -0,0 +1,4 @@ +export * from "./osm-tags.js"; +export * from "./types.js"; +export * from "./queue.js"; +export * from "./profiles.js"; diff --git a/shared/src/osm-tags.ts b/shared/src/osm-tags.ts new file mode 100644 index 0000000..c7106e8 --- /dev/null +++ b/shared/src/osm-tags.ts @@ -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( + CATEGORIES.map((c) => [c.id, c]), +); + +export const CATEGORY_IDS = CATEGORIES.map((c) => c.id); + +export const DEFAULT_WEIGHTS: Record = Object.fromEntries( + CATEGORIES.map((c) => [c.id, c.defaultWeight]), +) as Record; + +/** 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)]; +} diff --git a/shared/src/profiles.ts b/shared/src/profiles.ts new file mode 100644 index 0000000..7555c7f --- /dev/null +++ b/shared/src/profiles.ts @@ -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; + /** + * 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; +} + +/** 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 = { + // ── 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 = { + 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, + }, + }, +}; diff --git a/shared/src/queue.ts b/shared/src/queue.ts new file mode 100644 index 0000000..1ef2429 --- /dev/null +++ b/shared/src/queue.ts @@ -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 = { + "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 }, + }, +}; diff --git a/shared/src/types.ts b/shared/src/types.ts new file mode 100644 index 0000000..47bfa6e --- /dev/null +++ b/shared/src/types.ts @@ -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>; +} + +export interface HeatmapPayload { + citySlug: string; + travelMode: TravelMode; + thresholdMin: number; + weights: Record; + 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; +} diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 0000000..54af6fa --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..517bdf0 --- /dev/null +++ b/tsconfig.base.json @@ -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 + } +} diff --git a/worker/package.json b/worker/package.json new file mode 100644 index 0000000..84fedd7 --- /dev/null +++ b/worker/package.json @@ -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" + } +} diff --git a/worker/src/db.ts b/worker/src/db.ts new file mode 100644 index 0000000..3274ec9 --- /dev/null +++ b/worker/src/db.ts @@ -0,0 +1,14 @@ +import postgres from "postgres"; + +let _sql: ReturnType | null = null; + +export function getSql(): ReturnType { + if (!_sql) { + _sql = postgres(process.env.DATABASE_URL!, { + max: 20, + idle_timeout: 30, + connect_timeout: 15, + }); + } + return _sql; +} diff --git a/worker/src/index.ts b/worker/src/index.ts new file mode 100644 index 0000000..02090bb --- /dev/null +++ b/worker/src/index.ts @@ -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( + "pipeline", + async (job: Job, 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); + case "extract-pois": + return handleExtractPois(job as Job); + case "generate-grid": + return handleGenerateGrid(job as Job); + case "compute-scores": + return handleComputeScores(job as Job, token); + case "compute-routing": + return handleComputeRouting(job as Job); + case "refresh-city": + return handleRefreshCity(job as Job); + 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)"); diff --git a/worker/src/jobs/build-valhalla.ts b/worker/src/jobs/build-valhalla.ts new file mode 100644 index 0000000..9fdb8a0 --- /dev/null +++ b/worker/src/jobs/build-valhalla.ts @@ -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 { + try { + return JSON.parse(readFileSync(ROUTING_MANIFEST, "utf8")) as Record; + } catch { + return {}; + } +} + +function writeManifest(manifest: Record): void { + writeFileSync(ROUTING_MANIFEST, JSON.stringify(manifest, null, 2)); +} + +function runProcess(cmd: string, args: string[]): Promise { + 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; + +/** 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, + restartService: () => Promise, +): Promise { + 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); +} diff --git a/worker/src/jobs/compute-routing.ts b/worker/src/jobs/compute-routing.ts new file mode 100644 index 0000000..9918e54 --- /dev/null +++ b/worker/src/jobs/compute-routing.ts @@ -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( + concurrency: number, + items: T[], + fn: (item: T) => Promise, +): Promise { + const queue = [...items]; + async function worker(): Promise { + 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): Promise { + 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>(); + 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(); + 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(); + 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() + `); + } +} diff --git a/worker/src/jobs/compute-scores.ts b/worker/src/jobs/compute-scores.ts new file mode 100644 index 0000000..f30b1b3 --- /dev/null +++ b/worker/src/jobs/compute-scores.ts @@ -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, + token?: string, +): Promise { + 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(); + + 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); +} diff --git a/worker/src/jobs/download-pbf.ts b/worker/src/jobs/download-pbf.ts new file mode 100644 index 0000000..f9e7ede --- /dev/null +++ b/worker/src/jobs/download-pbf.ts @@ -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, +): Promise { + 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); +} diff --git a/worker/src/jobs/extract-pois.ts b/worker/src/jobs/extract-pois.ts new file mode 100644 index 0000000..3bf9a80 --- /dev/null +++ b/worker/src/jobs/extract-pois.ts @@ -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 = {}, + onLine?: (line: string) => void, +): Promise { + 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, +): Promise { + 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); +} diff --git a/worker/src/jobs/generate-grid.ts b/worker/src/jobs/generate-grid.ts new file mode 100644 index 0000000..e175474 --- /dev/null +++ b/worker/src/jobs/generate-grid.ts @@ -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, +): Promise { + 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); +} diff --git a/worker/src/jobs/refresh-city.ts b/worker/src/jobs/refresh-city.ts new file mode 100644 index 0000000..77cea31 --- /dev/null +++ b/worker/src/jobs/refresh-city.ts @@ -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, +): Promise { + 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); +} diff --git a/worker/src/redis.ts b/worker/src/redis.ts new file mode 100644 index 0000000..fa34546 --- /dev/null +++ b/worker/src/redis.ts @@ -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, + }; +} diff --git a/worker/src/valhalla-main.ts b/worker/src/valhalla-main.ts new file mode 100644 index 0000000..a5fe275 --- /dev/null +++ b/worker/src/valhalla-main.ts @@ -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 [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 { + 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 { + 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"); diff --git a/worker/src/valhalla.ts b/worker/src/valhalla.ts new file mode 100644 index 0000000..128003f --- /dev/null +++ b/worker/src/valhalla.ts @@ -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((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; +} diff --git a/worker/tsconfig.json b/worker/tsconfig.json new file mode 100644 index 0000000..1e6d2a9 --- /dev/null +++ b/worker/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +}