ПТИЦА cockpit: - map: POI/competitors/connections now render as glyph divIcon pins (per-category glyph + color) with dashed connection lines + a compass — matches the prototype (was plain CircleMarker dots). Legend/zoom/scale/base-toggle/tools kept. - lower+bottom grids: align-items stretch → equal-height rows; cards are flex columns with footer «Подробнее» pinned to bottom and sparse empty-states centred (fixes «выровняй тут все»). - rail: dropped sticky+height:100vh so it stretches to full page height like the prototype (fixes «обрезан»). Main Site Finder (entry landing) — restyled to the ПТИЦА dark cockpit theme, scoped to .sfRoot[data-theme=dark] (no leak to light pages): dark CARTO tiles, Leaflet attribution flag removed (prefix=false, © credit kept), dark markers / filter chips / legend / recent list / drawer / cad input.
254 lines
7.7 KiB
TypeScript
254 lines
7.7 KiB
TypeScript
"use client";
|
||
|
||
import dynamic from "next/dynamic";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { useRef, useState } from "react";
|
||
|
||
import { CadInput } from "@/components/site-finder/CadInput";
|
||
import { MapFilterBar } from "@/components/site-finder/entry/MapFilterBar";
|
||
import { ParcelDrawer } from "@/components/site-finder/entry/ParcelDrawer";
|
||
import { ParcelLegend } from "@/components/site-finder/entry/ParcelLegend";
|
||
import { RecentParcels } from "@/components/site-finder/entry/RecentParcels";
|
||
import {
|
||
addLocalRecentParcel,
|
||
useParcelsBboxQuery,
|
||
} from "@/lib/site-finder-api";
|
||
import type {
|
||
ParcelBboxFilters,
|
||
ParcelBboxItem,
|
||
BboxCoords,
|
||
} from "@/lib/site-finder-api";
|
||
import styles from "./site-finder.module.css";
|
||
|
||
// EntryMap uses Leaflet — must load without SSR
|
||
const EntryMap = dynamic(
|
||
() =>
|
||
import("@/components/site-finder/entry/EntryMap").then((m) => m.EntryMap),
|
||
{
|
||
ssr: false,
|
||
loading: () => (
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
minHeight: 480,
|
||
background: "rgba(9, 22, 31, 0.62)",
|
||
border: "1px dashed rgba(150, 192, 214, 0.4)",
|
||
borderRadius: 12,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
color: "#8ba6b3",
|
||
fontSize: 14,
|
||
}}
|
||
>
|
||
Загрузка карты...
|
||
</div>
|
||
),
|
||
},
|
||
);
|
||
|
||
// ── FilterCountBridge ──────────────────────────────────────────────────────────
|
||
// Reads parcel counts for current bbox without re-mounting EntryMap.
|
||
|
||
interface FilterBarBridgeProps {
|
||
filters: ParcelBboxFilters;
|
||
onChange: (f: ParcelBboxFilters) => void;
|
||
bbox: BboxCoords | null;
|
||
}
|
||
|
||
function FilterBarBridge({ filters, onChange, bbox }: FilterBarBridgeProps) {
|
||
// useParcelsBboxQuery has no keepPreviousData (unlike useParcelAnalyzeQuery),
|
||
// and its queryKey carries the bbox — so on every pan/zoom both `data` go
|
||
// undefined mid-fetch. We hold the last fully-resolved pair so the counter
|
||
// doesn't flash "0 / 0" while fetching (#1419), and so totalCount/matchCount
|
||
// are always taken from the *same* committed frame — never a new bbox's
|
||
// matchCount over an old bbox's totalCount, which could read matchCount >
|
||
// totalCount and break the X ⊆ Y invariant (#1420).
|
||
const allQuery = useParcelsBboxQuery(bbox, {});
|
||
const filteredQuery = useParcelsBboxQuery(bbox, filters);
|
||
|
||
const lastCounts = useRef<{ totalCount: number; matchCount: number } | null>(
|
||
null,
|
||
);
|
||
|
||
// Only commit a new pair once BOTH queries have data for the current render;
|
||
// until then keep the previous pair (placeholderData: keepPreviousData
|
||
// equivalent, scoped to this bridge).
|
||
if (allQuery.data != null && filteredQuery.data != null) {
|
||
lastCounts.current = {
|
||
totalCount: allQuery.data.length,
|
||
matchCount: filteredQuery.data.length,
|
||
};
|
||
}
|
||
|
||
const counts = lastCounts.current ?? { totalCount: 0, matchCount: 0 };
|
||
|
||
return (
|
||
<MapFilterBar
|
||
filters={filters}
|
||
onChange={onChange}
|
||
totalCount={counts.totalCount}
|
||
matchCount={counts.matchCount}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||
|
||
export default function SiteFinderPage() {
|
||
const router = useRouter();
|
||
const [filters, setFilters] = useState<ParcelBboxFilters>({});
|
||
const [selectedParcel, setSelectedParcel] = useState<ParcelBboxItem | null>(
|
||
null,
|
||
);
|
||
const [bbox, setBbox] = useState<BboxCoords | null>(null);
|
||
|
||
function handleParcelSelect(parcel: ParcelBboxItem) {
|
||
setSelectedParcel(parcel);
|
||
}
|
||
|
||
function handleParcelDeselect() {
|
||
setSelectedParcel(null);
|
||
}
|
||
|
||
function handleParcelOpen(parcel: ParcelBboxItem) {
|
||
// Record the visit so RecentParcels stays populated, then navigate.
|
||
addLocalRecentParcel({
|
||
cad_num: parcel.cad_num,
|
||
address: parcel.address,
|
||
area_ha: parcel.area_ha,
|
||
district: parcel.district,
|
||
visited_at: new Date().toISOString(),
|
||
});
|
||
router.push(`/site-finder/analysis/${encodeURIComponent(parcel.cad_num)}`);
|
||
}
|
||
|
||
function handleCadSubmit(cad: string) {
|
||
router.push(`/site-finder/analysis/${encodeURIComponent(cad)}`);
|
||
}
|
||
|
||
return (
|
||
<main
|
||
className={styles.sfRoot}
|
||
data-theme="dark"
|
||
style={{
|
||
minHeight: "100vh",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
}}
|
||
>
|
||
{/* Header */}
|
||
<header
|
||
style={{
|
||
background: "rgba(12, 30, 42, 0.94)",
|
||
borderBottom: "1px solid rgba(150, 192, 214, 0.18)",
|
||
padding: "12px 24px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 16,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<Link
|
||
href="/"
|
||
style={{
|
||
fontSize: 13,
|
||
color: "#8ba6b3",
|
||
textDecoration: "none",
|
||
}}
|
||
>
|
||
← Главная
|
||
</Link>
|
||
<span style={{ color: "rgba(150, 192, 214, 0.4)" }}>·</span>
|
||
<h1
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
color: "#f2fafe",
|
||
letterSpacing: "0.01em",
|
||
}}
|
||
>
|
||
SiteFinder · карта участков
|
||
</h1>
|
||
<Link
|
||
href="/site-finder/compare"
|
||
style={{
|
||
marginLeft: "auto",
|
||
fontSize: 13,
|
||
color: "#7fd0ee",
|
||
textDecoration: "none",
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
Сравнить участки
|
||
</Link>
|
||
</header>
|
||
|
||
{/* Filter bar */}
|
||
<div style={{ flexShrink: 0 }}>
|
||
<FilterBarBridge filters={filters} onChange={setFilters} bbox={bbox} />
|
||
</div>
|
||
|
||
{/* Main layout. На планшете/телефоне .gd-split встаёт в колонку
|
||
(карта над сайдбаром), .gd-map-shell держит высоту карты (issue #66). */}
|
||
<div
|
||
className="gd-split"
|
||
style={{
|
||
flex: 1,
|
||
display: "flex",
|
||
gap: 0,
|
||
overflow: "hidden",
|
||
minHeight: 0,
|
||
}}
|
||
>
|
||
{/* Map area — position: relative so ParcelDrawer can anchor absolutely here */}
|
||
<div
|
||
className="gd-map-shell"
|
||
style={{
|
||
flex: 1,
|
||
padding: 16,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
minHeight: 0,
|
||
position: "relative",
|
||
}}
|
||
>
|
||
<EntryMap
|
||
filters={filters}
|
||
selectedCad={selectedParcel?.cad_num ?? null}
|
||
onParcelOpen={handleParcelOpen}
|
||
onParcelSelect={handleParcelSelect}
|
||
onParcelDeselect={handleParcelDeselect}
|
||
onBboxChange={setBbox}
|
||
/>
|
||
|
||
{/* ParcelDrawer — anchored to map area right edge, not page */}
|
||
<ParcelDrawer
|
||
parcel={selectedParcel}
|
||
onClose={handleParcelDeselect}
|
||
/>
|
||
</div>
|
||
|
||
{/* Right sidebar — always visible; CadInput accessible parallel with drawer */}
|
||
<div
|
||
className="gd-split-aside"
|
||
style={{
|
||
width: 300,
|
||
flexShrink: 0,
|
||
padding: "16px 16px 16px 0",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 12,
|
||
overflowY: "auto",
|
||
}}
|
||
>
|
||
<CadInput onSubmit={handleCadSubmit} loading={false} />
|
||
<RecentParcels />
|
||
<ParcelLegend />
|
||
</div>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|