Redesign filter panel with range sliders, separated visualization card, and backend filter support

Simplify the filter UI to show only essential filters (type toggle, price/bedroom
range sliders, min size) by default, with advanced filters collapsed. Extract
visualization controls (color-by metric, POI travel mode) into a separate
VisualizationCard component. Wire up previously ignored backend filters: max_sqm,
min/max_price_per_sqm, and district_names now work end-to-end.
This commit is contained in:
Viktor Barzin 2026-02-08 18:50:06 +00:00
parent 1f4a3f858c
commit 743e018668
No known key found for this signature in database
GPG key ID: 0EB088298288D958
11 changed files with 422 additions and 588 deletions

View file

@ -50,15 +50,23 @@ def get_query_parameters(
min_price: int = 0, min_price: int = 0,
max_price: int = 10_000_000, max_price: int = 10_000_000,
min_sqm: Optional[int] = None, min_sqm: Optional[int] = None,
max_sqm: Optional[int] = None,
min_price_per_sqm: Optional[float] = None,
max_price_per_sqm: Optional[float] = None,
last_seen_days: Optional[int] = None, last_seen_days: Optional[int] = None,
let_date_available_from: Optional[datetime] = None, let_date_available_from: Optional[datetime] = None,
furnish_types: Optional[str] = None, # comma-separated list furnish_types: Optional[str] = None, # comma-separated list
district_names: Optional[str] = None, # comma-separated list
) -> QueryParameters: ) -> QueryParameters:
"""Parse query parameters into QueryParameters model.""" """Parse query parameters into QueryParameters model."""
parsed_furnish_types = None parsed_furnish_types = None
if furnish_types: if furnish_types:
parsed_furnish_types = [FurnishType(f.strip()) for f in furnish_types.split(",")] parsed_furnish_types = [FurnishType(f.strip()) for f in furnish_types.split(",")]
parsed_district_names: set[str] = set()
if district_names:
parsed_district_names = {d.strip() for d in district_names.split(",") if d.strip()}
return QueryParameters( return QueryParameters(
listing_type=listing_type, listing_type=listing_type,
min_bedrooms=min_bedrooms, min_bedrooms=min_bedrooms,
@ -66,9 +74,13 @@ def get_query_parameters(
min_price=min_price, min_price=min_price,
max_price=max_price, max_price=max_price,
min_sqm=min_sqm, min_sqm=min_sqm,
max_sqm=max_sqm,
min_price_per_sqm=min_price_per_sqm,
max_price_per_sqm=max_price_per_sqm,
last_seen_days=last_seen_days, last_seen_days=last_seen_days,
let_date_available_from=let_date_available_from, let_date_available_from=let_date_available_from,
furnish_types=parsed_furnish_types, furnish_types=parsed_furnish_types,
district_names=parsed_district_names,
) )

View file

@ -8,6 +8,7 @@ import LoginModal from './components/LoginModal';
import AuthCallback from './components/AuthCallback'; import AuthCallback from './components/AuthCallback';
import { Map } from './components/Map'; import { Map } from './components/Map';
import { FilterPanel, type ParameterValues, DEFAULT_FILTER_VALUES, Metric } from './components/FilterPanel'; import { FilterPanel, type ParameterValues, DEFAULT_FILTER_VALUES, Metric } from './components/FilterPanel';
import { VisualizationCard } from './components/VisualizationCard';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { StatsBar, type ViewMode } from './components/StatsBar'; import { StatsBar, type ViewMode } from './components/StatsBar';
import { ListView } from './components/ListView'; import { ListView } from './components/ListView';
@ -40,6 +41,7 @@ function App() {
travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT'; travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT';
} | null>(null); } | null>(null);
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({}); const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
// Ref to track accumulated features during streaming // Ref to track accumulated features during streaming
const accumulatedFeaturesRef = useRef<PropertyFeature[]>([]); const accumulatedFeaturesRef = useRef<PropertyFeature[]>([]);
@ -229,6 +231,7 @@ function App() {
}; };
const handleMetricChange = (metric: Metric) => { const handleMetricChange = (metric: Metric) => {
setCurrentMetric(metric);
setQueryParameters(prev => prev ? { ...prev, metric } : null); setQueryParameters(prev => prev ? { ...prev, metric } : null);
}; };
@ -351,9 +354,11 @@ function App() {
<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-80 shrink-0 h-full overflow-hidden"> <div className="hidden md:block w-80 shrink-0 h-full overflow-hidden">
<div className="h-full flex flex-col">
<div className="flex-1 min-h-0">
<FilterPanel <FilterPanel
onSubmit={onSubmit} onSubmit={onSubmit}
onMetricChange={handleMetricChange} currentMetric={currentMetric}
isLoading={isLoading} isLoading={isLoading}
listingCount={processedListingData?.features.length} listingCount={processedListingData?.features.length}
user={user} user={user}
@ -361,11 +366,20 @@ function App() {
onStartPoiPicking={handleStartPoiPicking} onStartPoiPicking={handleStartPoiPicking}
pickedPoiLocation={pickedPoiLocation} pickedPoiLocation={pickedPoiLocation}
userPOIs={userPOIs} userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
poiTravelFilters={poiTravelFilters} poiTravelFilters={poiTravelFilters}
onPoiTravelFiltersChange={setPoiTravelFilters} onPoiTravelFiltersChange={setPoiTravelFilters}
/> />
</div> </div>
<div className="shrink-0 p-4 border-r">
<VisualizationCard
metric={currentMetric}
onMetricChange={handleMetricChange}
userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
/>
</div>
</div>
</div>
{/* Filter Panel - Mobile (sheet) */} {/* Filter Panel - Mobile (sheet) */}
<div className="md:hidden fixed bottom-4 right-4 z-50"> <div className="md:hidden fixed bottom-4 right-4 z-50">
@ -376,9 +390,11 @@ function App() {
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="left" className="w-80 p-0"> <SheetContent side="left" className="w-80 p-0">
<div className="h-full flex flex-col">
<div className="flex-1 min-h-0">
<FilterPanel <FilterPanel
onSubmit={onSubmit} onSubmit={onSubmit}
onMetricChange={handleMetricChange} currentMetric={currentMetric}
isLoading={isLoading} isLoading={isLoading}
listingCount={processedListingData?.features.length} listingCount={processedListingData?.features.length}
user={user} user={user}
@ -386,10 +402,19 @@ function App() {
onStartPoiPicking={handleStartPoiPicking} onStartPoiPicking={handleStartPoiPicking}
pickedPoiLocation={pickedPoiLocation} pickedPoiLocation={pickedPoiLocation}
userPOIs={userPOIs} userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
poiTravelFilters={poiTravelFilters} poiTravelFilters={poiTravelFilters}
onPoiTravelFiltersChange={setPoiTravelFilters} onPoiTravelFiltersChange={setPoiTravelFilters}
/> />
</div>
<div className="shrink-0 p-4">
<VisualizationCard
metric={currentMetric}
onMetricChange={handleMetricChange}
userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
/>
</div>
</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
</div> </div>

View file

@ -4,12 +4,14 @@ import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Calendar29 } from "./ui/DatePicker"; import { Calendar29 } from "./ui/DatePicker";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel } from "./ui/form";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./ui/accordion"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./ui/accordion";
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
import { Loader2, Filter, RefreshCw, MapPin } from "lucide-react"; import { Loader2, Filter, RefreshCw, MapPin } from "lucide-react";
import { ScrollArea } from "./ui/scroll-area"; import { ScrollArea } from "./ui/scroll-area";
import { RangeSliderField } from "./ui/range-slider-field";
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"; import type { POI, POITravelFilter } from "@/types";
@ -70,7 +72,7 @@ export interface ParameterValues {
interface FilterPanelProps { interface FilterPanelProps {
onSubmit: (action: 'fetch-data' | 'visualize', fromValues: ParameterValues) => void; onSubmit: (action: 'fetch-data' | 'visualize', fromValues: ParameterValues) => void;
onMetricChange?: (metric: Metric) => void; currentMetric: Metric;
isLoading?: boolean; isLoading?: boolean;
listingCount?: number; listingCount?: number;
user?: AuthUser; user?: AuthUser;
@ -78,13 +80,11 @@ interface FilterPanelProps {
onStartPoiPicking?: () => void; onStartPoiPicking?: () => void;
pickedPoiLocation?: { lat: number; lng: number } | null; pickedPoiLocation?: { lat: number; lng: number } | null;
userPOIs?: POI[]; userPOIs?: POI[];
onPoiMetricChange?: (selection: { poiId: number; poiName: string; travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT' } | null) => void;
poiTravelFilters?: Record<number, POITravelFilter>; poiTravelFilters?: Record<number, POITravelFilter>;
onPoiTravelFiltersChange?: (filters: Record<number, POITravelFilter>) => void; onPoiTravelFiltersChange?: (filters: Record<number, POITravelFilter>) => void;
} }
const formSchema = z.object({ const formSchema = z.object({
metric: z.nativeEnum(Metric, { required_error: "Metric is required" }),
listing_type: z.nativeEnum(ListingType, { required_error: "Listing Type is required" }), listing_type: z.nativeEnum(ListingType, { required_error: "Listing Type is required" }),
min_bedrooms: z.number().min(0).max(10).optional(), min_bedrooms: z.number().min(0).max(10).optional(),
max_bedrooms: z.number().min(0).max(10).optional(), max_bedrooms: z.number().min(0).max(10).optional(),
@ -102,22 +102,39 @@ 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, onStartPoiPicking, pickedPoiLocation, userPOIs, onPoiMetricChange, poiTravelFilters, onPoiTravelFiltersChange }: FilterPanelProps) { // Price bounds per listing type
const PRICE_BOUNDS = {
[ListingType.RENT]: { min: 0, max: 10000, step: 50 },
[ListingType.BUY]: { min: 0, max: 2000000, step: 10000 },
} as const;
const BEDROOM_BOUNDS = { min: 0, max: 10, step: 1 } as const;
export function FilterPanel({ onSubmit, currentMetric, isLoading, listingCount, user, onTaskCreated, onStartPoiPicking, pickedPoiLocation, userPOIs, 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),
defaultValues: { defaultValues: {
...DEFAULT_FILTER_VALUES, listing_type: DEFAULT_FILTER_VALUES.listing_type,
available_from: new Date(), // Fresh date on each render min_bedrooms: DEFAULT_FILTER_VALUES.min_bedrooms,
max_bedrooms: DEFAULT_FILTER_VALUES.max_bedrooms,
min_price: DEFAULT_FILTER_VALUES.min_price,
max_price: DEFAULT_FILTER_VALUES.max_price,
min_sqm: DEFAULT_FILTER_VALUES.min_sqm,
max_sqm: undefined,
min_price_per_sqm: undefined,
max_price_per_sqm: undefined,
last_seen_days: DEFAULT_FILTER_VALUES.last_seen_days,
available_from: new Date(),
district: '',
}, },
}); });
// Watch listing_type to make filters type-aware // Watch listing_type to make filters type-aware
const watchedListingType = form.watch('listing_type'); const watchedListingType = form.watch('listing_type');
const priceBounds = PRICE_BOUNDS[watchedListingType];
// Update price defaults when listing type changes // Update price defaults when listing type changes
useEffect(() => { useEffect(() => {
@ -138,6 +155,7 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
return form.handleSubmit((values) => { return form.handleSubmit((values) => {
const params: ParameterValues = { const params: ParameterValues = {
...values, ...values,
metric: currentMetric,
furnish_types: selectedFurnishTypes.length > 0 ? selectedFurnishTypes : undefined, furnish_types: selectedFurnishTypes.length > 0 ? selectedFurnishTypes : undefined,
}; };
onSubmit(action, params); onSubmit(action, params);
@ -179,6 +197,12 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, [form, selectedFurnishTypes]); }, [form, selectedFurnishTypes]);
const formatPrice = (v: number) => {
if (v >= 1000000) return `£${(v / 1000000).toFixed(1)}M`;
if (v >= 1000) return `£${(v / 1000).toFixed(0)}k`;
return `£${v}`;
};
return ( return (
<div className="h-full flex flex-col bg-background border-r overflow-hidden"> <div className="h-full flex flex-col bg-background border-r overflow-hidden">
{/* Header */} {/* Header */}
@ -202,164 +226,59 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
{/* Filters */} {/* Filters */}
<ScrollArea className="flex-1 min-h-0"> <ScrollArea className="flex-1 min-h-0">
<Form {...form}> <Form {...form}>
<form className="p-4 space-y-4"> <form className="p-4 space-y-5">
<Accordion type="multiple" defaultValue={["visualization", "price-size", "features"]} className="w-full"> {/* ── Essential Filters (always visible) ── */}
{/* Visualization Options */}
<AccordionItem value="visualization"> {/* Listing Type Tabs */}
<AccordionTrigger className="py-2 text-sm font-medium">
Visualization
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<FormField
control={form.control}
name="metric"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Color by</FormLabel>
<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">
<SelectValue placeholder="Metric" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={Metric.qmprice}>Price per m²</SelectItem>
<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 <FormField
control={form.control} control={form.control}
name="listing_type" name="listing_type"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="text-xs">Type</FormLabel> <Tabs value={field.value} onValueChange={field.onChange}>
<Select onValueChange={field.onChange} defaultValue={field.value}> <TabsList>
<FormControl> <TabsTrigger value={ListingType.RENT}>Rent</TabsTrigger>
<SelectTrigger className="h-8 text-sm"> <TabsTrigger value={ListingType.BUY}>Buy</TabsTrigger>
<SelectValue placeholder="Type" /> </TabsList>
</SelectTrigger> </Tabs>
</FormControl>
<SelectContent>
<SelectItem value={ListingType.RENT}>For Rent</SelectItem>
<SelectItem value={ListingType.BUY}>For Sale</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div>
</AccordionContent>
</AccordionItem>
{/* Price & Size */} {/* Price Range Slider */}
<AccordionItem value="price-size"> <RangeSliderField
<AccordionTrigger className="py-2 text-sm font-medium"> label="Price (GBP)"
Price & Size min={priceBounds.min}
</AccordionTrigger> max={priceBounds.max}
<AccordionContent> step={priceBounds.step}
<div className="space-y-4"> value={[
<div className="grid grid-cols-2 gap-2"> form.watch('min_price') ?? priceBounds.min,
<FormField form.watch('max_price') ?? priceBounds.max,
control={form.control} ]}
name="min_price" onValueChange={([lo, hi]) => {
render={({ field }) => ( form.setValue('min_price', lo);
<FormItem> form.setValue('max_price', hi);
<FormLabel className="text-xs">Min Price (£)</FormLabel> }}
<FormControl> formatValue={formatPrice}
<Input
type="number"
placeholder="0"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/> />
</FormControl>
</FormItem> {/* Bedrooms Range Slider */}
)} <RangeSliderField
label="Bedrooms"
min={BEDROOM_BOUNDS.min}
max={BEDROOM_BOUNDS.max}
step={BEDROOM_BOUNDS.step}
value={[
form.watch('min_bedrooms') ?? BEDROOM_BOUNDS.min,
form.watch('max_bedrooms') ?? BEDROOM_BOUNDS.max,
]}
onValueChange={([lo, hi]) => {
form.setValue('min_bedrooms', lo);
form.setValue('max_bedrooms', hi);
}}
/> />
<FormField
control={form.control} {/* Min Size */}
name="max_price"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Max Price (£)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Any"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<FormField <FormField
control={form.control} control={form.control}
name="min_sqm" name="min_sqm"
@ -378,6 +297,16 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
</FormItem> </FormItem>
)} )}
/> />
{/* ── Advanced Filters (collapsed) ── */}
<Accordion type="multiple" className="w-full">
<AccordionItem value="advanced">
<AccordionTrigger className="py-2 text-sm font-medium">
Advanced Filters
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
{/* Max Size */}
<FormField <FormField
control={form.control} control={form.control}
name="max_sqm" name="max_sqm"
@ -396,7 +325,8 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
</FormItem> </FormItem>
)} )}
/> />
</div>
{/* Price per m² */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<FormField <FormField
control={form.control} control={form.control}
@ -435,59 +365,8 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
)} )}
/> />
</div> </div>
</div>
</AccordionContent>
</AccordionItem>
{/* Features */} {/* Furnishing (rent only) */}
<AccordionItem value="features">
<AccordionTrigger className="py-2 text-sm font-medium">
Features
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2">
<FormField
control={form.control}
name="min_bedrooms"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Min Beds</FormLabel>
<FormControl>
<Input
type="number"
placeholder="0"
className="h-8 text-sm"
min={0}
max={10}
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="max_bedrooms"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Max Beds</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Any"
className="h-8 text-sm"
min={0}
max={10}
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{watchedListingType === ListingType.RENT && ( {watchedListingType === ListingType.RENT && (
<div> <div>
<FormLabel className="text-xs">Furnishing</FormLabel> <FormLabel className="text-xs">Furnishing</FormLabel>
@ -513,16 +392,8 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
</div> </div>
</div> </div>
)} )}
</div>
</AccordionContent>
</AccordionItem>
{/* Location */} {/* District */}
<AccordionItem value="location">
<AccordionTrigger className="py-2 text-sm font-medium">
Location
</AccordionTrigger>
<AccordionContent>
<FormField <FormField
control={form.control} control={form.control}
name="district" name="district"
@ -543,16 +414,8 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
</FormItem> </FormItem>
)} )}
/> />
</AccordionContent>
</AccordionItem>
{/* Availability / Recency */} {/* Available From (rent only) */}
<AccordionItem value="availability">
<AccordionTrigger className="py-2 text-sm font-medium">
{watchedListingType === ListingType.RENT ? 'Availability' : 'Recency'}
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
{watchedListingType === ListingType.RENT && ( {watchedListingType === ListingType.RENT && (
<FormField <FormField
control={form.control} control={form.control}
@ -572,6 +435,8 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
)} )}
/> />
)} )}
{/* Last Seen Days */}
<FormField <FormField
control={form.control} control={form.control}
name="last_seen_days" name="last_seen_days"
@ -597,7 +462,7 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
{/* Points of Interest */} {/* Points of Interest (auth-only) */}
{user && ( {user && (
<AccordionItem value="poi"> <AccordionItem value="poi">
<AccordionTrigger className="py-2 text-sm font-medium"> <AccordionTrigger className="py-2 text-sm font-medium">

View file

@ -5,7 +5,7 @@ import { useEffect, useRef, useMemo, useCallback } from "react";
import { Crosshair } from "lucide-react"; import { Crosshair } from "lucide-react";
import { renderToString } from 'react-dom/server'; import { renderToString } from 'react-dom/server';
import "../assets/Map.css"; import "../assets/Map.css";
import { Metric, type ParameterValues } from "./Parameters"; import { Metric, type ParameterValues } from "./FilterPanel";
import { PropertyCard } from "./PropertyCard"; import { PropertyCard } from "./PropertyCard";
import { ScrollArea } from "./ui/scroll-area"; import { ScrollArea } from "./ui/scroll-area";
import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties, POI } from "@/types"; import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties, POI } from "@/types";
@ -97,7 +97,7 @@ export function Map(props: MapProps) {
// Compute color scale from valid metric values only // Compute color scale from valid metric values only
const values = data.features const values = data.features
.map(function (d: PropertyFeature) { .map(function (d: PropertyFeature) {
return (d.properties as Record<string, unknown>)[metricMode] as number; return (d.properties as unknown as Record<string, unknown>)[metricMode] as number;
}) })
.filter(function (v: number) { return typeof v === 'number' && isFinite(v) && 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; });
@ -405,6 +405,3 @@ export function Map(props: MapProps) {
</div> </div>
); );
} }
// Re-export types for backwards compatibility
export { Metric, type ParameterValues } from "./Parameters";

View file

@ -1,263 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { DialogTitle } from "@radix-ui/react-dialog";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "./ui/button";
import { Calendar29 } from "./ui/DatePicker";
import { Dialog, DialogContent, DialogTrigger } from "./ui/dialog";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
export enum Metric {
qmprice = 'qmprice',
rooms = 'rooms',
qm = 'qm',
price = 'total_price',
}
export enum ListingType {
RENT = 'RENT',
BUY = 'BUY'
}
export enum FurnishType {
FURNISHED = 'furnished',
PART_FURNISHED = 'partFurnished',
UNFURNISHED = 'unfurnished',
}
export interface ParameterValues {
metric: Metric
listing_type: ListingType
min_bedrooms?: number
max_bedrooms?: number
min_price?: number
max_price?: number
min_sqm?: number
max_sqm?: number
min_price_per_sqm?: number
max_price_per_sqm?: number
last_seen_days?: number
available_from?: Date
district: string
furnish_types?: FurnishType[]
}
export function Parameters(
props: {
isOpen: boolean,
onSubmit: (action: 'fetch-data' | 'visualize', fromValues: ParameterValues) => void,
setIsOpen: (isOpen: boolean) => void
}
) {
const {
register,
} = useForm<ParameterValues>()
const [action, setAction] = useState<'fetch-data' | 'visualize' | null>(null)
const [availableFromRawInput, setAvailableFromRawInput] = useState("now");
const formSchema = z.object({
metric: z.nativeEnum(Metric, { required_error: "Metric is required" }),
listing_type: z.nativeEnum(ListingType, { required_error: "Listing Type is required" }),
min_bedrooms: z.number().min(1).max(10).optional(),
max_bedrooms: z.number().min(1).max(10).optional(),
max_price: z.number().optional(),
min_price: z.number().min(0).optional(),
min_sqm: z.number().optional(),
last_seen_days: z.number().min(0).optional(),
available_from: z.date(),
district: z.string()
})
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
metric: Metric.qmprice,
listing_type: ListingType.RENT,
min_bedrooms: 1,
max_bedrooms: 3,
max_price: 3000,
min_price: 2000,
min_sqm: 50,
last_seen_days: 28,
available_from: new Date(),
district: ''
},
})
// 2. Define a submit handler.
function onSubmit(values: z.infer<typeof formSchema>) {
// Do something with the form values.
// ✅ This will be type-safe and validated.
console.log(values)
if (action) {
props.onSubmit(action, values)
}
}
return <>
<Dialog open={props.isOpen} onOpenChange={props.setIsOpen} >
{/* <Dialog > */}
<DialogTrigger asChild>
<Button variant="outline" onClick={() => props.setIsOpen(true)}>Open Parameters</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogTitle>
Visualization Parameters
</DialogTitle>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="metric"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-4">
<FormLabel>Metric to visualize</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Metric to Visualize" />
</SelectTrigger>
</FormControl>
<SelectContent {...register('metric')} >
<SelectItem value={Metric.qmprice}>Price per squaremeter</SelectItem>
<SelectItem value={Metric.rooms}>Number of rooms</SelectItem>
<SelectItem value={Metric.qm}>Area</SelectItem>
<SelectItem value={Metric.price}>Price</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="listing_type"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-4">
<FormLabel>Listing Type</FormLabel>
<FormControl >
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Metric to Visualize" />
</SelectTrigger>
</FormControl>
<SelectContent >
<SelectItem value={ListingType.BUY}>To buy</SelectItem>
<SelectItem value={ListingType.RENT}>To rent</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="min_sqm"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-4">
<FormLabel>Min square meters</FormLabel>
<FormControl >
<Input type="number" placeholder={"m²"} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="min_bedrooms"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-4">
<FormLabel>Minimum number of bedrooms</FormLabel>
<FormControl >
<Input type="number" placeholder={"# bedrooms"} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="max_bedrooms"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-4">
<FormLabel>Maximum number of bedrooms</FormLabel>
<FormControl >
<Input type="number" placeholder={"# bedrooms"} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="min_price"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-4">
<FormLabel>Min price</FormLabel>
<FormControl >
<Input type="number" placeholder={"£"} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="max_price"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-4">
<FormLabel>Max price</FormLabel>
<FormControl >
<Input type="number" placeholder={"£"} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="available_from"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Available from</FormLabel>
<FormControl>
<Calendar29 onSelect={field.onChange} selected={field.value} rawInputValue={availableFromRawInput} onChangeRawInputValue={setAvailableFromRawInput} />
</FormControl>
<FormDescription>
Applicable for renting listings only
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="last_seen_days"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-4">
<FormLabel>Last seen days</FormLabel>
<FormControl >
<Input type="number" placeholder={"days"} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" value={"visualize"} onClick={() => setAction("visualize")}>Visualize existing data</Button>
<Button type="submit" value={"fetch-data"} onClick={() => setAction("fetch-data")}>Submit data refresh job</Button>
</form>
</Form>
</DialogContent>
</Dialog>
</>
}

View file

@ -0,0 +1,93 @@
import { useState } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { Metric } from "./FilterPanel";
import type { POI } from "@/types";
interface VisualizationCardProps {
metric: Metric;
onMetricChange: (metric: Metric) => void;
userPOIs?: POI[];
onPoiMetricChange?: (selection: { poiId: number; poiName: string; travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT' } | null) => void;
}
export function VisualizationCard({ metric, onMetricChange, userPOIs, onPoiMetricChange }: VisualizationCardProps) {
const [selectedPoiId, setSelectedPoiId] = useState('');
const [selectedTravelMode, setSelectedTravelMode] = useState('');
return (
<div className="border rounded-lg p-4 space-y-3 bg-background">
<h3 className="text-sm font-semibold">Visualization</h3>
<div>
<label className="text-xs font-medium">Color by</label>
<Select
value={metric}
onValueChange={(value) => {
onMetricChange(value as Metric);
if (value !== Metric.poi_travel) {
onPoiMetricChange?.(null);
}
}}
>
<SelectTrigger className="h-8 text-sm mt-1">
<SelectValue placeholder="Metric" />
</SelectTrigger>
<SelectContent>
<SelectItem value={Metric.qmprice}>Price per m²</SelectItem>
<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>
</div>
{metric === Metric.poi_travel && userPOIs && userPOIs.length > 0 && (
<>
<div>
<label className="text-xs font-medium">POI</label>
<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 mt-1">
<SelectValue placeholder="Select POI" />
</SelectTrigger>
<SelectContent>
{userPOIs.map(poi => (
<SelectItem key={poi.id} value={String(poi.id)}>{poi.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-medium">Travel Mode</label>
<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 mt-1">
<SelectValue placeholder="Select mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="WALK">Walk</SelectItem>
<SelectItem value="BICYCLE">Bicycle</SelectItem>
<SelectItem value="TRANSIT">Transit</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,70 @@
import { Slider } from "./slider";
import { Input } from "./input";
interface RangeSliderFieldProps {
label: string;
min: number;
max: number;
step: number;
value: [number, number];
onValueChange: (value: [number, number]) => void;
formatValue?: (v: number) => string;
}
export function RangeSliderField({
label,
min,
max,
step,
value,
onValueChange,
formatValue = (v) => String(v),
}: RangeSliderFieldProps) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">{label}</span>
<span className="text-xs text-muted-foreground">
{formatValue(value[0])} {formatValue(value[1])}
</span>
</div>
<Slider
min={min}
max={max}
step={step}
value={value}
onValueChange={(v) => onValueChange(v as [number, number])}
/>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
className="h-7 text-xs"
min={min}
max={value[1]}
step={step}
value={value[0]}
onChange={(e) => {
const v = Number(e.target.value);
if (!isNaN(v)) {
onValueChange([Math.min(v, value[1]), value[1]]);
}
}}
/>
<Input
type="number"
className="h-7 text-xs"
min={value[0]}
max={max}
step={step}
value={value[1]}
onChange={(e) => {
const v = Number(e.target.value);
if (!isNaN(v)) {
onValueChange([value[0], Math.max(v, value[0])]);
}
}}
/>
</div>
</div>
);
}

View file

@ -19,7 +19,7 @@ const Slider = React.forwardRef<
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20"> <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" /> <SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track> </SliderPrimitive.Track>
{props.defaultValue?.map((_, index) => ( {(props.defaultValue ?? props.value)?.map((_, index) => (
<SliderPrimitive.Thumb <SliderPrimitive.Thumb
key={index} key={index}
className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"

View file

@ -1,7 +1,7 @@
// Color schemes for map visualization // Color schemes for map visualization
// Different color schemes for different metrics to improve clarity // Different color schemes for different metrics to improve clarity
import { Metric } from "@/components/Parameters"; import { Metric } from "@/components/FilterPanel";
// For metrics where LOW is GOOD (price, price per sqm): Green → Yellow → Red // For metrics where LOW is GOOD (price, price per sqm): Green → Yellow → Red
export const LOW_IS_GOOD_COLOR_STOPS: [number, string][] = [ export const LOW_IS_GOOD_COLOR_STOPS: [number, string][] = [
@ -31,6 +31,11 @@ export const LEGACY_COLOR_STOPS: [number, string][] = [
// Get the appropriate color scheme based on metric type // Get the appropriate color scheme based on metric type
export function getColorSchemeForMetric(metric: Metric | string): [number, string][] { export function getColorSchemeForMetric(metric: Metric | string): [number, string][] {
// Travel time metrics use LOW_IS_GOOD (shorter commute = green)
if (typeof metric === 'string' && metric.startsWith('poi_travel')) {
return LOW_IS_GOOD_COLOR_STOPS;
}
switch (metric) { switch (metric) {
case Metric.qmprice: case Metric.qmprice:
case Metric.price: case Metric.price:
@ -53,6 +58,11 @@ export function getColorSchemeForMetric(metric: Metric | string): [number, strin
// Get interpretation text for legend // Get interpretation text for legend
export function getMetricInterpretation(metric: Metric | string): { low: string; high: string; name: string } { export function getMetricInterpretation(metric: Metric | string): { low: string; high: string; name: string } {
// Travel time metrics
if (typeof metric === 'string' && metric.startsWith('poi_travel')) {
return { low: 'Quick commute', high: 'Long commute', name: 'Travel Time' };
}
switch (metric) { switch (metric) {
case Metric.qmprice: case Metric.qmprice:
case 'qmprice': case 'qmprice':

View file

@ -218,6 +218,9 @@ class QueryParameters(BaseModel):
let_date_available_from: datetime | None = None let_date_available_from: datetime | None = None
last_seen_days: int | None = None last_seen_days: int | None = None
min_sqm: int | None = None min_sqm: int | None = None
max_sqm: int | None = None
min_price_per_sqm: float | None = None
max_price_per_sqm: float | None = None
@model_validator(mode="after") @model_validator(mode="after")
def _validate_ranges(self) -> QueryParameters: def _validate_ranges(self) -> QueryParameters:
@ -237,4 +240,12 @@ class QueryParameters(BaseModel):
raise ValueError( raise ValueError(
f"min_bedrooms ({self.min_bedrooms}) must be <= max_bedrooms ({self.max_bedrooms})" f"min_bedrooms ({self.min_bedrooms}) must be <= max_bedrooms ({self.max_bedrooms})"
) )
if (
self.min_price_per_sqm is not None
and self.max_price_per_sqm is not None
and self.min_price_per_sqm > self.max_price_per_sqm
):
raise ValueError(
f"min_price_per_sqm ({self.min_price_per_sqm}) must be <= max_price_per_sqm ({self.max_price_per_sqm})"
)
return self return self

View file

@ -191,6 +191,20 @@ class ListingRepository:
) )
if query_parameters.min_sqm is not None: if query_parameters.min_sqm is not None:
query = query.where(model.square_meters >= query_parameters.min_sqm) query = query.where(model.square_meters >= query_parameters.min_sqm)
if query_parameters.max_sqm is not None:
query = query.where(model.square_meters <= query_parameters.max_sqm)
if query_parameters.min_price_per_sqm is not None:
query = query.where(
model.square_meters.is_not(None),
model.square_meters > 0,
(model.price / model.square_meters) >= query_parameters.min_price_per_sqm,
)
if query_parameters.max_price_per_sqm is not None:
query = query.where(
model.square_meters.is_not(None),
model.square_meters > 0,
(model.price / model.square_meters) <= query_parameters.max_price_per_sqm,
)
if query_parameters.furnish_types and model == RentListing: if query_parameters.furnish_types and model == RentListing:
query = query.where(model.furnish_type.in_(query_parameters.furnish_types)) query = query.where(model.furnish_type.in_(query_parameters.furnish_types))
if ( if (