diff --git a/apps/web/app/api/grid/route.ts b/apps/web/app/api/grid/route.ts index 314c869..9591dfe 100644 --- a/apps/web/app/api/grid/route.ts +++ b/apps/web/app/api/grid/route.ts @@ -2,14 +2,11 @@ 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 { CATEGORY_IDS, DEFAULT_WEIGHTS, TRAVEL_MODES, VALID_THRESHOLDS } 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"; @@ -17,7 +14,7 @@ export async function GET(req: NextRequest) { const threshold = parseInt(p.get("threshold") ?? "15", 10); const bboxStr = p.get("bbox"); - if (!VALID_MODES.includes(mode)) { + if (!(TRAVEL_MODES as readonly string[]).includes(mode)) { return NextResponse.json( { error: "Invalid mode", code: "INVALID_MODE" }, { status: 400 }, diff --git a/apps/web/app/api/isochrones/route.ts b/apps/web/app/api/isochrones/route.ts index 19c580b..e70e942 100644 --- a/apps/web/app/api/isochrones/route.ts +++ b/apps/web/app/api/isochrones/route.ts @@ -52,7 +52,9 @@ export async function POST(req: NextRequest) { `); if (cached.length > 0) { - return NextResponse.json({ ...cached[0].result, cached: true }); + const raw = cached[0].result; + const result = typeof raw === "string" ? JSON.parse(raw as string) : raw; + return NextResponse.json({ ...result, cached: true }); } // Refuse to call valhalla_service while tiles are being rebuilt — @@ -86,14 +88,17 @@ export async function POST(req: NextRequest) { ); } - // Store in PostGIS cache + // Store in PostGIS cache. + // Use an explicit ::jsonb cast so PostgreSQL receives a text parameter and + // parses it as JSON itself. Without the cast, postgres.js infers the JSONB + // column type and re-encodes the string as a JSONB string literal. 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)} + ${JSON.stringify(geojson)}::jsonb ) `); diff --git a/apps/web/app/api/location-score/route.ts b/apps/web/app/api/location-score/route.ts index 0bb02f3..5e64e59 100644 --- a/apps/web/app/api/location-score/route.ts +++ b/apps/web/app/api/location-score/route.ts @@ -4,13 +4,12 @@ import type { CategoryId, ProfileId } from "@transportationer/shared"; import { CATEGORY_IDS, PROFILE_IDS, + TRAVEL_MODES, + VALID_THRESHOLDS, } from "@transportationer/shared"; export const runtime = "nodejs"; -const VALID_MODES = ["walking", "cycling", "driving", "fifteen"]; -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") ?? ""); @@ -26,7 +25,7 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: "Invalid lat/lng" }, { status: 400 }); } if (!city) return NextResponse.json({ error: "Missing city" }, { status: 400 }); - if (!VALID_MODES.includes(mode)) { + if (!(TRAVEL_MODES as readonly string[]).includes(mode)) { return NextResponse.json({ error: "Invalid mode" }, { status: 400 }); } @@ -123,6 +122,11 @@ export async function GET(req: NextRequest) { recreation: r.time_recreation ?? undefined, }; + // "fifteen" is synthetic — grid_poi_details only stores the raw modes + // (walking, cycling, transit, driving). For fifteen we query all three + // contributing modes and pick the best (minimum travel_time_s) per subcategory. + const detailModes = mode === "fifteen" ? ["walking", "cycling", "transit"] : [mode]; + // Fetch nearest POI per subcategory for this grid point and mode. const detailRows = await Promise.resolve(sql<{ category: string; @@ -131,11 +135,12 @@ export async function GET(req: NextRequest) { distance_m: number | null; travel_time_s: number | null; }[]>` - SELECT category, subcategory, nearest_poi_name, distance_m, travel_time_s + SELECT DISTINCT ON (category, subcategory) + 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 + AND travel_mode = ANY(${detailModes}) + ORDER BY category, subcategory, travel_time_s ASC NULLS LAST `); type SubcategoryDetail = { diff --git a/apps/web/app/api/reachable-pois/route.ts b/apps/web/app/api/reachable-pois/route.ts new file mode 100644 index 0000000..ec12c52 --- /dev/null +++ b/apps/web/app/api/reachable-pois/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@/lib/db"; +import { isochroneContours } from "@/lib/isochrone"; + +export const runtime = "nodejs"; + +const CACHE_TOLERANCE_M = 50; + +type CacheFeature = { geometry: object; properties: { contour: number } }; + +export async function GET(req: NextRequest) { + console.log("[reachable-pois] GET called:", req.nextUrl.search); + const { searchParams } = req.nextUrl; + const city = searchParams.get("city"); + const lat = parseFloat(searchParams.get("lat") ?? ""); + const lng = parseFloat(searchParams.get("lng") ?? ""); + const mode = searchParams.get("mode") ?? "walking"; + const threshold = parseInt(searchParams.get("threshold") ?? "15", 10); + + if (!city || isNaN(lat) || isNaN(lng)) { + return NextResponse.json({ error: "Missing required params", code: "INVALID_PARAMS" }, { status: 400 }); + } + + // Map modes the same way the display isochrone route does. + // "fifteen" uses cycling as the representative isochrone mode. + // "transit" uses its own multimodal isochrone. + const isoMode = mode === "fifteen" ? "cycling" : mode; + + // Use the identical contours array that the display isochrone route caches with, + // so the exact-match lookup is guaranteed to find the entry once isochroneData + // is set on the client (which is our fetch trigger). + const contours = isochroneContours(threshold); + + const cached = await Promise.resolve(sql<{ result: { features: CacheFeature[] } }[]>` + SELECT result + FROM isochrone_cache + WHERE travel_mode = ${isoMode} + 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) { + // Should not happen if client fires this only after isochroneData is set. + console.warn(`[reachable-pois] cache miss for ${city} ${lat},${lng} mode=${isoMode} contours=${JSON.stringify(contours)}`); + return NextResponse.json({ pois: [], _debug: "cache_miss" }, { headers: { "Cache-Control": "no-store" } }); + } + + // Existing cache entries may have been stored with JSON.stringify() (double-encoded) + // resulting in a JSONB string instead of a JSONB object. Parse those on read. + const rawResult = cached[0].result; + const result: { features?: CacheFeature[] } = + typeof rawResult === "string" ? JSON.parse(rawResult as string) : rawResult; + + const features = result?.features; + if (!Array.isArray(features) || features.length === 0) { + console.warn(`[reachable-pois] bad features: ${JSON.stringify(rawResult).slice(0, 200)}`); + return NextResponse.json({ pois: [], _debug: "bad_features" }, { headers: { "Cache-Control": "no-store" } }); + } + + // Pick the feature matching the exact threshold contour, or fall back to the largest. + const contourValues = features.map((f) => f.properties?.contour); + const match = features.find((f) => f.properties?.contour === threshold); + const polygon = (match ?? features[features.length - 1])?.geometry; + console.log(`[reachable-pois] features=${features.length} contours=${JSON.stringify(contourValues)} match=${!!match} threshold=${threshold}`); + if (!polygon) { + console.warn(`[reachable-pois] no polygon geometry`); + return NextResponse.json({ pois: [], _debug: "no_polygon" }, { headers: { "Cache-Control": "no-store" } }); + } + + const pois = await Promise.resolve(sql<{ category: string; subcategory: string; name: string | null; lat: number; lng: number }[]>` + SELECT category, subcategory, name, + ST_Y(geom)::float AS lat, ST_X(geom)::float AS lng + FROM raw_pois + WHERE city_slug = ${city} + AND ST_Intersects(geom, ST_SetSRID(ST_GeomFromGeoJSON(${JSON.stringify(polygon)}), 4326)) + ORDER BY category, subcategory, name + `); + + if (pois.length === 0) { + // Log to help distinguish "no POIs in polygon" from a query error. + console.warn(`[reachable-pois] 0 pois for ${city} (cache hit, polygon type=${(polygon as { type?: string }).type})`); + return NextResponse.json({ pois: [], _debug: "empty_spatial" }, { headers: { "Cache-Control": "no-store" } }); + } + + return NextResponse.json( + { pois }, + { headers: { "Cache-Control": "public, max-age=3600" } }, + ); +} diff --git a/apps/web/app/api/tiles/grid/[...tile]/route.ts b/apps/web/app/api/tiles/grid/[...tile]/route.ts index 010d966..e971dfc 100644 --- a/apps/web/app/api/tiles/grid/[...tile]/route.ts +++ b/apps/web/app/api/tiles/grid/[...tile]/route.ts @@ -1,12 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { sql } from "@/lib/db"; -import { PROFILE_IDS } from "@transportationer/shared"; +import { PROFILE_IDS, TRAVEL_MODES, VALID_THRESHOLDS } from "@transportationer/shared"; export const runtime = "nodejs"; -const VALID_MODES = ["walking", "cycling", "driving", "transit", "fifteen"]; -const VALID_THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30]; - export async function GET( req: NextRequest, { params }: { params: Promise<{ tile: string[] }> }, @@ -30,7 +27,7 @@ export async function GET( 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 }); + if (!(TRAVEL_MODES as readonly string[]).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) => diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 936ba8f..73424a1 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -19,21 +19,13 @@ import { type OverlayMode, } from "@/components/location-score-panel"; import { MapLegend } from "@/components/map-legend"; +import { isochroneContours } from "@/lib/isochrone"; 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([]); @@ -53,6 +45,11 @@ export default function HomePage() { const [pinEstatePercentile, setPinEstatePercentile] = useState(null); const [pinEstateScorePercentile, setPinEstateScorePercentile] = useState(null); + // Reachable POI pins + type ReachablePoi = { category: string; subcategory: string; name: string | null; lat: number; lng: number }; + const [reachablePois, setReachablePois] = useState([]); + const [showPois, setShowPois] = useState(true); + // Overlay mode: isochrone (new default) or relative heatmap const [overlayMode, setOverlayMode] = useState("isochrone"); const [isochroneData, setIsochroneData] = useState(null); @@ -181,6 +178,22 @@ export default function HomePage() { }; }, [pinLocation, estateValueAvailable, selectedCity, mode, threshold, profile]); + // Fetch reachable POIs once isochroneData is set — guarantees the isochrone + // cache is warm so the GET route finds the entry with an exact contours match. + useEffect(() => { + if (!pinLocation || !selectedCity || !isochroneData) { setReachablePois([]); return; } + let cancelled = false; + const params = new URLSearchParams({ + city: selectedCity, lat: String(pinLocation.lat), lng: String(pinLocation.lng), + mode, threshold: String(threshold), + }); + fetch(`/api/reachable-pois?${params}`) + .then((r) => r.json()) + .then((data) => { if (!cancelled) setReachablePois(data.pois ?? []); }) + .catch(() => { if (!cancelled) setReachablePois([]); }); + return () => { cancelled = true; }; + }, [pinLocation, selectedCity, mode, threshold, isochroneData]); + // Pre-fetch the isochrone whenever a pin is placed (in accessibility mode). // overlayMode is intentionally NOT a dep — fetching is decoupled from display. // This means: @@ -204,9 +217,10 @@ export default function HomePage() { body: JSON.stringify({ lng: pinLocation.lng, lat: pinLocation.lat, - // "fifteen" and "transit" have no direct Valhalla isochrone costing — - // use walking as the representative display mode for both. - travelMode: (mode === "fifteen" || mode === "transit") ? "walking" : mode, + // "fifteen" uses cycling as the representative display mode (largest + // reliable non-time-dependent coverage). "transit" uses the real + // multimodal costing supported by fetchIsochrone. + travelMode: mode === "fifteen" ? "cycling" : mode, contourMinutes: isochroneContours(threshold), }), }) @@ -259,6 +273,7 @@ export default function HomePage() { setPinEstatePercentile(null); setPinEstateScorePercentile(null); setIsochroneData(null); + setReachablePois([]); } @@ -319,6 +334,7 @@ export default function HomePage() { } isochrones={overlayMode === "isochrone" ? isochroneData : null} baseOverlay={baseOverlay} + reachablePois={showPois ? reachablePois : []} onLocationClick={handleLocationClick} /> )} @@ -371,6 +387,9 @@ export default function HomePage() { estateScorePercentile={pinEstateScorePercentile} overlayMode={overlayMode} isochroneLoading={isochroneLoading} + showPois={showPois} + onTogglePois={() => setShowPois((v) => !v)} + poiCount={reachablePois.length} onOverlayModeChange={setOverlayMode} onClose={handlePinClose} /> diff --git a/apps/web/components/control-panel.tsx b/apps/web/components/control-panel.tsx index 786a110..d2a6bbf 100644 --- a/apps/web/components/control-panel.tsx +++ b/apps/web/components/control-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import { CATEGORIES, PROFILES, PROFILE_IDS } from "@transportationer/shared"; +import { CATEGORIES, PROFILES, PROFILE_IDS, VALID_THRESHOLDS } from "@transportationer/shared"; import type { CategoryId, TravelMode, ProfileId } from "@transportationer/shared"; const TRAVEL_MODES: Array<{ value: TravelMode; label: string; icon: string }> = @@ -12,7 +12,6 @@ const TRAVEL_MODES: Array<{ value: TravelMode; label: string; icon: string }> = { value: "driving", label: "Driving", icon: "🚗" }, ]; -const THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30]; type BaseOverlay = "accessibility" | "estate-value" | "hidden-gem"; @@ -116,7 +115,7 @@ export function ControlPanel({ Target Threshold

- {THRESHOLDS.map((t) => ( + {VALID_THRESHOLDS.map((t) => (
+ {/* POI pin toggle */} + {poiCount > 0 && ( +
+ +
+ )} + {/* Per-category score bars */}
{CATEGORIES.map((cat) => { diff --git a/apps/web/components/map-view.tsx b/apps/web/components/map-view.tsx index b202466..13a8df7 100644 --- a/apps/web/components/map-view.tsx +++ b/apps/web/components/map-view.tsx @@ -10,6 +10,22 @@ type Weights = Record; export type BaseOverlay = "accessibility" | "estate-value" | "hidden-gem"; +export type ReachablePoi = { + category: string; + subcategory: string; + name: string | null; + lat: number; + lng: number; +}; + +const CATEGORY_COLORS: Record = { + service_trade: "#f59e0b", + transport: "#0ea5e9", + work_school: "#8b5cf6", + culture_community: "#ec4899", + recreation: "#10b981", +}; + export interface MapViewProps { citySlug: string; cityBbox: [number, number, number, number]; @@ -25,6 +41,8 @@ export interface MapViewProps { isochrones?: object | null; /** Which base overlay to show (accessibility grid, estate value, or hidden gem). */ baseOverlay?: BaseOverlay; + /** Reachable POI pins to show on the map. */ + reachablePois?: ReachablePoi[] | null; onLocationClick?: (lat: number, lng: number, estateValue: number | null) => void; } @@ -98,6 +116,11 @@ function removeEstateValueLayers(map: maplibregl.Map) { if (map.getSource("estate-value-zones")) map.removeSource("estate-value-zones"); } +function removePoiLayers(map: maplibregl.Map) { + if (map.getLayer("poi-circles")) map.removeLayer("poi-circles"); + if (map.getSource("reachable-pois")) map.removeSource("reachable-pois"); +} + function removeHiddenGemLayers(map: maplibregl.Map) { if (map.getLayer("hidden-gems-fill")) map.removeLayer("hidden-gems-fill"); if (map.getSource("hidden-gems-tiles")) map.removeSource("hidden-gems-tiles"); @@ -142,6 +165,7 @@ export function MapView({ pinCategoryScores, isochrones, baseOverlay = "accessibility", + reachablePois, onLocationClick, }: MapViewProps) { const containerRef = useRef(null); @@ -159,6 +183,15 @@ export function MapView({ onLeave: () => void; } | null>(null); + // Ref for POI layer event handlers + const poiHandlersRef = useRef<{ + onEnter: () => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onMove: (e: any) => void; + onLeave: () => void; + } | null>(null); + const poiPopupRef = useRef(null); + // Tracked as state so effects re-run once the map style finishes loading. // Without this, effects that check isStyleLoaded() return early on first render // and never reapply state after the async on("load") fires. @@ -264,7 +297,7 @@ export function MapView({ maxContour * 0.5, "#fee08b", maxContour, "#d73027", ] as any, - "fill-opacity": 0.5, + "fill-opacity": 0.3, "fill-outline-color": "rgba(0,0,0,0.1)", }, }); @@ -440,6 +473,108 @@ export function MapView({ }; }, [mapLoaded, showEstateValue, citySlug]); // eslint-disable-line react-hooks/exhaustive-deps + // ── Reachable POI pins ──────────────────────────────────────────────────── + useEffect(() => { + if (!mapLoaded) return; + const map = mapRef.current!; + + // Clean up previous handlers + if (poiHandlersRef.current) { + const { onEnter, onMove, onLeave } = poiHandlersRef.current; + map.off("mouseenter", "poi-circles", onEnter); + map.off("mousemove", "poi-circles", onMove); + map.off("mouseleave", "poi-circles", onLeave); + poiHandlersRef.current = null; + } + poiPopupRef.current?.remove(); + poiPopupRef.current = null; + + if (!reachablePois || reachablePois.length === 0) { + removePoiLayers(map); + return; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const geojson: any = { + type: "FeatureCollection", + features: reachablePois.map((poi) => ({ + type: "Feature", + geometry: { type: "Point", coordinates: [poi.lng, poi.lat] }, + properties: { category: poi.category, subcategory: poi.subcategory, name: poi.name }, + })), + }; + + const existingSource = map.getSource("reachable-pois") as maplibregl.GeoJSONSource | undefined; + if (existingSource) { + existingSource.setData(geojson); + } else { + map.addSource("reachable-pois", { type: "geojson", data: geojson }); + + // Build match expression for category colors + const colorMatch: unknown[] = ["match", ["get", "category"]]; + for (const [cat, color] of Object.entries(CATEGORY_COLORS)) { + colorMatch.push(cat, color); + } + colorMatch.push("#888888"); // fallback + + map.addLayer({ + id: "poi-circles", + type: "circle", + source: "reachable-pois", + paint: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "circle-color": colorMatch as any, + "circle-radius": 6, + "circle-stroke-color": "#ffffff", + "circle-stroke-width": 1.5, + "circle-opacity": 0.85, + }, + }); + } + + const popup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, maxWidth: "200px" }); + poiPopupRef.current = popup; + + const onEnter = () => { map.getCanvas().style.cursor = "pointer"; }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const onMove = (e: any) => { + const feature = e.features?.[0]; + if (!feature) return; + const name = feature.properties?.name as string | null; + const subcategory = feature.properties?.subcategory as string; + popup.setLngLat(e.lngLat).setHTML( + `
+
${name ?? subcategory}
+ ${name ? `
${subcategory}
` : ""} +
` + ).addTo(map); + }; + const onLeave = () => { map.getCanvas().style.cursor = "crosshair"; popup.remove(); }; + + map.on("mouseenter", "poi-circles", onEnter); + map.on("mousemove", "poi-circles", onMove); + map.on("mouseleave", "poi-circles", onLeave); + poiHandlersRef.current = { onEnter, onMove, onLeave }; + + return () => { + if (poiHandlersRef.current) { + const { onEnter: e, onMove: m, onLeave: l } = poiHandlersRef.current; + map.off("mouseenter", "poi-circles", e); + map.off("mousemove", "poi-circles", m); + map.off("mouseleave", "poi-circles", l); + poiHandlersRef.current = null; + } + try { + if (map.isStyleLoaded()) { + removePoiLayers(map); + map.getCanvas().style.cursor = "crosshair"; + } + } catch { /* map removed */ } + poiPopupRef.current?.remove(); + poiPopupRef.current = null; + }; + }, [mapLoaded, reachablePois]); + // ── Initialize map (runs once on mount) ─────────────────────────────────── useEffect(() => { if (mountedRef.current || !containerRef.current) return; diff --git a/apps/web/lib/isochrone.ts b/apps/web/lib/isochrone.ts new file mode 100644 index 0000000..3225b00 --- /dev/null +++ b/apps/web/lib/isochrone.ts @@ -0,0 +1,9 @@ +/** Compute up to 3 evenly-spaced contour values up to threshold (deduped, min 1). */ +export 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)]; +} diff --git a/apps/web/lib/valhalla.ts b/apps/web/lib/valhalla.ts index d3b5945..03650f5 100644 --- a/apps/web/lib/valhalla.ts +++ b/apps/web/lib/valhalla.ts @@ -17,14 +17,19 @@ export interface IsochroneOpts { } export async function fetchIsochrone(opts: IsochroneOpts): Promise { - const costing = COSTING_MAP[opts.travelMode] ?? "pedestrian"; - const body = { + const isTransit = opts.travelMode === "transit"; + const costing = isTransit ? "multimodal" : (COSTING_MAP[opts.travelMode] ?? "pedestrian"); + const body: Record = { locations: [{ lon: opts.lng, lat: opts.lat }], costing, contours: opts.contourMinutes.map((time) => ({ time })), polygons: opts.polygons ?? true, show_locations: false, }; + if (isTransit) { + body.costing_options = { transit: { use_bus: 1.0, use_rail: 1.0, use_transfers: 1.0 } }; + body.date_time = { type: 0 }; // current time + } const res = await fetch(`${VALHALLA_BASE}/isochrone`, { method: "POST", diff --git a/shared/src/osm-tags.ts b/shared/src/osm-tags.ts index 4db4cda..96ef238 100644 --- a/shared/src/osm-tags.ts +++ b/shared/src/osm-tags.ts @@ -10,6 +10,9 @@ export type RoutingMode = "walking" | "cycling" | "driving" | "transit"; /** All display modes, including the synthetic "fifteen" (best-of walking+cycling+transit). */ export type TravelMode = RoutingMode | "fifteen"; +export const TRAVEL_MODES = ["walking", "cycling", "driving", "transit", "fifteen"] as const satisfies TravelMode[]; +export const VALID_THRESHOLDS = [5, 10, 15, 20, 30] as const; + export interface TagFilter { key: string; values: string[]; diff --git a/worker/src/jobs/refresh-city.ts b/worker/src/jobs/refresh-city.ts index c7d924d..73098b0 100644 --- a/worker/src/jobs/refresh-city.ts +++ b/worker/src/jobs/refresh-city.ts @@ -2,7 +2,7 @@ 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 { JOB_OPTIONS, VALID_THRESHOLDS } from "@transportationer/shared"; import type { JobProgress } from "@transportationer/shared"; export type RefreshCityData = { @@ -111,7 +111,7 @@ export async function handleRefreshCity( type: "compute-scores" as const, citySlug, modes: ["walking", "cycling", "driving", "transit"] as const, - thresholds: [5, 10, 15, 20, 30], + thresholds: [...VALID_THRESHOLDS], ingestBorisNi: niApplicable, }, opts: JOB_OPTIONS["compute-scores"],