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:
parent
81d31eaecf
commit
07d4fa5f84
5 changed files with 193 additions and 17 deletions
|
|
@ -15,7 +15,7 @@ import { StreamingProgressBar } from './components/StreamingProgressBar';
|
||||||
import { Sheet, SheetContent, SheetTrigger } from './components/ui/sheet';
|
import { Sheet, SheetContent, SheetTrigger } from './components/ui/sheet';
|
||||||
import { Button } from './components/ui/button';
|
import { Button } from './components/ui/button';
|
||||||
import { Filter } from 'lucide-react';
|
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 { refreshListings, fetchTasksForUser, streamListingGeoJSON, fetchUserPOIs, type StreamingProgress } from '@/services';
|
||||||
import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils';
|
import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils';
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ function App() {
|
||||||
poiName: string;
|
poiName: string;
|
||||||
travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT';
|
travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT';
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [maxTravelMinutes, setMaxTravelMinutes] = useState<number | undefined>(undefined);
|
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
|
||||||
|
|
||||||
// Ref to track accumulated features during streaming
|
// Ref to track accumulated features during streaming
|
||||||
const accumulatedFeaturesRef = useRef<PropertyFeature[]>([]);
|
const accumulatedFeaturesRef = useRef<PropertyFeature[]>([]);
|
||||||
|
|
@ -151,18 +151,26 @@ function App() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by max travel time
|
// Filter by per-POI max travel time (AND logic: listing must satisfy all active filters)
|
||||||
if (maxTravelMinutes !== undefined && poiMetricSelection) {
|
const activeFilters = Object.entries(poiTravelFilters)
|
||||||
const maxSeconds = maxTravelMinutes * 60;
|
.filter(([, f]) => f.maxMinutes !== undefined)
|
||||||
const propName = poiMetricPropertyName(poiMetricSelection.poiId, poiMetricSelection.travelMode);
|
.map(([id, f]) => ({ poiId: Number(id), travelMode: f.travelMode, maxMinutes: f.maxMinutes! }));
|
||||||
|
|
||||||
|
if (activeFilters.length > 0) {
|
||||||
features = features.filter((f) => {
|
features = features.filter((f) => {
|
||||||
const value = (f.properties as Record<string, unknown>)[propName] as number | undefined;
|
const distances = f.properties.poi_distances;
|
||||||
return value !== undefined && value <= maxSeconds;
|
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 };
|
return { ...listingData, features };
|
||||||
}, [listingData, poiMetricSelection, maxTravelMinutes]);
|
}, [listingData, poiMetricSelection, poiTravelFilters]);
|
||||||
|
|
||||||
// Compute the effective metric string for the heatmap
|
// Compute the effective metric string for the heatmap
|
||||||
const effectiveMetric = useMemo(() => {
|
const effectiveMetric = useMemo(() => {
|
||||||
|
|
@ -342,7 +350,7 @@ function App() {
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<div className="flex-1 flex overflow-hidden min-h-0">
|
<div className="flex-1 flex overflow-hidden min-h-0">
|
||||||
{/* Filter Panel - Desktop (fixed sidebar) */}
|
{/* Filter Panel - Desktop (fixed sidebar) */}
|
||||||
<div className="hidden md:block w-64 shrink-0 h-full overflow-hidden">
|
<div className="hidden md:block w-80 shrink-0 h-full overflow-hidden">
|
||||||
<FilterPanel
|
<FilterPanel
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onMetricChange={handleMetricChange}
|
onMetricChange={handleMetricChange}
|
||||||
|
|
@ -354,8 +362,8 @@ function App() {
|
||||||
pickedPoiLocation={pickedPoiLocation}
|
pickedPoiLocation={pickedPoiLocation}
|
||||||
userPOIs={userPOIs}
|
userPOIs={userPOIs}
|
||||||
onPoiMetricChange={setPoiMetricSelection}
|
onPoiMetricChange={setPoiMetricSelection}
|
||||||
maxTravelMinutes={maxTravelMinutes}
|
poiTravelFilters={poiTravelFilters}
|
||||||
onMaxTravelMinutesChange={setMaxTravelMinutes}
|
onPoiTravelFiltersChange={setPoiTravelFilters}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -379,8 +387,8 @@ function App() {
|
||||||
pickedPoiLocation={pickedPoiLocation}
|
pickedPoiLocation={pickedPoiLocation}
|
||||||
userPOIs={userPOIs}
|
userPOIs={userPOIs}
|
||||||
onPoiMetricChange={setPoiMetricSelection}
|
onPoiMetricChange={setPoiMetricSelection}
|
||||||
maxTravelMinutes={maxTravelMinutes}
|
poiTravelFilters={poiTravelFilters}
|
||||||
onMaxTravelMinutesChange={setMaxTravelMinutes}
|
onPoiTravelFiltersChange={setPoiTravelFilters}
|
||||||
/>
|
/>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,14 @@ import { Loader2, Filter, RefreshCw, MapPin } from "lucide-react";
|
||||||
import { ScrollArea } from "./ui/scroll-area";
|
import { ScrollArea } from "./ui/scroll-area";
|
||||||
import { POIManager } from "./POIManager";
|
import { POIManager } from "./POIManager";
|
||||||
import type { AuthUser } from "@/auth/types";
|
import type { AuthUser } from "@/auth/types";
|
||||||
|
import type { POI, POITravelFilter } from "@/types";
|
||||||
|
|
||||||
export enum Metric {
|
export enum Metric {
|
||||||
qmprice = 'qmprice',
|
qmprice = 'qmprice',
|
||||||
rooms = 'rooms',
|
rooms = 'rooms',
|
||||||
qm = 'qm',
|
qm = 'qm',
|
||||||
price = 'total_price',
|
price = 'total_price',
|
||||||
|
poi_travel = 'poi_travel',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ListingType {
|
export enum ListingType {
|
||||||
|
|
@ -73,6 +75,12 @@ interface FilterPanelProps {
|
||||||
listingCount?: number;
|
listingCount?: number;
|
||||||
user?: AuthUser;
|
user?: AuthUser;
|
||||||
onTaskCreated?: (taskId: string) => void;
|
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({
|
const formSchema = z.object({
|
||||||
|
|
@ -94,9 +102,11 @@ const formSchema = z.object({
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
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 [availableFromRawInput, setAvailableFromRawInput] = useState("now");
|
||||||
const [selectedFurnishTypes, setSelectedFurnishTypes] = useState<FurnishType[]>([]);
|
const [selectedFurnishTypes, setSelectedFurnishTypes] = useState<FurnishType[]>([]);
|
||||||
|
const [selectedPoiId, setSelectedPoiId] = useState<string>('');
|
||||||
|
const [selectedTravelMode, setSelectedTravelMode] = useState<string>('');
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
|
|
@ -210,6 +220,9 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
|
||||||
<Select onValueChange={(value) => {
|
<Select onValueChange={(value) => {
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
onMetricChange?.(value as Metric);
|
onMetricChange?.(value as Metric);
|
||||||
|
if (value !== Metric.poi_travel) {
|
||||||
|
onPoiMetricChange?.(null);
|
||||||
|
}
|
||||||
}} defaultValue={field.value}>
|
}} defaultValue={field.value}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger className="h-8 text-sm">
|
<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.rooms}>Bedrooms</SelectItem>
|
||||||
<SelectItem value={Metric.qm}>Size (m²)</SelectItem>
|
<SelectItem value={Metric.qm}>Size (m²)</SelectItem>
|
||||||
<SelectItem value={Metric.price}>Total Price</SelectItem>
|
<SelectItem value={Metric.price}>Total Price</SelectItem>
|
||||||
|
{userPOIs && userPOIs.length > 0 && (
|
||||||
|
<SelectItem value={Metric.poi_travel}>Travel Time</SelectItem>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="listing_type"
|
name="listing_type"
|
||||||
|
|
@ -549,7 +611,61 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
|
||||||
user={user}
|
user={user}
|
||||||
listingType={watchedListingType as 'RENT' | 'BUY'}
|
listingType={watchedListingType as 'RENT' | 'BUY'}
|
||||||
onTaskCreated={onTaskCreated}
|
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>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -99,18 +99,24 @@ export function Map(props: MapProps) {
|
||||||
.map(function (d: PropertyFeature) {
|
.map(function (d: PropertyFeature) {
|
||||||
return (d.properties as Record<string, unknown>)[metricMode] as number;
|
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; });
|
.sort(function (a: number, b: number) { return a - b; });
|
||||||
|
|
||||||
if (values.length > 0) {
|
if (values.length > 0) {
|
||||||
const minIndex = Math.round(values.length * PERCENTILE_CONFIG.MIN_BOUND);
|
const minIndex = Math.round(values.length * PERCENTILE_CONFIG.MIN_BOUND);
|
||||||
const maxIndex = Math.round(values.length * PERCENTILE_CONFIG.MAX_BOUND);
|
const maxIndex = Math.round(values.length * PERCENTILE_CONFIG.MAX_BOUND);
|
||||||
const min = values[minIndex];
|
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);
|
makeLegend(colorScheme, min, max);
|
||||||
const colorStopsValue = calculateColorStops(colorScheme, min, max);
|
const colorStopsValue = calculateColorStops(colorScheme, min, max);
|
||||||
heatmap.setColorStops(colorStopsValue);
|
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();
|
heatmap.update();
|
||||||
|
|
|
||||||
|
|
@ -115,3 +115,8 @@ export interface POIDistanceInfo {
|
||||||
duration_seconds: number;
|
duration_seconds: number;
|
||||||
distance_meters: number;
|
distance_meters: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface POITravelFilter {
|
||||||
|
travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT';
|
||||||
|
maxMinutes: number | undefined;
|
||||||
|
}
|
||||||
|
|
|
||||||
41
frontend/src/utils/poiUtils.ts
Normal file
41
frontend/src/utils/poiUtils.ts
Normal file
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue