feat: add selection by city boundary, show that, stats only for city, not bbox
This commit is contained in:
parent
b891ca79ac
commit
e94d660686
12 changed files with 482 additions and 302 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||||
import type { GeofabrikFeature, GeofabrikIndex } from "@transportationer/shared";
|
import type { GeofabrikFeature, GeofabrikIndex } from "@transportationer/shared";
|
||||||
import { useJobProgress } from "@/hooks/use-job-progress";
|
import { useJobProgress } from "@/hooks/use-job-progress";
|
||||||
|
import type { StageStatus, RoutingDetail as RoutingDetailType } from "@/hooks/use-job-progress";
|
||||||
|
|
||||||
type Step = "browse" | "confirm" | "ingest";
|
type Step = "browse" | "confirm" | "ingest";
|
||||||
|
|
||||||
|
|
@ -186,7 +187,7 @@ function GeofabrikBrowser({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Nominatim geocoder + radius selector + mini map ─────────────────────────
|
// ─── Nominatim polygon geocoder + mini map ───────────────────────────────────
|
||||||
|
|
||||||
interface NominatimResult {
|
interface NominatimResult {
|
||||||
place_id: number;
|
place_id: number;
|
||||||
|
|
@ -195,71 +196,34 @@ interface NominatimResult {
|
||||||
lon: string;
|
lon: string;
|
||||||
type: string;
|
type: string;
|
||||||
class: string;
|
class: string;
|
||||||
}
|
geojson?: { type: string; coordinates: unknown };
|
||||||
|
|
||||||
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({
|
function LocationSelector({
|
||||||
regionGeometry,
|
regionGeometry,
|
||||||
onBboxChange,
|
onBoundaryChange,
|
||||||
}: {
|
}: {
|
||||||
regionGeometry: GeofabrikFeature["geometry"];
|
regionGeometry: GeofabrikFeature["geometry"];
|
||||||
onBboxChange: (bbox: [number, number, number, number] | null) => void;
|
onBoundaryChange: (boundary: { type: string; coordinates: unknown } | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<NominatimResult[]>([]);
|
const [results, setResults] = useState<NominatimResult[]>([]);
|
||||||
const [selected, setSelected] = useState<NominatimResult | null>(null);
|
const [selected, setSelected] = useState<NominatimResult | null>(null);
|
||||||
const [radius, setRadius] = useState(10);
|
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<import("maplibre-gl").Map | null>(null);
|
const mapRef = useRef<import("maplibre-gl").Map | null>(null);
|
||||||
const mapReadyRef = useRef(false);
|
const mapReadyRef = useRef(false);
|
||||||
|
|
||||||
const onBboxChangeRef = useRef(onBboxChange);
|
const onBoundaryChangeRef = useRef(onBoundaryChange);
|
||||||
onBboxChangeRef.current = onBboxChange;
|
onBoundaryChangeRef.current = onBoundaryChange;
|
||||||
|
|
||||||
const bbox = useMemo((): [number, number, number, number] | null => {
|
// Notify parent when selection changes
|
||||||
if (!selected) return null;
|
|
||||||
return computeBbox(parseFloat(selected.lat), parseFloat(selected.lon), radius);
|
|
||||||
}, [selected, radius]);
|
|
||||||
|
|
||||||
// Notify parent when bbox changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onBboxChangeRef.current(bbox);
|
onBoundaryChangeRef.current(selected?.geojson ?? null);
|
||||||
}, [bbox]);
|
}, [selected]);
|
||||||
|
|
||||||
// Debounced Nominatim search
|
// Debounced Nominatim search — request polygon_geojson + featuretype=settlement
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
|
@ -269,11 +233,15 @@ function LocationSelector({
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=6&addressdetails=0`,
|
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&polygon_geojson=1&featuretype=settlement&format=json&limit=8`,
|
||||||
{ headers: { "User-Agent": "Transportationer/1.0 (15-minute city analyzer)" } },
|
{ headers: { "User-Agent": "Transportationer/1.0 (15-minute city analyzer)" } },
|
||||||
);
|
);
|
||||||
const data: NominatimResult[] = await res.json();
|
const data: NominatimResult[] = await res.json();
|
||||||
setResults(data);
|
// Keep only results that have a real polygon boundary
|
||||||
|
const polygons = data.filter(
|
||||||
|
(r) => r.geojson?.type === "Polygon" || r.geojson?.type === "MultiPolygon",
|
||||||
|
);
|
||||||
|
setResults(polygons);
|
||||||
setShowDropdown(true);
|
setShowDropdown(true);
|
||||||
} catch {
|
} catch {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
|
@ -284,15 +252,27 @@ function LocationSelector({
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
// Apply bbox to mini map
|
// Apply boundary polygon to mini map
|
||||||
const applyBboxToMap = useCallback(
|
const applyBoundaryToMap = useCallback(
|
||||||
(map: import("maplibre-gl").Map, b: [number, number, number, number] | null) => {
|
(map: import("maplibre-gl").Map, geojson: NominatimResult["geojson"] | null) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const src = map.getSource("area-bbox") as any;
|
const src = map.getSource("area-boundary") as any;
|
||||||
if (!src) return;
|
if (!src) return;
|
||||||
if (b) {
|
if (geojson) {
|
||||||
src.setData(bboxToGeoJSON(b));
|
src.setData({ type: "Feature", geometry: geojson, properties: {} });
|
||||||
map.fitBounds([b[0], b[1], b[2], b[3]], { padding: 40, duration: 500 });
|
// Fit to boundary bbox
|
||||||
|
try {
|
||||||
|
const coords: number[][] =
|
||||||
|
geojson.type === "Polygon"
|
||||||
|
? (geojson.coordinates as number[][][])[0]
|
||||||
|
: (geojson.coordinates as number[][][][])[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: 40, duration: 500 },
|
||||||
|
);
|
||||||
|
} catch { /* ignore */ }
|
||||||
} else {
|
} else {
|
||||||
src.setData({ type: "FeatureCollection", features: [] });
|
src.setData({ type: "FeatureCollection", features: [] });
|
||||||
}
|
}
|
||||||
|
|
@ -300,11 +280,11 @@ function LocationSelector({
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update mini map when bbox changes
|
// Update mini map when selection changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (map && mapReadyRef.current) applyBboxToMap(map, bbox);
|
if (map && mapReadyRef.current) applyBoundaryToMap(map, selected?.geojson ?? null);
|
||||||
}, [bbox, applyBboxToMap]);
|
}, [selected, applyBoundaryToMap]);
|
||||||
|
|
||||||
// Initialize mini map
|
// Initialize mini map
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -357,49 +337,39 @@ function LocationSelector({
|
||||||
paint: { "line-color": "#64748b", "line-width": 1.5, "line-dasharray": [3, 2] },
|
paint: { "line-color": "#64748b", "line-width": 1.5, "line-dasharray": [3, 2] },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Selected sub-area bbox
|
// Selected city boundary polygon
|
||||||
map.addSource("area-bbox", {
|
map.addSource("area-boundary", {
|
||||||
type: "geojson",
|
type: "geojson",
|
||||||
data: { type: "FeatureCollection", features: [] },
|
data: { type: "FeatureCollection", features: [] },
|
||||||
});
|
});
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: "area-bbox-fill",
|
id: "area-boundary-fill",
|
||||||
type: "fill",
|
type: "fill",
|
||||||
source: "area-bbox",
|
source: "area-boundary",
|
||||||
paint: { "fill-color": "#2563eb", "fill-opacity": 0.15 },
|
paint: { "fill-color": "#2563eb", "fill-opacity": 0.15 },
|
||||||
});
|
});
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: "area-bbox-line",
|
id: "area-boundary-line",
|
||||||
type: "line",
|
type: "line",
|
||||||
source: "area-bbox",
|
source: "area-boundary",
|
||||||
paint: { "line-color": "#2563eb", "line-width": 2 },
|
paint: { "line-color": "#2563eb", "line-width": 2 },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fit to region if available
|
// Fit to region if available
|
||||||
if (regionGeometry) {
|
if (regionGeometry) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
try {
|
||||||
const src = map.getSource("area-region") as any;
|
const coords: number[][] =
|
||||||
if (src) {
|
regionGeometry.type === "Polygon"
|
||||||
// Fit map to the region outline
|
? regionGeometry.coordinates[0]
|
||||||
try {
|
: regionGeometry.coordinates[0][0];
|
||||||
const coords: number[][] =
|
const lngs = coords.map((c) => c[0]);
|
||||||
regionGeometry.type === "Polygon"
|
const lats = coords.map((c) => c[1]);
|
||||||
? regionGeometry.coordinates[0]
|
map.fitBounds(
|
||||||
: regionGeometry.coordinates[0][0];
|
[Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)],
|
||||||
const lngs = coords.map((c) => c[0]);
|
{ padding: 20 },
|
||||||
const lats = coords.map((c) => c[1]);
|
);
|
||||||
map.fitBounds(
|
} catch { /* ignore */ }
|
||||||
[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);
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
@ -418,7 +388,7 @@ function LocationSelector({
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search for a city or location…"
|
placeholder="Search for a city or municipality…"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value);
|
setQuery(e.target.value);
|
||||||
|
|
@ -451,35 +421,13 @@ function LocationSelector({
|
||||||
<span className="text-gray-400 ml-1 text-xs">
|
<span className="text-gray-400 ml-1 text-xs">
|
||||||
{r.display_name.split(",").slice(1, 3).join(",")}
|
{r.display_name.split(",").slice(1, 3).join(",")}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="ml-2 text-xs text-blue-500">{r.geojson?.type}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Radius selector */}
|
|
||||||
{selected && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<label className="text-sm text-gray-600 shrink-0">Radius:</label>
|
|
||||||
<div className="flex gap-1.5 flex-wrap">
|
|
||||||
{RADIUS_OPTIONS.map((r) => (
|
|
||||||
<button
|
|
||||||
key={r}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRadius(r)}
|
|
||||||
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition-colors ${
|
|
||||||
radius === r
|
|
||||||
? "bg-brand-600 text-white border-brand-600"
|
|
||||||
: "bg-white text-gray-600 border-gray-300 hover:border-brand-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{r} km
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mini map */}
|
{/* Mini map */}
|
||||||
<div
|
<div
|
||||||
ref={mapContainerRef}
|
ref={mapContainerRef}
|
||||||
|
|
@ -487,14 +435,14 @@ function LocationSelector({
|
||||||
style={{ height: 220 }}
|
style={{ height: 220 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{bbox && (
|
{selected?.geojson && (
|
||||||
<p className="text-xs text-green-700">
|
<p className="text-xs text-green-700">
|
||||||
✓ Sub-region: {radius} km around {selected!.display_name.split(",")[0]} — bbox [{bbox.map((v) => v.toFixed(4)).join(", ")}]
|
✓ Boundary: {selected.display_name.split(",").slice(0, 2).join(", ")} ({selected.geojson.type})
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!selected && (
|
{!selected && (
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
Search for a location to select a sub-region, or leave empty to use the entire dataset.
|
Search for a city to use its administrative boundary for stats coverage, or leave empty to use the entire dataset.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -514,18 +462,18 @@ function ConfirmStep({
|
||||||
slug: string,
|
slug: string,
|
||||||
name: string,
|
name: string,
|
||||||
countryCode: string,
|
countryCode: string,
|
||||||
bbox: [number, number, number, number] | null,
|
boundary: { type: string; coordinates: unknown } | null,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const defaultSlug = region.properties.id.replace(/\//g, "-");
|
const defaultSlug = region.properties.id.replace(/\//g, "-");
|
||||||
const [slug, setSlug] = useState(defaultSlug);
|
const [slug, setSlug] = useState(defaultSlug);
|
||||||
const [name, setName] = useState(region.properties.name);
|
const [name, setName] = useState(region.properties.name);
|
||||||
const [countryCode, setCountryCode] = useState("");
|
const [countryCode, setCountryCode] = useState("");
|
||||||
const [bbox, setBbox] = useState<[number, number, number, number] | null>(null);
|
const [boundary, setBoundary] = useState<{ type: string; coordinates: unknown } | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handleBboxChange = useCallback(
|
const handleBoundaryChange = useCallback(
|
||||||
(b: [number, number, number, number] | null) => setBbox(b),
|
(b: { type: string; coordinates: unknown } | null) => setBoundary(b),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -572,20 +520,20 @@ function ConfirmStep({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sub-region selector */}
|
{/* City boundary selector */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Sub-region{" "}
|
City Boundary{" "}
|
||||||
<span className="text-gray-400 font-normal">(optional — clip to a city area)</span>
|
<span className="text-gray-400 font-normal">(optional — for accurate coverage stats)</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-gray-500 mb-3">
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
Select a city center and radius to ingest only that sub-area of the dataset.
|
Search for the city municipality to use its administrative boundary for
|
||||||
Recommended for large regions (e.g. entire states). Affects OSM import,
|
coverage statistics. The bounding box is still used for OSM import, routing,
|
||||||
routing tiles, and grid analysis.
|
and grid generation.
|
||||||
</p>
|
</p>
|
||||||
<LocationSelector
|
<LocationSelector
|
||||||
regionGeometry={region.geometry}
|
regionGeometry={region.geometry}
|
||||||
onBboxChange={handleBboxChange}
|
onBoundaryChange={handleBoundaryChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -604,7 +552,7 @@ function ConfirmStep({
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await onConfirm(slug, name, countryCode, bbox);
|
await onConfirm(slug, name, countryCode, boundary);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}}
|
}}
|
||||||
disabled={loading || !slug || !name}
|
disabled={loading || !slug || !name}
|
||||||
|
|
@ -619,39 +567,147 @@ function ConfirmStep({
|
||||||
|
|
||||||
// ─── Progress step ────────────────────────────────────────────────────────────
|
// ─── Progress step ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
function StageIcon({ status }: { status: StageStatus["status"] }) {
|
||||||
|
if (status === "completed")
|
||||||
|
return (
|
||||||
|
<span className="w-5 h-5 flex items-center justify-center rounded-full bg-green-100 text-green-600 text-xs shrink-0">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
if (status === "failed")
|
||||||
|
return (
|
||||||
|
<span className="w-5 h-5 flex items-center justify-center rounded-full bg-red-100 text-red-600 text-xs shrink-0">
|
||||||
|
✗
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
if (status === "active")
|
||||||
|
return (
|
||||||
|
<span className="w-5 h-5 flex items-center justify-center shrink-0">
|
||||||
|
<svg className="animate-spin w-4 h-4 text-brand-600" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" opacity="0.25" />
|
||||||
|
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return <span className="w-5 h-5 rounded-full border-2 border-gray-300 shrink-0" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StageRow({
|
||||||
|
stage,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
stage: StageStatus;
|
||||||
|
error?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<StageIcon status={stage.status} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={`text-sm font-medium ${stage.status === "pending" ? "text-gray-400" : "text-gray-900"}`}>
|
||||||
|
{stage.label}
|
||||||
|
</p>
|
||||||
|
{stage.status === "active" && (
|
||||||
|
<>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-1 mt-1.5">
|
||||||
|
<div
|
||||||
|
className="bg-brand-600 h-1 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${stage.pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5 truncate">{stage.message}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{stage.status === "failed" && error && (
|
||||||
|
<p className="text-xs text-red-600 mt-0.5">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoutingGrid({ routingDetail }: { routingDetail: RoutingDetailType }) {
|
||||||
|
const MODE_LABELS: Record<string, string> = {
|
||||||
|
walking: "Walking",
|
||||||
|
cycling: "Cycling",
|
||||||
|
driving: "Driving",
|
||||||
|
transit: "Transit",
|
||||||
|
};
|
||||||
|
const entries = Object.entries(routingDetail);
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="mt-2 space-y-1.5 pl-8">
|
||||||
|
{entries.map(([mode, { done, total }]) => (
|
||||||
|
<div key={mode} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500 w-14 shrink-0">{MODE_LABELS[mode] ?? mode}</span>
|
||||||
|
<div className="flex-1 bg-gray-200 rounded-full h-1.5">
|
||||||
|
<div
|
||||||
|
className="bg-brand-500 h-1.5 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: total > 0 ? `${(done / total) * 100}%` : done > 0 ? "100%" : "0%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-400 w-10 text-right shrink-0">
|
||||||
|
{total > 1 ? `${done}/${total}` : done >= 1 ? "done" : "…"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ProgressStep({ jobId }: { jobId: string | null }) {
|
function ProgressStep({ jobId }: { jobId: string | null }) {
|
||||||
const { stages, overall, error } = useJobProgress(jobId);
|
const { stages, overall, error, routingDetail } = useJobProgress(jobId);
|
||||||
|
|
||||||
|
// Group consecutive parallel stages together for rendering
|
||||||
|
type StageGroup =
|
||||||
|
| { kind: "single"; stage: StageStatus }
|
||||||
|
| { kind: "parallel"; stages: StageStatus[] };
|
||||||
|
|
||||||
|
const groups: StageGroup[] = [];
|
||||||
|
for (const stage of stages) {
|
||||||
|
if (stage.parallelGroup) {
|
||||||
|
const last = groups[groups.length - 1];
|
||||||
|
if (last?.kind === "parallel" && last.stages[0].parallelGroup === stage.parallelGroup) {
|
||||||
|
last.stages.push(stage);
|
||||||
|
} else {
|
||||||
|
groups.push({ kind: "parallel", stages: [stage] });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
groups.push({ kind: "single", stage });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card max-w-lg">
|
<div className="card max-w-lg">
|
||||||
<h2 className="text-lg font-semibold mb-6">Processing City Data</h2>
|
<h2 className="text-lg font-semibold mb-6">Processing City Data</h2>
|
||||||
|
|
||||||
<ol className="space-y-4">
|
<div className="space-y-4">
|
||||||
{stages.map((stage) => (
|
{groups.map((group, gi) =>
|
||||||
<li key={stage.key} className="flex items-start gap-3">
|
group.kind === "single" ? (
|
||||||
<StageIcon status={stage.status} />
|
<div key={group.stage.key}>
|
||||||
<div className="flex-1 min-w-0">
|
<StageRow stage={group.stage} error={error} />
|
||||||
<p className="text-sm font-medium text-gray-900">{stage.label}</p>
|
{/* Show per-mode routing grid under the compute-accessibility stage */}
|
||||||
{stage.status === "active" && (
|
{group.stage.key === "Computing scores" &&
|
||||||
<>
|
group.stage.status === "active" &&
|
||||||
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-1.5">
|
routingDetail && (
|
||||||
<div
|
<RoutingGrid routingDetail={routingDetail} />
|
||||||
className="bg-brand-600 h-1.5 rounded-full transition-all duration-500"
|
)}
|
||||||
style={{ width: `${stage.pct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-1 truncate">
|
|
||||||
{stage.message}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{stage.status === "failed" && error && (
|
|
||||||
<p className="text-xs text-red-600 mt-1">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
) : (
|
||||||
))}
|
<div
|
||||||
</ol>
|
key={`group-${gi}`}
|
||||||
|
className="rounded-lg border border-gray-200 bg-gray-50 p-3 space-y-3"
|
||||||
|
>
|
||||||
|
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">
|
||||||
|
Running in parallel
|
||||||
|
</p>
|
||||||
|
{group.stages.map((s) => (
|
||||||
|
<StageRow key={s.key} stage={s} error={error} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{overall === "completed" && (
|
{overall === "completed" && (
|
||||||
<div className="mt-6 p-4 bg-green-50 rounded-lg text-green-800 text-sm">
|
<div className="mt-6 p-4 bg-green-50 rounded-lg text-green-800 text-sm">
|
||||||
|
|
@ -669,7 +725,7 @@ function ProgressStep({ jobId }: { jobId: string | null }) {
|
||||||
|
|
||||||
{overall === "failed" && (
|
{overall === "failed" && (
|
||||||
<div className="mt-6 p-4 bg-red-50 rounded-lg text-red-800 text-sm">
|
<div className="mt-6 p-4 bg-red-50 rounded-lg text-red-800 text-sm">
|
||||||
✗ Ingestion failed: {error}.{" "}
|
✗ Ingestion failed: {error}{" "}
|
||||||
<a href="/admin" className="underline">
|
<a href="/admin" className="underline">
|
||||||
Return to dashboard
|
Return to dashboard
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -680,51 +736,6 @@ function ProgressStep({ jobId }: { jobId: string | null }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StageIcon({ status }: { status: StageStatus["status"] }) {
|
|
||||||
if (status === "completed")
|
|
||||||
return (
|
|
||||||
<span className="w-5 h-5 flex items-center justify-center rounded-full bg-green-100 text-green-600 text-xs mt-0.5 shrink-0">
|
|
||||||
✓
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
if (status === "failed")
|
|
||||||
return (
|
|
||||||
<span className="w-5 h-5 flex items-center justify-center rounded-full bg-red-100 text-red-600 text-xs mt-0.5 shrink-0">
|
|
||||||
✗
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
if (status === "active")
|
|
||||||
return (
|
|
||||||
<span className="w-5 h-5 flex items-center justify-center mt-0.5 shrink-0">
|
|
||||||
<svg
|
|
||||||
className="animate-spin w-4 h-4 text-brand-600"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
opacity="0.25"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M12 2a10 10 0 0 1 10 10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<span className="w-5 h-5 rounded-full border-2 border-gray-300 mt-0.5 shrink-0" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
import type { StageStatus } from "@/hooks/use-job-progress";
|
|
||||||
|
|
||||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AddCityPage() {
|
export default function AddCityPage() {
|
||||||
|
|
@ -737,7 +748,7 @@ export default function AddCityPage() {
|
||||||
slug: string,
|
slug: string,
|
||||||
name: string,
|
name: string,
|
||||||
countryCode: string,
|
countryCode: string,
|
||||||
bbox: [number, number, number, number] | null,
|
boundary: { type: string; coordinates: unknown } | null,
|
||||||
) => {
|
) => {
|
||||||
setIngestError(null);
|
setIngestError(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -749,7 +760,7 @@ export default function AddCityPage() {
|
||||||
name,
|
name,
|
||||||
countryCode,
|
countryCode,
|
||||||
geofabrikUrl: selected!.properties.urls.pbf,
|
geofabrikUrl: selected!.properties.urls.pbf,
|
||||||
...(bbox ? { bbox } : {}),
|
...(boundary ? { boundary } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export async function POST(req: NextRequest) {
|
||||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, slug, countryCode, geofabrikUrl, resolutionM = 200, bbox } = body;
|
const { name, slug, countryCode, geofabrikUrl, resolutionM = 200, bbox, boundary } = body;
|
||||||
|
|
||||||
if (typeof slug !== "string" || !/^[a-z0-9-]+$/.test(slug)) {
|
if (typeof slug !== "string" || !/^[a-z0-9-]+$/.test(slug)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|
@ -67,9 +67,20 @@ export async function POST(req: NextRequest) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate optional boundary GeoJSON (Polygon or MultiPolygon)
|
||||||
|
if (boundary !== undefined) {
|
||||||
|
const b = boundary as Record<string, unknown>;
|
||||||
|
if (b.type !== "Polygon" && b.type !== "MultiPolygon") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "boundary must be a GeoJSON Polygon or MultiPolygon" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate optional bbox [minLng, minLat, maxLng, maxLat]
|
// Validate optional bbox [minLng, minLat, maxLng, maxLat]
|
||||||
let validBbox: [number, number, number, number] | null = null;
|
let validBbox: [number, number, number, number] | null = null;
|
||||||
if (bbox !== undefined) {
|
if (bbox !== undefined && boundary === undefined) {
|
||||||
if (
|
if (
|
||||||
!Array.isArray(bbox) ||
|
!Array.isArray(bbox) ||
|
||||||
bbox.length !== 4 ||
|
bbox.length !== 4 ||
|
||||||
|
|
@ -90,7 +101,27 @@ export async function POST(req: NextRequest) {
|
||||||
validBbox = [minLng, minLat, maxLng, maxLat];
|
validBbox = [minLng, minLat, maxLng, maxLat];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validBbox) {
|
if (boundary !== undefined) {
|
||||||
|
const boundaryJson = JSON.stringify(boundary);
|
||||||
|
await Promise.resolve(sql`
|
||||||
|
INSERT INTO cities (slug, name, country_code, geofabrik_url, bbox, boundary, status)
|
||||||
|
VALUES (
|
||||||
|
${slug as string},
|
||||||
|
${(name as string) ?? slug},
|
||||||
|
${(countryCode as string) ?? ""},
|
||||||
|
${geofabrikUrl},
|
||||||
|
ST_Envelope(ST_GeomFromGeoJSON(${boundaryJson})),
|
||||||
|
ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(${boundaryJson}), 4326)),
|
||||||
|
'pending'
|
||||||
|
)
|
||||||
|
ON CONFLICT (slug) DO UPDATE
|
||||||
|
SET status = 'pending',
|
||||||
|
geofabrik_url = EXCLUDED.geofabrik_url,
|
||||||
|
bbox = EXCLUDED.bbox,
|
||||||
|
boundary = EXCLUDED.boundary,
|
||||||
|
error_message = NULL
|
||||||
|
`);
|
||||||
|
} else if (validBbox) {
|
||||||
const [minLng, minLat, maxLng, maxLat] = validBbox;
|
const [minLng, minLat, maxLng, maxLat] = validBbox;
|
||||||
await Promise.resolve(sql`
|
await Promise.resolve(sql`
|
||||||
INSERT INTO cities (slug, name, country_code, geofabrik_url, bbox, status)
|
INSERT INTO cities (slug, name, country_code, geofabrik_url, bbox, status)
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@ import { NextRequest } from "next/server";
|
||||||
import { Job } from "bullmq";
|
import { Job } from "bullmq";
|
||||||
import { getPipelineQueue, getValhallaQueue } from "@/lib/queue";
|
import { getPipelineQueue, getValhallaQueue } from "@/lib/queue";
|
||||||
import type { PipelineJobData, JobProgress, ComputeScoresJobData, RefreshCityJobData } from "@/lib/queue";
|
import type { PipelineJobData, JobProgress, ComputeScoresJobData, RefreshCityJobData } from "@/lib/queue";
|
||||||
import type { SSEEvent } from "@transportationer/shared";
|
import type { SSEEvent, RoutingDetail } from "@transportationer/shared";
|
||||||
import { CATEGORY_IDS } from "@transportationer/shared";
|
import { CATEGORY_IDS } from "@transportationer/shared";
|
||||||
|
import { sql } from "@/lib/db";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
|
@ -79,7 +80,7 @@ export async function GET(
|
||||||
const [pipelineActive, valhallaActive, waitingChildren] = await Promise.all([
|
const [pipelineActive, valhallaActive, waitingChildren] = await Promise.all([
|
||||||
queue.getActive(0, 100),
|
queue.getActive(0, 100),
|
||||||
valhallaQueue.getActive(0, 100),
|
valhallaQueue.getActive(0, 100),
|
||||||
queue.getWaitingChildren(0, 100),
|
queue.getWaitingChildren(0, 200),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 1a. Parallel routing phase: compute-scores is waiting for its routing
|
// 1a. Parallel routing phase: compute-scores is waiting for its routing
|
||||||
|
|
@ -87,8 +88,6 @@ export async function GET(
|
||||||
// Only enter this branch when routingDispatched=true (Phase 1 has run).
|
// Only enter this branch when routingDispatched=true (Phase 1 has run).
|
||||||
// Before that, compute-scores is in waiting-children while generate-grid
|
// Before that, compute-scores is in waiting-children while generate-grid
|
||||||
// is running — fall through to the sequential active-job check instead.
|
// is running — fall through to the sequential active-job check instead.
|
||||||
// Match by job ID (exact) when available; fall back to citySlug for the
|
|
||||||
// brief window before computeScoresJobId is written to the job record.
|
|
||||||
const csWaiting = waitingChildren.find(
|
const csWaiting = waitingChildren.find(
|
||||||
(j) =>
|
(j) =>
|
||||||
j.data.type === "compute-scores" &&
|
j.data.type === "compute-scores" &&
|
||||||
|
|
@ -97,55 +96,95 @@ export async function GET(
|
||||||
);
|
);
|
||||||
if (csWaiting) {
|
if (csWaiting) {
|
||||||
const csData = csWaiting.data as ComputeScoresJobData;
|
const csData = csWaiting.data as ComputeScoresJobData;
|
||||||
// Transit uses a single compute-transit child, not per-category routing jobs.
|
|
||||||
const routingModes = csData.modes.filter((m) => m !== "transit");
|
const routingModes = csData.modes.filter((m) => m !== "transit");
|
||||||
const totalRoutingJobs = routingModes.length * CATEGORY_IDS.length;
|
const totalRoutingJobs = routingModes.length * CATEGORY_IDS.length;
|
||||||
const hasTransit = csData.modes.includes("transit");
|
const hasTransit = csData.modes.includes("transit");
|
||||||
|
|
||||||
// Count jobs that haven't finished yet (active or still waiting in queue)
|
|
||||||
const pipelineWaiting = await queue.getWaiting(0, 200);
|
const pipelineWaiting = await queue.getWaiting(0, 200);
|
||||||
const stillRoutingActive = pipelineActive.filter(
|
|
||||||
(j) => j.data.citySlug === citySlug && j.data.type === "compute-routing",
|
// Build per-mode routing detail for the UI
|
||||||
).length;
|
const routingDetail: RoutingDetail = {};
|
||||||
const stillRoutingWaiting = pipelineWaiting.filter(
|
for (const mode of routingModes) {
|
||||||
(j) => j.data.citySlug === citySlug && j.data.type === "compute-routing",
|
const total = CATEGORY_IDS.length;
|
||||||
).length;
|
const stillActive = pipelineActive.filter(
|
||||||
const completedRouting = Math.max(0, totalRoutingJobs - stillRoutingActive - stillRoutingWaiting);
|
(j) => j.data.citySlug === citySlug && j.data.type === "compute-routing" && (j.data as any).mode === mode,
|
||||||
|
).length;
|
||||||
|
const stillWaiting = pipelineWaiting.filter(
|
||||||
|
(j) => j.data.citySlug === citySlug && j.data.type === "compute-routing" && (j.data as any).mode === mode,
|
||||||
|
).length;
|
||||||
|
routingDetail[mode] = { done: Math.max(0, total - stillActive - stillWaiting), total };
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedRouting = Object.values(routingDetail).reduce((s, v) => s + v.done, 0);
|
||||||
|
|
||||||
// Check if compute-transit is still running
|
// Check if compute-transit is still running
|
||||||
const transitRunning =
|
const transitActive = hasTransit && pipelineActive.some(
|
||||||
hasTransit &&
|
(j) => j.data.citySlug === citySlug && j.data.type === "compute-transit",
|
||||||
(pipelineActive.some((j) => j.data.citySlug === citySlug && j.data.type === "compute-transit") ||
|
);
|
||||||
pipelineWaiting.some((j) => j.data.citySlug === citySlug && j.data.type === "compute-transit"));
|
const transitWaiting = hasTransit && pipelineWaiting.some(
|
||||||
|
(j) => j.data.citySlug === citySlug && j.data.type === "compute-transit",
|
||||||
|
);
|
||||||
|
const transitRunning = transitActive || transitWaiting;
|
||||||
|
|
||||||
// compute-transit job also shows its own progress when active — prefer that
|
if (hasTransit) {
|
||||||
|
routingDetail["transit"] = { done: transitRunning ? 0 : 1, total: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer reporting transit job's own progress when it's active
|
||||||
const transitActiveJob = pipelineActive.find(
|
const transitActiveJob = pipelineActive.find(
|
||||||
(j) => j.data.citySlug === citySlug && j.data.type === "compute-transit",
|
(j) => j.data.citySlug === citySlug && j.data.type === "compute-transit",
|
||||||
);
|
);
|
||||||
if (transitActiveJob) {
|
if (transitActiveJob) {
|
||||||
const p = transitActiveJob.progress as JobProgress | undefined;
|
const p = transitActiveJob.progress as JobProgress | undefined;
|
||||||
if (p?.stage) {
|
if (p?.stage) {
|
||||||
enqueue({ type: "progress", stage: p.stage, pct: p.pct, message: p.message });
|
enqueue({ type: "progress", stage: p.stage, pct: p.pct, message: p.message, routingDetail });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pct = totalRoutingJobs > 0
|
const pct = totalRoutingJobs > 0
|
||||||
? Math.round((completedRouting / totalRoutingJobs) * 100)
|
? Math.round((completedRouting / totalRoutingJobs) * 100)
|
||||||
: transitRunning ? 99 : 100;
|
: transitRunning ? 50 : 100;
|
||||||
const message = transitRunning && completedRouting >= totalRoutingJobs
|
const message = transitRunning && completedRouting >= totalRoutingJobs
|
||||||
? "Routing done — computing transit isochrones…"
|
? "Routing done — computing transit isochrones…"
|
||||||
: `${completedRouting} / ${totalRoutingJobs} routing jobs`;
|
: `${completedRouting} / ${totalRoutingJobs} routing jobs`;
|
||||||
|
|
||||||
enqueue({ type: "progress", stage: "Computing scores", pct, message });
|
enqueue({ type: "progress", stage: "Computing scores", pct, message, routingDetail });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1b. Sequential phase: report whichever single job is currently active.
|
// 1b. OSM parallel phase: extract-pois and build-valhalla run concurrently.
|
||||||
const activeJob = [...pipelineActive, ...valhallaActive].find(
|
// Emit a separate progress event for each so the UI can track them independently.
|
||||||
|
const extractPoisJob = pipelineActive.find(
|
||||||
|
(j) => j.data.citySlug === citySlug && j.data.type === "extract-pois",
|
||||||
|
);
|
||||||
|
const buildValhallaJob = valhallaActive.find(
|
||||||
|
(j) => j.data.citySlug === citySlug && j.data.type === "build-valhalla",
|
||||||
|
);
|
||||||
|
if (extractPoisJob || buildValhallaJob) {
|
||||||
|
const pe = extractPoisJob?.progress as JobProgress | undefined;
|
||||||
|
const pv = buildValhallaJob?.progress as JobProgress | undefined;
|
||||||
|
if (pe?.stage) enqueue({ type: "progress", stage: pe.stage, pct: pe.pct, message: pe.message });
|
||||||
|
if (pv?.stage) enqueue({ type: "progress", stage: pv.stage, pct: pv.pct, message: pv.message });
|
||||||
|
if (!pe?.stage && !pv?.stage) enqueue({ type: "heartbeat" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1c. Sequential phase: report whichever single job is currently active.
|
||||||
|
// Two download-pbf jobs are enqueued per city (one child of extract-pois,
|
||||||
|
// one child of build-valhalla). The idempotency guard makes one skip
|
||||||
|
// immediately at pct=100 while the other does the real download.
|
||||||
|
// Prefer the job that has actual byte progress so the UI doesn't
|
||||||
|
// regress from 100% → 5% when the skip job is seen first.
|
||||||
|
const allCityActive = [...pipelineActive, ...valhallaActive].filter(
|
||||||
(j) => j.data.citySlug === citySlug && j.data.type !== "refresh-city",
|
(j) => j.data.citySlug === citySlug && j.data.type !== "refresh-city",
|
||||||
);
|
);
|
||||||
|
const activeJob =
|
||||||
|
allCityActive.find(
|
||||||
|
(j) =>
|
||||||
|
j.data.type === "download-pbf" &&
|
||||||
|
((j.progress as JobProgress | undefined)?.bytesDownloaded ?? 0) > 0,
|
||||||
|
) ?? allCityActive[0];
|
||||||
if (activeJob) {
|
if (activeJob) {
|
||||||
const p = activeJob.progress as JobProgress | undefined;
|
const p = activeJob.progress as JobProgress | undefined;
|
||||||
if (p?.stage) {
|
if (p?.stage) {
|
||||||
|
|
@ -163,7 +202,30 @@ export async function GET(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. No active stage — check for a failure that occurred after this refresh started.
|
// 2. Check city status in DB — authoritative ground truth for completion/error.
|
||||||
|
// This catches completion reliably regardless of BullMQ job retention,
|
||||||
|
// and catches errors set by the worker's failed-job handler.
|
||||||
|
const cityRows = await Promise.resolve(sql<{ status: string; error_message: string | null }[]>`
|
||||||
|
SELECT status, error_message FROM cities WHERE slug = ${citySlug}
|
||||||
|
`);
|
||||||
|
const cityStatus = cityRows[0]?.status;
|
||||||
|
if (cityStatus === "ready") {
|
||||||
|
enqueue({ type: "completed", jobId: computeScoresJobId ?? id });
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cityStatus === "error") {
|
||||||
|
enqueue({
|
||||||
|
type: "failed",
|
||||||
|
jobId: id,
|
||||||
|
error: cityRows[0]?.error_message ?? "Pipeline failed",
|
||||||
|
});
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check BullMQ failed queue as a secondary signal (catches failures
|
||||||
|
// before the worker's DB update propagates, e.g. DB connection issues).
|
||||||
const [pipelineFailed, valhallaFailed] = await Promise.all([
|
const [pipelineFailed, valhallaFailed] = await Promise.all([
|
||||||
queue.getFailed(0, 50),
|
queue.getFailed(0, 50),
|
||||||
valhallaQueue.getFailed(0, 50),
|
valhallaQueue.getFailed(0, 50),
|
||||||
|
|
@ -184,23 +246,6 @@ export async function GET(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Check if the specific compute-scores job completed → pipeline done.
|
|
||||||
// Use exact job ID match (computeScoresJobId) to avoid false positives
|
|
||||||
// from a previous run's completed record still in BullMQ's retention window.
|
|
||||||
const completed = await queue.getCompleted(0, 100);
|
|
||||||
const finalDone = completed.find((j) =>
|
|
||||||
computeScoresJobId
|
|
||||||
? j.id === computeScoresJobId
|
|
||||||
: j.data.citySlug === citySlug &&
|
|
||||||
j.data.type === "compute-scores" &&
|
|
||||||
(j.finishedOn ?? 0) > jobCreatedAt,
|
|
||||||
);
|
|
||||||
if (finalDone) {
|
|
||||||
enqueue({ type: "completed", jobId: finalDone.id ?? "" });
|
|
||||||
cleanup();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Still pending — heartbeat.
|
// 4. Still pending — heartbeat.
|
||||||
enqueue({ type: "heartbeat" });
|
enqueue({ type: "heartbeat" });
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import type { City } from "@transportationer/shared";
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const cacheKey = "api:cities:all";
|
const cacheKey = "api:cities:all:v2";
|
||||||
const cached = await cacheGet<City[]>(cacheKey);
|
const cached = await cacheGet<City[]>(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return NextResponse.json(cached, { headers: { "X-Cache": "HIT" } });
|
return NextResponse.json(cached, { headers: { "X-Cache": "HIT" } });
|
||||||
|
|
@ -18,6 +18,7 @@ export async function GET() {
|
||||||
country_code: string;
|
country_code: string;
|
||||||
geofabrik_url: string;
|
geofabrik_url: string;
|
||||||
bbox_arr: number[] | null;
|
bbox_arr: number[] | null;
|
||||||
|
boundary_geojson: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
last_ingested: string | null;
|
last_ingested: string | null;
|
||||||
}[]>`
|
}[]>`
|
||||||
|
|
@ -32,6 +33,7 @@ export async function GET() {
|
||||||
ST_XMax(bbox)::float,
|
ST_XMax(bbox)::float,
|
||||||
ST_YMax(bbox)::float
|
ST_YMax(bbox)::float
|
||||||
] END AS bbox_arr,
|
] END AS bbox_arr,
|
||||||
|
ST_AsGeoJSON(boundary) AS boundary_geojson,
|
||||||
status,
|
status,
|
||||||
last_ingested
|
last_ingested
|
||||||
FROM cities
|
FROM cities
|
||||||
|
|
@ -44,6 +46,7 @@ export async function GET() {
|
||||||
countryCode: r.country_code,
|
countryCode: r.country_code,
|
||||||
geofabrikUrl: r.geofabrik_url,
|
geofabrikUrl: r.geofabrik_url,
|
||||||
bbox: (r.bbox_arr as [number, number, number, number]) ?? [0, 0, 0, 0],
|
bbox: (r.bbox_arr as [number, number, number, number]) ?? [0, 0, 0, 0],
|
||||||
|
boundary: r.boundary_geojson ? JSON.parse(r.boundary_geojson) : null,
|
||||||
status: r.status as City["status"],
|
status: r.status as City["status"],
|
||||||
lastIngested: r.last_ingested,
|
lastIngested: r.last_ingested,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,11 @@ export async function GET(req: NextRequest) {
|
||||||
GROUP BY category
|
GROUP BY category
|
||||||
`),
|
`),
|
||||||
Promise.resolve(sql<{ count: number }[]>`
|
Promise.resolve(sql<{ count: number }[]>`
|
||||||
SELECT COUNT(*)::int AS count FROM grid_points WHERE city_slug = ${city}
|
SELECT COUNT(*)::int AS count
|
||||||
|
FROM grid_points gp
|
||||||
|
JOIN cities c ON c.slug = ${city}
|
||||||
|
WHERE gp.city_slug = ${city}
|
||||||
|
AND (c.boundary IS NULL OR ST_Within(gp.geom, c.boundary))
|
||||||
`),
|
`),
|
||||||
Promise.resolve(sql<{
|
Promise.resolve(sql<{
|
||||||
category: string;
|
category: string;
|
||||||
|
|
@ -46,7 +50,9 @@ export async function GET(req: NextRequest) {
|
||||||
(COUNT(*) FILTER (WHERE gs.score >= 0.5) * 100.0 / COUNT(*))::float AS coverage_pct
|
(COUNT(*) FILTER (WHERE gs.score >= 0.5) * 100.0 / COUNT(*))::float AS coverage_pct
|
||||||
FROM grid_scores gs
|
FROM grid_scores gs
|
||||||
JOIN grid_points gp ON gp.id = gs.grid_point_id
|
JOIN grid_points gp ON gp.id = gs.grid_point_id
|
||||||
|
JOIN cities c ON c.slug = ${city}
|
||||||
WHERE gp.city_slug = ${city}
|
WHERE gp.city_slug = ${city}
|
||||||
|
AND (c.boundary IS NULL OR ST_Within(gp.geom, c.boundary))
|
||||||
AND gs.travel_mode = ${mode}
|
AND gs.travel_mode = ${mode}
|
||||||
AND gs.threshold_min = ${threshold}
|
AND gs.threshold_min = ${threshold}
|
||||||
AND gs.profile = ${profile}
|
AND gs.profile = ${profile}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ export default function HomePage() {
|
||||||
// Derived city data — used in effects below so must be declared before them
|
// Derived city data — used in effects below so must be declared before them
|
||||||
const selectedCityData = cities.find((c) => c.slug === selectedCity);
|
const selectedCityData = cities.find((c) => c.slug === selectedCity);
|
||||||
const cityBbox = selectedCityData?.bbox;
|
const cityBbox = selectedCityData?.bbox;
|
||||||
|
const cityBoundary = selectedCityData?.boundary ?? null;
|
||||||
const estateValueAvailable =
|
const estateValueAvailable =
|
||||||
cityBbox != null &&
|
cityBbox != null &&
|
||||||
cityBbox[0] < 11.779 &&
|
cityBbox[0] < 11.779 &&
|
||||||
|
|
@ -335,6 +336,7 @@ export default function HomePage() {
|
||||||
isochrones={overlayMode === "isochrone" ? isochroneData : null}
|
isochrones={overlayMode === "isochrone" ? isochroneData : null}
|
||||||
baseOverlay={baseOverlay}
|
baseOverlay={baseOverlay}
|
||||||
reachablePois={showPois ? reachablePois : []}
|
reachablePois={showPois ? reachablePois : []}
|
||||||
|
cityBoundary={cityBoundary}
|
||||||
onLocationClick={handleLocationClick}
|
onLocationClick={handleLocationClick}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ export interface MapViewProps {
|
||||||
baseOverlay?: BaseOverlay;
|
baseOverlay?: BaseOverlay;
|
||||||
/** Reachable POI pins to show on the map. */
|
/** Reachable POI pins to show on the map. */
|
||||||
reachablePois?: ReachablePoi[] | null;
|
reachablePois?: ReachablePoi[] | null;
|
||||||
|
/** City administrative boundary polygon to outline on the map. */
|
||||||
|
cityBoundary?: object | null;
|
||||||
onLocationClick?: (lat: number, lng: number, estateValue: number | null) => void;
|
onLocationClick?: (lat: number, lng: number, estateValue: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,6 +168,7 @@ export function MapView({
|
||||||
isochrones,
|
isochrones,
|
||||||
baseOverlay = "accessibility",
|
baseOverlay = "accessibility",
|
||||||
reachablePois,
|
reachablePois,
|
||||||
|
cityBoundary,
|
||||||
onLocationClick,
|
onLocationClick,
|
||||||
}: MapViewProps) {
|
}: MapViewProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -575,6 +578,22 @@ export function MapView({
|
||||||
};
|
};
|
||||||
}, [mapLoaded, reachablePois]);
|
}, [mapLoaded, reachablePois]);
|
||||||
|
|
||||||
|
// ── City boundary outline ─────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapLoaded) return;
|
||||||
|
const src = mapRef.current?.getSource("city-boundary") as maplibregl.GeoJSONSource | undefined;
|
||||||
|
if (!src) return;
|
||||||
|
src.setData(
|
||||||
|
cityBoundary
|
||||||
|
? {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
features: [{ type: "Feature", geometry: cityBoundary as any, properties: {} }],
|
||||||
|
}
|
||||||
|
: { type: "FeatureCollection", features: [] },
|
||||||
|
);
|
||||||
|
}, [mapLoaded, cityBoundary]);
|
||||||
|
|
||||||
// ── Initialize map (runs once on mount) ───────────────────────────────────
|
// ── Initialize map (runs once on mount) ───────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mountedRef.current || !containerRef.current) return;
|
if (mountedRef.current || !containerRef.current) return;
|
||||||
|
|
@ -615,6 +634,24 @@ export function MapView({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// City boundary outline — persistent layer, updated reactively via setData
|
||||||
|
map.addSource("city-boundary", {
|
||||||
|
type: "geojson",
|
||||||
|
data: { type: "FeatureCollection", features: [] },
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: "city-boundary-line",
|
||||||
|
type: "line",
|
||||||
|
source: "city-boundary",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#1e293b",
|
||||||
|
"line-width": 1.5,
|
||||||
|
"line-opacity": 0.55,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
"line-dasharray": [5, 3] as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
map.on("click", (e) => {
|
map.on("click", (e) => {
|
||||||
const evFeatures = map.getLayer("estate-value-fill")
|
const evFeatures = map.getLayer("estate-value-fill")
|
||||||
? map.queryRenderedFeatures(e.point, { layers: ["estate-value-fill"] })
|
? map.queryRenderedFeatures(e.point, { layers: ["estate-value-fill"] })
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,26 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useReducer, useRef } from "react";
|
import { useEffect, useReducer, useRef } from "react";
|
||||||
import type { SSEEvent } from "@transportationer/shared";
|
import type { SSEEvent, RoutingDetail } from "@transportationer/shared";
|
||||||
|
export type { RoutingDetail };
|
||||||
|
|
||||||
export type PipelineStageKey =
|
// ─── Stage specification ──────────────────────────────────────────────────────
|
||||||
| "download-pbf"
|
|
||||||
| "extract-pois"
|
interface StageSpec {
|
||||||
| "generate-grid"
|
key: string;
|
||||||
| "build-valhalla"
|
label: string;
|
||||||
| "compute-scores"
|
/** Stages sharing the same parallelGroup run concurrently. */
|
||||||
| "refresh-city";
|
parallelGroup?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STAGE_SPECS: StageSpec[] = [
|
||||||
|
{ key: "Downloading PBF", label: "Download OSM & GTFS data" },
|
||||||
|
{ key: "Extract POIs", label: "Extract & import POIs", parallelGroup: "osm" },
|
||||||
|
{ key: "Building routing graph",label: "Build routing graph", parallelGroup: "osm" },
|
||||||
|
{ key: "Generating grid", label: "Generate analysis grid" },
|
||||||
|
{ key: "Computing scores", label: "Compute accessibility" },
|
||||||
|
{ key: "Aggregating scores", label: "Aggregate scores" },
|
||||||
|
];
|
||||||
|
|
||||||
export interface StageStatus {
|
export interface StageStatus {
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -17,58 +28,59 @@ export interface StageStatus {
|
||||||
status: "pending" | "active" | "completed" | "failed";
|
status: "pending" | "active" | "completed" | "failed";
|
||||||
pct: number;
|
pct: number;
|
||||||
message: string;
|
message: string;
|
||||||
|
parallelGroup?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Four logical UI stages that map to the actual (parallel) pipeline jobs.
|
|
||||||
// extract-pois and build-valhalla run concurrently — they share "Processing OSM"
|
|
||||||
// so the linear mark-prior-as-completed logic stays correct.
|
|
||||||
const STAGE_ORDER: Array<{ key: string; label: string }> = [
|
|
||||||
{ key: "Downloading PBF", label: "Download OSM data" },
|
|
||||||
{ key: "Processing OSM", label: "Process OSM & build routes" },
|
|
||||||
{ key: "Generating grid", label: "Generate analysis grid" },
|
|
||||||
{ key: "Computing scores", label: "Compute accessibility scores" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps raw worker stage strings → UI stage keys.
|
* Maps raw worker stage strings → UI stage keys.
|
||||||
* All three parallel worker stages (extract-pois sub-stages + build-valhalla)
|
* Returns null for stages that should be silently ignored
|
||||||
* fold into "Processing OSM". Routing sub-jobs and BORIS NI ingest fold
|
* (e.g. the brief "Orchestrating pipeline" from refresh-city).
|
||||||
* into "Computing scores" (they run during compute-scores Phase 1).
|
|
||||||
*/
|
*/
|
||||||
function normalizeStage(raw: string): string {
|
function normalizeStage(raw: string): string | null {
|
||||||
|
if (raw === "Downloading GTFS") return "Downloading PBF";
|
||||||
if (
|
if (
|
||||||
raw === "Clipping to bounding box" ||
|
raw === "Clipping to bounding box" ||
|
||||||
raw === "Filtering OSM tags" ||
|
raw === "Filtering OSM tags" ||
|
||||||
raw === "Importing to PostGIS" ||
|
raw === "Importing to PostGIS"
|
||||||
raw === "Building routing graph"
|
) return "Extract POIs";
|
||||||
) return "Processing OSM";
|
// "Building routing graph" → direct key match
|
||||||
if (raw.startsWith("Routing ") || raw === "Ingesting BORIS NI") {
|
// "Generating grid" → direct key match
|
||||||
return "Computing scores";
|
if (
|
||||||
}
|
raw.startsWith("Routing ") ||
|
||||||
|
raw === "Transit routing" ||
|
||||||
|
raw === "Ingesting BORIS NI"
|
||||||
|
) return "Computing scores";
|
||||||
|
// "Computing scores" (Phase 1 brief dispatch update) → direct key match
|
||||||
|
// "Aggregating scores" (Phase 2) → direct key match
|
||||||
|
if (raw === "Orchestrating pipeline") return null; // ignore, handled by refresh-city
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── State ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type OverallStatus = "pending" | "active" | "completed" | "failed";
|
export type OverallStatus = "pending" | "active" | "completed" | "failed";
|
||||||
|
|
||||||
interface ProgressState {
|
interface ProgressState {
|
||||||
stages: StageStatus[];
|
stages: StageStatus[];
|
||||||
overall: OverallStatus;
|
overall: OverallStatus;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
routingDetail?: RoutingDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| { type: "progress"; stage: string; pct: number; message: string }
|
| { type: "progress"; stage: string; pct: number; message: string; routingDetail?: RoutingDetail }
|
||||||
| { type: "completed" }
|
| { type: "completed" }
|
||||||
| { type: "failed"; error: string };
|
| { type: "failed"; error: string };
|
||||||
|
|
||||||
function initialState(): ProgressState {
|
function initialState(): ProgressState {
|
||||||
return {
|
return {
|
||||||
stages: STAGE_ORDER.map((s) => ({
|
stages: STAGE_SPECS.map((s) => ({
|
||||||
key: s.key,
|
key: s.key,
|
||||||
label: s.label,
|
label: s.label,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
pct: 0,
|
pct: 0,
|
||||||
message: "",
|
message: "",
|
||||||
|
parallelGroup: s.parallelGroup,
|
||||||
})),
|
})),
|
||||||
overall: "pending",
|
overall: "pending",
|
||||||
};
|
};
|
||||||
|
|
@ -78,45 +90,58 @@ function reducer(state: ProgressState, action: Action): ProgressState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "progress": {
|
case "progress": {
|
||||||
const stageKey = normalizeStage(action.stage);
|
const stageKey = normalizeStage(action.stage);
|
||||||
|
if (stageKey === null) return { ...state, overall: "active" };
|
||||||
|
|
||||||
|
const stageIdx = STAGE_SPECS.findIndex((s) => s.key === stageKey);
|
||||||
|
if (stageIdx === -1) {
|
||||||
|
// Unknown stage — keep pipeline active but don't corrupt stage state
|
||||||
|
return { ...state, overall: "active" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeGroup = STAGE_SPECS[stageIdx].parallelGroup;
|
||||||
let found = false;
|
let found = false;
|
||||||
const stages = state.stages.map((s) => {
|
|
||||||
|
const stages = state.stages.map((s, i) => {
|
||||||
|
const spec = STAGE_SPECS[i];
|
||||||
if (s.key === stageKey) {
|
if (s.key === stageKey) {
|
||||||
found = true;
|
found = true;
|
||||||
return {
|
return { ...s, status: "active" as const, pct: action.pct, message: action.message };
|
||||||
...s,
|
|
||||||
status: "active" as const,
|
|
||||||
pct: action.pct,
|
|
||||||
message: action.message,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
// Mark prior stages completed once a later stage is active
|
// Don't auto-complete parallel siblings — they track their own lifecycle
|
||||||
|
if (activeGroup && spec.parallelGroup === activeGroup) return s;
|
||||||
|
// Mark all prior (non-sibling) stages as completed
|
||||||
if (!found) return { ...s, status: "completed" as const, pct: 100 };
|
if (!found) return { ...s, status: "completed" as const, pct: 100 };
|
||||||
return s;
|
return s;
|
||||||
});
|
});
|
||||||
return { ...state, stages, overall: "active" };
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
stages,
|
||||||
|
overall: "active",
|
||||||
|
routingDetail: action.routingDetail ?? state.routingDetail,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "completed":
|
case "completed":
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
overall: "completed",
|
overall: "completed",
|
||||||
stages: state.stages.map((s) => ({
|
stages: state.stages.map((s) => ({ ...s, status: "completed", pct: 100 })),
|
||||||
...s,
|
|
||||||
status: "completed",
|
|
||||||
pct: 100,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
case "failed":
|
case "failed":
|
||||||
return { ...state, overall: "failed", error: action.error };
|
return { ...state, overall: "failed", error: action.error };
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useJobProgress(jobId: string | null): ProgressState {
|
export function useJobProgress(jobId: string | null): ProgressState {
|
||||||
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
||||||
const esRef = useRef<EventSource | null>(null);
|
const esRef = useRef<EventSource | null>(null);
|
||||||
// Tracks whether the stream ended with a legitimate "completed" event so
|
|
||||||
// the subsequent connection-close (which fires onerror) is ignored.
|
|
||||||
const completedRef = useRef(false);
|
const completedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -130,8 +155,6 @@ export function useJobProgress(jobId: string | null): ProgressState {
|
||||||
const payload = JSON.parse(event.data) as SSEEvent;
|
const payload = JSON.parse(event.data) as SSEEvent;
|
||||||
if (payload.type === "heartbeat") return;
|
if (payload.type === "heartbeat") return;
|
||||||
if (payload.type === "completed") {
|
if (payload.type === "completed") {
|
||||||
// Close before the server closes so the subsequent connection-close
|
|
||||||
// does not trigger onerror and overwrite the completed state.
|
|
||||||
completedRef.current = true;
|
completedRef.current = true;
|
||||||
es.close();
|
es.close();
|
||||||
esRef.current = null;
|
esRef.current = null;
|
||||||
|
|
@ -140,7 +163,7 @@ export function useJobProgress(jobId: string | null): ProgressState {
|
||||||
};
|
};
|
||||||
|
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
if (completedRef.current) return; // Normal close after completion — ignore
|
if (completedRef.current) return;
|
||||||
dispatch({ type: "failed", error: "Lost connection to job stream" });
|
dispatch({ type: "failed", error: "Lost connection to job stream" });
|
||||||
es.close();
|
es.close();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,10 @@ CREATE TABLE IF NOT EXISTS cities (
|
||||||
|
|
||||||
-- Migration for existing databases
|
-- Migration for existing databases
|
||||||
ALTER TABLE cities ADD COLUMN IF NOT EXISTS resolution_m INTEGER NOT NULL DEFAULT 200;
|
ALTER TABLE cities ADD COLUMN IF NOT EXISTS resolution_m INTEGER NOT NULL DEFAULT 200;
|
||||||
|
ALTER TABLE cities ADD COLUMN IF NOT EXISTS boundary geometry(MultiPolygon, 4326);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_cities_bbox ON cities USING GIST (bbox);
|
CREATE INDEX IF NOT EXISTS idx_cities_bbox ON cities USING GIST (bbox);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cities_boundary ON cities USING GIST (boundary);
|
||||||
|
|
||||||
-- ─── Raw POIs (created and managed by osm2pgsql flex output) ─────────────────
|
-- ─── Raw POIs (created and managed by osm2pgsql flex output) ─────────────────
|
||||||
-- osm2pgsql --drop recreates this table on each ingest using the Lua script.
|
-- osm2pgsql --drop recreates this table on each ingest using the Lua script.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export interface City {
|
||||||
countryCode: string;
|
countryCode: string;
|
||||||
geofabrikUrl: string;
|
geofabrikUrl: string;
|
||||||
bbox: [number, number, number, number]; // [minLng, minLat, maxLng, maxLat]
|
bbox: [number, number, number, number]; // [minLng, minLat, maxLng, maxLat]
|
||||||
|
boundary?: object | null;
|
||||||
status: CityStatus;
|
status: CityStatus;
|
||||||
lastIngested: string | null;
|
lastIngested: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -108,8 +109,10 @@ export interface JobSummary {
|
||||||
|
|
||||||
// ─── SSE Events ──────────────────────────────────────────────────────────────
|
// ─── SSE Events ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type RoutingDetail = Record<string, { done: number; total: number }>;
|
||||||
|
|
||||||
export type SSEEvent =
|
export type SSEEvent =
|
||||||
| { type: "progress"; stage: string; pct: number; message: string; bytesDownloaded?: number; totalBytes?: number }
|
| { type: "progress"; stage: string; pct: number; message: string; bytesDownloaded?: number; totalBytes?: number; routingDetail?: RoutingDetail }
|
||||||
| { type: "completed"; jobId: string }
|
| { type: "completed"; jobId: string }
|
||||||
| { type: "failed"; jobId: string; error: string }
|
| { type: "failed"; jobId: string; error: string }
|
||||||
| { type: "heartbeat" };
|
| { type: "heartbeat" };
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Worker, type Job } from "bullmq";
|
import { Worker, type Job } from "bullmq";
|
||||||
import { createBullMQConnection } from "./redis.js";
|
import { createBullMQConnection } from "./redis.js";
|
||||||
|
import { getSql } from "./db.js";
|
||||||
import type { PipelineJobData } from "@transportationer/shared";
|
import type { PipelineJobData } from "@transportationer/shared";
|
||||||
import { handleDownloadPbf } from "./jobs/download-pbf.js";
|
import { handleDownloadPbf } from "./jobs/download-pbf.js";
|
||||||
import { handleExtractPois } from "./jobs/extract-pois.js";
|
import { handleExtractPois } from "./jobs/extract-pois.js";
|
||||||
|
|
@ -54,8 +55,24 @@ worker.on("completed", (job) => {
|
||||||
console.log(`[worker] ✓ Job ${job.id} (${job.data.type}) completed`);
|
console.log(`[worker] ✓ Job ${job.id} (${job.data.type}) completed`);
|
||||||
});
|
});
|
||||||
|
|
||||||
worker.on("failed", (job, err) => {
|
worker.on("failed", async (job, err) => {
|
||||||
console.error(`[worker] ✗ Job ${job?.id} (${job?.data?.type}) failed:`, err.message);
|
console.error(`[worker] ✗ Job ${job?.id} (${job?.data?.type}) failed:`, err.message);
|
||||||
|
// On final failure (all retries exhausted), mark the city as errored so
|
||||||
|
// the SSE stream and admin UI can reflect the true state immediately.
|
||||||
|
if (job?.data && "citySlug" in job.data && job.data.citySlug) {
|
||||||
|
const attemptsExhausted = job.attemptsMade >= (job.opts.attempts ?? 1);
|
||||||
|
if (attemptsExhausted) {
|
||||||
|
try {
|
||||||
|
const sql = getSql();
|
||||||
|
await sql`
|
||||||
|
UPDATE cities SET status = 'error', error_message = ${err.message.slice(0, 500)}
|
||||||
|
WHERE slug = ${job.data.citySlug}
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[worker] Failed to update city error status:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
worker.on("active", (job) => {
|
worker.on("active", (job) => {
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ export async function handleComputeScores(
|
||||||
|
|
||||||
// ── Phase 2: aggregate scores from grid_poi_details ──────────────────────
|
// ── Phase 2: aggregate scores from grid_poi_details ──────────────────────
|
||||||
await job.updateProgress({
|
await job.updateProgress({
|
||||||
stage: "Computing scores",
|
stage: "Aggregating scores",
|
||||||
pct: 70,
|
pct: 70,
|
||||||
message: `All routing complete — computing profile scores…`,
|
message: `All routing complete — computing profile scores…`,
|
||||||
} satisfies JobProgress);
|
} satisfies JobProgress);
|
||||||
|
|
@ -360,7 +360,7 @@ export async function handleComputeScores(
|
||||||
|
|
||||||
completedThresholds++;
|
completedThresholds++;
|
||||||
await job.updateProgress({
|
await job.updateProgress({
|
||||||
stage: "Computing scores",
|
stage: "Aggregating scores",
|
||||||
pct: 70 + Math.round((completedThresholds / thresholds.length) * 28),
|
pct: 70 + Math.round((completedThresholds / thresholds.length) * 28),
|
||||||
message: `${completedThresholds} / ${thresholds.length} thresholds done…`,
|
message: `${completedThresholds} / ${thresholds.length} thresholds done…`,
|
||||||
} satisfies JobProgress);
|
} satisfies JobProgress);
|
||||||
|
|
@ -388,7 +388,7 @@ export async function handleComputeScores(
|
||||||
`);
|
`);
|
||||||
if (n > 0) {
|
if (n > 0) {
|
||||||
await job.updateProgress({
|
await job.updateProgress({
|
||||||
stage: "Computing scores",
|
stage: "Aggregating scores",
|
||||||
pct: 99,
|
pct: 99,
|
||||||
message: "Computing hidden gem scores…",
|
message: "Computing hidden gem scores…",
|
||||||
} satisfies JobProgress);
|
} satisfies JobProgress);
|
||||||
|
|
@ -436,7 +436,7 @@ export async function handleComputeScores(
|
||||||
}
|
}
|
||||||
|
|
||||||
await job.updateProgress({
|
await job.updateProgress({
|
||||||
stage: "Computing scores",
|
stage: "Aggregating scores",
|
||||||
pct: 100,
|
pct: 100,
|
||||||
message: `All scores computed for ${citySlug}`,
|
message: `All scores computed for ${citySlug}`,
|
||||||
} satisfies JobProgress);
|
} satisfies JobProgress);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue