gendesign/frontend/src/components/site-finder/entry/EntryMap.tsx
lekss361 0084773abf feat(sf-fe-a2): EntryMap + MapFilterBar + ParcelDrawer + RecentParcels + ParcelLegend
- EntryMap: Leaflet карта ЕКБ с CircleMarker парцелей, bbox-aware refetch (TanStack Query),
  click-to-select, deselect on backdrop, isFetching indicator
- MapFilterBar: chip-фильтры (status/area/vri) + district select + counter, bbox-linked
  counter через FilterBarBridge без ремаунта карты
- ParcelDrawer: slide-in panel 360px — 4 KPI (status/area/district/vri) + coords +
  CTA «Открыть анализ» -> /site-finder/analysis/[cad], fallback ПКК Росреестр
- RecentParcels: TanStack Query hook с localStorage fallback (gd_recent_parcels),
  merges B2 server list + local-only items; empty state + hover effects
- ParcelLegend: status color legend (free/in_progress/favorite) + STATUS_COLORS export
  reused in EntryMap markers
- site-finder-api.ts: useParcelsBboxQuery + useRecentParcels hooks, mock fallback via
  MOCK_PARCELS_BBOX / MOCK_RECENT_PARCELS; getLocalRecentParcels / addLocalRecentParcel
- parcels-bbox.json: 20 ЕКБ-парцелей фикстура (6 районов, все статусы, разные ВРИ)
- page.tsx: заменены MapPlaceholder + SidebarPlaceholder на реальные компоненты
2026-05-18 01:12:32 +03:00

224 lines
6.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,
useMapEvents,
} from "react-leaflet";
import type { LeafletMouseEvent, Map as LeafletMap } from "leaflet";
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 } 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;
onSelect: (parcel: ParcelBboxItem) => void;
}
function ParcelMarkers({ parcels, selectedCad, onSelect }: ParcelMarkersProps) {
return (
<>
{parcels.map((parcel) => {
const color = STATUS_COLORS[parcel.status] ?? "#73767E";
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,
}}
eventHandlers={{
click: (e: LeafletMouseEvent) => {
e.originalEvent.stopPropagation();
onSelect(parcel);
},
}}
>
<Tooltip direction="top" offset={[0, -8]} opacity={0.95}>
<div style={{ fontSize: 12 }}>
<div style={{ fontWeight: 600 }}>{parcel.cad_num}</div>
<div style={{ color: "#5B6066" }}>
{parcel.area_ha.toFixed(2)} га · {parcel.district}
</div>
</div>
</Tooltip>
</CircleMarker>
);
})}
</>
);
}
// ── EntryMap ───────────────────────────────────────────────────────────────────
// Yekaterinburg center
const EKB_CENTER: [number, number] = [56.8389, 60.6057];
const DEFAULT_ZOOM = 12;
interface EntryMapProps {
filters: ParcelBboxFilters;
selectedCad: string | null;
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,
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}
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>
);
}