- 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 на реальные компоненты
224 lines
6.6 KiB
TypeScript
224 lines
6.6 KiB
TypeScript
"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='© <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>
|
||
);
|
||
}
|