Add frontend POI management and travel time display
POIManager component in FilterPanel for creating/deleting POIs and triggering distance calculations. PropertyCard shows travel time badges (walk/cycle/transit) per POI. Map renders POI locations as red markers. API client extended with POST body support for POI endpoints.
This commit is contained in:
parent
bb489c2032
commit
8509a0326f
9 changed files with 414 additions and 10 deletions
|
|
@ -8,8 +8,10 @@ import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, For
|
|||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./ui/accordion";
|
||||
import { Loader2, Filter, RefreshCw } from "lucide-react";
|
||||
import { Loader2, Filter, RefreshCw, MapPin } from "lucide-react";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { POIManager } from "./POIManager";
|
||||
import type { AuthUser } from "@/auth/types";
|
||||
|
||||
export enum Metric {
|
||||
qmprice = 'qmprice',
|
||||
|
|
@ -69,6 +71,8 @@ interface FilterPanelProps {
|
|||
onMetricChange?: (metric: Metric) => void;
|
||||
isLoading?: boolean;
|
||||
listingCount?: number;
|
||||
user?: AuthUser;
|
||||
onTaskCreated?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
|
|
@ -90,7 +94,7 @@ const formSchema = z.object({
|
|||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount }: FilterPanelProps) {
|
||||
export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount, user, onTaskCreated }: FilterPanelProps) {
|
||||
const [availableFromRawInput, setAvailableFromRawInput] = useState("now");
|
||||
const [selectedFurnishTypes, setSelectedFurnishTypes] = useState<FurnishType[]>([]);
|
||||
|
||||
|
|
@ -530,6 +534,25 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount
|
|||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Points of Interest */}
|
||||
{user && (
|
||||
<AccordionItem value="poi">
|
||||
<AccordionTrigger className="py-2 text-sm font-medium">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
Points of Interest
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<POIManager
|
||||
user={user}
|
||||
listingType={watchedListingType as 'RENT' | 'BUY'}
|
||||
onTaskCreated={onTaskCreated}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
</form>
|
||||
</Form>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import "../assets/Map.css";
|
|||
import { Metric, type ParameterValues } from "./Parameters";
|
||||
import { PropertyCard } from "./PropertyCard";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties } from "@/types";
|
||||
import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties, POI } from "@/types";
|
||||
import { MAP_CONFIG, HEATMAP_CONFIG, PERCENTILE_CONFIG } from "@/constants";
|
||||
import { getColorSchemeForMetric, getMetricInterpretation } from "@/constants/colorSchemes";
|
||||
import { clone, percentile, calculateColorStops } from "@/utils/mapUtils";
|
||||
|
|
@ -40,6 +40,7 @@ interface MapProps {
|
|||
listingData: GeoJSONFeatureCollection;
|
||||
queryParameters: ParameterValues | null;
|
||||
onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void;
|
||||
pois?: POI[];
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
|
|
@ -58,6 +59,7 @@ export function Map(props: MapProps) {
|
|||
const updateTimeoutRef = useRef<number | null>(null);
|
||||
const isMapLoadedRef = useRef<boolean>(false);
|
||||
const lastDataLengthRef = useRef<number>(0);
|
||||
const poiMarkersRef = useRef<mapboxgl.Marker[]>([]);
|
||||
|
||||
const filter: FilterState = { city: 'London', country: null, mode: Metric.qmprice };
|
||||
if (props.queryParameters) {
|
||||
|
|
@ -237,6 +239,33 @@ export function Map(props: MapProps) {
|
|||
};
|
||||
}, [data, updateHeatmap]);
|
||||
|
||||
// Update POI markers when pois prop changes
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || !isMapLoadedRef.current) return;
|
||||
|
||||
// Remove existing markers
|
||||
poiMarkersRef.current.forEach(m => m.remove());
|
||||
poiMarkersRef.current = [];
|
||||
|
||||
if (!props.pois) return;
|
||||
|
||||
for (const poi of props.pois) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'poi-marker';
|
||||
el.style.cssText = 'width:24px;height:24px;background:#ef4444;border:2px solid white;border-radius:50%;box-shadow:0 2px 4px rgba(0,0,0,0.3);cursor:pointer;';
|
||||
el.title = poi.name;
|
||||
|
||||
const marker = new mapboxgl.Marker({ element: el })
|
||||
.setLngLat([poi.longitude, poi.latitude])
|
||||
.setPopup(new mapboxgl.Popup({ offset: 12 }).setHTML(
|
||||
`<div style="padding:4px 8px"><strong>${poi.name}</strong><br/><span style="color:#666;font-size:12px">${poi.address}</span></div>`
|
||||
))
|
||||
.addTo(mapRef.current);
|
||||
|
||||
poiMarkersRef.current.push(marker);
|
||||
}
|
||||
}, [props.pois]);
|
||||
|
||||
function makeLegend(colorstops: [number, string][], minValue: number, maxValue: number) {
|
||||
const svg_height = 280, svg_width = 80;
|
||||
d3.select('#svg').selectAll('*').remove();
|
||||
|
|
|
|||
187
frontend/src/components/POIManager.tsx
Normal file
187
frontend/src/components/POIManager.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { MapPin, Plus, Trash2, Calculator, Loader2 } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
import type { POI } from '@/types';
|
||||
import { fetchUserPOIs, createPOI, deletePOI, triggerPOICalculation } from '@/services/poiService';
|
||||
|
||||
interface POIManagerProps {
|
||||
user: AuthUser;
|
||||
listingType: 'RENT' | 'BUY';
|
||||
onTaskCreated?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export function POIManager({ user, listingType, onTaskCreated }: POIManagerProps) {
|
||||
const [pois, setPois] = useState<POI[]>([]);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [lat, setLat] = useState('');
|
||||
const [lng, setLng] = useState('');
|
||||
const [calculating, setCalculating] = useState<number | null>(null);
|
||||
const [selectedModes, setSelectedModes] = useState<string[]>(['WALK', 'BICYCLE', 'TRANSIT']);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserPOIs(user).then(setPois).catch(() => {});
|
||||
}, [user]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name || !lat || !lng) return;
|
||||
try {
|
||||
const poi = await createPOI(user, {
|
||||
name,
|
||||
address: address || name,
|
||||
latitude: parseFloat(lat),
|
||||
longitude: parseFloat(lng),
|
||||
});
|
||||
setPois(prev => [...prev, poi]);
|
||||
setIsAdding(false);
|
||||
setName('');
|
||||
setAddress('');
|
||||
setLat('');
|
||||
setLng('');
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (poiId: number) => {
|
||||
try {
|
||||
await deletePOI(user, poiId);
|
||||
setPois(prev => prev.filter(p => p.id !== poiId));
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
|
||||
const handleCalculate = async (poiId: number) => {
|
||||
setCalculating(poiId);
|
||||
try {
|
||||
const result = await triggerPOICalculation(user, poiId, selectedModes, listingType);
|
||||
onTaskCreated?.(result.task_id);
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setCalculating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMode = (mode: string) => {
|
||||
setSelectedModes(prev =>
|
||||
prev.includes(mode) ? prev.filter(m => m !== mode) : [...prev, mode]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* POI List */}
|
||||
{pois.map(poi => (
|
||||
<div key={poi.id} className="flex items-center gap-2 p-2 rounded-md border text-sm">
|
||||
<MapPin className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 truncate font-medium">{poi.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => handleCalculate(poi.id)}
|
||||
disabled={calculating === poi.id}
|
||||
>
|
||||
{calculating === poi.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Calculator className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(poi.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Travel mode toggles */}
|
||||
{pois.length > 0 && (
|
||||
<div className="flex gap-1.5">
|
||||
{['WALK', 'BICYCLE', 'TRANSIT'].map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => toggleMode(mode)}
|
||||
className={`px-2 py-0.5 text-xs rounded-md border transition-colors ${
|
||||
selectedModes.includes(mode)
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-muted border-input'
|
||||
}`}
|
||||
>
|
||||
{mode === 'WALK' ? 'Walk' : mode === 'BICYCLE' ? 'Cycle' : 'Transit'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add POI Form */}
|
||||
{isAdding ? (
|
||||
<div className="space-y-2 p-2 border rounded-md">
|
||||
<Input
|
||||
placeholder="Name (e.g., Office)"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Address (optional)"
|
||||
value={address}
|
||||
onChange={e => setAddress(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="Latitude"
|
||||
value={lat}
|
||||
onChange={e => setLat(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="Longitude"
|
||||
value={lng}
|
||||
onChange={e => setLng(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="h-7 text-xs flex-1" onClick={handleCreate}>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setIsAdding(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full h-7 text-xs"
|
||||
onClick={() => setIsAdding(true)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add POI
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,51 @@
|
|||
import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building } from 'lucide-react';
|
||||
import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building, Footprints, Bike, Train } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import type { PropertyProperties } from '@/types';
|
||||
import type { PropertyProperties, POIDistanceInfo } from '@/types';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
function TravelModeIcon({ mode }: { mode: string }) {
|
||||
switch (mode) {
|
||||
case 'WALK': return <Footprints className="h-3 w-3" />;
|
||||
case 'BICYCLE': return <Bike className="h-3 w-3" />;
|
||||
case 'TRANSIT': return <Train className="h-3 w-3" />;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function POIDistanceBadges({ distances }: { distances: POIDistanceInfo[] }) {
|
||||
if (!distances || distances.length === 0) return null;
|
||||
|
||||
// Group by POI name
|
||||
const byPoi = new Map<string, POIDistanceInfo[]>();
|
||||
for (const d of distances) {
|
||||
const existing = byPoi.get(d.poi_name) || [];
|
||||
existing.push(d);
|
||||
byPoi.set(d.poi_name, existing);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{Array.from(byPoi.entries()).map(([poiName, dists]) => (
|
||||
<div key={poiName} className="flex items-center gap-1 text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded">
|
||||
<span className="font-medium">{poiName}:</span>
|
||||
{dists.map(d => (
|
||||
<span key={d.travel_mode} className="flex items-center gap-0.5" title={`${d.travel_mode} to ${poiName}`}>
|
||||
<TravelModeIcon mode={d.travel_mode} />
|
||||
{formatDuration(d.duration_seconds)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PropertyCardProps {
|
||||
property: PropertyProperties;
|
||||
|
|
@ -91,6 +136,7 @@ export function PropertyCard({
|
|||
</span>
|
||||
<span className="truncate">{property.agency}</span>
|
||||
</div>
|
||||
<POIDistanceBadges distances={property.poi_distances || []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -171,6 +217,14 @@ export function PropertyCard({
|
|||
<span>Seen {lastSeenDays} days ago</span>
|
||||
</div>
|
||||
|
||||
{/* POI Distances */}
|
||||
{property.poi_distances && property.poi_distances.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">Travel times</div>
|
||||
<POIDistanceBadges distances={property.poi_distances} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price history */}
|
||||
{property.price_history.length > 1 && (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue