feat: embedded interactive map in property + match detail views
Integrates react-leaflet with OpenStreetMap tiles. PropertyDetailView and MatchDetail now show property photo + zoomable/pannable map above the tabs/content — always visible on card click, no redirect. Map renders at correct lat/lng with a location marker and popup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
// Fix default marker icons in Vite build
|
||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: new URL('leaflet/dist/images/marker-icon-2x.png', import.meta.url).href,
|
||||
iconUrl: new URL('leaflet/dist/images/marker-icon.png', import.meta.url).href,
|
||||
shadowUrl: new URL('leaflet/dist/images/marker-shadow.png', import.meta.url).href,
|
||||
})
|
||||
|
||||
interface Props {
|
||||
lat: number
|
||||
lng: number
|
||||
label?: string
|
||||
height?: number
|
||||
}
|
||||
|
||||
function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
map.setView([lat, lng])
|
||||
}, [lat, lng, map])
|
||||
return null
|
||||
}
|
||||
|
||||
export function PropertyMap({ lat, lng, label, height = 260 }: Props) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height,
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
// Ensure leaflet controls appear above MUI elements
|
||||
'& .leaflet-container': { height: '100%', width: '100%', zIndex: 0 },
|
||||
'& .leaflet-control': { zIndex: 1 },
|
||||
}}
|
||||
>
|
||||
<MapContainer
|
||||
center={[lat, lng]}
|
||||
zoom={14}
|
||||
scrollWheelZoom
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<RecenterOnChange lat={lat} lng={lng} />
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Marker position={[lat, lng]}>
|
||||
{label && <Popup>{label}</Popup>}
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { ViewToggle } from './ViewToggle'
|
||||
export { LocationPreview } from './LocationPreview'
|
||||
export { PropertyMap } from './PropertyMap'
|
||||
export { getScoreTier, SCORE_THEME } from './scoreTheme'
|
||||
export type { ScoreTier } from './scoreTheme'
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { X } from 'lucide-react'
|
||||
import { NegotiationInsightsPanel } from './NegotiationInsightsPanel'
|
||||
import { DataQualityPanel as DQPanel, ProvenancePanel } from '../data-quality'
|
||||
import { LocationPreview, PropertyMap } from '../shared'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { Match } from '../../domain/match'
|
||||
import type { FutureSignal } from '../../domain/futureSignal'
|
||||
@@ -364,6 +365,28 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Photo + Map */}
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
{property.images?.[0] && (
|
||||
<LocationPreview
|
||||
imageUrl={property.images[0]}
|
||||
lat={property.location.coordinates?.lat}
|
||||
lng={property.location.coordinates?.lng}
|
||||
address={`${property.address.street} ${property.address.houseNumber}, ${property.address.city}`}
|
||||
cityLabel={property.location.city}
|
||||
height={180}
|
||||
/>
|
||||
)}
|
||||
{property.location.coordinates && (
|
||||
<PropertyMap
|
||||
lat={property.location.coordinates.lat}
|
||||
lng={property.location.coordinates.lng}
|
||||
label={property.title}
|
||||
height={240}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={tab}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useCompareStore } from '../../stores/compareStore'
|
||||
import { useShortlistStore } from '../../stores/shortlistStore'
|
||||
import { AddToShortlistDialog } from '../../components/shortlist'
|
||||
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
|
||||
import { LocationPreview, PropertyMap } from '../../components/shared'
|
||||
import {
|
||||
LocationIntelligencePanel,
|
||||
MatchDetailHeader,
|
||||
@@ -142,6 +143,30 @@ export default function MatchDetail() {
|
||||
onShortlist={handleShortlist}
|
||||
/>
|
||||
|
||||
{/* Photo + Map */}
|
||||
{!isFuture && property && (
|
||||
<Box sx={{ mb: 2, borderRadius: 2, overflow: 'hidden', border: '1px solid #e2e8f0' }}>
|
||||
{property.images?.[0] && (
|
||||
<LocationPreview
|
||||
imageUrl={property.images[0]}
|
||||
lat={property.location.coordinates?.lat}
|
||||
lng={property.location.coordinates?.lng}
|
||||
address={`${property.address.street} ${property.address.houseNumber}, ${property.address.city}`}
|
||||
cityLabel={property.location.city}
|
||||
height={200}
|
||||
/>
|
||||
)}
|
||||
{property.location.coordinates && (
|
||||
<PropertyMap
|
||||
lat={property.location.coordinates.lat}
|
||||
lng={property.location.coordinates.lng}
|
||||
label={property.title}
|
||||
height={280}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
|
||||
{/* Main column */}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
|
||||
Reference in New Issue
Block a user