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,
max_price: int = 10_000_000,
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,
let_date_available_from: Optional[datetime] = None,
furnish_types: Optional[str] = None, # comma-separated list
district_names: Optional[str] = None, # comma-separated list
) -> QueryParameters:
"""Parse query parameters into QueryParameters model."""
parsed_furnish_types = None
if furnish_types:
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(
listing_type=listing_type,
min_bedrooms=min_bedrooms,
@ -66,9 +74,13 @@ def get_query_parameters(
min_price=min_price,
max_price=max_price,
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,
let_date_available_from=let_date_available_from,
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 { Map } from './components/Map';
import { FilterPanel, type ParameterValues, DEFAULT_FILTER_VALUES, Metric } from './components/FilterPanel';
import { VisualizationCard } from './components/VisualizationCard';
import { Header } from './components/Header';
import { StatsBar, type ViewMode } from './components/StatsBar';
import { ListView } from './components/ListView';
@ -40,6 +41,7 @@ function App() {
travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT';
} | null>(null);
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
// Ref to track accumulated features during streaming
const accumulatedFeaturesRef = useRef<PropertyFeature[]>([]);
@ -229,6 +231,7 @@ function App() {
};
const handleMetricChange = (metric: Metric) => {
setCurrentMetric(metric);
setQueryParameters(prev => prev ? { ...prev, metric } : null);
};
@ -351,20 +354,31 @@ function App() {
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Filter Panel - Desktop (fixed sidebar) */}
<div className="hidden md:block w-80 shrink-0 h-full overflow-hidden">
<FilterPanel
onSubmit={onSubmit}
onMetricChange={handleMetricChange}
isLoading={isLoading}
listingCount={processedListingData?.features.length}
user={user}
onTaskCreated={handlePOITaskCreated}
onStartPoiPicking={handleStartPoiPicking}
pickedPoiLocation={pickedPoiLocation}
userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
poiTravelFilters={poiTravelFilters}
onPoiTravelFiltersChange={setPoiTravelFilters}
/>
<div className="h-full flex flex-col">
<div className="flex-1 min-h-0">
<FilterPanel
onSubmit={onSubmit}
currentMetric={currentMetric}
isLoading={isLoading}
listingCount={processedListingData?.features.length}
user={user}
onTaskCreated={handlePOITaskCreated}
onStartPoiPicking={handleStartPoiPicking}
pickedPoiLocation={pickedPoiLocation}
userPOIs={userPOIs}
poiTravelFilters={poiTravelFilters}
onPoiTravelFiltersChange={setPoiTravelFilters}
/>
</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) */}
@ -376,20 +390,31 @@ function App() {
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-80 p-0">
<FilterPanel
onSubmit={onSubmit}
onMetricChange={handleMetricChange}
isLoading={isLoading}
listingCount={processedListingData?.features.length}
user={user}
onTaskCreated={handlePOITaskCreated}
onStartPoiPicking={handleStartPoiPicking}
pickedPoiLocation={pickedPoiLocation}
userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
poiTravelFilters={poiTravelFilters}
onPoiTravelFiltersChange={setPoiTravelFilters}
/>
<div className="h-full flex flex-col">
<div className="flex-1 min-h-0">
<FilterPanel
onSubmit={onSubmit}
currentMetric={currentMetric}
isLoading={isLoading}
listingCount={processedListingData?.features.length}
user={user}
onTaskCreated={handlePOITaskCreated}
onStartPoiPicking={handleStartPoiPicking}
pickedPoiLocation={pickedPoiLocation}
userPOIs={userPOIs}
poiTravelFilters={poiTravelFilters}
onPoiTravelFiltersChange={setPoiTravelFilters}
/>
</div>
<div className="shrink-0 p-4">
<VisualizationCard
metric={currentMetric}
onMetricChange={handleMetricChange}
userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
/>
</div>
</div>
</SheetContent>
</Sheet>
</div>

View file

@ -4,12 +4,14 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "./ui/button";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./ui/accordion";
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
import { Loader2, Filter, RefreshCw, MapPin } from "lucide-react";
import { ScrollArea } from "./ui/scroll-area";
import { RangeSliderField } from "./ui/range-slider-field";
import { POIManager } from "./POIManager";
import type { AuthUser } from "@/auth/types";
import type { POI, POITravelFilter } from "@/types";
@ -70,7 +72,7 @@ export interface ParameterValues {
interface FilterPanelProps {
onSubmit: (action: 'fetch-data' | 'visualize', fromValues: ParameterValues) => void;
onMetricChange?: (metric: Metric) => void;
currentMetric: Metric;
isLoading?: boolean;
listingCount?: number;
user?: AuthUser;
@ -78,13 +80,11 @@ interface FilterPanelProps {
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({
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(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>;
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 [selectedFurnishTypes, setSelectedFurnishTypes] = useState<FurnishType[]>([]);
const [selectedPoiId, setSelectedPoiId] = useState<string>('');
const [selectedTravelMode, setSelectedTravelMode] = useState<string>('');
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
...DEFAULT_FILTER_VALUES,
available_from: new Date(), // Fresh date on each render
listing_type: DEFAULT_FILTER_VALUES.listing_type,
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
const watchedListingType = form.watch('listing_type');
const priceBounds = PRICE_BOUNDS[watchedListingType];
// Update price defaults when listing type changes
useEffect(() => {
@ -138,6 +155,7 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
return form.handleSubmit((values) => {
const params: ParameterValues = {
...values,
metric: currentMetric,
furnish_types: selectedFurnishTypes.length > 0 ? selectedFurnishTypes : undefined,
};
onSubmit(action, params);
@ -179,6 +197,12 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
return () => subscription.unsubscribe();
}, [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 (
<div className="h-full flex flex-col bg-background border-r overflow-hidden">
{/* Header */}
@ -202,201 +226,107 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
{/* Filters */}
<ScrollArea className="flex-1 min-h-0">
<Form {...form}>
<form className="p-4 space-y-4">
<Accordion type="multiple" defaultValue={["visualization", "price-size", "features"]} className="w-full">
{/* Visualization Options */}
<AccordionItem value="visualization">
<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
control={form.control}
name="listing_type"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Type</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={ListingType.RENT}>For Rent</SelectItem>
<SelectItem value={ListingType.BUY}>For Sale</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
<form className="p-4 space-y-5">
{/* ── Essential Filters (always visible) ── */}
{/* Price & Size */}
<AccordionItem value="price-size">
{/* Listing Type Tabs */}
<FormField
control={form.control}
name="listing_type"
render={({ field }) => (
<FormItem>
<Tabs value={field.value} onValueChange={field.onChange}>
<TabsList>
<TabsTrigger value={ListingType.RENT}>Rent</TabsTrigger>
<TabsTrigger value={ListingType.BUY}>Buy</TabsTrigger>
</TabsList>
</Tabs>
</FormItem>
)}
/>
{/* Price Range Slider */}
<RangeSliderField
label="Price (GBP)"
min={priceBounds.min}
max={priceBounds.max}
step={priceBounds.step}
value={[
form.watch('min_price') ?? priceBounds.min,
form.watch('max_price') ?? priceBounds.max,
]}
onValueChange={([lo, hi]) => {
form.setValue('min_price', lo);
form.setValue('max_price', hi);
}}
formatValue={formatPrice}
/>
{/* 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);
}}
/>
{/* Min Size */}
<FormField
control={form.control}
name="min_sqm"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Min Size (m²)</FormLabel>
<FormControl>
<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>
)}
/>
{/* ── Advanced Filters (collapsed) ── */}
<Accordion type="multiple" className="w-full">
<AccordionItem value="advanced">
<AccordionTrigger className="py-2 text-sm font-medium">
Price & Size
Advanced Filters
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2">
<FormField
control={form.control}
name="min_price"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Min Price (£)</FormLabel>
<FormControl>
<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>
)}
/>
<FormField
control={form.control}
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
control={form.control}
name="min_sqm"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Min Size (m²)</FormLabel>
<FormControl>
<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>
)}
/>
<FormField
control={form.control}
name="max_sqm"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Max Size (m²)</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>
{/* Max Size */}
<FormField
control={form.control}
name="max_sqm"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Max Size (m²)</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>
)}
/>
{/* Price per m² */}
<div className="grid grid-cols-2 gap-2">
<FormField
control={form.control}
@ -435,59 +365,8 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
)}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
{/* Features */}
<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>
{/* Furnishing (rent only) */}
{watchedListingType === ListingType.RENT && (
<div>
<FormLabel className="text-xs">Furnishing</FormLabel>
@ -513,46 +392,30 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
</div>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
{/* Location */}
<AccordionItem value="location">
<AccordionTrigger className="py-2 text-sm font-medium">
Location
</AccordionTrigger>
<AccordionContent>
<FormField
control={form.control}
name="district"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">District</FormLabel>
<FormControl>
<Input
type="text"
placeholder="e.g., Westminster, Camden"
className="h-8 text-sm"
{...field}
/>
</FormControl>
<FormDescription className="text-xs">
Comma-separated list of districts
</FormDescription>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
{/* District */}
<FormField
control={form.control}
name="district"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">District</FormLabel>
<FormControl>
<Input
type="text"
placeholder="e.g., Westminster, Camden"
className="h-8 text-sm"
{...field}
/>
</FormControl>
<FormDescription className="text-xs">
Comma-separated list of districts
</FormDescription>
</FormItem>
)}
/>
{/* Availability / Recency */}
<AccordionItem value="availability">
<AccordionTrigger className="py-2 text-sm font-medium">
{watchedListingType === ListingType.RENT ? 'Availability' : 'Recency'}
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
{/* Available From (rent only) */}
{watchedListingType === ListingType.RENT && (
<FormField
control={form.control}
@ -572,6 +435,8 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
)}
/>
)}
{/* Last Seen Days */}
<FormField
control={form.control}
name="last_seen_days"
@ -597,7 +462,7 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
</AccordionContent>
</AccordionItem>
{/* Points of Interest */}
{/* Points of Interest (auth-only) */}
{user && (
<AccordionItem value="poi">
<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 { renderToString } from 'react-dom/server';
import "../assets/Map.css";
import { Metric, type ParameterValues } from "./Parameters";
import { Metric, type ParameterValues } from "./FilterPanel";
import { PropertyCard } from "./PropertyCard";
import { ScrollArea } from "./ui/scroll-area";
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
const values = data.features
.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; })
.sort(function (a: number, b: number) { return a - b; });
@ -405,6 +405,3 @@ export function Map(props: MapProps) {
</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.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
{props.defaultValue?.map((_, index) => (
{(props.defaultValue ?? props.value)?.map((_, index) => (
<SliderPrimitive.Thumb
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"

View file

@ -1,7 +1,7 @@
// Color schemes for map visualization
// 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
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
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) {
case Metric.qmprice:
case Metric.price:
@ -53,6 +58,11 @@ export function getColorSchemeForMetric(metric: Metric | string): [number, strin
// Get interpretation text for legend
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) {
case Metric.qmprice:
case 'qmprice':

View file

@ -218,6 +218,9 @@ class QueryParameters(BaseModel):
let_date_available_from: datetime | None = None
last_seen_days: 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")
def _validate_ranges(self) -> QueryParameters:
@ -237,4 +240,12 @@ class QueryParameters(BaseModel):
raise ValueError(
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

View file

@ -191,6 +191,20 @@ class ListingRepository:
)
if query_parameters.min_sqm is not None:
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:
query = query.where(model.furnish_type.in_(query_parameters.furnish_types))
if (