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.
This commit is contained in:
Viktor Barzin 2026-02-08 16:02:46 +00:00
parent 81d31eaecf
commit 07d4fa5f84
No known key found for this signature in database
GPG key ID: 0EB088298288D958
5 changed files with 193 additions and 17 deletions

View file

@ -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<number, POITravelFilter>;
onPoiTravelFiltersChange?: (filters: Record<number, POITravelFilter>) => void;
}
const formSchema = z.object({
@ -94,9 +102,11 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
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<FurnishType[]>([]);
const [selectedPoiId, setSelectedPoiId] = useState<string>('');
const [selectedTravelMode, setSelectedTravelMode] = useState<string>('');
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
@ -210,6 +220,9 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
<Select onValueChange={(value) => {
field.onChange(value);
onMetricChange?.(value as Metric);
if (value !== Metric.poi_travel) {
onPoiMetricChange?.(null);
}
}} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="h-8 text-sm">
@ -221,12 +234,61 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
<SelectItem value={Metric.rooms}>Bedrooms</SelectItem>
<SelectItem value={Metric.qm}>Size (m²)</SelectItem>
<SelectItem value={Metric.price}>Total Price</SelectItem>
{userPOIs && userPOIs.length > 0 && (
<SelectItem value={Metric.poi_travel}>Travel Time</SelectItem>
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{form.watch('metric') === Metric.poi_travel && userPOIs && userPOIs.length > 0 && (
<>
<div>
<FormLabel className="text-xs">POI</FormLabel>
<Select value={selectedPoiId} onValueChange={(value) => {
setSelectedPoiId(value);
if (value && selectedTravelMode) {
const poi = userPOIs.find(p => String(p.id) === value);
if (poi) {
onPoiMetricChange?.({ poiId: poi.id, poiName: poi.name, travelMode: selectedTravelMode as 'WALK' | 'BICYCLE' | 'TRANSIT' });
}
}
}}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select POI" />
</SelectTrigger>
<SelectContent>
{userPOIs.map(poi => (
<SelectItem key={poi.id} value={String(poi.id)}>{poi.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<FormLabel className="text-xs">Travel Mode</FormLabel>
<Select value={selectedTravelMode} onValueChange={(value) => {
setSelectedTravelMode(value);
if (selectedPoiId && value) {
const poi = userPOIs.find(p => String(p.id) === selectedPoiId);
if (poi) {
onPoiMetricChange?.({ poiId: poi.id, poiName: poi.name, travelMode: value as 'WALK' | 'BICYCLE' | 'TRANSIT' });
}
}
}}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="WALK">Walk</SelectItem>
<SelectItem value="BICYCLE">Bicycle</SelectItem>
<SelectItem value="TRANSIT">Transit</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
<FormField
control={form.control}
name="listing_type"
@ -549,7 +611,61 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
user={user}
listingType={watchedListingType as 'RENT' | 'BUY'}
onTaskCreated={onTaskCreated}
onStartPicking={onStartPoiPicking}
pickedLocation={pickedPoiLocation}
/>
{userPOIs && userPOIs.length > 0 && (
<div className="mt-4 pt-3 border-t">
<FormLabel className="text-xs">Max Travel Time</FormLabel>
<div className="space-y-2 mt-2">
{userPOIs.map((poi) => {
const filter = poiTravelFilters?.[poi.id];
const travelMode = filter?.travelMode ?? 'WALK';
const maxMinutes = filter?.maxMinutes;
return (
<div key={poi.id} className="flex items-center gap-1.5">
<span className="text-xs truncate w-16 shrink-0" title={poi.name}>{poi.name}</span>
<Select
value={travelMode}
onValueChange={(value) => {
onPoiTravelFiltersChange?.({
...poiTravelFilters,
[poi.id]: { travelMode: value as 'WALK' | 'BICYCLE' | 'TRANSIT', maxMinutes },
});
}}
>
<SelectTrigger className="h-7 text-xs w-[88px] shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="WALK">Walk</SelectItem>
<SelectItem value="BICYCLE">Bicycle</SelectItem>
<SelectItem value="TRANSIT">Transit</SelectItem>
</SelectContent>
</Select>
<Input
type="number"
placeholder="—"
className="h-7 text-xs w-14 shrink-0"
min={1}
value={maxMinutes ?? ''}
onChange={(e) => {
onPoiTravelFiltersChange?.({
...poiTravelFilters,
[poi.id]: {
travelMode,
maxMinutes: e.target.value ? Number(e.target.value) : undefined,
},
});
}}
/>
<span className="text-xs text-muted-foreground shrink-0">min</span>
</div>
);
})}
</div>
</div>
)}
</AccordionContent>
</AccordionItem>
)}

View file

@ -99,18 +99,24 @@ export function Map(props: MapProps) {
.map(function (d: PropertyFeature) {
return (d.properties as Record<string, unknown>)[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();