feat(ptica): map glyph pins + compass, equal-height grids, full-height rail; dark Site Finder
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

ПТИЦА 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.
This commit is contained in:
Light1YT 2026-06-21 03:41:13 +05:00
parent ff77987260
commit b3fd053e3d
10 changed files with 591 additions and 206 deletions

View file

@ -125,9 +125,11 @@
flex-direction: column; flex-direction: column;
padding: 16px 10px 12px; padding: 16px 10px 12px;
gap: 14px; gap: 14px;
position: sticky; /* Prototype .sidebar is a plain flex grid-child that stretches to the FULL
top: 0; page height (footer pinned via .navFoot margin-top:auto). The old
height: 100vh; `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 { .brand {
display: flex; display: flex;
@ -2451,6 +2453,19 @@
backdrop-filter: blur(6px); 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) ===================== /* ===================== LOWER GRID (cockpit) =====================
Ported from prototype .lower-grid: ОКС · Development Potential · Ported from prototype .lower-grid: ОКС · Development Potential ·
Recommended Product · Инсоляционная матрица. */ Recommended Product · Инсоляционная матрица. */
@ -2458,11 +2473,45 @@
display: grid; display: grid;
grid-template-columns: 1.05fr 1.05fr 1.15fr 0.95fr; grid-template-columns: 1.05fr 1.05fr 1.15fr 0.95fr;
gap: 10px; gap: 10px;
/* HEADLINE BUG FIX: default align-items:stretch made the sparse/empty ОКС /* Equal-height row (прот.: «выровняй тут все»). Each card is a flex column
card (isoBox 84px + emptyState flex:1 + button) stretch to the tallest (rule below) so a stretched card lays out intentionally: the footer action
sibling, and .emptyState's flex:1 then filled the void huge empty box. pins to the bottom (margin-top:auto) and sparse empty-states (.emptyState /
align-items:start lets each card size to its own content. */ .placeholder flex:1) centre their content instead of leaving an awkward gap. */
align-items: start; 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 / /* Lower-grid квартирография bars match the prototype .bar-row (narrow label /
@ -2592,9 +2641,11 @@
display: grid; display: grid;
grid-template-columns: 1.5fr 0.62fr 0.86fr 1.2fr; grid-template-columns: 1.5fr 0.62fr 0.86fr 1.2fr;
gap: 10px; gap: 10px;
/* Same grid-stretch defect as .lowerGrid sparse Legal Status / cards must /* Equal-height row (как .lowerGrid · «выровняй тут все»). Cards are flex
not stretch to the tallest sibling. Pin each card to the top. */ columns (rule above): footer actions pin to the bottom, sparse bodies
align-items: start; (.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 */ /* Investment Clearance — bignum row */
@ -2639,10 +2690,12 @@
color: var(--text-soft); color: var(--text-soft);
} }
/* Buy Signal — centered gauge */ /* Buy Signal centered gauge. Flex-column centering (not grid) so it composes
.buySignalCard { with the `.bottomGrid > .card` flex-column stretch rule; the gauge body sits
display: grid; centred in the equal-height row. */
place-items: center; .bottomGrid > .card.buySignalCard {
align-items: center;
justify-content: center;
} }
.buySignalTitle { .buySignalTitle {
align-self: flex-start; align-self: flex-start;

View file

@ -19,6 +19,7 @@ import type {
ParcelBboxItem, ParcelBboxItem,
BboxCoords, BboxCoords,
} from "@/lib/site-finder-api"; } from "@/lib/site-finder-api";
import styles from "./site-finder.module.css";
// EntryMap uses Leaflet — must load without SSR // EntryMap uses Leaflet — must load without SSR
const EntryMap = dynamic( const EntryMap = dynamic(
@ -31,13 +32,13 @@ const EntryMap = dynamic(
style={{ style={{
flex: 1, flex: 1,
minHeight: 480, minHeight: 480,
background: "var(--bg-card-alt)", background: "rgba(9, 22, 31, 0.62)",
border: "1px dashed var(--border-strong)", border: "1px dashed rgba(150, 192, 214, 0.4)",
borderRadius: 12, borderRadius: 12,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
color: "var(--fg-tertiary)", color: "#8ba6b3",
fontSize: 14, fontSize: 14,
}} }}
> >
@ -129,9 +130,10 @@ export default function SiteFinderPage() {
return ( return (
<main <main
className={styles.sfRoot}
data-theme="dark"
style={{ style={{
minHeight: "100vh", minHeight: "100vh",
background: "var(--bg-app)",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}} }}
@ -139,8 +141,8 @@ export default function SiteFinderPage() {
{/* Header */} {/* Header */}
<header <header
style={{ style={{
background: "var(--bg-card)", background: "rgba(12, 30, 42, 0.94)",
borderBottom: "1px solid var(--border-card)", borderBottom: "1px solid rgba(150, 192, 214, 0.18)",
padding: "12px 24px", padding: "12px 24px",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@ -152,19 +154,20 @@ export default function SiteFinderPage() {
href="/" href="/"
style={{ style={{
fontSize: 13, fontSize: 13,
color: "var(--fg-secondary)", color: "#8ba6b3",
textDecoration: "none", textDecoration: "none",
}} }}
> >
Главная Главная
</Link> </Link>
<span style={{ color: "var(--border-soft)" }}>·</span> <span style={{ color: "rgba(150, 192, 214, 0.4)" }}>·</span>
<h1 <h1
style={{ style={{
margin: 0, margin: 0,
fontSize: 16, fontSize: 16,
fontWeight: 600, fontWeight: 600,
color: "var(--fg-primary)", color: "#f2fafe",
letterSpacing: "0.01em",
}} }}
> >
SiteFinder · карта участков SiteFinder · карта участков
@ -174,7 +177,7 @@ export default function SiteFinderPage() {
style={{ style={{
marginLeft: "auto", marginLeft: "auto",
fontSize: 13, fontSize: 13,
color: "var(--accent)", color: "#7fd0ee",
textDecoration: "none", textDecoration: "none",
fontWeight: 500, 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 ( 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 <label
htmlFor="cad-input" htmlFor="cad-input"
style={{ style={{
@ -38,6 +46,7 @@ export function CadInput({ onSubmit, loading }: Props) {
fontWeight: 600, fontWeight: 600,
marginBottom: 6, marginBottom: 6,
fontSize: 14, fontSize: 14,
color: "#f2fafe",
}} }}
> >
Кадастровый номер Кадастровый номер
@ -54,14 +63,17 @@ export function CadInput({ onSubmit, loading }: Props) {
placeholder="66:41:0204016:10" placeholder="66:41:0204016:10"
style={{ style={{
flex: 1, flex: 1,
minWidth: 0,
padding: "8px 12px", padding: "8px 12px",
fontSize: 14, fontSize: 14,
background: "rgba(4, 12, 18, 0.6)",
color: "#dcebf2",
border: error border: error
? "1px solid var(--danger, #B3261E)" ? "1px solid #d2655b"
: "1px solid var(--border-strong, #D1D5DB)", : "1px solid rgba(150, 192, 214, 0.4)",
borderRadius: 8, borderRadius: 8,
outline: "none", outline: "none",
fontFamily: "monospace", fontFamily: "'IBM Plex Mono', 'Roboto Mono', ui-monospace, monospace",
}} }}
disabled={loading} disabled={loading}
/> />
@ -73,10 +85,12 @@ export function CadInput({ onSubmit, loading }: Props) {
fontSize: 14, fontSize: 14,
fontWeight: 600, fontWeight: 600,
background: loading background: loading
? "var(--fg-tertiary, #73767E)" ? "rgba(26, 52, 67, 0.55)"
: "var(--accent, #1D4ED8)", : "rgba(127, 208, 238, 0.16)",
color: "var(--fg-on-dark, #E2E8F0)", color: loading ? "#5f7886" : "#8ed3ff",
border: "none", border: loading
? "1px solid rgba(150, 192, 214, 0.18)"
: "1px solid rgba(127, 208, 238, 0.55)",
borderRadius: 8, borderRadius: 8,
cursor: loading ? "default" : "pointer", cursor: loading ? "default" : "pointer",
whiteSpace: "nowrap", whiteSpace: "nowrap",
@ -90,7 +104,7 @@ export function CadInput({ onSubmit, loading }: Props) {
style={{ style={{
marginTop: 8, marginTop: 8,
fontSize: 12, fontSize: 12,
color: "var(--danger, #B3261E)", color: "#d2655b",
}} }}
> >
{error} {error}
@ -100,7 +114,7 @@ export function CadInput({ onSubmit, loading }: Props) {
style={{ style={{
marginTop: 4, marginTop: 4,
fontSize: 11, fontSize: 11,
color: "var(--fg-tertiary, #73767E)", color: "#5f7886",
}} }}
> >
Примеры: <code>66:41:0204016:10</code> (участок) ·{" "} Примеры: <code>66:41:0204016:10</code> (участок) ·{" "}

View file

@ -7,6 +7,7 @@ import {
CircleMarker, CircleMarker,
Tooltip, Tooltip,
Popup, Popup,
AttributionControl,
useMapEvents, useMapEvents,
} from "react-leaflet"; } from "react-leaflet";
import type { Map as LeafletMap } from "leaflet"; import type { Map as LeafletMap } from "leaflet";
@ -84,7 +85,7 @@ function ParcelMarkers({
return ( return (
<> <>
{parcels.map((parcel) => { {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 statusLabel = STATUS_LABELS[parcel.status] ?? parcel.status;
const isSelected = parcel.cad_num === selectedCad; const isSelected = parcel.cad_num === selectedCad;
return ( return (
@ -93,22 +94,26 @@ function ParcelMarkers({
center={[parcel.lat, parcel.lon]} center={[parcel.lat, parcel.lon]}
radius={isSelected ? 10 : 7} radius={isSelected ? 10 : 7}
pathOptions={{ 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, fillColor: color,
fillOpacity: 0.85, fillOpacity: 0.9,
weight: isSelected ? 2.5 : 1.5, weight: isSelected ? 3 : 1.5,
}} }}
// Leaflet renders interactive vector markers with a pointer cursor // Leaflet renders interactive vector markers with a pointer cursor
// and keeps them keyboard-focusable; click opens the Popup below. // and keeps them keyboard-focusable; click opens the Popup below.
> >
{/* Hover hint — quick identity without committing to the analyze POST */} {/* Hover hint — quick identity without committing to the analyze POST */}
<Tooltip direction="top" offset={[0, -8]} opacity={0.95}> <Tooltip direction="top" offset={[0, -8]} opacity={1}>
<div style={{ fontSize: 12 }}> <div style={{ fontSize: 12, color: "#dcebf2" }}>
<div style={{ fontWeight: 600 }}>{parcel.cad_num}</div> <div style={{ fontWeight: 600, color: "#f2fafe" }}>
<div style={{ color: "var(--fg-secondary)" }}> {parcel.cad_num}
</div>
<div style={{ color: "#8ba6b3" }}>
{parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"} га · {statusLabel} {parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"} га · {statusLabel}
</div> </div>
<div style={{ color: "var(--fg-tertiary)", marginTop: 2 }}> <div style={{ color: "#5f7886", marginTop: 2 }}>
Нажмите, чтобы открыть анализ Нажмите, чтобы открыть анализ
</div> </div>
</div> </div>
@ -120,15 +125,13 @@ function ParcelMarkers({
<div <div
style={{ style={{
fontWeight: 600, fontWeight: 600,
color: "var(--fg-primary)", color: "#f2fafe",
fontVariantNumeric: "tabular-nums", fontVariantNumeric: "tabular-nums",
}} }}
> >
{parcel.cad_num} {parcel.cad_num}
</div> </div>
<div <div style={{ color: "#8ba6b3", marginBottom: 8 }}>
style={{ color: "var(--fg-secondary)", marginBottom: 8 }}
>
{parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"} га · {statusLabel} {parcel.area_ha != null ? parcel.area_ha.toFixed(2) : "—"} га · {statusLabel}
</div> </div>
<button <button
@ -141,9 +144,9 @@ function ParcelMarkers({
justifyContent: "center", justifyContent: "center",
gap: 6, gap: 6,
width: "100%", width: "100%",
background: "var(--accent)", background: "rgba(127, 208, 238, 0.16)",
color: "#fff", color: "#8ed3ff",
border: "none", border: "1px solid rgba(127, 208, 238, 0.55)",
borderRadius: 6, borderRadius: 6,
padding: "6px 12px", padding: "6px 12px",
fontSize: 13, fontSize: 13,
@ -162,7 +165,7 @@ function ParcelMarkers({
width: "100%", width: "100%",
background: "none", background: "none",
border: "none", border: "none",
color: "var(--fg-secondary)", color: "#8ba6b3",
padding: "6px 12px 0", padding: "6px 12px 0",
fontSize: 12, fontSize: 12,
cursor: "pointer", cursor: "pointer",
@ -226,13 +229,19 @@ export function EntryMap({
zoom={DEFAULT_ZOOM} zoom={DEFAULT_ZOOM}
style={{ width: "100%", height: "100%", borderRadius: 12 }} style={{ width: "100%", height: "100%", borderRadius: 12 }}
ref={mapRef} 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 // Click on map backdrop deselects parcel
// handled via eventHandlers on markers + backdrop div // handled via eventHandlers on markers + backdrop div
> >
<TileLayer <TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' attribution="© OpenStreetMap · © CARTO"
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
subdomains="abcd"
/> />
<AttributionControl prefix={false} position="bottomright" />
<BboxListener onBboxChange={handleBboxChange} /> <BboxListener onBboxChange={handleBboxChange} />
{parcels && parcels.length > 0 && ( {parcels && parcels.length > 0 && (
<ParcelMarkers <ParcelMarkers
@ -267,12 +276,13 @@ export function EntryMap({
left: "50%", left: "50%",
transform: "translateX(-50%)", transform: "translateX(-50%)",
zIndex: 1000, zIndex: 1000,
background: "var(--bg-card)", background: "rgba(12, 30, 42, 0.94)",
border: "1px solid var(--border-card)", border: "1px solid rgba(150, 192, 214, 0.18)",
borderRadius: 20, borderRadius: 20,
padding: "4px 14px", padding: "4px 14px",
fontSize: 12, 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%", left: "50%",
transform: "translate(-50%, -50%)", transform: "translate(-50%, -50%)",
zIndex: 1000, zIndex: 1000,
background: "var(--bg-card)", background: "rgba(12, 30, 42, 0.94)",
border: "1px solid var(--border-card)", border: "1px solid rgba(150, 192, 214, 0.18)",
borderRadius: 12, borderRadius: 12,
padding: "16px 24px", padding: "16px 24px",
fontSize: 13, fontSize: 13,
color: "var(--fg-secondary)", color: "#8ba6b3",
textAlign: "center", 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", padding: "4px 12px",
borderRadius: 999, borderRadius: 999,
border: selected border: selected
? "1px solid var(--accent)" ? "1px solid rgba(127, 208, 238, 0.55)"
: "1px solid var(--border-card)", : "1px solid rgba(150, 192, 214, 0.18)",
background: selected ? "var(--accent-soft)" : "var(--bg-card)", background: selected
color: selected ? "var(--accent)" : "var(--fg-secondary)", ? "rgba(127, 208, 238, 0.16)"
: "rgba(9, 22, 31, 0.62)",
color: selected ? "#8ed3ff" : "#8ba6b3",
fontSize: 12, fontSize: 12,
fontWeight: selected ? 500 : 400, fontWeight: selected ? 500 : 400,
cursor: disabled ? "not-allowed" : "pointer", cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.45 : 1, opacity: disabled ? 0.4 : 1,
whiteSpace: "nowrap", whiteSpace: "nowrap",
transition: "background 0.12s, color 0.12s, border-color 0.12s", transition: "background 0.12s, color 0.12s, border-color 0.12s",
}} }}
@ -130,8 +132,8 @@ export function MapFilterBar({
return ( return (
<div <div
style={{ style={{
background: "var(--bg-card)", background: "rgba(12, 30, 42, 0.94)",
borderBottom: "1px solid var(--border-card)", borderBottom: "1px solid rgba(150, 192, 214, 0.18)",
padding: "8px 16px", padding: "8px 16px",
display: "flex", display: "flex",
flexWrap: "wrap", flexWrap: "wrap",
@ -143,7 +145,7 @@ export function MapFilterBar({
<span <span
style={{ style={{
fontSize: 12, fontSize: 12,
color: "var(--fg-secondary)", color: "#8ba6b3",
marginRight: 4, marginRight: 4,
flexShrink: 0, flexShrink: 0,
fontVariantNumeric: "tabular-nums", fontVariantNumeric: "tabular-nums",
@ -175,7 +177,7 @@ export function MapFilterBar({
style={{ style={{
width: 1, width: 1,
height: 20, height: 20,
background: "var(--border-soft)", background: "rgba(150, 192, 214, 0.18)",
flexShrink: 0, flexShrink: 0,
}} }}
/> />
@ -197,7 +199,7 @@ export function MapFilterBar({
style={{ style={{
width: 1, width: 1,
height: 20, height: 20,
background: "var(--border-soft)", background: "rgba(150, 192, 214, 0.18)",
flexShrink: 0, flexShrink: 0,
}} }}
/> />
@ -223,7 +225,7 @@ export function MapFilterBar({
style={{ style={{
width: 1, width: 1,
height: 20, height: 20,
background: "var(--border-soft)", background: "rgba(150, 192, 214, 0.18)",
flexShrink: 0, flexShrink: 0,
}} }}
/> />
@ -246,18 +248,18 @@ export function MapFilterBar({
borderRadius: 999, borderRadius: 999,
border: border:
DISTRICT_VRI_FILTERS_AVAILABLE && filters.district DISTRICT_VRI_FILTERS_AVAILABLE && filters.district
? "1px solid var(--accent)" ? "1px solid rgba(127, 208, 238, 0.55)"
: "1px solid var(--border-card)", : "1px solid rgba(150, 192, 214, 0.18)",
background: background:
DISTRICT_VRI_FILTERS_AVAILABLE && filters.district DISTRICT_VRI_FILTERS_AVAILABLE && filters.district
? "var(--accent-soft)" ? "rgba(127, 208, 238, 0.16)"
: "var(--bg-card)", : "rgba(9, 22, 31, 0.62)",
color: color:
DISTRICT_VRI_FILTERS_AVAILABLE && filters.district DISTRICT_VRI_FILTERS_AVAILABLE && filters.district
? "var(--accent)" ? "#8ed3ff"
: "var(--fg-secondary)", : "#8ba6b3",
cursor: DISTRICT_VRI_FILTERS_AVAILABLE ? "pointer" : "not-allowed", 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> <option value="">Все районы</option>
@ -274,7 +276,7 @@ export function MapFilterBar({
onClick={clearAll} onClick={clearAll}
style={{ style={{
fontSize: 12, fontSize: 12,
color: "var(--danger)", color: "#d2655b",
background: "none", background: "none",
border: "none", border: "none",
cursor: "pointer", cursor: "pointer",

View file

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

View file

@ -1,10 +1,12 @@
"use client"; "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> = { export const STATUS_COLORS: Record<string, string> = {
free: "#0A7A3A", free: "#38c172",
in_progress: "#9A6700", in_progress: "#e0a93b",
favorite: "#1D4ED8", favorite: "#7fd0ee",
}; };
export const STATUS_LABELS: Record<string, string> = { export const STATUS_LABELS: Record<string, string> = {
@ -21,8 +23,8 @@ export function ParcelLegend() {
return ( return (
<div <div
style={{ style={{
background: "var(--bg-card)", background: "rgba(11, 26, 36, 0.72)",
border: "1px solid var(--border-card)", border: "1px solid rgba(150, 192, 214, 0.18)",
borderRadius: 8, borderRadius: 8,
padding: "8px 12px", padding: "8px 12px",
display: "flex", display: "flex",
@ -37,7 +39,7 @@ export function ParcelLegend() {
fontWeight: 500, fontWeight: 500,
textTransform: "uppercase", textTransform: "uppercase",
letterSpacing: "0.04em", letterSpacing: "0.04em",
color: "var(--fg-secondary)", color: "#8ba6b3",
}} }}
> >
Статус участка Статус участка
@ -57,7 +59,7 @@ export function ParcelLegend() {
display: "inline-block", display: "inline-block",
}} }}
/> />
<span style={{ fontSize: 12, color: "var(--fg-primary)" }}> <span style={{ fontSize: 12, color: "#dcebf2" }}>
{STATUS_LABELS[status]} {STATUS_LABELS[status]}
</span> </span>
</div> </div>

View file

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

View file

@ -10,13 +10,16 @@
* - REAL data layers, reusing the existing analysis-map components so logic is * - REAL data layers, reusing the existing analysis-map components so logic is
* not duplicated: * not duplicated:
* · parcel polygon (cyan, geom_geojson) * · parcel polygon (cyan, geom_geojson)
* · POI markers from score_breakdown bucketed ЖК / Школы·Детсады / * · POI markers from score_breakdown glyph divIcon pins (prototype
* Парки·Метро like the prototype legend (CircleMarker, colored) * pinIcon + POI_GLYPH) bucketed Школы·Детсады / Парки·Метро, one glyph
* · competitors / pipeline (MarketLayers ЖК + будущие проекты) * per OSM category (school / kindergarten / park / metro / tram / bus)
* · competitors / pipeline (ЖК + будущие проекты) жилой-дом glyph pins
* in the market colors (viz-2 / viz-5)
* · ЗОУИТ охранные зоны (ZouitLayer) * · ЗОУИТ охранные зоны (ZouitLayer)
* · connection points + colored polylines to the parcel (ConnectionPoints- * · connection points resource glyph pins (RES_GLYPH: Электро/Вода/
* Layer + per-category lines from the centroid) * Канализация/Тепло/Газ/) + dashed colored polylines from the centroid
* · custom POI (CustomPoiLayer + add/edit/delete via useCustomPois) * · custom POI (CustomPoiLayer + add/edit/delete via useCustomPois)
* · static compass (.mapCompass) bottom-right
* - The map-overlay legend rows TOGGLE each layer; the «Точки подключения * - The map-overlay legend rows TOGGLE each layer; the «Точки подключения
* ресурсов» list is driven by real connection-points data (nearest distance * ресурсов» list is driven by real connection-points data (nearest distance
* per resource category). * per resource category).
@ -31,9 +34,8 @@ import {
MapContainer, MapContainer,
TileLayer, TileLayer,
GeoJSON, GeoJSON,
CircleMarker, Marker,
Polyline, Polyline,
Popup,
Tooltip, Tooltip,
AttributionControl, AttributionControl,
ZoomControl, ZoomControl,
@ -41,24 +43,24 @@ import {
useMap, useMap,
useMapEvents, useMapEvents,
} from "react-leaflet"; } from "react-leaflet";
import L from "leaflet";
import type { Geometry, Position } from "geojson"; import type { Geometry, Position } from "geojson";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import { EKB_CENTER } from "@/components/site-finder/ptica/ptica-adapt"; 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 { ConnectionPointsResponse } from "@/types/nspd";
import type { CustomPoi } from "@/types/customPoi"; import type { CustomPoi } from "@/types/customPoi";
import { MARKET_COLORS } from "@/components/site-finder/MarketLayers";
import { import {
MarketLayers,
MARKET_COLORS,
} from "@/components/site-finder/MarketLayers";
import {
ConnectionPointsLayer,
CP_CATEGORY_STYLES, CP_CATEGORY_STYLES,
CP_ALL_CATEGORIES, CP_ALL_CATEGORIES,
classifyStructure, classifyStructure,
groupStructuresByCategory,
type CpCategory, type CpCategory,
} from "@/components/site-finder/ConnectionPointsLayer"; } from "@/components/site-finder/ConnectionPointsLayer";
import { import {
@ -77,6 +79,64 @@ import {
} from "@/hooks/useCustomPois"; } from "@/hooks/useCustomPois";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys"; 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: ЖК / Школы·Детсады / Парки·Метро) ─────────── // ── POI legend buckets (prototype: ЖК / Школы·Детсады / Парки·Метро) ───────────
type PoiBucket = "edu" | "greenmetro"; type PoiBucket = "edu" | "greenmetro";
@ -84,13 +144,12 @@ type PoiBucket = "edu" | "greenmetro";
interface PoiBucketStyle { interface PoiBucketStyle {
color: string; color: string;
label: string; label: string;
radius: number;
} }
// token-hex (map exception per ui-tokens.md) — match prototype poiColor() // token-hex (map exception per ui-tokens.md) — match prototype poiColor()
const POI_BUCKET_STYLES: Record<PoiBucket, PoiBucketStyle> = { const POI_BUCKET_STYLES: Record<PoiBucket, PoiBucketStyle> = {
edu: { color: "#3fa77d", label: "Школы · Дет.сады", radius: 6 }, // --res-poi edu: { color: "#3fa77d", label: "Школы · Дет.сады" }, // --res-poi
greenmetro: { color: "#6aa15f", label: "Парки · Метро", radius: 6 }, greenmetro: { color: "#6aa15f", label: "Парки · Метро" },
}; };
const EDU_CATEGORIES = new Set(["school", "kindergarten"]); const EDU_CATEGORIES = new Set(["school", "kindergarten"]);
@ -107,6 +166,11 @@ function poiBucket(category: string): PoiBucket | null {
return 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 ───────────────────────────────────── // ── Narrowing: unknown → GeoJSON Geometry ─────────────────────────────────────
const GEOMETRY_TYPES = new Set([ const GEOMETRY_TYPES = new Set([
@ -332,11 +396,17 @@ export default function PticaMapInner({
setAddMode(false); setAddMode(false);
} }
// ── POI markers from score_breakdown, bucketed ─────────────────────────────── // ── POI markers from score_breakdown, bucketed (category kept for glyph) ──────
const poiByBucket = useMemo(() => { const poiByBucket = useMemo(() => {
const buckets: Record< const buckets: Record<
PoiBucket, PoiBucket,
{ lat: number; lon: number; name: string | null; distance_m: number }[] {
lat: number;
lon: number;
name: string | null;
distance_m: number;
category: string;
}[]
> = { > = {
edu: [], edu: [],
greenmetro: [], greenmetro: [],
@ -351,6 +421,7 @@ export default function PticaMapInner({
lon: p.lon, lon: p.lon,
name: p.name, name: p.name,
distance_m: p.distance_m, distance_m: p.distance_m,
category: cat,
}); });
} }
} }
@ -358,23 +429,60 @@ export default function PticaMapInner({
}, [analysis.score_breakdown]); }, [analysis.score_breakdown]);
// ── Market data (competitors = ЖК, pipeline = будущие проекты) ─────────────── // ── Market data (competitors = ЖК, pipeline = будущие проекты) ───────────────
const competitorList = analysis.competitors ?? []; // Filtered to objects with usable coords (drop (0,0) Атлантика sentinels) so
const pipelineList = analysis.pipeline_24mo?.top_objects ?? []; // the glyph pins below render only real points; counts drive the legend rows.
const competitorCount = competitorList.filter( const competitorList = useMemo<
(c) => typeof c.lat === "number" && typeof c.lon === "number", (ParcelAnalysisCompetitor & { lat: number; 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(
() => () =>
connectionPoints (analysis.competitors ?? []).filter(
? groupStructuresByCategory(connectionPoints.engineering_structures) (c): c is ParcelAnalysisCompetitor & { lat: number; lon: number } =>
: new Map<CpCategory, never[]>(), typeof c.lat === "number" &&
[connectionPoints], 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; const cpCount = connectionPoints?.engineering_structures.length ?? 0;
// Nearest distance per category — drives the «Точки подключения» list. // Nearest distance per category — drives the «Точки подключения» list.
@ -476,46 +584,63 @@ export default function PticaMapInner({
/> />
)} )}
{/* Competitors (ЖК) + pipeline (будущие проекты). */} {/* Competitors (ЖК) — glyph pins (prototype жилой-дом glyph, viz-2). */}
{(visible.has("competitors") || visible.has("pipeline")) && ( {visible.has("competitors") &&
<MarketLayers competitorList.map((c, idx) => (
competitors={competitorList} <Marker
pipelineObjects={pipelineList} key={`comp-${c.obj_id}-${idx}`}
riskZones={[]} position={[c.lat, c.lon]}
opportunityParcels={[]} icon={pinIcon(MARKET_COLORS.competitor, ZHK_GLYPH)}
redLines={[]} >
showCompetitors={visible.has("competitors")} <Tooltip direction="top">
showPipeline={visible.has("pipeline")} {c.comm_name ?? "ЖК"}
showRiskZones={false} {typeof c.distance_m === "number"
showOpportunity={false} ? ` · ${formatMeters(c.distance_m)}`
showRedLines={false} : ""}
/> </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) => { {(["edu", "greenmetro"] as PoiBucket[]).flatMap((bucket) => {
if (!visible.has(bucket)) return []; if (!visible.has(bucket)) return [];
const style = POI_BUCKET_STYLES[bucket]; const style = POI_BUCKET_STYLES[bucket];
return poiByBucket[bucket].map((poi, idx) => ( return poiByBucket[bucket].map((poi, idx) => (
<CircleMarker <Marker
key={`${bucket}-${idx}`} key={`${bucket}-${idx}`}
center={[poi.lat, poi.lon]} position={[poi.lat, poi.lon]}
radius={style.radius} icon={pinIcon(
pathOptions={{ style.color,
color: style.color, POI_GLYPH[poi.category] ?? POI_GLYPH_FALLBACK,
fillColor: style.color, true,
fillOpacity: 0.85, )}
weight: 1.5,
}}
> >
<Tooltip direction="top"> <Tooltip direction="top">
{poi.name ?? style.label} · {formatMeters(poi.distance_m)} {poi.name ?? style.label} · {formatMeters(poi.distance_m)}
</Tooltip> </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 && ( {visible.has("connections") && connectionPoints && (
<> <>
{connectionLines.map((line, idx) => ( {connectionLines.map((line, idx) => (
@ -527,13 +652,26 @@ export default function PticaMapInner({
weight: 2.2, weight: 2.2,
opacity: 0.85, opacity: 0.85,
dashArray: "4 6", dashArray: "4 6",
// global ported class (`.mapMount :global(.conn-glow)` adds a
// currentColor drop-shadow to the dashed connection lines).
className: "conn-glow",
}} }}
/> />
))} ))}
<ConnectionPointsLayer {cpMarkers.map((m, idx) => {
grouped={cpGrouped} const cpStyle = CP_CATEGORY_STYLES[m.category];
visibleCategories={new Set(CP_ALL_CATEGORIES)} 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> </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 && ( {addMode && (
<div className={styles.mapHint}>Кликните на карте для точки</div> <div className={styles.mapHint}>Кликните на карте для точки</div>
)} )}