From 07d4fa5f845e2a0b93bc8e1c9f236bb21ca2266c Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 8 Feb 2026 16:02:46 +0000 Subject: [PATCH] Add per-POI travel time filtering and fix heatmap color stops Replace the single global max travel time filter with per-POI filters. Each POI gets its own travel mode selector and max minutes input in the filter panel. Listings must satisfy ALL active filters (AND logic). Fix Mapbox "Input is not a number" error by ensuring color stops are always strictly monotonic (guard min === max) and always set (even when no valid metric values exist). Also filter Infinity values from the color scale computation. Widen the filter panel from w-64 to w-80. --- frontend/src/App.tsx | 36 +++++--- frontend/src/components/FilterPanel.tsx | 118 +++++++++++++++++++++++- frontend/src/components/Map.tsx | 10 +- frontend/src/types/index.ts | 5 + frontend/src/utils/poiUtils.ts | 41 ++++++++ 5 files changed, 193 insertions(+), 17 deletions(-) create mode 100644 frontend/src/utils/poiUtils.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 67f5e58..7332054 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,7 +15,7 @@ import { StreamingProgressBar } from './components/StreamingProgressBar'; import { Sheet, SheetContent, SheetTrigger } from './components/ui/sheet'; import { Button } from './components/ui/button'; import { Filter } from 'lucide-react'; -import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI } from '@/types'; +import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI, POITravelFilter } from '@/types'; import { refreshListings, fetchTasksForUser, streamListingGeoJSON, fetchUserPOIs, type StreamingProgress } from '@/services'; import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils'; @@ -39,7 +39,7 @@ function App() { poiName: string; travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT'; } | null>(null); - const [maxTravelMinutes, setMaxTravelMinutes] = useState(undefined); + const [poiTravelFilters, setPoiTravelFilters] = useState>({}); // Ref to track accumulated features during streaming const accumulatedFeaturesRef = useRef([]); @@ -151,18 +151,26 @@ function App() { ); } - // Filter by max travel time - if (maxTravelMinutes !== undefined && poiMetricSelection) { - const maxSeconds = maxTravelMinutes * 60; - const propName = poiMetricPropertyName(poiMetricSelection.poiId, poiMetricSelection.travelMode); + // Filter by per-POI max travel time (AND logic: listing must satisfy all active filters) + const activeFilters = Object.entries(poiTravelFilters) + .filter(([, f]) => f.maxMinutes !== undefined) + .map(([id, f]) => ({ poiId: Number(id), travelMode: f.travelMode, maxMinutes: f.maxMinutes! })); + + if (activeFilters.length > 0) { features = features.filter((f) => { - const value = (f.properties as Record)[propName] as number | undefined; - return value !== undefined && value <= maxSeconds; + const distances = f.properties.poi_distances; + if (!distances) return false; + return activeFilters.every((filter) => { + const dist = distances.find( + (d) => d.poi_id === filter.poiId && d.travel_mode === filter.travelMode, + ); + return dist !== undefined && dist.duration_seconds <= filter.maxMinutes * 60; + }); }); } return { ...listingData, features }; - }, [listingData, poiMetricSelection, maxTravelMinutes]); + }, [listingData, poiMetricSelection, poiTravelFilters]); // Compute the effective metric string for the heatmap const effectiveMetric = useMemo(() => { @@ -342,7 +350,7 @@ function App() { {/* Main content area */}
{/* Filter Panel - Desktop (fixed sidebar) */} -
+
@@ -379,8 +387,8 @@ function App() { pickedPoiLocation={pickedPoiLocation} userPOIs={userPOIs} onPoiMetricChange={setPoiMetricSelection} - maxTravelMinutes={maxTravelMinutes} - onMaxTravelMinutesChange={setMaxTravelMinutes} + poiTravelFilters={poiTravelFilters} + onPoiTravelFiltersChange={setPoiTravelFilters} /> diff --git a/frontend/src/components/FilterPanel.tsx b/frontend/src/components/FilterPanel.tsx index aaf3477..af82db2 100644 --- a/frontend/src/components/FilterPanel.tsx +++ b/frontend/src/components/FilterPanel.tsx @@ -12,12 +12,14 @@ import { Loader2, Filter, RefreshCw, MapPin } from "lucide-react"; import { ScrollArea } from "./ui/scroll-area"; import { POIManager } from "./POIManager"; import type { AuthUser } from "@/auth/types"; +import type { POI, POITravelFilter } from "@/types"; export enum Metric { qmprice = 'qmprice', rooms = 'rooms', qm = 'qm', price = 'total_price', + poi_travel = 'poi_travel', } export enum ListingType { @@ -73,6 +75,12 @@ interface FilterPanelProps { listingCount?: number; user?: AuthUser; onTaskCreated?: (taskId: string) => void; + onStartPoiPicking?: () => void; + pickedPoiLocation?: { lat: number; lng: number } | null; + userPOIs?: POI[]; + onPoiMetricChange?: (selection: { poiId: number; poiName: string; travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT' } | null) => void; + poiTravelFilters?: Record; + onPoiTravelFiltersChange?: (filters: Record) => void; } const formSchema = z.object({ @@ -94,9 +102,11 @@ const formSchema = z.object({ type FormValues = z.infer; -export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount, user, onTaskCreated }: FilterPanelProps) { +export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount, user, onTaskCreated, onStartPoiPicking, pickedPoiLocation, userPOIs, onPoiMetricChange, poiTravelFilters, onPoiTravelFiltersChange }: FilterPanelProps) { const [availableFromRawInput, setAvailableFromRawInput] = useState("now"); const [selectedFurnishTypes, setSelectedFurnishTypes] = useState([]); + const [selectedPoiId, setSelectedPoiId] = useState(''); + const [selectedTravelMode, setSelectedTravelMode] = useState(''); const form = useForm({ resolver: zodResolver(formSchema), @@ -210,6 +220,9 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount, )} /> + {form.watch('metric') === Metric.poi_travel && userPOIs && userPOIs.length > 0 && ( + <> +
+ POI + +
+
+ Travel Mode + +
+ + )} + {userPOIs && userPOIs.length > 0 && ( +
+ Max Travel Time +
+ {userPOIs.map((poi) => { + const filter = poiTravelFilters?.[poi.id]; + const travelMode = filter?.travelMode ?? 'WALK'; + const maxMinutes = filter?.maxMinutes; + return ( +
+ {poi.name} + + { + onPoiTravelFiltersChange?.({ + ...poiTravelFilters, + [poi.id]: { + travelMode, + maxMinutes: e.target.value ? Number(e.target.value) : undefined, + }, + }); + }} + /> + min +
+ ); + })} +
+
+ )} )} diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx index 4ff0074..739dcf5 100644 --- a/frontend/src/components/Map.tsx +++ b/frontend/src/components/Map.tsx @@ -99,18 +99,24 @@ export function Map(props: MapProps) { .map(function (d: PropertyFeature) { return (d.properties as Record)[metricMode] as number; }) - .filter(function (v: number) { return typeof v === 'number' && v > 0; }) + .filter(function (v: number) { return typeof v === 'number' && isFinite(v) && v > 0; }) .sort(function (a: number, b: number) { return a - b; }); if (values.length > 0) { const minIndex = Math.round(values.length * PERCENTILE_CONFIG.MIN_BOUND); const maxIndex = Math.round(values.length * PERCENTILE_CONFIG.MAX_BOUND); const min = values[minIndex]; - const max = values[maxIndex]; + // Ensure max > min so color stops are strictly monotonic + const max = Math.max(values[maxIndex], min + 1); makeLegend(colorScheme, min, max); const colorStopsValue = calculateColorStops(colorScheme, min, max); heatmap.setColorStops(colorStopsValue); + } else { + // Set safe default stops so stale stops from a previous metric don't cause + // Mapbox expression errors when the hexgrid produces cells with different value ranges + const colorStopsValue = calculateColorStops(colorScheme, 0, 1); + heatmap.setColorStops(colorStopsValue); } heatmap.update(); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f0976a4..d5e5e7a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -115,3 +115,8 @@ export interface POIDistanceInfo { duration_seconds: number; distance_meters: number; } + +export interface POITravelFilter { + travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT'; + maxMinutes: number | undefined; +} diff --git a/frontend/src/utils/poiUtils.ts b/frontend/src/utils/poiUtils.ts new file mode 100644 index 0000000..b3ee2ad --- /dev/null +++ b/frontend/src/utils/poiUtils.ts @@ -0,0 +1,41 @@ +import type { PropertyFeature, POIDistanceInfo } from '@/types'; + +/** + * Build the flat property name used by the hexgrid heatmap to read + * travel-time values directly from feature.properties. + */ +export function poiMetricPropertyName(poiId: number, travelMode: string): string { + return `poi_travel_${poiId}_${travelMode}`; +} + +/** + * Shallow-copy every feature and inject a flat numeric property + * (e.g. `poi_travel_7_TRANSIT = 1800`) so the heatmap can color by it. + * + * Features without matching POI distance data get `undefined` for that property, + * which the heatmap will skip (same as a listing with no sqm value). + */ +export function injectPoiMetricProperty( + features: PropertyFeature[], + poiId: number, + travelMode: string, +): PropertyFeature[] { + const propName = poiMetricPropertyName(poiId, travelMode); + + return features.map((feature) => { + const distances: POIDistanceInfo[] | undefined = feature.properties.poi_distances; + const match = distances?.find( + (d) => d.poi_id === poiId && d.travel_mode === travelMode, + ); + + if (match === undefined) return feature; + + return { + ...feature, + properties: { + ...feature.properties, + [propName]: match.duration_seconds, + }, + }; + }); +}