Marker click opens an interactive Leaflet popup with a primary "Открыть анализ" action that navigates to /site-finder/analysis/<cad> (encodeURIComponent — matches the [cad] route's single-decode contract) and records the visit in RecentParcels. Hover tooltip enriched with status + actionable hint. Existing ParcelDrawer preview kept, reachable via the popup's secondary "Подробнее". ESLint + tsc clean.
300 lines
9.6 KiB
TypeScript
300 lines
9.6 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import {
|
||
MapContainer,
|
||
TileLayer,
|
||
CircleMarker,
|
||
Tooltip,
|
||
Popup,
|
||
useMapEvents,
|
||
} from "react-leaflet";
|
||
import type { Map as LeafletMap } from "leaflet";
|
||
import { ArrowRight } from "lucide-react";
|
||
import "leaflet/dist/leaflet.css";
|
||
|
||
import type {
|
||
ParcelBboxItem,
|
||
BboxCoords,
|
||
ParcelBboxFilters,
|
||
} from "@/lib/site-finder-api";
|
||
import { useParcelsBboxQuery } from "@/lib/site-finder-api";
|
||
import { STATUS_COLORS, STATUS_LABELS } from "./ParcelLegend";
|
||
|
||
// ── Bbox change listener ───────────────────────────────────────────────────────
|
||
|
||
interface BboxListenerProps {
|
||
onBboxChange: (bbox: BboxCoords) => void;
|
||
}
|
||
|
||
function BboxListener({ onBboxChange }: BboxListenerProps) {
|
||
const map = useMapEvents({
|
||
moveend() {
|
||
const bounds = map.getBounds();
|
||
onBboxChange({
|
||
minLat: bounds.getSouth(),
|
||
minLon: bounds.getWest(),
|
||
maxLat: bounds.getNorth(),
|
||
maxLon: bounds.getEast(),
|
||
});
|
||
},
|
||
zoomend() {
|
||
const bounds = map.getBounds();
|
||
onBboxChange({
|
||
minLat: bounds.getSouth(),
|
||
minLon: bounds.getWest(),
|
||
maxLat: bounds.getNorth(),
|
||
maxLon: bounds.getEast(),
|
||
});
|
||
},
|
||
});
|
||
|
||
// Emit initial bbox on mount
|
||
useEffect(() => {
|
||
const bounds = map.getBounds();
|
||
onBboxChange({
|
||
minLat: bounds.getSouth(),
|
||
minLon: bounds.getWest(),
|
||
maxLat: bounds.getNorth(),
|
||
maxLon: bounds.getEast(),
|
||
});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
return null;
|
||
}
|
||
|
||
// ── Parcel markers ─────────────────────────────────────────────────────────────
|
||
|
||
interface ParcelMarkersProps {
|
||
parcels: ParcelBboxItem[];
|
||
selectedCad: string | null;
|
||
/** Open the full analysis page for a parcel (primary action). */
|
||
onOpen: (parcel: ParcelBboxItem) => void;
|
||
/** Open the side drawer with a parcel preview (secondary action). */
|
||
onSelect: (parcel: ParcelBboxItem) => void;
|
||
}
|
||
|
||
function ParcelMarkers({
|
||
parcels,
|
||
selectedCad,
|
||
onOpen,
|
||
onSelect,
|
||
}: ParcelMarkersProps) {
|
||
return (
|
||
<>
|
||
{parcels.map((parcel) => {
|
||
const color = STATUS_COLORS[parcel.status] ?? "#73767E";
|
||
const statusLabel = STATUS_LABELS[parcel.status] ?? parcel.status;
|
||
const isSelected = parcel.cad_num === selectedCad;
|
||
return (
|
||
<CircleMarker
|
||
key={parcel.cad_num}
|
||
center={[parcel.lat, parcel.lon]}
|
||
radius={isSelected ? 10 : 7}
|
||
pathOptions={{
|
||
color: isSelected ? "#0F172A" : color,
|
||
fillColor: color,
|
||
fillOpacity: 0.85,
|
||
weight: isSelected ? 2.5 : 1.5,
|
||
}}
|
||
// Leaflet renders interactive vector markers with a pointer cursor
|
||
// and keeps them keyboard-focusable; click opens the Popup below.
|
||
>
|
||
{/* Hover hint — quick identity without committing to the analyze POST */}
|
||
<Tooltip direction="top" offset={[0, -8]} opacity={0.95}>
|
||
<div style={{ fontSize: 12 }}>
|
||
<div style={{ fontWeight: 600 }}>{parcel.cad_num}</div>
|
||
<div style={{ color: "var(--fg-secondary)" }}>
|
||
{parcel.area_ha.toFixed(2)} га · {statusLabel}
|
||
</div>
|
||
<div style={{ color: "var(--fg-tertiary)", marginTop: 2 }}>
|
||
Нажмите, чтобы открыть анализ
|
||
</div>
|
||
</div>
|
||
</Tooltip>
|
||
|
||
{/* Click target — interactive popup with the primary navigate action */}
|
||
<Popup>
|
||
<div style={{ fontSize: 12, minWidth: 168 }}>
|
||
<div
|
||
style={{
|
||
fontWeight: 600,
|
||
color: "var(--fg-primary)",
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
{parcel.cad_num}
|
||
</div>
|
||
<div
|
||
style={{ color: "var(--fg-secondary)", marginBottom: 8 }}
|
||
>
|
||
{parcel.area_ha.toFixed(2)} га · {statusLabel}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => onOpen(parcel)}
|
||
aria-label={`Открыть анализ участка ${parcel.cad_num}`}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
gap: 6,
|
||
width: "100%",
|
||
background: "var(--accent)",
|
||
color: "#fff",
|
||
border: "none",
|
||
borderRadius: 6,
|
||
padding: "6px 12px",
|
||
fontSize: 13,
|
||
fontWeight: 500,
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
Открыть анализ
|
||
<ArrowRight size={14} strokeWidth={1.5} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onSelect(parcel)}
|
||
style={{
|
||
display: "block",
|
||
width: "100%",
|
||
background: "none",
|
||
border: "none",
|
||
color: "var(--fg-secondary)",
|
||
padding: "6px 12px 0",
|
||
fontSize: 12,
|
||
cursor: "pointer",
|
||
textAlign: "center",
|
||
}}
|
||
>
|
||
Подробнее в карточке
|
||
</button>
|
||
</div>
|
||
</Popup>
|
||
</CircleMarker>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ── EntryMap ───────────────────────────────────────────────────────────────────
|
||
|
||
// Yekaterinburg center
|
||
const EKB_CENTER: [number, number] = [56.8389, 60.6057];
|
||
const DEFAULT_ZOOM = 12;
|
||
|
||
interface EntryMapProps {
|
||
filters: ParcelBboxFilters;
|
||
selectedCad: string | null;
|
||
/** Primary action: navigate to the analysis page for a parcel. */
|
||
onParcelOpen: (parcel: ParcelBboxItem) => void;
|
||
/** Secondary action: open the preview drawer for a parcel. */
|
||
onParcelSelect: (parcel: ParcelBboxItem) => void;
|
||
onParcelDeselect: () => void;
|
||
/** Called whenever the map viewport changes — allows parent to read bbox for filter counters */
|
||
onBboxChange?: (bbox: BboxCoords) => void;
|
||
}
|
||
|
||
export function EntryMap({
|
||
filters,
|
||
selectedCad,
|
||
onParcelOpen,
|
||
onParcelSelect,
|
||
onParcelDeselect,
|
||
onBboxChange,
|
||
}: EntryMapProps) {
|
||
const [bbox, setBbox] = useState<BboxCoords | null>(null);
|
||
const mapRef = useRef<LeafletMap | null>(null);
|
||
|
||
const { data: parcels, isFetching } = useParcelsBboxQuery(bbox, filters);
|
||
|
||
const handleBboxChange = useCallback(
|
||
(newBbox: BboxCoords) => {
|
||
setBbox(newBbox);
|
||
onBboxChange?.(newBbox);
|
||
},
|
||
[onBboxChange],
|
||
);
|
||
|
||
return (
|
||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||
<MapContainer
|
||
center={EKB_CENTER}
|
||
zoom={DEFAULT_ZOOM}
|
||
style={{ width: "100%", height: "100%", borderRadius: 12 }}
|
||
ref={mapRef}
|
||
// Click on map backdrop deselects parcel
|
||
// handled via eventHandlers on markers + backdrop div
|
||
>
|
||
<TileLayer
|
||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||
/>
|
||
<BboxListener onBboxChange={handleBboxChange} />
|
||
{parcels && parcels.length > 0 && (
|
||
<ParcelMarkers
|
||
parcels={parcels}
|
||
selectedCad={selectedCad}
|
||
onOpen={onParcelOpen}
|
||
onSelect={onParcelSelect}
|
||
/>
|
||
)}
|
||
</MapContainer>
|
||
|
||
{/* Deselect on map click — overlay to capture clicks outside markers */}
|
||
{selectedCad && (
|
||
<div
|
||
style={{ position: "absolute", inset: 0, zIndex: 399 }}
|
||
onClick={onParcelDeselect}
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
|
||
{/* Fetching indicator */}
|
||
{isFetching && (
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
top: 12,
|
||
left: "50%",
|
||
transform: "translateX(-50%)",
|
||
zIndex: 1000,
|
||
background: "var(--bg-card)",
|
||
border: "1px solid var(--border-card)",
|
||
borderRadius: 20,
|
||
padding: "4px 14px",
|
||
fontSize: 12,
|
||
color: "var(--fg-secondary)",
|
||
}}
|
||
>
|
||
Загрузка участков...
|
||
</div>
|
||
)}
|
||
|
||
{/* Empty state */}
|
||
{!isFetching && parcels && parcels.length === 0 && (
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
top: "50%",
|
||
left: "50%",
|
||
transform: "translate(-50%, -50%)",
|
||
zIndex: 1000,
|
||
background: "var(--bg-card)",
|
||
border: "1px solid var(--border-card)",
|
||
borderRadius: 12,
|
||
padding: "16px 24px",
|
||
fontSize: 13,
|
||
color: "var(--fg-secondary)",
|
||
textAlign: "center",
|
||
}}
|
||
>
|
||
Нет участков в текущем виде. Измените фильтры или область карты.
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|