Compare commits
No commits in common. "d0b29278bd5e3339eb558e14b4c6179e159cdf3c" and "43a1c71dd198c43b64e0eb1adf94980e2c6d76d1" have entirely different histories.
d0b29278bd
...
43a1c71dd1
3 changed files with 16 additions and 76 deletions
|
|
@ -8,13 +8,6 @@ const CACHE_TOLERANCE_M = 50;
|
|||
|
||||
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) {
|
||||
console.log("[reachable-pois] GET called:", req.nextUrl.search);
|
||||
const { searchParams } = req.nextUrl;
|
||||
|
|
@ -36,9 +29,6 @@ export async function GET(req: NextRequest) {
|
|||
mode === "transit_walk" ? "transit" :
|
||||
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,
|
||||
// so the exact-match lookup is guaranteed to find the entry once isochroneData
|
||||
// is set on the client (which is our fetch trigger).
|
||||
|
|
@ -86,34 +76,13 @@ export async function GET(req: NextRequest) {
|
|||
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; scored: boolean;
|
||||
}[]>`
|
||||
WITH nearest_gp AS (
|
||||
SELECT id AS grid_point_id
|
||||
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
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -40,14 +40,13 @@ export default function HomePage() {
|
|||
// Pin / location rating
|
||||
const [pinLocation, setPinLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [pinData, setPinData] = useState<LocationScoreData | null>(null);
|
||||
const [pinScoreError, setPinScoreError] = useState(false);
|
||||
const [pinAddress, setPinAddress] = useState<string | undefined>(undefined);
|
||||
const [pinEstateValue, setPinEstateValue] = useState<number | null>(null);
|
||||
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; scored: boolean };
|
||||
type ReachablePoi = { category: string; subcategory: string; name: string | null; lat: number; lng: number };
|
||||
const [reachablePois, setReachablePois] = useState<ReachablePoi[]>([]);
|
||||
const [showPois, setShowPois] = useState(true);
|
||||
|
||||
|
|
@ -101,13 +100,11 @@ export default function HomePage() {
|
|||
useEffect(() => {
|
||||
if (!pinLocation || !selectedCity) {
|
||||
setPinData(null);
|
||||
setPinScoreError(false);
|
||||
setPinAddress(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setPinScoreError(false);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
lat: String(pinLocation.lat),
|
||||
|
|
@ -131,15 +128,14 @@ export default function HomePage() {
|
|||
.then(([scoreData, address]) => {
|
||||
if (cancelled) return;
|
||||
if (scoreData?.error) {
|
||||
// No grid data for this mode — keep the pin (isochrone may still show) but
|
||||
// display an error state instead of an infinite loading skeleton.
|
||||
setPinScoreError(true);
|
||||
// No grid data for this location — clear the pin so the skeleton doesn't persist.
|
||||
setPinLocation(null);
|
||||
return;
|
||||
}
|
||||
setPinData(scoreData as LocationScoreData);
|
||||
setPinAddress(address);
|
||||
})
|
||||
.catch(() => { if (!cancelled) setPinScoreError(true); });
|
||||
.catch(() => { if (!cancelled) setPinLocation(null); });
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [pinLocation, selectedCity, mode, threshold, profile]);
|
||||
|
|
@ -256,7 +252,6 @@ export default function HomePage() {
|
|||
// Clear pin when profile changes so scores are re-fetched with new profile
|
||||
setPinLocation(null);
|
||||
setPinData(null);
|
||||
setPinScoreError(false);
|
||||
setPinAddress(undefined);
|
||||
setPinEstateValue(null);
|
||||
setPinEstatePercentile(null);
|
||||
|
|
@ -267,7 +262,6 @@ export default function HomePage() {
|
|||
function handleLocationClick(lat: number, lng: number, estateValue: number | null) {
|
||||
setPinLocation({ lat, lng });
|
||||
setPinData(null);
|
||||
setPinScoreError(false);
|
||||
setPinAddress(undefined);
|
||||
setPinEstateValue(estateValue);
|
||||
setPinEstatePercentile(null);
|
||||
|
|
@ -278,7 +272,6 @@ export default function HomePage() {
|
|||
function handlePinClose() {
|
||||
setPinLocation(null);
|
||||
setPinData(null);
|
||||
setPinScoreError(false);
|
||||
setPinAddress(undefined);
|
||||
setPinEstateValue(null);
|
||||
setPinEstatePercentile(null);
|
||||
|
|
@ -358,21 +351,7 @@ export default function HomePage() {
|
|||
hasPinData={!!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 && (
|
||||
{pinLocation && !pinData && (
|
||||
<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}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ export type BaseOverlay = "accessibility" | "estate-value" | "hidden-gem";
|
|||
export type ReachablePoi = {
|
||||
category: string;
|
||||
subcategory: string;
|
||||
scored?: boolean;
|
||||
name: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
|
|
@ -504,7 +503,7 @@ export function MapView({
|
|||
features: reachablePois.map((poi) => ({
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: [poi.lng, poi.lat] },
|
||||
properties: { category: poi.category, subcategory: poi.subcategory, name: poi.name, scored: poi.scored ?? false },
|
||||
properties: { category: poi.category, subcategory: poi.subcategory, name: poi.name },
|
||||
})),
|
||||
};
|
||||
|
||||
|
|
@ -528,17 +527,10 @@ export function MapView({
|
|||
paint: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
"circle-color": colorMatch as any,
|
||||
// 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-radius": 6,
|
||||
"circle-stroke-color": "#ffffff",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
"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,
|
||||
"circle-stroke-width": 1.5,
|
||||
"circle-opacity": 0.85,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue