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:
parent
1f4a3f858c
commit
743e018668
11 changed files with 422 additions and 588 deletions
12
api/app.py
12
api/app.py
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
|
||||
93
frontend/src/components/VisualizationCard.tsx
Normal file
93
frontend/src/components/VisualizationCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
frontend/src/components/ui/range-slider-field.tsx
Normal file
70
frontend/src/components/ui/range-slider-field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue