104 lines
3.3 KiB
TypeScript
104 lines
3.3 KiB
TypeScript
"use client"
|
|||
|
|
|
||
|
|
import { useEffect, useRef } from "react"
|
||
|
|
import type * as Leaflet from "leaflet"
|
||
|
|
import "leaflet/dist/leaflet.css"
|
||
|
|
import "./property-map.css"
|
||
|
|
|
||
|
|
export type MapMarker = {
|
||
|
|
id: string
|
||
|
|
name: string
|
||
|
|
lat: number
|
||
|
|
lng: number
|
||
|
|
subtitle?: string
|
||
|
|
href?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
// Indigo pin as an inline SVG divIcon — avoids Leaflet's default marker image
|
||
|
|
// assets (which break under bundlers) and matches the app accent.
|
||
|
|
const PIN_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="28" height="28" fill="#6366f1" stroke="#0b0b12" stroke-width="1.2"><path d="M12 0.5C6.9 0.5 2.8 4.6 2.8 9.7c0 6.6 8.2 13.9 8.6 14.2a0.9 0.9 0 0 0 1.2 0c0.4-0.3 8.6-7.6 8.6-14.2C21.2 4.6 17.1 0.5 12 0.5z"/><circle cx="12" cy="9.7" r="3.3" fill="#0b0b12"/></svg>`
|
||
|
|
|
||
|
|
function escapeHtml(s: string): string {
|
||
|
|
return s.replace(/[&<>"']/g, (c) =>
|
||
|
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Leaflet map (OpenStreetMap tiles) rendering property markers. Client-only:
|
||
|
|
* Leaflet touches `window`, so it's dynamically imported inside an effect.
|
||
|
|
*/
|
||
|
|
export function PropertyMap({
|
||
|
|
markers,
|
||
|
|
className = "h-72 w-full",
|
||
|
|
zoom = 15,
|
||
|
|
}: {
|
||
|
|
markers: MapMarker[]
|
||
|
|
className?: string
|
||
|
|
zoom?: number
|
||
|
|
}) {
|
||
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
||
|
|
const mapRef = useRef<Leaflet.Map | null>(null)
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
let cancelled = false
|
||
|
|
|
||
|
|
void (async () => {
|
||
|
|
const mod = await import("leaflet")
|
||
|
|
const L = ((mod as unknown as { default?: typeof Leaflet }).default ?? mod) as typeof Leaflet
|
||
|
|
if (cancelled || !containerRef.current || mapRef.current) return
|
||
|
|
|
||
|
|
const map = L.map(containerRef.current, {
|
||
|
|
scrollWheelZoom: false,
|
||
|
|
zoomControl: true,
|
||
|
|
})
|
||
|
|
mapRef.current = map
|
||
|
|
|
||
|
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||
|
|
attribution:
|
||
|
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||
|
|
maxZoom: 19,
|
||
|
|
}).addTo(map)
|
||
|
|
|
||
|
|
const icon = L.divIcon({
|
||
|
|
html: PIN_SVG,
|
||
|
|
className: "pmn-map-pin",
|
||
|
|
iconSize: [28, 28],
|
||
|
|
iconAnchor: [14, 28],
|
||
|
|
popupAnchor: [0, -26],
|
||
|
|
})
|
||
|
|
|
||
|
|
const latlngs: [number, number][] = []
|
||
|
|
for (const m of markers) {
|
||
|
|
if (!Number.isFinite(m.lat) || !Number.isFinite(m.lng)) continue
|
||
|
|
const marker = L.marker([m.lat, m.lng], { icon }).addTo(map)
|
||
|
|
const link = m.href
|
||
|
|
? `<div style="margin-top:4px"><a href="${escapeHtml(m.href)}">View details →</a></div>`
|
||
|
|
: ""
|
||
|
|
const sub = m.subtitle ? `<div style="color:#9ca3af">${escapeHtml(m.subtitle)}</div>` : ""
|
||
|
|
marker.bindPopup(`<div style="font-weight:600">${escapeHtml(m.name)}</div>${sub}${link}`)
|
||
|
|
latlngs.push([m.lat, m.lng])
|
||
|
|
}
|
||
|
|
|
||
|
|
if (latlngs.length === 1) {
|
||
|
|
map.setView(latlngs[0], zoom)
|
||
|
|
} else if (latlngs.length > 1) {
|
||
|
|
map.fitBounds(latlngs, { padding: [40, 40] })
|
||
|
|
} else {
|
||
|
|
map.setView([39.8283, -98.5795], 4) // continental US fallback
|
||
|
|
}
|
||
|
|
|
||
|
|
// Tiles can render blank if the container sized after init.
|
||
|
|
setTimeout(() => map.invalidateSize(), 0)
|
||
|
|
})()
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
cancelled = true
|
||
|
|
mapRef.current?.remove()
|
||
|
|
mapRef.current = null
|
||
|
|
}
|
||
|
|
}, [markers, zoom])
|
||
|
|
|
||
|
|
return <div ref={containerRef} className={`pmn-map-dark ${className}`} />
|
||
|
|
}
|