fix: isochrone for poi selection and calculation was wrong, now it is not
This commit is contained in:
parent
bfe6146645
commit
b891ca79ac
13 changed files with 331 additions and 40 deletions
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
95
apps/web/app/api/reachable-pois/route.ts
Normal file
95
apps/web/app/api/reachable-pois/route.ts
Normal file
|
|
@ -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" } },
|
||||
);
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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: () => <div className="flex-1 bg-gray-200 animate-pulse" /> },
|
||||
);
|
||||
|
||||
/** Compute 3 evenly-spaced contour values up to the threshold (deduped, min 1). */
|
||||
function isochroneContours(threshold: number): number[] {
|
||||
const raw = [
|
||||
Math.max(1, Math.round(threshold / 3)),
|
||||
Math.max(2, Math.round((threshold * 2) / 3)),
|
||||
threshold,
|
||||
];
|
||||
return [...new Set(raw)];
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [cities, setCities] = useState<City[]>([]);
|
||||
|
|
@ -53,6 +45,11 @@ export default function HomePage() {
|
|||
const [pinEstatePercentile, setPinEstatePercentile] = useState<number | null>(null);
|
||||
const [pinEstateScorePercentile, setPinEstateScorePercentile] = useState<number | null>(null);
|
||||
|
||||
// Reachable POI pins
|
||||
type ReachablePoi = { category: string; subcategory: string; name: string | null; lat: number; lng: number };
|
||||
const [reachablePois, setReachablePois] = useState<ReachablePoi[]>([]);
|
||||
const [showPois, setShowPois] = useState(true);
|
||||
|
||||
// Overlay mode: isochrone (new default) or relative heatmap
|
||||
const [overlayMode, setOverlayMode] = useState<OverlayMode>("isochrone");
|
||||
const [isochroneData, setIsochroneData] = useState<object | null>(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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{THRESHOLDS.map((t) => (
|
||||
{VALID_THRESHOLDS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => onThresholdChange(t)}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,9 @@ export function LocationScorePanel({
|
|||
estateScorePercentile,
|
||||
overlayMode,
|
||||
isochroneLoading,
|
||||
showPois,
|
||||
onTogglePois,
|
||||
poiCount,
|
||||
onOverlayModeChange,
|
||||
onClose,
|
||||
}: {
|
||||
|
|
@ -130,6 +133,9 @@ export function LocationScorePanel({
|
|||
estateScorePercentile?: number | null;
|
||||
overlayMode: OverlayMode;
|
||||
isochroneLoading: boolean;
|
||||
showPois: boolean;
|
||||
onTogglePois: () => void;
|
||||
poiCount: number;
|
||||
onOverlayModeChange: (mode: OverlayMode) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
|
|
@ -205,6 +211,22 @@ export function LocationScorePanel({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* POI pin toggle */}
|
||||
{poiCount > 0 && (
|
||||
<div className="mb-3">
|
||||
<button
|
||||
onClick={onTogglePois}
|
||||
className={`w-full py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
showPois
|
||||
? "bg-blue-100 text-blue-700 hover:bg-blue-200"
|
||||
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{showPois ? `Hide ${poiCount} POIs` : `Show ${poiCount} POIs`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-category score bars */}
|
||||
<div className="space-y-2.5">
|
||||
{CATEGORIES.map((cat) => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,22 @@ type Weights = Record<CategoryId, number>;
|
|||
|
||||
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<string, string> = {
|
||||
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<HTMLDivElement>(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<maplibregl.Popup | null>(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(
|
||||
`<div style="font:13px/1.5 system-ui,sans-serif;min-width:100px">
|
||||
<div style="font-weight:600;color:#111">${name ?? subcategory}</div>
|
||||
${name ? `<div style="color:#777;font-size:11px">${subcategory}</div>` : ""}
|
||||
</div>`
|
||||
).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;
|
||||
|
|
|
|||
9
apps/web/lib/isochrone.ts
Normal file
9
apps/web/lib/isochrone.ts
Normal file
|
|
@ -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)];
|
||||
}
|
||||
|
|
@ -17,14 +17,19 @@ export interface IsochroneOpts {
|
|||
}
|
||||
|
||||
export async function fetchIsochrone(opts: IsochroneOpts): Promise<object> {
|
||||
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<string, unknown> = {
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Reference in a new issue