Compare commits

...

2 commits

3 changed files with 76 additions and 16 deletions

View file

@ -8,6 +8,13 @@ const CACHE_TOLERANCE_M = 50;
type CacheFeature = { geometry: object; properties: { contour: number } }; type CacheFeature = { geometry: object; properties: { contour: number } };
// Which raw travel modes feed into each synthetic persona mode for scoring.
const DETAIL_MODES: Record<string, string[]> = {
cyclist: ["walking", "cycling", "transit"],
cycling_walk: ["walking", "cycling"],
transit_walk: ["walking", "transit"],
};
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
console.log("[reachable-pois] GET called:", req.nextUrl.search); console.log("[reachable-pois] GET called:", req.nextUrl.search);
const { searchParams } = req.nextUrl; const { searchParams } = req.nextUrl;
@ -29,6 +36,9 @@ export async function GET(req: NextRequest) {
mode === "transit_walk" ? "transit" : mode === "transit_walk" ? "transit" :
mode; mode;
// Raw travel modes that feed into this mode's scoring (for scored POI lookup).
const detailModes = DETAIL_MODES[mode] ?? [mode];
// Use the identical contours array that the display isochrone route caches with, // 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 // so the exact-match lookup is guaranteed to find the entry once isochroneData
// is set on the client (which is our fetch trigger). // is set on the client (which is our fetch trigger).
@ -76,13 +86,34 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ pois: [], _debug: "no_polygon" }, { headers: { "Cache-Control": "no-store" } }); 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 }[]>` const pois = await Promise.resolve(sql<{
SELECT category, subcategory, name, category: string; subcategory: string; name: string | null;
ST_Y(geom)::float AS lat, ST_X(geom)::float AS lng lat: number; lng: number; scored: boolean;
FROM raw_pois }[]>`
WITH nearest_gp AS (
SELECT id AS grid_point_id
FROM grid_points
WHERE city_slug = ${city} WHERE city_slug = ${city}
AND ST_Intersects(geom, ST_SetSRID(ST_GeomFromGeoJSON(${JSON.stringify(polygon)}), 4326)) ORDER BY geom <-> ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)
ORDER BY category, subcategory, name LIMIT 1
),
scored_ids AS (
SELECT DISTINCT gpd.nearest_poi_id
FROM grid_poi_details gpd
CROSS JOIN nearest_gp ng
WHERE gpd.grid_point_id = ng.grid_point_id
AND gpd.travel_mode = ANY(${detailModes})
AND gpd.nearest_poi_id IS NOT NULL
)
SELECT
p.category, p.subcategory, p.name,
ST_Y(p.geom)::float AS lat, ST_X(p.geom)::float AS lng,
(s.nearest_poi_id IS NOT NULL) AS scored
FROM raw_pois p
LEFT JOIN scored_ids s ON p.osm_id = s.nearest_poi_id
WHERE p.city_slug = ${city}
AND ST_Intersects(p.geom, ST_SetSRID(ST_GeomFromGeoJSON(${JSON.stringify(polygon)}), 4326))
ORDER BY p.category, p.subcategory, p.name
`); `);
if (pois.length === 0) { if (pois.length === 0) {

View file

@ -40,13 +40,14 @@ export default function HomePage() {
// Pin / location rating // Pin / location rating
const [pinLocation, setPinLocation] = useState<{ lat: number; lng: number } | null>(null); const [pinLocation, setPinLocation] = useState<{ lat: number; lng: number } | null>(null);
const [pinData, setPinData] = useState<LocationScoreData | null>(null); const [pinData, setPinData] = useState<LocationScoreData | null>(null);
const [pinScoreError, setPinScoreError] = useState(false);
const [pinAddress, setPinAddress] = useState<string | undefined>(undefined); const [pinAddress, setPinAddress] = useState<string | undefined>(undefined);
const [pinEstateValue, setPinEstateValue] = useState<number | null>(null); const [pinEstateValue, setPinEstateValue] = useState<number | null>(null);
const [pinEstatePercentile, setPinEstatePercentile] = useState<number | null>(null); const [pinEstatePercentile, setPinEstatePercentile] = useState<number | null>(null);
const [pinEstateScorePercentile, setPinEstateScorePercentile] = useState<number | null>(null); const [pinEstateScorePercentile, setPinEstateScorePercentile] = useState<number | null>(null);
// Reachable POI pins // Reachable POI pins
type ReachablePoi = { category: string; subcategory: string; name: string | null; lat: number; lng: number }; type ReachablePoi = { category: string; subcategory: string; name: string | null; lat: number; lng: number; scored: boolean };
const [reachablePois, setReachablePois] = useState<ReachablePoi[]>([]); const [reachablePois, setReachablePois] = useState<ReachablePoi[]>([]);
const [showPois, setShowPois] = useState(true); const [showPois, setShowPois] = useState(true);
@ -100,11 +101,13 @@ export default function HomePage() {
useEffect(() => { useEffect(() => {
if (!pinLocation || !selectedCity) { if (!pinLocation || !selectedCity) {
setPinData(null); setPinData(null);
setPinScoreError(false);
setPinAddress(undefined); setPinAddress(undefined);
return; return;
} }
let cancelled = false; let cancelled = false;
setPinScoreError(false);
const params = new URLSearchParams({ const params = new URLSearchParams({
lat: String(pinLocation.lat), lat: String(pinLocation.lat),
@ -128,14 +131,15 @@ export default function HomePage() {
.then(([scoreData, address]) => { .then(([scoreData, address]) => {
if (cancelled) return; if (cancelled) return;
if (scoreData?.error) { if (scoreData?.error) {
// No grid data for this location — clear the pin so the skeleton doesn't persist. // No grid data for this mode — keep the pin (isochrone may still show) but
setPinLocation(null); // display an error state instead of an infinite loading skeleton.
setPinScoreError(true);
return; return;
} }
setPinData(scoreData as LocationScoreData); setPinData(scoreData as LocationScoreData);
setPinAddress(address); setPinAddress(address);
}) })
.catch(() => { if (!cancelled) setPinLocation(null); }); .catch(() => { if (!cancelled) setPinScoreError(true); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [pinLocation, selectedCity, mode, threshold, profile]); }, [pinLocation, selectedCity, mode, threshold, profile]);
@ -252,6 +256,7 @@ export default function HomePage() {
// Clear pin when profile changes so scores are re-fetched with new profile // Clear pin when profile changes so scores are re-fetched with new profile
setPinLocation(null); setPinLocation(null);
setPinData(null); setPinData(null);
setPinScoreError(false);
setPinAddress(undefined); setPinAddress(undefined);
setPinEstateValue(null); setPinEstateValue(null);
setPinEstatePercentile(null); setPinEstatePercentile(null);
@ -262,6 +267,7 @@ export default function HomePage() {
function handleLocationClick(lat: number, lng: number, estateValue: number | null) { function handleLocationClick(lat: number, lng: number, estateValue: number | null) {
setPinLocation({ lat, lng }); setPinLocation({ lat, lng });
setPinData(null); setPinData(null);
setPinScoreError(false);
setPinAddress(undefined); setPinAddress(undefined);
setPinEstateValue(estateValue); setPinEstateValue(estateValue);
setPinEstatePercentile(null); setPinEstatePercentile(null);
@ -272,6 +278,7 @@ export default function HomePage() {
function handlePinClose() { function handlePinClose() {
setPinLocation(null); setPinLocation(null);
setPinData(null); setPinData(null);
setPinScoreError(false);
setPinAddress(undefined); setPinAddress(undefined);
setPinEstateValue(null); setPinEstateValue(null);
setPinEstatePercentile(null); setPinEstatePercentile(null);
@ -351,7 +358,21 @@ export default function HomePage() {
hasPinData={!!pinData} hasPinData={!!pinData}
/> />
{pinLocation && !pinData && ( {pinLocation && !pinData && pinScoreError && (
<div className="absolute bottom-8 right-4 z-20 bg-white rounded-xl shadow-lg border border-gray-100 w-72 p-4">
<button
onClick={handlePinClose}
className="absolute top-3 right-3 text-gray-300 hover:text-gray-500 text-xl leading-none"
aria-label="Close"
>×</button>
<p className="text-sm text-gray-500 text-center py-4">
No score data for this mode yet.<br />
<span className="text-xs text-gray-400">Re-run ingest to compute new modes.</span>
</p>
</div>
)}
{pinLocation && !pinData && !pinScoreError && (
<div className="absolute bottom-8 right-4 z-20 bg-white rounded-xl shadow-lg border border-gray-100 w-72 p-4"> <div className="absolute bottom-8 right-4 z-20 bg-white rounded-xl shadow-lg border border-gray-100 w-72 p-4">
<button <button
onClick={handlePinClose} onClick={handlePinClose}

View file

@ -13,6 +13,7 @@ export type BaseOverlay = "accessibility" | "estate-value" | "hidden-gem";
export type ReachablePoi = { export type ReachablePoi = {
category: string; category: string;
subcategory: string; subcategory: string;
scored?: boolean;
name: string | null; name: string | null;
lat: number; lat: number;
lng: number; lng: number;
@ -503,7 +504,7 @@ export function MapView({
features: reachablePois.map((poi) => ({ features: reachablePois.map((poi) => ({
type: "Feature", type: "Feature",
geometry: { type: "Point", coordinates: [poi.lng, poi.lat] }, geometry: { type: "Point", coordinates: [poi.lng, poi.lat] },
properties: { category: poi.category, subcategory: poi.subcategory, name: poi.name }, properties: { category: poi.category, subcategory: poi.subcategory, name: poi.name, scored: poi.scored ?? false },
})), })),
}; };
@ -527,10 +528,17 @@ export function MapView({
paint: { paint: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
"circle-color": colorMatch as any, "circle-color": colorMatch as any,
"circle-radius": 6, // Scored POIs (in KNN set) are larger and fully opaque;
// out-of-KNN POIs are smaller and faded.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
"circle-radius": ["case", ["get", "scored"], 6, 4] as any,
"circle-stroke-color": "#ffffff", "circle-stroke-color": "#ffffff",
"circle-stroke-width": 1.5, // eslint-disable-next-line @typescript-eslint/no-explicit-any
"circle-opacity": 0.85, "circle-stroke-width": ["case", ["get", "scored"], 1.5, 1] as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
"circle-opacity": ["case", ["get", "scored"], 0.9, 0.5] as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
"circle-stroke-opacity": ["case", ["get", "scored"], 0.9, 0.5] as any,
}, },
}); });
} }