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, + }, + }; + }); +}