feat(ptica): map glyph pins + compass, equal-height grids, full-height rail; dark Site Finder #1861

Merged
bot-backend merged 1 commit from feat/ptica-map-glyphs-sitefinder-dark into main 2026-06-20 22:41:19 +00:00
10 changed files with 591 additions and 206 deletions

View file

@ -125,9 +125,11 @@
flex-direction: column;
padding: 16px 10px 12px;
gap: 14px;
position: sticky;
top: 0;
height: 100vh;
/* Prototype .sidebar is a plain flex grid-child that stretches to the FULL
page height (footer pinned via .navFoot margin-top:auto). The old
`position:sticky; top:0; height:100vh` capped the rail strip at one viewport
so on tall pages it looked «обрезан» partway down. Drop it the rail now
stretches to the full .shell grid height like the prototype. */
}
.brand {
display: flex;
@ -2451,6 +2453,19 @@
backdrop-filter: blur(6px);
}
/* Static compass (prototype .map-compass) bottom-right "N" arrow above the
Leaflet panes; non-interactive so it never blocks panning. */
.mapCompass {
position: absolute;
right: 18px;
bottom: 16px;
width: 46px;
height: 46px;
color: var(--text-muted);
z-index: 4;
pointer-events: none;
}
/* ===================== LOWER GRID (cockpit) =====================
Ported from prototype .lower-grid: ОКС · Development Potential ·
Recommended Product · Инсоляционная матрица. */
@ -2458,11 +2473,45 @@
display: grid;
grid-template-columns: 1.05fr 1.05fr 1.15fr 0.95fr;
gap: 10px;
/* HEADLINE BUG FIX: default align-items:stretch made the sparse/empty ОКС
card (isoBox 84px + emptyState flex:1 + button) stretch to the tallest
sibling, and .emptyState's flex:1 then filled the void huge empty box.
align-items:start lets each card size to its own content. */
align-items: start;
/* Equal-height row (прот.: «выровняй тут все»). Each card is a flex column
(rule below) so a stretched card lays out intentionally: the footer action
pins to the bottom (margin-top:auto) and sparse empty-states (.emptyState /
.placeholder flex:1) centre their content instead of leaving an awkward gap. */
align-items: stretch;
}
/* Make every lower/bottom-grid card a flex column so stretching is intentional:
the footer «Подробнее» button drops to the bottom and the .emptyState /
.placeholder body grows to fill (centred) rather than the card being a tall
empty box. */
.lowerGrid > .card,
.bottomGrid > .card {
display: flex;
flex-direction: column;
}
/* Footer actions (DIRECT children of the card the «Подробнее» button or the
Инсоляция .placeholder block) pinned to the bottom of a stretched flex-column
card. Header buttons (inside .cardHead) are NOT direct children, so they stay
put. */
.lowerGrid > .card > .detailBtn,
.bottomGrid > .card > .detailBtn,
.lowerGrid > .card > .placeholder {
margin-top: auto;
align-self: flex-start;
}
/* The Инсоляция .placeholder is itself a centred column its OWN button keeps
normal spacing inside it (the placeholder block is what gets pinned). */
.lowerGrid > .card > .placeholder {
align-self: stretch;
}
.lowerGrid > .card > .placeholder .detailBtn {
margin-top: 8px;
align-self: center;
}
/* Sparse bodies grow + centre when their card is stretched (ОКС «Нет данных»). */
.lowerGrid > .card > .emptyState,
.bottomGrid > .card > .emptyState {
flex: 1;
}
/* Lower-grid квартирография bars match the prototype .bar-row (narrow label /
@ -2592,9 +2641,11 @@
display: grid;
grid-template-columns: 1.5fr 0.62fr 0.86fr 1.2fr;
gap: 10px;
/* Same grid-stretch defect as .lowerGrid sparse Legal Status / cards must
not stretch to the tallest sibling. Pin each card to the top. */
align-items: start;
/* Equal-height row (как .lowerGrid · «выровняй тут все»). Cards are flex
columns (rule above): footer actions pin to the bottom, sparse bodies
(.emptyState flex:1) centre, and Buy Signal re-centres its gauge so a
stretched card lays out intentionally instead of leaving an awkward gap. */
align-items: stretch;
}
/* Investment Clearance — bignum row */
@ -2639,10 +2690,12 @@
color: var(--text-soft);
}
/* Buy Signal — centered gauge */
.buySignalCard {
display: grid;
place-items: center;
/* Buy Signal centered gauge. Flex-column centering (not grid) so it composes
with the `.bottomGrid > .card` flex-column stretch rule; the gauge body sits
centred in the equal-height row. */
.bottomGrid > .card.buySignalCard {
align-items: center;
justify-content: center;
}
.buySignalTitle {
align-self: flex-start;

View file

@ -19,6 +19,7 @@ import type {
ParcelBboxItem,
BboxCoords,
} from "@/lib/site-finder-api";
import styles from "./site-finder.module.css";
// EntryMap uses Leaflet — must load without SSR
const EntryMap = dynamic(
@ -31,13 +32,13 @@ const EntryMap = dynamic(
style={{
flex: 1,
minHeight: 480,
background: "var(--bg-card-alt)",
border: "1px dashed var(--border-strong)",
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: "var(--fg-tertiary)",
color: "#8ba6b3",
fontSize: 14,
}}
>
@ -129,9 +130,10 @@ export default function SiteFinderPage() {
return (
<main
className={styles.sfRoot}
data-theme="dark"
style={{
minHeight: "100vh",
background: "var(--bg-app)",
display: "flex",
flexDirection: "column",
}}
@ -139,8 +141,8 @@ export default function SiteFinderPage() {
{/* Header */}
<header
style={{
background: "var(--bg-card)",
borderBottom: "1px solid var(--border-card)",
background: "rgba(12, 30, 42, 0.94)",
borderBottom: "1px solid rgba(150, 192, 214, 0.18)",
padding: "12px 24px",
display: "flex",
alignItems: "center",
@ -152,19 +154,20 @@ export default function SiteFinderPage() {
href="/"
style={{
fontSize: 13,
color: "var(--fg-secondary)",
color: "#8ba6b3",
textDecoration: "none",
}}
>
Главная
</Link>
<span style={{ color: "var(--border-soft)" }}>·</span>
<span style={{ color: "rgba(150, 192, 214, 0.4)" }}>·</span>
<h1
style={{
margin: 0,
fontSize: 16,
fontWeight: 600,
color: "var(--fg-primary)",
color: "#f2fafe",
letterSpacing: "0.01em",
}}
>
SiteFinder · карта участков
@ -174,7 +177,7 @@ export default function SiteFinderPage() {
style={{
marginLeft: "auto",
fontSize: 13,
color: "var(--accent)",
color: "#7fd0ee",
textDecoration: "none",
fontWeight: 500,
}}

View file

@ -0,0 +1,132 @@
/*
* SiteFinder entry page ПТИЦА dark "operator terminal" skin (Track B).
*
* Every design token is scoped under `.sfRoot[data-theme="dark"]` (the page
* wrapper carries BOTH the class and the attribute) so these dark vars NEVER
* leak onto the global light theme of other pages (analytics etc.). This
* mirrors how the ПТИЦА cockpit scopes its dark tokens under
* `.pticaRoot[data-theme="dark"]`. The dark palette is the SAME cockpit palette
* (#060f16 / #08131c surfaces, #dcebf2 text, #7fd0ee accent-cyan, token borders).
*/
/* ===================== SCOPED TOKENS (dark) ===================== */
.sfRoot[data-theme="dark"] {
--sf-font-mono: "IBM Plex Mono", "Roboto Mono", ui-monospace, monospace;
/* accents */
--sf-accent-cyan: #7fd0ee;
--sf-accent-cyan-strong: #8ed3ff;
--sf-accent-green: #38c172;
--sf-accent-yellow: #e0a93b;
--sf-accent-red: #d2655b;
/* dark surface palette */
--sf-bg: #060f16;
--sf-bg-2: #08131c;
--sf-grid-line: rgba(120, 165, 190, 0.06);
--sf-surface: rgba(11, 26, 36, 0.72);
--sf-surface-2: rgba(9, 22, 31, 0.62);
--sf-surface-strong: rgba(12, 30, 42, 0.94);
--sf-surface-muted: rgba(26, 52, 67, 0.55);
--sf-surface-inset: rgba(4, 12, 18, 0.6);
--sf-text: #dcebf2;
--sf-text-strong: #f2fafe;
--sf-text-muted: #8ba6b3;
--sf-text-soft: #5f7886;
--sf-border: rgba(150, 192, 214, 0.18);
--sf-border-strong: rgba(150, 192, 214, 0.4);
--sf-border-faint: rgba(150, 192, 214, 0.1);
--sf-map-filter: saturate(0.7) contrast(1.12) brightness(0.7);
--sf-glow: rgba(110, 200, 240, 0.28);
--sf-glow-strong: rgba(110, 200, 240, 0.55);
--sf-shadow: 0 18px 70px rgba(0, 0, 0, 0.5);
/* root paint — subtle grid + corner glow, same recipe as the cockpit */
color: var(--sf-text);
font-variant-numeric: tabular-nums;
-webkit-font-smoothing: antialiased;
background:
linear-gradient(90deg, transparent 31px, var(--sf-grid-line) 32px),
linear-gradient(0deg, transparent 31px, var(--sf-grid-line) 32px),
radial-gradient(circle at 82% -8%, var(--sf-glow), transparent 42%),
var(--sf-bg);
background-size:
32px 32px,
32px 32px,
100% 100%,
100% 100%;
}
/* ===================== LEAFLET (dark map chrome) ===================== */
.sfRoot :global(.leaflet-container) {
background: var(--sf-bg-2);
font-family: inherit;
}
/* dark base tiles → muted / contrast-bumped so markers stay readable */
.sfRoot :global(.leaflet-tile-pane) {
filter: var(--sf-map-filter);
}
/* glassy dark popups (match the cockpit chrome) */
.sfRoot :global(.leaflet-popup-content-wrapper) {
background: var(--sf-surface-strong);
color: var(--sf-text);
border: 1px solid var(--sf-border);
border-radius: 8px;
box-shadow: var(--sf-shadow);
}
.sfRoot :global(.leaflet-popup-tip) {
background: var(--sf-surface-strong);
}
.sfRoot :global(.leaflet-popup-content) {
margin: 10px 12px;
}
.sfRoot :global(.leaflet-container a.leaflet-popup-close-button) {
color: var(--sf-text-muted);
}
/* dark tooltips */
.sfRoot :global(.leaflet-tooltip) {
background: var(--sf-surface-strong);
color: var(--sf-text);
border: 1px solid var(--sf-border);
box-shadow: var(--sf-shadow);
}
.sfRoot :global(.leaflet-tooltip-top::before) {
border-top-color: var(--sf-border);
}
.sfRoot :global(.leaflet-control-attribution) {
background: rgba(8, 19, 28, 0.55);
color: var(--sf-text-soft);
font-size: 9px;
padding: 1px 6px;
opacity: 0.65;
backdrop-filter: blur(4px);
}
.sfRoot :global(.leaflet-control-attribution a) {
color: var(--sf-text-muted);
}
/* dark-styled zoom +/- control (prototype map controls) */
.sfRoot :global(.leaflet-control-zoom) {
border: 1px solid var(--sf-border-strong);
border-radius: 4px;
overflow: hidden;
box-shadow: 0 0 12px var(--sf-glow);
}
.sfRoot :global(.leaflet-control-zoom a) {
background: var(--sf-surface-strong);
color: var(--sf-text);
border-bottom: 1px solid var(--sf-border);
width: 28px;
height: 28px;
line-height: 28px;
}
.sfRoot :global(.leaflet-control-zoom a:hover) {
background: var(--sf-surface-muted);
color: var(--sf-accent-cyan);
}
.sfRoot :global(.leaflet-control-zoom a.leaflet-disabled) {
background: var(--sf-surface-strong);
color: var(--sf-text-soft);
}

View file

@ -30,7 +30,15 @@ export function CadInput({ onSubmit, loading }: Props) {
}
return (
<form onSubmit={handleSubmit}>
<form
onSubmit={handleSubmit}
style={{
background: "rgba(11, 26, 36, 0.72)",
border: "1px solid rgba(150, 192, 214, 0.18)",
borderRadius: 12,
padding: "14px 16px",
}}
>
<label
htmlFor="cad-input"
style={{
@ -38,6 +46,7 @@ export function CadInput({ onSubmit, loading }: Props) {
fontWeight: 600,
marginBottom: 6,
fontSize: 14,
color: "#f2fafe",
}}
>
Кадастровый номер
@ -54,14 +63,17 @@ export function CadInput({ onSubmit, loading }: Props) {
placeholder="66:41:0204016:10"
style={{
flex: 1,
minWidth: 0,
padding: "8px 12px",
fontSize: 14,
background: "rgba(4, 12, 18, 0.6)",
color: "#dcebf2",
border: error
? "1px solid var(--danger, #B3261E)"
: "1px solid var(--border-strong, #D1D5DB)",
? "1px solid #d2655b"
: "1px solid rgba(150, 192, 214, 0.4)",
borderRadius: 8,
outline: "none",
fontFamily: "monospace",
fontFamily: "'IBM Plex Mono', 'Roboto Mono', ui-monospace, monospace",
}}
disabled={loading}
/>
@ -73,10 +85,12 @@ export function CadInput({ onSubmit, loading }: Props) {
fontSize: 14,
fontWeight: 600,
background: loading
? "var(--fg-tertiary, #73767E)"
: "var(--accent, #1D4ED8)",
color: "var(--fg-on-dark, #E2E8F0)",
border: "none",
? "rgba(26, 52, 67, 0.55)"
: "rgba(127, 208, 238, 0.16)",
color: loading ? "#5f7886" : "#8ed3ff",
border: loading
? "1px solid rgba(150, 192, 214, 0.18)"
: "1px solid rgba(127, 208, 238, 0.55)",
borderRadius: 8,
cursor: loading ? "default" : "pointer",
whiteSpace: "nowrap",
@ -90,7 +104,7 @@ export function CadInput({ onSubmit, loading }: Props) {
style={{
marginTop: 8,
fontSize: 12,
color: "var(--danger, #B3261E)",
color: "#d2655b",
}}
>
{error}
@ -100,7 +114,7 @@ export function CadInput({ onSubmit, loading }: Props) {
style={{
marginTop: 4,
fontSize: 11,
color: "var(--fg-tertiary, #73767E)",
color: "#5f7886",
}}
>
Примеры: <code>66:41:0204016:10</code> (участок) ·{" "}

View file

@ -7,6 +7,7 @@ import {
CircleMarker,
Tooltip,
Popup,
AttributionControl,
useMapEvents,
} from "react-leaflet";
import type { Map as LeafletMap } from "leaflet";
@ -84,7 +85,7 @@ function ParcelMarkers({
return (
<>
{parcels.map((parcel) => {
const color = STATUS_COLORS[parcel.status] ?? "#73767E";
const color = STATUS_COLORS[parcel.status] ?? "#8ba6b3";
const statusLabel = STATUS_LABELS[parcel.status] ?? parcel.status;
const isSelected = parcel.cad_num === selectedCad;
return (
@ -93,22 +94,26 @@ function ParcelMarkers({
center={[parcel.lat, parcel.lon]}
radius={isSelected ? 10 : 7}
pathOptions={{
color: isSelected ? "#0F172A" : color,
// Selected → bright cyan ring so it pops on the dark base; others
// get a soft dark halo for separation from the tiles.
color: isSelected ? "#8ed3ff" : "rgba(6, 15, 22, 0.85)",
fillColor: color,
fillOpacity: 0.85,
weight: isSelected ? 2.5 : 1.5,
fillOpacity: 0.9,
weight: isSelected ? 3 : 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)" }}>
<Tooltip direction="top" offset={[0, -8]} opacity={1}>
<div style={{ fontSize: 12, color: "#dcebf2" }}>
<div style={{ fontWeight: 600, color: "#f2fafe" }}>
{parcel.cad_num}
</div>
<div style={{ color: "#8ba6b3" }}>
{parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"} га · {statusLabel}
</div>
<div style={{ color: "var(--fg-tertiary)", marginTop: 2 }}>
<div style={{ color: "#5f7886", marginTop: 2 }}>
Нажмите, чтобы открыть анализ
</div>
</div>
@ -120,15 +125,13 @@ function ParcelMarkers({
<div
style={{
fontWeight: 600,
color: "var(--fg-primary)",
color: "#f2fafe",
fontVariantNumeric: "tabular-nums",
}}
>
{parcel.cad_num}
</div>
<div
style={{ color: "var(--fg-secondary)", marginBottom: 8 }}
>
<div style={{ color: "#8ba6b3", marginBottom: 8 }}>
{parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"} га · {statusLabel}
</div>
<button
@ -141,9 +144,9 @@ function ParcelMarkers({
justifyContent: "center",
gap: 6,
width: "100%",
background: "var(--accent)",
color: "#fff",
border: "none",
background: "rgba(127, 208, 238, 0.16)",
color: "#8ed3ff",
border: "1px solid rgba(127, 208, 238, 0.55)",
borderRadius: 6,
padding: "6px 12px",
fontSize: 13,
@ -162,7 +165,7 @@ function ParcelMarkers({
width: "100%",
background: "none",
border: "none",
color: "var(--fg-secondary)",
color: "#8ba6b3",
padding: "6px 12px 0",
fontSize: 12,
cursor: "pointer",
@ -226,13 +229,19 @@ export function EntryMap({
zoom={DEFAULT_ZOOM}
style={{ width: "100%", height: "100%", borderRadius: 12 }}
ref={mapRef}
// Drop Leaflet's built-in attribution control (it carries the "Leaflet"
// flag prefix). We add our own below with prefix={false} so only the data
// credit shows — no Leaflet branding.
attributionControl={false}
// 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"
attribution="© OpenStreetMap · © CARTO"
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
subdomains="abcd"
/>
<AttributionControl prefix={false} position="bottomright" />
<BboxListener onBboxChange={handleBboxChange} />
{parcels && parcels.length > 0 && (
<ParcelMarkers
@ -267,12 +276,13 @@ export function EntryMap({
left: "50%",
transform: "translateX(-50%)",
zIndex: 1000,
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
background: "rgba(12, 30, 42, 0.94)",
border: "1px solid rgba(150, 192, 214, 0.18)",
borderRadius: 20,
padding: "4px 14px",
fontSize: 12,
color: "var(--fg-secondary)",
color: "#8ba6b3",
boxShadow: "0 0 12px rgba(110, 200, 240, 0.28)",
}}
>
Загрузка участков...
@ -288,13 +298,14 @@ export function EntryMap({
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 1000,
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
background: "rgba(12, 30, 42, 0.94)",
border: "1px solid rgba(150, 192, 214, 0.18)",
borderRadius: 12,
padding: "16px 24px",
fontSize: 13,
color: "var(--fg-secondary)",
color: "#8ba6b3",
textAlign: "center",
boxShadow: "0 18px 70px rgba(0, 0, 0, 0.5)",
}}
>
Нет участков в текущем виде. Измените фильтры или область карты.

View file

@ -32,14 +32,16 @@ function Chip({ label, selected, onClick, disabled, title }: ChipProps) {
padding: "4px 12px",
borderRadius: 999,
border: selected
? "1px solid var(--accent)"
: "1px solid var(--border-card)",
background: selected ? "var(--accent-soft)" : "var(--bg-card)",
color: selected ? "var(--accent)" : "var(--fg-secondary)",
? "1px solid rgba(127, 208, 238, 0.55)"
: "1px solid rgba(150, 192, 214, 0.18)",
background: selected
? "rgba(127, 208, 238, 0.16)"
: "rgba(9, 22, 31, 0.62)",
color: selected ? "#8ed3ff" : "#8ba6b3",
fontSize: 12,
fontWeight: selected ? 500 : 400,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.45 : 1,
opacity: disabled ? 0.4 : 1,
whiteSpace: "nowrap",
transition: "background 0.12s, color 0.12s, border-color 0.12s",
}}
@ -130,8 +132,8 @@ export function MapFilterBar({
return (
<div
style={{
background: "var(--bg-card)",
borderBottom: "1px solid var(--border-card)",
background: "rgba(12, 30, 42, 0.94)",
borderBottom: "1px solid rgba(150, 192, 214, 0.18)",
padding: "8px 16px",
display: "flex",
flexWrap: "wrap",
@ -143,7 +145,7 @@ export function MapFilterBar({
<span
style={{
fontSize: 12,
color: "var(--fg-secondary)",
color: "#8ba6b3",
marginRight: 4,
flexShrink: 0,
fontVariantNumeric: "tabular-nums",
@ -175,7 +177,7 @@ export function MapFilterBar({
style={{
width: 1,
height: 20,
background: "var(--border-soft)",
background: "rgba(150, 192, 214, 0.18)",
flexShrink: 0,
}}
/>
@ -197,7 +199,7 @@ export function MapFilterBar({
style={{
width: 1,
height: 20,
background: "var(--border-soft)",
background: "rgba(150, 192, 214, 0.18)",
flexShrink: 0,
}}
/>
@ -223,7 +225,7 @@ export function MapFilterBar({
style={{
width: 1,
height: 20,
background: "var(--border-soft)",
background: "rgba(150, 192, 214, 0.18)",
flexShrink: 0,
}}
/>
@ -246,18 +248,18 @@ export function MapFilterBar({
borderRadius: 999,
border:
DISTRICT_VRI_FILTERS_AVAILABLE && filters.district
? "1px solid var(--accent)"
: "1px solid var(--border-card)",
? "1px solid rgba(127, 208, 238, 0.55)"
: "1px solid rgba(150, 192, 214, 0.18)",
background:
DISTRICT_VRI_FILTERS_AVAILABLE && filters.district
? "var(--accent-soft)"
: "var(--bg-card)",
? "rgba(127, 208, 238, 0.16)"
: "rgba(9, 22, 31, 0.62)",
color:
DISTRICT_VRI_FILTERS_AVAILABLE && filters.district
? "var(--accent)"
: "var(--fg-secondary)",
? "#8ed3ff"
: "#8ba6b3",
cursor: DISTRICT_VRI_FILTERS_AVAILABLE ? "pointer" : "not-allowed",
opacity: DISTRICT_VRI_FILTERS_AVAILABLE ? 1 : 0.45,
opacity: DISTRICT_VRI_FILTERS_AVAILABLE ? 1 : 0.4,
}}
>
<option value="">Все районы</option>
@ -274,7 +276,7 @@ export function MapFilterBar({
onClick={clearAll}
style={{
fontSize: 12,
color: "var(--danger)",
color: "#d2655b",
background: "none",
border: "none",
cursor: "pointer",

View file

@ -22,7 +22,7 @@ const VRI_LABELS: Record<string, string> = {
export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
if (!parcel) return null;
const statusColor = STATUS_COLORS[parcel.status] ?? "#73767E";
const statusColor = STATUS_COLORS[parcel.status] ?? "#8ba6b3";
const statusLabel = STATUS_LABELS[parcel.status] ?? parcel.status;
return (
@ -37,13 +37,13 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
bottom: 16,
width: 360,
zIndex: 20,
background: "var(--bg-card)",
borderLeft: "1px solid var(--border-card)",
border: "1px solid var(--border-card)",
background: "rgba(12, 30, 42, 0.94)",
border: "1px solid rgba(150, 192, 214, 0.18)",
borderRadius: 12,
display: "flex",
flexDirection: "column",
boxShadow: "-4px 0 24px rgba(0,0,0,0.10)",
boxShadow: "0 18px 70px rgba(0, 0, 0, 0.5)",
backdropFilter: "blur(6px)",
}}
role="dialog"
aria-label="Карточка участка"
@ -52,7 +52,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
<div
style={{
padding: "16px 20px 12px",
borderBottom: "1px solid var(--border-soft)",
borderBottom: "1px solid rgba(150, 192, 214, 0.1)",
display: "flex",
alignItems: "flex-start",
gap: 8,
@ -62,7 +62,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
size={16}
strokeWidth={1.5}
style={{
color: "var(--fg-secondary)",
color: "#8ba6b3",
marginTop: 2,
flexShrink: 0,
}}
@ -74,7 +74,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-tertiary)",
color: "#5f7886",
fontVariantNumeric: "tabular-nums",
}}
>
@ -83,7 +83,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
<div
style={{
fontSize: 13,
color: "var(--fg-secondary)",
color: "#8ba6b3",
marginTop: 2,
overflow: "hidden",
textOverflow: "ellipsis",
@ -101,7 +101,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
border: "none",
cursor: "pointer",
padding: 4,
color: "var(--fg-tertiary)",
color: "#8ba6b3",
flexShrink: 0,
}}
>
@ -121,8 +121,8 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
{/* Status */}
<div
style={{
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
background: "rgba(4, 12, 18, 0.6)",
border: "1px solid rgba(150, 192, 214, 0.18)",
borderRadius: 8,
padding: "10px 12px",
}}
@ -133,7 +133,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-secondary)",
color: "#8ba6b3",
marginBottom: 4,
}}
>
@ -159,7 +159,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--fg-primary)",
color: "#dcebf2",
}}
>
{statusLabel}
@ -170,8 +170,8 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
{/* Area */}
<div
style={{
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
background: "rgba(4, 12, 18, 0.6)",
border: "1px solid rgba(150, 192, 214, 0.18)",
borderRadius: 8,
padding: "10px 12px",
}}
@ -182,7 +182,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-secondary)",
color: "#8ba6b3",
marginBottom: 4,
}}
>
@ -192,7 +192,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
style={{
fontSize: 18,
fontWeight: 600,
color: "var(--fg-primary)",
color: "#dcebf2",
fontVariantNumeric: "tabular-nums",
}}
>
@ -201,7 +201,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
style={{
fontSize: 13,
fontWeight: 400,
color: "var(--fg-secondary)",
color: "#8ba6b3",
}}
>
га
@ -212,8 +212,8 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
{/* District */}
<div
style={{
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
background: "rgba(4, 12, 18, 0.6)",
border: "1px solid rgba(150, 192, 214, 0.18)",
borderRadius: 8,
padding: "10px 12px",
}}
@ -224,7 +224,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-secondary)",
color: "#8ba6b3",
marginBottom: 4,
}}
>
@ -234,7 +234,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--fg-primary)",
color: "#dcebf2",
}}
>
{parcel.district}
@ -244,8 +244,8 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
{/* VRI */}
<div
style={{
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
background: "rgba(4, 12, 18, 0.6)",
border: "1px solid rgba(150, 192, 214, 0.18)",
borderRadius: 8,
padding: "10px 12px",
}}
@ -256,7 +256,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-secondary)",
color: "#8ba6b3",
marginBottom: 4,
}}
>
@ -266,7 +266,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--fg-primary)",
color: "#dcebf2",
}}
>
{VRI_LABELS[parcel.vri] ?? parcel.vri}
@ -276,8 +276,8 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
{/* Coords */}
<div
style={{
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
background: "rgba(4, 12, 18, 0.6)",
border: "1px solid rgba(150, 192, 214, 0.18)",
borderRadius: 8,
padding: "10px 12px",
gridColumn: "1 / -1",
@ -289,7 +289,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-secondary)",
color: "#8ba6b3",
marginBottom: 4,
}}
>
@ -298,7 +298,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
<div
style={{
fontSize: 12,
color: "var(--fg-secondary)",
color: "#8ba6b3",
fontVariantNumeric: "tabular-nums",
}}
>
@ -314,7 +314,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
<div
style={{
padding: "16px 20px",
borderTop: "1px solid var(--border-soft)",
borderTop: "1px solid rgba(150, 192, 214, 0.1)",
display: "flex",
flexDirection: "column",
gap: 8,
@ -337,8 +337,9 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
alignItems: "center",
justifyContent: "center",
gap: 8,
background: "var(--accent)",
color: "#fff",
background: "rgba(127, 208, 238, 0.16)",
color: "#8ed3ff",
border: "1px solid rgba(127, 208, 238, 0.55)",
borderRadius: 8,
padding: "10px 20px",
fontSize: 14,
@ -359,7 +360,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
justifyContent: "center",
gap: 8,
background: "none",
color: "var(--fg-secondary)",
color: "#8ba6b3",
borderRadius: 8,
padding: "8px 20px",
fontSize: 13,

View file

@ -1,10 +1,12 @@
"use client";
// Status color constants — kept in sync with EntryMap marker colours
// Status color constants — kept in sync with EntryMap marker colours.
// ПТИЦА cockpit palette (dark map): green = свободный, amber = в работе,
// cyan = избранный — semantics preserved, tuned to read on the dark base.
export const STATUS_COLORS: Record<string, string> = {
free: "#0A7A3A",
in_progress: "#9A6700",
favorite: "#1D4ED8",
free: "#38c172",
in_progress: "#e0a93b",
favorite: "#7fd0ee",
};
export const STATUS_LABELS: Record<string, string> = {
@ -21,8 +23,8 @@ export function ParcelLegend() {
return (
<div
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
background: "rgba(11, 26, 36, 0.72)",
border: "1px solid rgba(150, 192, 214, 0.18)",
borderRadius: 8,
padding: "8px 12px",
display: "flex",
@ -37,7 +39,7 @@ export function ParcelLegend() {
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-secondary)",
color: "#8ba6b3",
}}
>
Статус участка
@ -57,7 +59,7 @@ export function ParcelLegend() {
display: "inline-block",
}}
/>
<span style={{ fontSize: 12, color: "var(--fg-primary)" }}>
<span style={{ fontSize: 12, color: "#dcebf2" }}>
{STATUS_LABELS[status]}
</span>
</div>

View file

@ -25,8 +25,8 @@ export function RecentParcels() {
return (
<div
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
background: "rgba(11, 26, 36, 0.72)",
border: "1px solid rgba(150, 192, 214, 0.18)",
borderRadius: 12,
padding: "12px 16px",
}}
@ -34,7 +34,7 @@ export function RecentParcels() {
<div
style={{
height: 14,
background: "var(--bg-card-alt)",
background: "rgba(26, 52, 67, 0.55)",
borderRadius: 4,
marginBottom: 12,
width: 120,
@ -45,7 +45,7 @@ export function RecentParcels() {
key={i}
style={{
height: 40,
background: "var(--bg-card-alt)",
background: "rgba(26, 52, 67, 0.55)",
borderRadius: 6,
marginBottom: 6,
}}
@ -61,8 +61,8 @@ export function RecentParcels() {
return (
<div
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
background: "rgba(11, 26, 36, 0.72)",
border: "1px solid rgba(150, 192, 214, 0.18)",
borderRadius: 12,
padding: "12px 16px",
}}
@ -78,7 +78,7 @@ export function RecentParcels() {
<Clock
size={14}
strokeWidth={1.5}
style={{ color: "var(--fg-secondary)" }}
style={{ color: "#8ba6b3" }}
/>
<span
style={{
@ -86,7 +86,7 @@ export function RecentParcels() {
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-secondary)",
color: "#8ba6b3",
}}
>
Недавние участки
@ -95,7 +95,7 @@ export function RecentParcels() {
<p
style={{
fontSize: 12,
color: "var(--fg-tertiary)",
color: "#5f7886",
margin: 0,
}}
>
@ -108,8 +108,8 @@ export function RecentParcels() {
return (
<div
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
background: "rgba(11, 26, 36, 0.72)",
border: "1px solid rgba(150, 192, 214, 0.18)",
borderRadius: 12,
overflow: "hidden",
}}
@ -118,7 +118,7 @@ export function RecentParcels() {
<div
style={{
padding: "10px 14px 8px",
borderBottom: "1px solid var(--border-soft)",
borderBottom: "1px solid rgba(150, 192, 214, 0.1)",
display: "flex",
alignItems: "center",
gap: 6,
@ -127,7 +127,7 @@ export function RecentParcels() {
<Clock
size={14}
strokeWidth={1.5}
style={{ color: "var(--fg-secondary)" }}
style={{ color: "#8ba6b3" }}
/>
<span
style={{
@ -135,7 +135,7 @@ export function RecentParcels() {
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-secondary)",
color: "#8ba6b3",
}}
>
Недавние участки
@ -156,13 +156,13 @@ export function RecentParcels() {
textDecoration: "none",
borderBottom:
idx < shown.length - 1
? "1px solid var(--border-soft)"
? "1px solid rgba(150, 192, 214, 0.1)"
: "none",
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background =
"var(--bg-card-alt)";
"rgba(26, 52, 67, 0.55)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
@ -172,7 +172,7 @@ export function RecentParcels() {
size={14}
strokeWidth={1.5}
style={{
color: "var(--fg-tertiary)",
color: "#5f7886",
marginTop: 2,
flexShrink: 0,
}}
@ -182,7 +182,7 @@ export function RecentParcels() {
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--fg-primary)",
color: "#dcebf2",
fontVariantNumeric: "tabular-nums",
overflow: "hidden",
textOverflow: "ellipsis",
@ -194,7 +194,7 @@ export function RecentParcels() {
<div
style={{
fontSize: 11,
color: "var(--fg-tertiary)",
color: "#5f7886",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",

View file

@ -10,13 +10,16 @@
* - REAL data layers, reusing the existing analysis-map components so logic is
* not duplicated:
* · parcel polygon (cyan, geom_geojson)
* · POI markers from score_breakdown bucketed ЖК / Школы·Детсады /
* Парки·Метро like the prototype legend (CircleMarker, colored)
* · competitors / pipeline (MarketLayers ЖК + будущие проекты)
* · POI markers from score_breakdown glyph divIcon pins (prototype
* pinIcon + POI_GLYPH) bucketed Школы·Детсады / Парки·Метро, one glyph
* per OSM category (school / kindergarten / park / metro / tram / bus)
* · competitors / pipeline (ЖК + будущие проекты) жилой-дом glyph pins
* in the market colors (viz-2 / viz-5)
* · ЗОУИТ охранные зоны (ZouitLayer)
* · connection points + colored polylines to the parcel (ConnectionPoints-
* Layer + per-category lines from the centroid)
* · connection points resource glyph pins (RES_GLYPH: Электро/Вода/
* Канализация/Тепло/Газ/) + dashed colored polylines from the centroid
* · custom POI (CustomPoiLayer + add/edit/delete via useCustomPois)
* · static compass (.mapCompass) bottom-right
* - The map-overlay legend rows TOGGLE each layer; the «Точки подключения
* ресурсов» list is driven by real connection-points data (nearest distance
* per resource category).
@ -31,9 +34,8 @@ import {
MapContainer,
TileLayer,
GeoJSON,
CircleMarker,
Marker,
Polyline,
Popup,
Tooltip,
AttributionControl,
ZoomControl,
@ -41,24 +43,24 @@ import {
useMap,
useMapEvents,
} from "react-leaflet";
import L from "leaflet";
import type { Geometry, Position } from "geojson";
import "leaflet/dist/leaflet.css";
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import { EKB_CENTER } from "@/components/site-finder/ptica/ptica-adapt";
import type { ParcelAnalysis } from "@/types/site-finder";
import type {
ParcelAnalysis,
ParcelAnalysisCompetitor,
PipelineObject,
} from "@/types/site-finder";
import type { ConnectionPointsResponse } from "@/types/nspd";
import type { CustomPoi } from "@/types/customPoi";
import { MARKET_COLORS } from "@/components/site-finder/MarketLayers";
import {
MarketLayers,
MARKET_COLORS,
} from "@/components/site-finder/MarketLayers";
import {
ConnectionPointsLayer,
CP_CATEGORY_STYLES,
CP_ALL_CATEGORIES,
classifyStructure,
groupStructuresByCategory,
type CpCategory,
} from "@/components/site-finder/ConnectionPointsLayer";
import {
@ -77,6 +79,64 @@ import {
} from "@/hooks/useCustomPois";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
// ── divIcon glyph pins (prototype app.js pinIcon / POI_GLYPH / RES_GLYPH) ───────
// Inline SVG glyphs reused from the prototype's schematic markers. The glyph
// stroke/fill is dark (#08131c) so it reads on the bright colored disc. The
// `.map-pin` / `.map-pin.poi-dot` CSS is already ported into ptica.module.css.
// POI glyphs — one per OSM category bucket present in score_breakdown.
const POI_GLYPH: Record<string, string> = {
// школа — академическая шапочка
school:
'<svg viewBox="0 0 24 24" fill="none"><path d="M3 9l9-4 9 4-9 4-9-4z" stroke="#08131c" stroke-width="2" stroke-linejoin="round"/><path d="M7 11v4c0 1.5 2.5 3 5 3s5-1.5 5-3v-4" stroke="#08131c" stroke-width="2"/></svg>',
// детский сад — домик с сердечком (мягче школьной шапочки)
kindergarten:
'<svg viewBox="0 0 24 24" fill="none"><path d="M5 21V10l7-5 7 5v11" stroke="#08131c" stroke-width="2" stroke-linejoin="round"/><path d="M12 18c-1.6-1.4-3-2.4-3-3.8a1.5 1.5 0 013-0.4 1.5 1.5 0 013 0.4c0 1.4-1.4 2.4-3 3.8z" fill="#08131c"/></svg>',
// парк — дерево
park: '<svg viewBox="0 0 24 24" fill="none"><path d="M12 3l5 8h-3l3 5H7l3-5H7l5-8zM12 16v5" stroke="#08131c" stroke-width="2" stroke-linejoin="round"/></svg>',
// метро — арочный вход в метро
metro_stop:
'<svg viewBox="0 0 24 24" fill="none"><path d="M4 18L12 6l8 12M4 18h16M9 18v-4l3-4 3 4v4" stroke="#08131c" stroke-width="2" stroke-linejoin="round"/></svg>',
// трамвай — вагон на рельсах
tram_stop:
'<svg viewBox="0 0 24 24" fill="none"><rect x="6" y="4" width="12" height="13" rx="2" stroke="#08131c" stroke-width="1.8"/><path d="M6 10h12M9 21l-1.5-3M15 21l1.5-3" stroke="#08131c" stroke-width="1.8" stroke-linecap="round"/></svg>',
// остановка автобуса — корпус автобуса
bus_stop:
'<svg viewBox="0 0 24 24" fill="none"><rect x="5" y="5" width="14" height="11" rx="2" stroke="#08131c" stroke-width="1.8"/><path d="M5 11h14M8 20l1-4M16 20l-1-4" stroke="#08131c" stroke-width="1.8" stroke-linecap="round"/></svg>',
};
// ЖК / pipeline (competitor + future project) — жилой дом glyph.
const ZHK_GLYPH =
'<svg viewBox="0 0 24 24" fill="none"><path d="M5 21V8l7-4 7 4v13M9 21v-5h6v5" stroke="#08131c" stroke-width="2" stroke-linejoin="round"/></svg>';
// Connection-point resource glyphs — keyed by CpCategory.
const RES_GLYPH: Record<CpCategory, string> = {
electricity:
'<svg viewBox="0 0 24 24" fill="none"><path d="M13 3l-7 10h5l-1 8 7-11h-5l1-7z" fill="#08131c"/></svg>',
water:
'<svg viewBox="0 0 24 24" fill="none"><path d="M12 4c4 5 6 8 6 11a6 6 0 11-12 0c0-3 2-6 6-11z" fill="#08131c"/></svg>',
sewage:
'<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="7" stroke="#08131c" stroke-width="2"/><path d="M9 12h6" stroke="#08131c" stroke-width="2"/></svg>',
heat: '<svg viewBox="0 0 24 24" fill="none"><path d="M12 3c2 3-1 4 0 7 1 2 4 3 4 6a6 6 0 11-9-5" stroke="#08131c" stroke-width="2" stroke-linecap="round"/></svg>',
gas: '<svg viewBox="0 0 24 24" fill="none"><path d="M9 3v4M15 3v4M6 7h12v9a6 6 0 11-12 0V7z" stroke="#08131c" stroke-width="1.8" stroke-linejoin="round"/></svg>',
telecom:
'<svg viewBox="0 0 24 24" fill="none"><path d="M12 18v-7M7 9a7 7 0 0110 0M9.5 11.5a3.5 3.5 0 015 0" stroke="#08131c" stroke-width="1.8" stroke-linecap="round"/><circle cx="12" cy="19" r="1.4" fill="#08131c"/></svg>',
other:
'<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="4" fill="#08131c"/></svg>',
};
// Build a Leaflet divIcon pin (colored disc + dark glyph). `poiDot` renders the
// smaller flat POI variant (prototype `.map-pin.poi-dot`).
function pinIcon(color: string, glyph: string, poiDot = false): L.DivIcon {
const size = poiDot ? 14 : 22;
return L.divIcon({
className: "",
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
html: `<div class="map-pin${poiDot ? " poi-dot" : ""}" style="background:${color};color:${color}">${glyph}</div>`,
});
}
// ── POI legend buckets (prototype: ЖК / Школы·Детсады / Парки·Метро) ───────────
type PoiBucket = "edu" | "greenmetro";
@ -84,13 +144,12 @@ type PoiBucket = "edu" | "greenmetro";
interface PoiBucketStyle {
color: string;
label: string;
radius: number;
}
// token-hex (map exception per ui-tokens.md) — match prototype poiColor()
const POI_BUCKET_STYLES: Record<PoiBucket, PoiBucketStyle> = {
edu: { color: "#3fa77d", label: "Школы · Дет.сады", radius: 6 }, // --res-poi
greenmetro: { color: "#6aa15f", label: "Парки · Метро", radius: 6 },
edu: { color: "#3fa77d", label: "Школы · Дет.сады" }, // --res-poi
greenmetro: { color: "#6aa15f", label: "Парки · Метро" },
};
const EDU_CATEGORIES = new Set(["school", "kindergarten"]);
@ -107,6 +166,11 @@ function poiBucket(category: string): PoiBucket | null {
return null;
}
// Fallback POI glyph (small filled dot) for any category without a dedicated
// glyph — keeps the pin readable instead of an empty disc.
const POI_GLYPH_FALLBACK =
'<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="4" fill="#08131c"/></svg>';
// ── Narrowing: unknown → GeoJSON Geometry ─────────────────────────────────────
const GEOMETRY_TYPES = new Set([
@ -332,11 +396,17 @@ export default function PticaMapInner({
setAddMode(false);
}
// ── POI markers from score_breakdown, bucketed ───────────────────────────────
// ── POI markers from score_breakdown, bucketed (category kept for glyph) ──────
const poiByBucket = useMemo(() => {
const buckets: Record<
PoiBucket,
{ lat: number; lon: number; name: string | null; distance_m: number }[]
{
lat: number;
lon: number;
name: string | null;
distance_m: number;
category: string;
}[]
> = {
edu: [],
greenmetro: [],
@ -351,6 +421,7 @@ export default function PticaMapInner({
lon: p.lon,
name: p.name,
distance_m: p.distance_m,
category: cat,
});
}
}
@ -358,23 +429,60 @@ export default function PticaMapInner({
}, [analysis.score_breakdown]);
// ── Market data (competitors = ЖК, pipeline = будущие проекты) ───────────────
const competitorList = analysis.competitors ?? [];
const pipelineList = analysis.pipeline_24mo?.top_objects ?? [];
const competitorCount = competitorList.filter(
(c) => typeof c.lat === "number" && typeof c.lon === "number",
).length;
const pipelineCount = pipelineList.filter(
(p) => typeof p.lat === "number" && typeof p.lon === "number",
).length;
// ── Connection points (grouped by resource category) ─────────────────────────
const cpGrouped = useMemo(
// Filtered to objects with usable coords (drop (0,0) Атлантика sentinels) so
// the glyph pins below render only real points; counts drive the legend rows.
const competitorList = useMemo<
(ParcelAnalysisCompetitor & { lat: number; lon: number })[]
>(
() =>
connectionPoints
? groupStructuresByCategory(connectionPoints.engineering_structures)
: new Map<CpCategory, never[]>(),
[connectionPoints],
(analysis.competitors ?? []).filter(
(c): c is ParcelAnalysisCompetitor & { lat: number; lon: number } =>
typeof c.lat === "number" &&
typeof c.lon === "number" &&
Number.isFinite(c.lat) &&
Number.isFinite(c.lon) &&
!(c.lat === 0 && c.lon === 0),
),
[analysis.competitors],
);
const pipelineList = useMemo<
(PipelineObject & { lat: number; lon: number })[]
>(
() =>
(analysis.pipeline_24mo?.top_objects ?? []).filter(
(p): p is PipelineObject & { lat: number; lon: number } =>
typeof p.lat === "number" &&
typeof p.lon === "number" &&
Number.isFinite(p.lat) &&
Number.isFinite(p.lon) &&
!(p.lat === 0 && p.lon === 0),
),
[analysis.pipeline_24mo],
);
const competitorCount = competitorList.length;
const pipelineCount = pipelineList.length;
// ── Connection points → glyph-pin markers (prototype pinIcon + RES_GLYPH) ─────
const cpMarkers = useMemo(() => {
if (!connectionPoints) return [];
const out: {
latLon: [number, number];
category: CpCategory;
name: string | null;
distance_m: number;
}[] = [];
for (const s of connectionPoints.engineering_structures) {
const latLon = structureLatLon(s.geometry_geojson);
if (!latLon) continue;
out.push({
latLon,
category: classifyStructure(s),
name: s.name ?? s.type ?? null,
distance_m: s.distance_to_boundary_m,
});
}
return out;
}, [connectionPoints]);
const cpCount = connectionPoints?.engineering_structures.length ?? 0;
// Nearest distance per category — drives the «Точки подключения» list.
@ -476,46 +584,63 @@ export default function PticaMapInner({
/>
)}
{/* Competitors (ЖК) + pipeline (будущие проекты). */}
{(visible.has("competitors") || visible.has("pipeline")) && (
<MarketLayers
competitors={competitorList}
pipelineObjects={pipelineList}
riskZones={[]}
opportunityParcels={[]}
redLines={[]}
showCompetitors={visible.has("competitors")}
showPipeline={visible.has("pipeline")}
showRiskZones={false}
showOpportunity={false}
showRedLines={false}
/>
)}
{/* Competitors (ЖК) — glyph pins (prototype жилой-дом glyph, viz-2). */}
{visible.has("competitors") &&
competitorList.map((c, idx) => (
<Marker
key={`comp-${c.obj_id}-${idx}`}
position={[c.lat, c.lon]}
icon={pinIcon(MARKET_COLORS.competitor, ZHK_GLYPH)}
>
<Tooltip direction="top">
{c.comm_name ?? "ЖК"}
{typeof c.distance_m === "number"
? ` · ${formatMeters(c.distance_m)}`
: ""}
</Tooltip>
</Marker>
))}
{/* POI markers (Школы·Детсады / Парки·Метро). */}
{/* Будущие проекты (pipeline) — glyph pins (жилой-дом glyph, viz-5). */}
{visible.has("pipeline") &&
pipelineList.map((p, idx) => (
<Marker
key={`pipe-${p.obj_id}-${idx}`}
position={[p.lat, p.lon]}
icon={pinIcon(MARKET_COLORS.pipeline, ZHK_GLYPH)}
>
<Tooltip direction="top">
{p.comm_name ?? "Будущий проект"}
{typeof p.distance_m === "number"
? ` · ${formatMeters(p.distance_m)}`
: ""}
</Tooltip>
</Marker>
))}
{/* POI markers (Школы·Детсады / Парки·Метро) — glyph divIcon pins. */}
{(["edu", "greenmetro"] as PoiBucket[]).flatMap((bucket) => {
if (!visible.has(bucket)) return [];
const style = POI_BUCKET_STYLES[bucket];
return poiByBucket[bucket].map((poi, idx) => (
<CircleMarker
<Marker
key={`${bucket}-${idx}`}
center={[poi.lat, poi.lon]}
radius={style.radius}
pathOptions={{
color: style.color,
fillColor: style.color,
fillOpacity: 0.85,
weight: 1.5,
}}
position={[poi.lat, poi.lon]}
icon={pinIcon(
style.color,
POI_GLYPH[poi.category] ?? POI_GLYPH_FALLBACK,
true,
)}
>
<Tooltip direction="top">
{poi.name ?? style.label} · {formatMeters(poi.distance_m)}
</Tooltip>
</CircleMarker>
</Marker>
));
})}
{/* Connection points + polylines to the parcel centroid. */}
{/* Connection points: dashed polylines from the centroid + glyph pins
per resource (prototype RES_GLYPH disc + conn-glow lines). */}
{visible.has("connections") && connectionPoints && (
<>
{connectionLines.map((line, idx) => (
@ -527,13 +652,26 @@ export default function PticaMapInner({
weight: 2.2,
opacity: 0.85,
dashArray: "4 6",
// global ported class (`.mapMount :global(.conn-glow)` adds a
// currentColor drop-shadow to the dashed connection lines).
className: "conn-glow",
}}
/>
))}
<ConnectionPointsLayer
grouped={cpGrouped}
visibleCategories={new Set(CP_ALL_CATEGORIES)}
/>
{cpMarkers.map((m, idx) => {
const cpStyle = CP_CATEGORY_STYLES[m.category];
return (
<Marker
key={`cp-${m.category}-${idx}`}
position={m.latLon}
icon={pinIcon(cpStyle.color, RES_GLYPH[m.category])}
>
<Tooltip direction="top">
{m.name ?? cpStyle.label} · {formatMeters(m.distance_m)}
</Tooltip>
</Marker>
);
})}
</>
)}
@ -735,6 +873,35 @@ export default function PticaMapInner({
)}
</div>
{/* Static compass (prototype .map-compass) bottom-right, above the
Leaflet panes; non-interactive so the map stays pannable beneath it. */}
<svg
className={styles.mapCompass}
viewBox="0 0 46 46"
fill="none"
aria-hidden="true"
>
<circle
cx="23"
cy="23"
r="20"
stroke="currentColor"
strokeWidth="1"
opacity="0.5"
/>
<path d="M23 7l4 16-4-3-4 3 4-16z" fill="var(--accent-cyan)" />
<text
x="23"
y="42"
textAnchor="middle"
fontSize="8"
fill="currentColor"
fontFamily="monospace"
>
N
</text>
</svg>
{addMode && (
<div className={styles.mapHint}>Кликните на карте для точки</div>
)}