Compare commits

..

No commits in common. "701e8e9fe76773c763447028de3911056099b003" and "ff779872600aeea1f0b1babc840cbf49c0272036" have entirely different histories.

10 changed files with 207 additions and 592 deletions

View file

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

View file

@ -19,7 +19,6 @@ 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(
@ -32,13 +31,13 @@ const EntryMap = dynamic(
style={{ style={{
flex: 1, flex: 1,
minHeight: 480, minHeight: 480,
background: "rgba(9, 22, 31, 0.62)", background: "var(--bg-card-alt)",
border: "1px dashed rgba(150, 192, 214, 0.4)", border: "1px dashed var(--border-strong)",
borderRadius: 12, borderRadius: 12,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
color: "#8ba6b3", color: "var(--fg-tertiary)",
fontSize: 14, fontSize: 14,
}} }}
> >
@ -130,10 +129,9 @@ 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",
}} }}
@ -141,8 +139,8 @@ export default function SiteFinderPage() {
{/* Header */} {/* Header */}
<header <header
style={{ style={{
background: "rgba(12, 30, 42, 0.94)", background: "var(--bg-card)",
borderBottom: "1px solid rgba(150, 192, 214, 0.18)", borderBottom: "1px solid var(--border-card)",
padding: "12px 24px", padding: "12px 24px",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@ -154,20 +152,19 @@ export default function SiteFinderPage() {
href="/" href="/"
style={{ style={{
fontSize: 13, fontSize: 13,
color: "#8ba6b3", color: "var(--fg-secondary)",
textDecoration: "none", textDecoration: "none",
}} }}
> >
Главная Главная
</Link> </Link>
<span style={{ color: "rgba(150, 192, 214, 0.4)" }}>·</span> <span style={{ color: "var(--border-soft)" }}>·</span>
<h1 <h1
style={{ style={{
margin: 0, margin: 0,
fontSize: 16, fontSize: 16,
fontWeight: 600, fontWeight: 600,
color: "#f2fafe", color: "var(--fg-primary)",
letterSpacing: "0.01em",
}} }}
> >
SiteFinder · карта участков SiteFinder · карта участков
@ -177,7 +174,7 @@ export default function SiteFinderPage() {
style={{ style={{
marginLeft: "auto", marginLeft: "auto",
fontSize: 13, fontSize: 13,
color: "#7fd0ee", color: "var(--accent)",
textDecoration: "none", textDecoration: "none",
fontWeight: 500, fontWeight: 500,
}} }}

View file

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

View file

@ -7,7 +7,6 @@ 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";
@ -85,7 +84,7 @@ function ParcelMarkers({
return ( return (
<> <>
{parcels.map((parcel) => { {parcels.map((parcel) => {
const color = STATUS_COLORS[parcel.status] ?? "#8ba6b3"; const color = STATUS_COLORS[parcel.status] ?? "#73767E";
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 (
@ -94,26 +93,22 @@ function ParcelMarkers({
center={[parcel.lat, parcel.lon]} center={[parcel.lat, parcel.lon]}
radius={isSelected ? 10 : 7} radius={isSelected ? 10 : 7}
pathOptions={{ pathOptions={{
// Selected → bright cyan ring so it pops on the dark base; others color: isSelected ? "#0F172A" : color,
// 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.9, fillOpacity: 0.85,
weight: isSelected ? 3 : 1.5, weight: isSelected ? 2.5 : 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={1}> <Tooltip direction="top" offset={[0, -8]} opacity={0.95}>
<div style={{ fontSize: 12, color: "#dcebf2" }}> <div style={{ fontSize: 12 }}>
<div style={{ fontWeight: 600, color: "#f2fafe" }}> <div style={{ fontWeight: 600 }}>{parcel.cad_num}</div>
{parcel.cad_num} <div style={{ color: "var(--fg-secondary)" }}>
</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: "#5f7886", marginTop: 2 }}> <div style={{ color: "var(--fg-tertiary)", marginTop: 2 }}>
Нажмите, чтобы открыть анализ Нажмите, чтобы открыть анализ
</div> </div>
</div> </div>
@ -125,13 +120,15 @@ function ParcelMarkers({
<div <div
style={{ style={{
fontWeight: 600, fontWeight: 600,
color: "#f2fafe", color: "var(--fg-primary)",
fontVariantNumeric: "tabular-nums", fontVariantNumeric: "tabular-nums",
}} }}
> >
{parcel.cad_num} {parcel.cad_num}
</div> </div>
<div style={{ color: "#8ba6b3", marginBottom: 8 }}> <div
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
@ -144,9 +141,9 @@ function ParcelMarkers({
justifyContent: "center", justifyContent: "center",
gap: 6, gap: 6,
width: "100%", width: "100%",
background: "rgba(127, 208, 238, 0.16)", background: "var(--accent)",
color: "#8ed3ff", color: "#fff",
border: "1px solid rgba(127, 208, 238, 0.55)", border: "none",
borderRadius: 6, borderRadius: 6,
padding: "6px 12px", padding: "6px 12px",
fontSize: 13, fontSize: 13,
@ -165,7 +162,7 @@ function ParcelMarkers({
width: "100%", width: "100%",
background: "none", background: "none",
border: "none", border: "none",
color: "#8ba6b3", color: "var(--fg-secondary)",
padding: "6px 12px 0", padding: "6px 12px 0",
fontSize: 12, fontSize: 12,
cursor: "pointer", cursor: "pointer",
@ -229,19 +226,13 @@ 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="© OpenStreetMap · © CARTO" attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
subdomains="abcd"
/> />
<AttributionControl prefix={false} position="bottomright" />
<BboxListener onBboxChange={handleBboxChange} /> <BboxListener onBboxChange={handleBboxChange} />
{parcels && parcels.length > 0 && ( {parcels && parcels.length > 0 && (
<ParcelMarkers <ParcelMarkers
@ -276,13 +267,12 @@ export function EntryMap({
left: "50%", left: "50%",
transform: "translateX(-50%)", transform: "translateX(-50%)",
zIndex: 1000, zIndex: 1000,
background: "rgba(12, 30, 42, 0.94)", background: "var(--bg-card)",
border: "1px solid rgba(150, 192, 214, 0.18)", border: "1px solid var(--border-card)",
borderRadius: 20, borderRadius: 20,
padding: "4px 14px", padding: "4px 14px",
fontSize: 12, fontSize: 12,
color: "#8ba6b3", color: "var(--fg-secondary)",
boxShadow: "0 0 12px rgba(110, 200, 240, 0.28)",
}} }}
> >
Загрузка участков... Загрузка участков...
@ -298,14 +288,13 @@ export function EntryMap({
left: "50%", left: "50%",
transform: "translate(-50%, -50%)", transform: "translate(-50%, -50%)",
zIndex: 1000, zIndex: 1000,
background: "rgba(12, 30, 42, 0.94)", background: "var(--bg-card)",
border: "1px solid rgba(150, 192, 214, 0.18)", border: "1px solid var(--border-card)",
borderRadius: 12, borderRadius: 12,
padding: "16px 24px", padding: "16px 24px",
fontSize: 13, fontSize: 13,
color: "#8ba6b3", color: "var(--fg-secondary)",
textAlign: "center", textAlign: "center",
boxShadow: "0 18px 70px rgba(0, 0, 0, 0.5)",
}} }}
> >
Нет участков в текущем виде. Измените фильтры или область карты. Нет участков в текущем виде. Измените фильтры или область карты.

View file

@ -32,16 +32,14 @@ function Chip({ label, selected, onClick, disabled, title }: ChipProps) {
padding: "4px 12px", padding: "4px 12px",
borderRadius: 999, borderRadius: 999,
border: selected border: selected
? "1px solid rgba(127, 208, 238, 0.55)" ? "1px solid var(--accent)"
: "1px solid rgba(150, 192, 214, 0.18)", : "1px solid var(--border-card)",
background: selected background: selected ? "var(--accent-soft)" : "var(--bg-card)",
? "rgba(127, 208, 238, 0.16)" color: selected ? "var(--accent)" : "var(--fg-secondary)",
: "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.4 : 1, opacity: disabled ? 0.45 : 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",
}} }}
@ -132,8 +130,8 @@ export function MapFilterBar({
return ( return (
<div <div
style={{ style={{
background: "rgba(12, 30, 42, 0.94)", background: "var(--bg-card)",
borderBottom: "1px solid rgba(150, 192, 214, 0.18)", borderBottom: "1px solid var(--border-card)",
padding: "8px 16px", padding: "8px 16px",
display: "flex", display: "flex",
flexWrap: "wrap", flexWrap: "wrap",
@ -145,7 +143,7 @@ export function MapFilterBar({
<span <span
style={{ style={{
fontSize: 12, fontSize: 12,
color: "#8ba6b3", color: "var(--fg-secondary)",
marginRight: 4, marginRight: 4,
flexShrink: 0, flexShrink: 0,
fontVariantNumeric: "tabular-nums", fontVariantNumeric: "tabular-nums",
@ -177,7 +175,7 @@ export function MapFilterBar({
style={{ style={{
width: 1, width: 1,
height: 20, height: 20,
background: "rgba(150, 192, 214, 0.18)", background: "var(--border-soft)",
flexShrink: 0, flexShrink: 0,
}} }}
/> />
@ -199,7 +197,7 @@ export function MapFilterBar({
style={{ style={{
width: 1, width: 1,
height: 20, height: 20,
background: "rgba(150, 192, 214, 0.18)", background: "var(--border-soft)",
flexShrink: 0, flexShrink: 0,
}} }}
/> />
@ -225,7 +223,7 @@ export function MapFilterBar({
style={{ style={{
width: 1, width: 1,
height: 20, height: 20,
background: "rgba(150, 192, 214, 0.18)", background: "var(--border-soft)",
flexShrink: 0, flexShrink: 0,
}} }}
/> />
@ -248,18 +246,18 @@ export function MapFilterBar({
borderRadius: 999, borderRadius: 999,
border: border:
DISTRICT_VRI_FILTERS_AVAILABLE && filters.district DISTRICT_VRI_FILTERS_AVAILABLE && filters.district
? "1px solid rgba(127, 208, 238, 0.55)" ? "1px solid var(--accent)"
: "1px solid rgba(150, 192, 214, 0.18)", : "1px solid var(--border-card)",
background: background:
DISTRICT_VRI_FILTERS_AVAILABLE && filters.district DISTRICT_VRI_FILTERS_AVAILABLE && filters.district
? "rgba(127, 208, 238, 0.16)" ? "var(--accent-soft)"
: "rgba(9, 22, 31, 0.62)", : "var(--bg-card)",
color: color:
DISTRICT_VRI_FILTERS_AVAILABLE && filters.district DISTRICT_VRI_FILTERS_AVAILABLE && filters.district
? "#8ed3ff" ? "var(--accent)"
: "#8ba6b3", : "var(--fg-secondary)",
cursor: DISTRICT_VRI_FILTERS_AVAILABLE ? "pointer" : "not-allowed", cursor: DISTRICT_VRI_FILTERS_AVAILABLE ? "pointer" : "not-allowed",
opacity: DISTRICT_VRI_FILTERS_AVAILABLE ? 1 : 0.4, opacity: DISTRICT_VRI_FILTERS_AVAILABLE ? 1 : 0.45,
}} }}
> >
<option value="">Все районы</option> <option value="">Все районы</option>
@ -276,7 +274,7 @@ export function MapFilterBar({
onClick={clearAll} onClick={clearAll}
style={{ style={{
fontSize: 12, fontSize: 12,
color: "#d2655b", color: "var(--danger)",
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] ?? "#8ba6b3"; const statusColor = STATUS_COLORS[parcel.status] ?? "#73767E";
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: "rgba(12, 30, 42, 0.94)", background: "var(--bg-card)",
border: "1px solid rgba(150, 192, 214, 0.18)", borderLeft: "1px solid var(--border-card)",
border: "1px solid var(--border-card)",
borderRadius: 12, borderRadius: 12,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
boxShadow: "0 18px 70px rgba(0, 0, 0, 0.5)", boxShadow: "-4px 0 24px rgba(0,0,0,0.10)",
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 rgba(150, 192, 214, 0.1)", borderBottom: "1px solid var(--border-soft)",
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: "#8ba6b3", color: "var(--fg-secondary)",
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: "#5f7886", color: "var(--fg-tertiary)",
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: "#8ba6b3", color: "var(--fg-secondary)",
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: "#8ba6b3", color: "var(--fg-tertiary)",
flexShrink: 0, flexShrink: 0,
}} }}
> >
@ -121,8 +121,8 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
{/* Status */} {/* Status */}
<div <div
style={{ style={{
background: "rgba(4, 12, 18, 0.6)", background: "var(--bg-card-alt)",
border: "1px solid rgba(150, 192, 214, 0.18)", border: "1px solid var(--border-card)",
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: "#8ba6b3", color: "var(--fg-secondary)",
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: "#dcebf2", color: "var(--fg-primary)",
}} }}
> >
{statusLabel} {statusLabel}
@ -170,8 +170,8 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
{/* Area */} {/* Area */}
<div <div
style={{ style={{
background: "rgba(4, 12, 18, 0.6)", background: "var(--bg-card-alt)",
border: "1px solid rgba(150, 192, 214, 0.18)", border: "1px solid var(--border-card)",
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: "#8ba6b3", color: "var(--fg-secondary)",
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: "#dcebf2", color: "var(--fg-primary)",
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: "#8ba6b3", color: "var(--fg-secondary)",
}} }}
> >
га га
@ -212,8 +212,8 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
{/* District */} {/* District */}
<div <div
style={{ style={{
background: "rgba(4, 12, 18, 0.6)", background: "var(--bg-card-alt)",
border: "1px solid rgba(150, 192, 214, 0.18)", border: "1px solid var(--border-card)",
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: "#8ba6b3", color: "var(--fg-secondary)",
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: "#dcebf2", color: "var(--fg-primary)",
}} }}
> >
{parcel.district} {parcel.district}
@ -244,8 +244,8 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
{/* VRI */} {/* VRI */}
<div <div
style={{ style={{
background: "rgba(4, 12, 18, 0.6)", background: "var(--bg-card-alt)",
border: "1px solid rgba(150, 192, 214, 0.18)", border: "1px solid var(--border-card)",
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: "#8ba6b3", color: "var(--fg-secondary)",
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: "#dcebf2", color: "var(--fg-primary)",
}} }}
> >
{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: "rgba(4, 12, 18, 0.6)", background: "var(--bg-card-alt)",
border: "1px solid rgba(150, 192, 214, 0.18)", border: "1px solid var(--border-card)",
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: "#8ba6b3", color: "var(--fg-secondary)",
marginBottom: 4, marginBottom: 4,
}} }}
> >
@ -298,7 +298,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
<div <div
style={{ style={{
fontSize: 12, fontSize: 12,
color: "#8ba6b3", color: "var(--fg-secondary)",
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 rgba(150, 192, 214, 0.1)", borderTop: "1px solid var(--border-soft)",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 8, gap: 8,
@ -337,9 +337,8 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
gap: 8, gap: 8,
background: "rgba(127, 208, 238, 0.16)", background: "var(--accent)",
color: "#8ed3ff", color: "#fff",
border: "1px solid rgba(127, 208, 238, 0.55)",
borderRadius: 8, borderRadius: 8,
padding: "10px 20px", padding: "10px 20px",
fontSize: 14, fontSize: 14,
@ -360,7 +359,7 @@ export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
justifyContent: "center", justifyContent: "center",
gap: 8, gap: 8,
background: "none", background: "none",
color: "#8ba6b3", color: "var(--fg-secondary)",
borderRadius: 8, borderRadius: 8,
padding: "8px 20px", padding: "8px 20px",
fontSize: 13, fontSize: 13,

View file

@ -1,12 +1,10 @@
"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: "#38c172", free: "#0A7A3A",
in_progress: "#e0a93b", in_progress: "#9A6700",
favorite: "#7fd0ee", favorite: "#1D4ED8",
}; };
export const STATUS_LABELS: Record<string, string> = { export const STATUS_LABELS: Record<string, string> = {
@ -23,8 +21,8 @@ export function ParcelLegend() {
return ( return (
<div <div
style={{ style={{
background: "rgba(11, 26, 36, 0.72)", background: "var(--bg-card)",
border: "1px solid rgba(150, 192, 214, 0.18)", border: "1px solid var(--border-card)",
borderRadius: 8, borderRadius: 8,
padding: "8px 12px", padding: "8px 12px",
display: "flex", display: "flex",
@ -39,7 +37,7 @@ export function ParcelLegend() {
fontWeight: 500, fontWeight: 500,
textTransform: "uppercase", textTransform: "uppercase",
letterSpacing: "0.04em", letterSpacing: "0.04em",
color: "#8ba6b3", color: "var(--fg-secondary)",
}} }}
> >
Статус участка Статус участка
@ -59,7 +57,7 @@ export function ParcelLegend() {
display: "inline-block", display: "inline-block",
}} }}
/> />
<span style={{ fontSize: 12, color: "#dcebf2" }}> <span style={{ fontSize: 12, color: "var(--fg-primary)" }}>
{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: "rgba(11, 26, 36, 0.72)", background: "var(--bg-card)",
border: "1px solid rgba(150, 192, 214, 0.18)", border: "1px solid var(--border-card)",
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: "rgba(26, 52, 67, 0.55)", background: "var(--bg-card-alt)",
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: "rgba(26, 52, 67, 0.55)", background: "var(--bg-card-alt)",
borderRadius: 6, borderRadius: 6,
marginBottom: 6, marginBottom: 6,
}} }}
@ -61,8 +61,8 @@ export function RecentParcels() {
return ( return (
<div <div
style={{ style={{
background: "rgba(11, 26, 36, 0.72)", background: "var(--bg-card)",
border: "1px solid rgba(150, 192, 214, 0.18)", border: "1px solid var(--border-card)",
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: "#8ba6b3" }} style={{ color: "var(--fg-secondary)" }}
/> />
<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: "#8ba6b3", color: "var(--fg-secondary)",
}} }}
> >
Недавние участки Недавние участки
@ -95,7 +95,7 @@ export function RecentParcels() {
<p <p
style={{ style={{
fontSize: 12, fontSize: 12,
color: "#5f7886", color: "var(--fg-tertiary)",
margin: 0, margin: 0,
}} }}
> >
@ -108,8 +108,8 @@ export function RecentParcels() {
return ( return (
<div <div
style={{ style={{
background: "rgba(11, 26, 36, 0.72)", background: "var(--bg-card)",
border: "1px solid rgba(150, 192, 214, 0.18)", border: "1px solid var(--border-card)",
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 rgba(150, 192, 214, 0.1)", borderBottom: "1px solid var(--border-soft)",
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: "#8ba6b3" }} style={{ color: "var(--fg-secondary)" }}
/> />
<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: "#8ba6b3", color: "var(--fg-secondary)",
}} }}
> >
Недавние участки Недавние участки
@ -156,13 +156,13 @@ export function RecentParcels() {
textDecoration: "none", textDecoration: "none",
borderBottom: borderBottom:
idx < shown.length - 1 idx < shown.length - 1
? "1px solid rgba(150, 192, 214, 0.1)" ? "1px solid var(--border-soft)"
: "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 =
"rgba(26, 52, 67, 0.55)"; "var(--bg-card-alt)";
}} }}
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: "#5f7886", color: "var(--fg-tertiary)",
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: "#dcebf2", color: "var(--fg-primary)",
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: "#5f7886", color: "var(--fg-tertiary)",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", whiteSpace: "nowrap",

View file

@ -10,16 +10,13 @@
* - 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 glyph divIcon pins (prototype * · POI markers from score_breakdown bucketed ЖК / Школы·Детсады /
* pinIcon + POI_GLYPH) bucketed Школы·Детсады / Парки·Метро, one glyph * Парки·Метро like the prototype legend (CircleMarker, colored)
* per OSM category (school / kindergarten / park / metro / tram / bus) * · competitors / pipeline (MarketLayers ЖК + будущие проекты)
* · competitors / pipeline (ЖК + будущие проекты) жилой-дом glyph pins
* in the market colors (viz-2 / viz-5)
* · ЗОУИТ охранные зоны (ZouitLayer) * · ЗОУИТ охранные зоны (ZouitLayer)
* · connection points resource glyph pins (RES_GLYPH: Электро/Вода/ * · connection points + colored polylines to the parcel (ConnectionPoints-
* Канализация/Тепло/Газ/) + dashed colored polylines from the centroid * Layer + per-category lines 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).
@ -34,8 +31,9 @@ import {
MapContainer, MapContainer,
TileLayer, TileLayer,
GeoJSON, GeoJSON,
Marker, CircleMarker,
Polyline, Polyline,
Popup,
Tooltip, Tooltip,
AttributionControl, AttributionControl,
ZoomControl, ZoomControl,
@ -43,24 +41,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 { import type { ParcelAnalysis } from "@/types/site-finder";
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 {
@ -79,64 +77,6 @@ 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";
@ -144,12 +84,13 @@ 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: "Школы · Дет.сады" }, // --res-poi edu: { color: "#3fa77d", label: "Школы · Дет.сады", radius: 6 }, // --res-poi
greenmetro: { color: "#6aa15f", label: "Парки · Метро" }, greenmetro: { color: "#6aa15f", label: "Парки · Метро", radius: 6 },
}; };
const EDU_CATEGORIES = new Set(["school", "kindergarten"]); const EDU_CATEGORIES = new Set(["school", "kindergarten"]);
@ -166,11 +107,6 @@ 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([
@ -396,17 +332,11 @@ export default function PticaMapInner({
setAddMode(false); setAddMode(false);
} }
// ── POI markers from score_breakdown, bucketed (category kept for glyph) ────── // ── POI markers from score_breakdown, bucketed ───────────────────────────────
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: [],
@ -421,7 +351,6 @@ 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,
}); });
} }
} }
@ -429,60 +358,23 @@ export default function PticaMapInner({
}, [analysis.score_breakdown]); }, [analysis.score_breakdown]);
// ── Market data (competitors = ЖК, pipeline = будущие проекты) ─────────────── // ── Market data (competitors = ЖК, pipeline = будущие проекты) ───────────────
// Filtered to objects with usable coords (drop (0,0) Атлантика sentinels) so const competitorList = analysis.competitors ?? [];
// the glyph pins below render only real points; counts drive the legend rows. const pipelineList = analysis.pipeline_24mo?.top_objects ?? [];
const competitorList = useMemo< const competitorCount = competitorList.filter(
(ParcelAnalysisCompetitor & { lat: number; lon: number })[] (c) => typeof c.lat === "number" && typeof c.lon === "number",
>( ).length;
() => const pipelineCount = pipelineList.filter(
(analysis.competitors ?? []).filter( (p) => typeof p.lat === "number" && typeof p.lon === "number",
(c): c is ParcelAnalysisCompetitor & { lat: number; lon: number } => ).length;
typeof c.lat === "number" &&
typeof c.lon === "number" &&
Number.isFinite(c.lat) &&
Number.isFinite(c.lon) &&
!(c.lat === 0 && c.lon === 0),
),
[analysis.competitors],
);
const pipelineList = useMemo<
(PipelineObject & { lat: number; lon: number })[]
>(
() =>
(analysis.pipeline_24mo?.top_objects ?? []).filter(
(p): p is PipelineObject & { lat: number; lon: number } =>
typeof p.lat === "number" &&
typeof p.lon === "number" &&
Number.isFinite(p.lat) &&
Number.isFinite(p.lon) &&
!(p.lat === 0 && p.lon === 0),
),
[analysis.pipeline_24mo],
);
const competitorCount = competitorList.length;
const pipelineCount = pipelineList.length;
// ── Connection points → glyph-pin markers (prototype pinIcon + RES_GLYPH) ───── // ── Connection points (grouped by resource category) ─────────────────────────
const cpMarkers = useMemo(() => { const cpGrouped = useMemo(
if (!connectionPoints) return []; () =>
const out: { connectionPoints
latLon: [number, number]; ? groupStructuresByCategory(connectionPoints.engineering_structures)
category: CpCategory; : new Map<CpCategory, never[]>(),
name: string | null; [connectionPoints],
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.
@ -584,63 +476,46 @@ export default function PticaMapInner({
/> />
)} )}
{/* Competitors (ЖК) — glyph pins (prototype жилой-дом glyph, viz-2). */} {/* Competitors (ЖК) + pipeline (будущие проекты). */}
{visible.has("competitors") && {(visible.has("competitors") || visible.has("pipeline")) && (
competitorList.map((c, idx) => ( <MarketLayers
<Marker competitors={competitorList}
key={`comp-${c.obj_id}-${idx}`} pipelineObjects={pipelineList}
position={[c.lat, c.lon]} riskZones={[]}
icon={pinIcon(MARKET_COLORS.competitor, ZHK_GLYPH)} opportunityParcels={[]}
> redLines={[]}
<Tooltip direction="top"> showCompetitors={visible.has("competitors")}
{c.comm_name ?? "ЖК"} showPipeline={visible.has("pipeline")}
{typeof c.distance_m === "number" showRiskZones={false}
? ` · ${formatMeters(c.distance_m)}` showOpportunity={false}
: ""} showRedLines={false}
</Tooltip> />
</Marker> )}
))}
{/* Будущие проекты (pipeline) — glyph pins (жилой-дом glyph, viz-5). */} {/* POI markers (Школы·Детсады / Парки·Метро). */}
{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) => (
<Marker <CircleMarker
key={`${bucket}-${idx}`} key={`${bucket}-${idx}`}
position={[poi.lat, poi.lon]} center={[poi.lat, poi.lon]}
icon={pinIcon( radius={style.radius}
style.color, pathOptions={{
POI_GLYPH[poi.category] ?? POI_GLYPH_FALLBACK, color: style.color,
true, fillColor: style.color,
)} 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>
</Marker> </CircleMarker>
)); ));
})} })}
{/* Connection points: dashed polylines from the centroid + glyph pins {/* Connection points + polylines to the parcel centroid. */}
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) => (
@ -652,26 +527,13 @@ 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",
}} }}
/> />
))} ))}
{cpMarkers.map((m, idx) => { <ConnectionPointsLayer
const cpStyle = CP_CATEGORY_STYLES[m.category]; grouped={cpGrouped}
return ( visibleCategories={new Set(CP_ALL_CATEGORIES)}
<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>
);
})}
</> </>
)} )}
@ -873,35 +735,6 @@ 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>
)} )}