Compare commits
2 commits
43a1c71dd1
...
d0b29278bd
| Author | SHA1 | Date | |
|---|---|---|---|
| d0b29278bd | |||
| 09d23e6666 |
3 changed files with 76 additions and 16 deletions
|
|
@ -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
|
}[]>`
|
||||||
WHERE city_slug = ${city}
|
WITH nearest_gp AS (
|
||||||
AND ST_Intersects(geom, ST_SetSRID(ST_GeomFromGeoJSON(${JSON.stringify(polygon)}), 4326))
|
SELECT id AS grid_point_id
|
||||||
ORDER BY category, subcategory, name
|
FROM grid_points
|
||||||
|
WHERE city_slug = ${city}
|
||||||
|
ORDER BY geom <-> ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)
|
||||||
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue