gendesign/frontend/src/components/site-finder/entry/EntryMap.tsx
Light1YT 8279b0ee1f fix(week-review): cross-file добивка код-ревью (#1338, #1363, #1419, #1421, #1424, #1480)
- #1338 concepts.py: geometry.generate вынесен в run_in_threadpool (не блокирует event loop)
- #1363 house_imv_backfill.py + scrape_pipeline.py: периодический heartbeat в долгих IMV-фазах (reap_zombies не помечает живой sweep zombie)
- #1419 site-finder-api.ts: placeholderData keepPreviousData для bbox-запроса (KPI не мигает «0/0» при пане/зуме)
- #1421 site-finder-api.ts: area_ha nullable + адаптер не подставляет 0; null-гейт потребителей EntryMap.tsx, ParcelDrawer.tsx
- #1424/#1480 EstimateResult.tsx: блок «Параметры объекта» скрыт при восстановлении по shared-ссылке (нет мусора «0 м²/Студия/Этаж 0 из 0»)

Верификация: tsc --noEmit 0 ошибок; py_compile OK.
2026-06-15 20:29:00 +05:00

305 lines
10 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 != null ? 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 != null ? 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.
zIndex must stay below ParcelDrawer (z20, sibling in .gd-map-shell):
neither this root nor .gd-map-shell creates a stacking context, so a
higher zIndex here would paint over the drawer and swallow its clicks
(X / "Открыть анализ" / ПКК link). z10 keeps the overlay above the
Leaflet map content but under the drawer card. */}
{selectedCad && (
<div
style={{ position: "absolute", inset: 0, zIndex: 10 }}
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>
);
}