gendesign/frontend/src/components/site-finder/entry/EntryMap.tsx
Light1YT 3cea915e48
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m47s
Deploy / deploy (push) Successful in 1m7s
feat(site-finder): clickable parcel markers open analysis from the map
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.
2026-06-04 12:50:37 +05:00

300 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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='&copy; <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>
);
}