gendesign/frontend/src/app/site-finder/page.tsx
Light1YT b3fd053e3d
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 55s
CI / openapi-codegen-check (pull_request) Successful in 1m58s
feat(ptica): map glyph pins + compass, equal-height grids, full-height rail; dark Site Finder
ПТИЦА 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.
2026-06-21 03:41:13 +05:00

254 lines
7.7 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 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>
);
}