feat(ptica): map glyph pins + compass, equal-height grids, full-height rail; dark Site Finder
ПТИЦА cockpit: - map: POI/competitors/connections now render as glyph divIcon pins (per-category glyph + color) with dashed connection lines + a compass — matches the prototype (was plain CircleMarker dots). Legend/zoom/scale/base-toggle/tools kept. - lower+bottom grids: align-items stretch → equal-height rows; cards are flex columns with footer «Подробнее» pinned to bottom and sparse empty-states centred (fixes «выровняй тут все»). - rail: dropped sticky+height:100vh so it stretches to full page height like the prototype (fixes «обрезан»). Main Site Finder (entry landing) — restyled to the ПТИЦА dark cockpit theme, scoped to .sfRoot[data-theme=dark] (no leak to light pages): dark CARTO tiles, Leaflet attribution flag removed (prefix=false, © credit kept), dark markers / filter chips / legend / recent list / drawer / cad input.
This commit is contained in:
parent
ff77987260
commit
b3fd053e3d
10 changed files with 591 additions and 206 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
132
frontend/src/app/site-finder/site-finder.module.css
Normal file
132
frontend/src/app/site-finder/site-finder.module.css
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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> (участок) ·{" "}
|
||||||
|
|
|
||||||
|
|
@ -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='© <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)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Нет участков в текущем виде. Измените фильтры или область карты.
|
Нет участков в текущем виде. Измените фильтры или область карты.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue