feat(ptica): rich dark cockpit map + ПТИЦА as the default analysis route
Fix two regressions vs the prototype: 1. PticaMapInner: render real data layers over the dark base by REUSING the existing analysis-map components — POI by category, competitors/pipeline (MarketLayers), connection points + colored polylines (ConnectionPointsLayer), ЗОУИТ (ZouitLayer), custom POI add/edit/delete (CustomPoiLayer + mutation hooks). Add Спутник/Схема base toggle (Esri default + --map-filter-sat); legend rows toggle layers; «Точки подключения» driven by real CP data. Isochrones left «скоро» (ORS is on-demand, not in /analyze). 2. /site-finder/analysis/[cad] now renders the ПТИЦА cockpit — every parcel entry opens ПТИЦА, not the old design. [cad]/ptica redirects to the parent (single cockpit copy). Old AnalysisPageContent kept in repo; legacy UI at /legacy/site-finder. 3. Surface 3D massing: new «Инсоляция · 3D-масса» scan card + 3D legend row + 3D map tool all open the insolation/future3d drawers. SSR-safe (Leaflet only behind dynamic ssr:false); real POI from score_breakdown; no redirect loop. tsc/lint/prettier/build green. code-reviewer APPROVE.
This commit is contained in:
parent
09656077c1
commit
ba05a4cb62
8 changed files with 1084 additions and 86 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
import { AnalysisPageContent } from "./AnalysisPageContent";
|
import { PticaPageContent } from "./ptica/PticaPageContent";
|
||||||
|
|
||||||
// ── Metadata ──────────────────────────────────────────────────────────────────
|
// ── Metadata ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -21,13 +21,18 @@ export async function generateMetadata({
|
||||||
const { cad: cadRaw } = await params;
|
const { cad: cadRaw } = await params;
|
||||||
const cad = decodeURIComponent(cadRaw);
|
const cad = decodeURIComponent(cadRaw);
|
||||||
return {
|
return {
|
||||||
title: `Анализ ${cad} — Site Finder`,
|
title: `ПТИЦА · ${cad}`,
|
||||||
description: `Анализ инвестиционного участка ${cad}`,
|
description: `Когнитивный анализ участка ${cad} — платформа ПТИЦА`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// The ПТИЦА cockpit is now the PRIMARY analysis view: every entry point
|
||||||
|
// (entry-map click, recent parcels, cad input, direct URL) lands here. The old
|
||||||
|
// AnalysisPageContent stays in the repo for reference but is no longer rendered
|
||||||
|
// on this route. The sibling `[cad]/ptica` subroute redirects back here so a
|
||||||
|
// single PticaPageContent is the only live copy of the cockpit.
|
||||||
export default async function AnalysisPage({ params }: PageProps) {
|
export default async function AnalysisPage({ params }: PageProps) {
|
||||||
const { cad: cadRaw } = await params;
|
const { cad: cadRaw } = await params;
|
||||||
const cad = decodeURIComponent(cadRaw);
|
const cad = decodeURIComponent(cadRaw);
|
||||||
|
|
@ -36,18 +41,23 @@ export default async function AnalysisPage({ params }: PageProps) {
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div
|
<div
|
||||||
|
// Inline hex intentionally duplicates --bg / --text-muted: the scoped
|
||||||
|
// CSS-module tokens live under .pticaRoot[data-theme="dark"] and aren't
|
||||||
|
// reachable from this server-rendered Suspense fallback.
|
||||||
style={{
|
style={{
|
||||||
padding: "40px 24px",
|
minHeight: "100vh",
|
||||||
textAlign: "center",
|
display: "grid",
|
||||||
color: "var(--fg-tertiary, #73767E)",
|
placeItems: "center",
|
||||||
|
background: "#060f16",
|
||||||
|
color: "#8ba6b3",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Загрузка анализа…
|
Загрузка ПТИЦА…
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AnalysisPageContent cad={cad} />
|
<PticaPageContent cad={cad} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,18 @@
|
||||||
import { Suspense } from "react";
|
import { redirect } from "next/navigation";
|
||||||
import type { Metadata } from "next";
|
|
||||||
|
|
||||||
import { PticaPageContent } from "./PticaPageContent";
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ── Metadata ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ cad: string }>;
|
params: Promise<{ cad: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next.js delivers `cad` URL-encoded (":" -> "%3A"); decode once at the page
|
// The ПТИЦА cockpit is now rendered by the parent `[cad]/page.tsx` route, so
|
||||||
// boundary so the cockpit + hooks see the canonical "66:41:0204016:10" (and
|
// this subroute is redundant — it permanently redirects to the parent to avoid
|
||||||
// re-encode exactly once when building API URLs). Mirrors the sibling page.tsx.
|
// two divergent copies of PticaPageContent. Old `/site-finder/analysis/{cad}/
|
||||||
export async function generateMetadata({
|
// ptica` links (and any cached bookmarks) keep working via this hop.
|
||||||
params,
|
export default async function PticaSubroutePage({ params }: PageProps) {
|
||||||
}: PageProps): Promise<Metadata> {
|
|
||||||
const { cad: cadRaw } = await params;
|
const { cad: cadRaw } = await params;
|
||||||
const cad = decodeURIComponent(cadRaw);
|
// `cad` arrives URL-encoded; keep it encoded for the redirect target so the
|
||||||
return {
|
// canonical decode happens once in the parent page boundary.
|
||||||
title: `ПТИЦА · ${cad}`,
|
redirect(`/site-finder/analysis/${cadRaw}`);
|
||||||
description: `Когнитивный анализ участка ${cad} — платформа ПТИЦА`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default async function PticaPage({ params }: PageProps) {
|
|
||||||
const { cad: cadRaw } = await params;
|
|
||||||
const cad = decodeURIComponent(cadRaw);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div
|
|
||||||
// Inline hex intentionally duplicates --bg / --text-muted: the scoped
|
|
||||||
// CSS-module tokens live under .pticaRoot[data-theme="dark"] and aren't
|
|
||||||
// reachable from this server-rendered Suspense fallback.
|
|
||||||
style={{
|
|
||||||
minHeight: "100vh",
|
|
||||||
display: "grid",
|
|
||||||
placeItems: "center",
|
|
||||||
background: "#060f16",
|
|
||||||
color: "#8ba6b3",
|
|
||||||
fontSize: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Загрузка ПТИЦА…
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PticaPageContent cad={cad} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,14 @@
|
||||||
--accent-purple: #9b80d6;
|
--accent-purple: #9b80d6;
|
||||||
--accent-orange: #e46c35;
|
--accent-orange: #e46c35;
|
||||||
|
|
||||||
|
/* resource (connection-point) accents — ported from prototype tokens.css */
|
||||||
|
--res-electric: #e6b23c;
|
||||||
|
--res-water: #4d9bd7;
|
||||||
|
--res-sewer: #9b80d6;
|
||||||
|
--res-heat: #e46c35;
|
||||||
|
--res-gas: #d2655b;
|
||||||
|
--res-poi: #3fa77d;
|
||||||
|
|
||||||
--gauge-track: 9px;
|
--gauge-track: 9px;
|
||||||
|
|
||||||
/* dark surface palette */
|
/* dark surface palette */
|
||||||
|
|
@ -56,6 +64,7 @@
|
||||||
|
|
||||||
--map-bg: #0a1820;
|
--map-bg: #0a1820;
|
||||||
--map-filter: saturate(0.7) contrast(1.12) brightness(0.7);
|
--map-filter: saturate(0.7) contrast(1.12) brightness(0.7);
|
||||||
|
--map-filter-sat: brightness(0.62) saturate(0.8) contrast(1.05);
|
||||||
--glow: rgba(110, 200, 240, 0.28);
|
--glow: rgba(110, 200, 240, 0.28);
|
||||||
--glow-strong: rgba(110, 200, 240, 0.55);
|
--glow-strong: rgba(110, 200, 240, 0.55);
|
||||||
--shadow: 0 18px 70px rgba(0, 0, 0, 0.5);
|
--shadow: 0 18px 70px rgba(0, 0, 0, 0.5);
|
||||||
|
|
@ -363,6 +372,55 @@
|
||||||
.mapMount :global(.leaflet-tile-pane) {
|
.mapMount :global(.leaflet-tile-pane) {
|
||||||
filter: var(--map-filter);
|
filter: var(--map-filter);
|
||||||
}
|
}
|
||||||
|
/* satellite base → its own (darker) filter so markers stay readable */
|
||||||
|
.mapMount.baseSat :global(.leaflet-tile-pane) {
|
||||||
|
filter: var(--map-filter-sat);
|
||||||
|
}
|
||||||
|
/* dark, glassy Leaflet popups (match the cockpit chrome) */
|
||||||
|
.mapMount :global(.leaflet-popup-content-wrapper) {
|
||||||
|
background: var(--surface-strong);
|
||||||
|
color: var(--text);
|
||||||
|
border: var(--line) solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.mapMount :global(.leaflet-popup-tip) {
|
||||||
|
background: var(--surface-strong);
|
||||||
|
}
|
||||||
|
.mapMount :global(.leaflet-popup-content) {
|
||||||
|
margin: 9px 11px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
.mapMount :global(.leaflet-container a.leaflet-popup-close-button) {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
/* data-driven divIcon pins (resource + POI markers) */
|
||||||
|
.mapMount :global(.map-pin) {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #08131c;
|
||||||
|
border: 1.5px solid rgba(255, 255, 255, 0.85);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 3px rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 14px currentColor;
|
||||||
|
}
|
||||||
|
.mapMount :global(.map-pin svg) {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
.mapMount :global(.map-pin.poi-dot) {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-width: 1.5px;
|
||||||
|
}
|
||||||
|
.mapMount :global(.parcel-glow) {
|
||||||
|
filter: drop-shadow(0 0 6px var(--glow-strong));
|
||||||
|
}
|
||||||
|
.mapMount :global(.conn-glow) {
|
||||||
|
filter: drop-shadow(0 0 5px currentColor);
|
||||||
|
}
|
||||||
.mapMount :global(.leaflet-control-attribution) {
|
.mapMount :global(.leaflet-control-attribution) {
|
||||||
background: rgba(8, 19, 28, 0.55);
|
background: rgba(8, 19, 28, 0.55);
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
|
|
@ -1865,3 +1923,247 @@
|
||||||
height: 320px;
|
height: 320px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== MAP OVERLAY (legend + layer toggles + CP list) ===== */
|
||||||
|
.mapOverlay {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
top: 14px;
|
||||||
|
width: 224px;
|
||||||
|
max-height: calc(100% - 28px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 13px;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border: var(--line) solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
.mapOverlay h4 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 9.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.mapOverlay h4 .live {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-green);
|
||||||
|
box-shadow: 0 0 8px var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* base-layer segmented control (Спутник | Схема) */
|
||||||
|
.baseToggle {
|
||||||
|
display: inline-flex;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
border: var(--line) solid var(--border);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
}
|
||||||
|
.baseToggle button {
|
||||||
|
padding: 4px 9px;
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-soft);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.baseToggle button + button {
|
||||||
|
border-left: var(--line) solid var(--border);
|
||||||
|
}
|
||||||
|
.baseToggle button:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.baseToggle button.baseToggleActive {
|
||||||
|
color: #08131c;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* layer-toggle list */
|
||||||
|
.layerList {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.layerRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 14px 1fr auto;
|
||||||
|
gap: 7px;
|
||||||
|
align-items: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.layerRow:not(.layerRowSoon) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.layerRow .swatch {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.layerRow .swatch3d {
|
||||||
|
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
|
||||||
|
}
|
||||||
|
.layerRow .cnt {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
.layerRowOff {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.layerRowOff .swatch {
|
||||||
|
background: var(--text-soft) !important;
|
||||||
|
}
|
||||||
|
.layerRowSoon {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.layerRowSoon .swatch {
|
||||||
|
background: var(--text-soft) !important;
|
||||||
|
}
|
||||||
|
.layerRow3d {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.openTag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border: var(--line) solid var(--accent-cyan);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
.openTag::before {
|
||||||
|
content: "";
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 6px var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* isochrone sub-legend (5/10/15 мин) — only visible when layer is on */
|
||||||
|
.isoSublegend {
|
||||||
|
display: none;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 4px 0 2px;
|
||||||
|
font-size: 8.5px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.isoSublegendOn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.isoSublegend i {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.isoSublegend i::before {
|
||||||
|
content: "";
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
.isoSublegend i.b10::before {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.isoSublegend i.b15::before {
|
||||||
|
opacity: 0.32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayDivider {
|
||||||
|
margin: 10px 0 8px;
|
||||||
|
border: none;
|
||||||
|
border-top: var(--line) solid var(--border);
|
||||||
|
}
|
||||||
|
.overlaySub {
|
||||||
|
font-size: 8.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-soft);
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
.resRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 16px 1fr auto;
|
||||||
|
gap: 7px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.resRow .dot {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.resRow b {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* map tool buttons (zoom / fit / add-POI) */
|
||||||
|
.mapTools {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
.mapTools .tool {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: var(--line) solid var(--border);
|
||||||
|
background: var(--surface-strong);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.mapTools .tool:hover {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
.mapTools .tool svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
.mapTools .toolActive {
|
||||||
|
border-color: var(--accent-yellow);
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
box-shadow: 0 0 10px rgba(224, 169, 59, 0.45);
|
||||||
|
}
|
||||||
|
.mapHint {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 14px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 4;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 9.5px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-strong);
|
||||||
|
border: var(--line) solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
adaptEconomyCard,
|
adaptEconomyCard,
|
||||||
adaptEnvironmentCard,
|
adaptEnvironmentCard,
|
||||||
adaptOksCard,
|
adaptOksCard,
|
||||||
|
adaptInsolationCard,
|
||||||
adaptRiskGauge,
|
adaptRiskGauge,
|
||||||
} from "@/components/site-finder/ptica/ptica-adapt";
|
} from "@/components/site-finder/ptica/ptica-adapt";
|
||||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||||
|
|
@ -89,6 +90,13 @@ export function DevelopmentScan({ analysis, onOpenDrawer }: Props) {
|
||||||
onOpen={onOpenDrawer}
|
onOpen={onOpenDrawer}
|
||||||
/>
|
/>
|
||||||
<ScanCard card={adaptOksCard()} drawerKey="oks" onOpen={onOpenDrawer} />
|
<ScanCard card={adaptOksCard()} drawerKey="oks" onOpen={onOpenDrawer} />
|
||||||
|
{/* Инсоляция · 3D-масса — opens the future3d/insolation drawer (3D
|
||||||
|
massing + shadow preview) so the 3D module is discoverable here. */}
|
||||||
|
<ScanCard
|
||||||
|
card={adaptInsolationCard(analysis)}
|
||||||
|
drawerKey="insolation"
|
||||||
|
onOpen={onOpenDrawer}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function PticaHero({ analysis, forecastReport, onOpenDrawer }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="passport" className={styles.hero}>
|
<section id="passport" className={styles.hero}>
|
||||||
<PticaMap geojson={analysis.geom_geojson} />
|
<PticaMap analysis={analysis} onOpenDrawer={onOpenDrawer} />
|
||||||
|
|
||||||
<ParcelPassportCard analysis={analysis} onOpenDrawer={onOpenDrawer} />
|
<ParcelPassportCard analysis={analysis} onOpenDrawer={onOpenDrawer} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PticaMap — SSR-safe wrapper around the Leaflet cockpit map. Leaflet touches
|
* PticaMap — SSR-safe wrapper around the rich Leaflet cockpit map. Leaflet
|
||||||
* `window`, so PticaMapInner is loaded via dynamic(ssr:false) with a dark
|
* touches `window`, so PticaMapInner is loaded via dynamic(ssr:false) with a
|
||||||
* loading placeholder (never imported into a server component).
|
* dark loading placeholder (never imported into a server component).
|
||||||
|
*
|
||||||
|
* This wrapper owns the client-only data the rich map needs beyond the /analyze
|
||||||
|
* payload: connection points (utility structures + distances) and the user's
|
||||||
|
* custom POIs. Both are cached TanStack queries keyed by cad; they hydrate the
|
||||||
|
* map's «Точки подключения ресурсов» list and the custom-POI layer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
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 type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
|
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
|
||||||
|
import { useCustomPois } from "@/hooks/useCustomPois";
|
||||||
|
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||||
|
|
||||||
const PticaMapInner = dynamic(
|
const PticaMapInner = dynamic(
|
||||||
() => import("@/components/site-finder/ptica/PticaMapInner"),
|
() => import("@/components/site-finder/ptica/PticaMapInner"),
|
||||||
|
|
@ -19,13 +28,24 @@ const PticaMapInner = dynamic(
|
||||||
);
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
geojson: unknown;
|
analysis: ParcelAnalysis;
|
||||||
|
onOpenDrawer?: (key: DrawerKey) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PticaMap({ geojson }: Props) {
|
export function PticaMap({ analysis, onOpenDrawer }: Props) {
|
||||||
|
const cad = analysis.cad_num;
|
||||||
|
const { data: connectionPoints } = useConnectionPoints(cad);
|
||||||
|
const { data: customPois } = useCustomPois(cad);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.mapCard}>
|
<div className={styles.mapCard}>
|
||||||
<PticaMapInner geojson={geojson} />
|
<PticaMapInner
|
||||||
|
analysis={analysis}
|
||||||
|
cad={cad}
|
||||||
|
connectionPoints={connectionPoints}
|
||||||
|
customPois={customPois}
|
||||||
|
onOpenDrawer={onOpenDrawer}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,107 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PticaMapInner — react-leaflet map for the cockpit hero (client-only; loaded
|
* PticaMapInner — the rich dark cockpit map (client-only; loaded via
|
||||||
* via dynamic(ssr:false) from PticaMap). CartoDB dark_all tiles + the parcel
|
* dynamic(ssr:false) from PticaMap). Prototype parity (ptica-redesign/app.js):
|
||||||
* polygon in cyan, centered on the geometry centroid (or EKB when absent).
|
|
||||||
*
|
*
|
||||||
* Mirrors the SiteMap pattern: react-leaflet MapContainer + the Leaflet default-
|
* - Two base layers, toggled by the «Спутник | Схема» segmented control: Esri
|
||||||
* icon webpack fix. `geojson` arrives as `unknown` (the wire type of
|
* World Imagery (default, `.baseSat` → darker --map-filter-sat) and CartoDB
|
||||||
* ParcelAnalysis.geom_geojson) and is narrowed to a GeoJSON Geometry here.
|
* dark vector tiles. The parcel polygon reads clearly over both.
|
||||||
|
* - REAL data layers, reusing the existing analysis-map components so logic is
|
||||||
|
* not duplicated:
|
||||||
|
* · parcel polygon (cyan, geom_geojson)
|
||||||
|
* · POI markers from score_breakdown — bucketed ЖК / Школы·Детсады /
|
||||||
|
* Парки·Метро like the prototype legend (CircleMarker, colored)
|
||||||
|
* · competitors / pipeline (MarketLayers — ЖК + будущие проекты)
|
||||||
|
* · ЗОУИТ охранные зоны (ZouitLayer)
|
||||||
|
* · connection points + colored polylines to the parcel (ConnectionPoints-
|
||||||
|
* Layer + per-category lines from the centroid)
|
||||||
|
* · custom POI (CustomPoiLayer + add/edit/delete via useCustomPois)
|
||||||
|
* - The map-overlay legend rows TOGGLE each layer; the «Точки подключения
|
||||||
|
* ресурсов» list is driven by real connection-points data (nearest distance
|
||||||
|
* per resource category).
|
||||||
|
* - Isochrones: the only real source (useIsochrones) is an on-demand ORS
|
||||||
|
* mutation, not part of /analyze — per the task we DO NOT fake it; the legend
|
||||||
|
* row stays «скоро».
|
||||||
|
* - The «3D-масса застройки» legend row + map tool open the future3d drawer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { MapContainer, TileLayer, GeoJSON } from "react-leaflet";
|
import {
|
||||||
|
MapContainer,
|
||||||
|
TileLayer,
|
||||||
|
GeoJSON,
|
||||||
|
CircleMarker,
|
||||||
|
Polyline,
|
||||||
|
Popup,
|
||||||
|
Tooltip,
|
||||||
|
useMap,
|
||||||
|
useMapEvents,
|
||||||
|
} from "react-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 { ConnectionPointsResponse } from "@/types/nspd";
|
||||||
|
import type { CustomPoi } from "@/types/customPoi";
|
||||||
|
import {
|
||||||
|
MarketLayers,
|
||||||
|
MARKET_COLORS,
|
||||||
|
} from "@/components/site-finder/MarketLayers";
|
||||||
|
import {
|
||||||
|
ConnectionPointsLayer,
|
||||||
|
CP_CATEGORY_STYLES,
|
||||||
|
CP_ALL_CATEGORIES,
|
||||||
|
classifyStructure,
|
||||||
|
groupStructuresByCategory,
|
||||||
|
type CpCategory,
|
||||||
|
} from "@/components/site-finder/ConnectionPointsLayer";
|
||||||
|
import {
|
||||||
|
ZouitLayer,
|
||||||
|
ZOUIT_ALL_SEVERITIES,
|
||||||
|
groupZouitBySeverity,
|
||||||
|
type ZouitSeverity,
|
||||||
|
} from "@/components/site-finder/ZouitLayer";
|
||||||
|
import { CustomPoiLayer } from "@/components/site-finder/CustomPoiLayer";
|
||||||
|
import { CustomPoiAddModal } from "@/components/site-finder/CustomPoiAddModal";
|
||||||
|
import { CustomPoiEditModal } from "@/components/site-finder/CustomPoiEditModal";
|
||||||
|
import {
|
||||||
|
useAddCustomPoi,
|
||||||
|
useUpdateCustomPoi,
|
||||||
|
useDeleteCustomPoi,
|
||||||
|
} from "@/hooks/useCustomPois";
|
||||||
|
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||||
|
|
||||||
interface Props {
|
// ── POI legend buckets (prototype: ЖК / Школы·Детсады / Парки·Метро) ───────────
|
||||||
geojson: unknown;
|
|
||||||
|
type PoiBucket = "edu" | "greenmetro";
|
||||||
|
|
||||||
|
interface PoiBucketStyle {
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// token-hex (map exception per ui-tokens.md) — match prototype poiColor()
|
||||||
|
const POI_BUCKET_STYLES: Record<PoiBucket, PoiBucketStyle> = {
|
||||||
|
edu: { color: "#3fa77d", label: "Школы · Дет.сады", radius: 6 }, // --res-poi
|
||||||
|
greenmetro: { color: "#6aa15f", label: "Парки · Метро", radius: 6 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const EDU_CATEGORIES = new Set(["school", "kindergarten"]);
|
||||||
|
const GREENMETRO_CATEGORIES = new Set([
|
||||||
|
"park",
|
||||||
|
"metro_stop",
|
||||||
|
"tram_stop",
|
||||||
|
"bus_stop",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function poiBucket(category: string): PoiBucket | null {
|
||||||
|
if (EDU_CATEGORIES.has(category)) return "edu";
|
||||||
|
if (GREENMETRO_CATEGORIES.has(category)) return "greenmetro";
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Narrowing: unknown → GeoJSON Geometry ─────────────────────────────────────
|
// ── Narrowing: unknown → GeoJSON Geometry ─────────────────────────────────────
|
||||||
|
|
@ -42,7 +124,7 @@ function asGeometry(value: unknown): Geometry | null {
|
||||||
return value as Geometry;
|
return value as Geometry;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Centroid (bounding-box center — sufficient for zoom-to fit) ───────────────
|
// ── Centroid (bounding-box center — sufficient for connection-line origin) ─────
|
||||||
|
|
||||||
function flattenCoords(geom: Geometry): Position[] {
|
function flattenCoords(geom: Geometry): Position[] {
|
||||||
switch (geom.type) {
|
switch (geom.type) {
|
||||||
|
|
@ -79,7 +161,96 @@ function geomCenter(geom: Geometry): [number, number] {
|
||||||
return [(minLat + maxLat) / 2, (minLon + maxLon) / 2];
|
return [(minLat + maxLat) / 2, (minLon + maxLon) / 2];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PticaMapInner({ geojson }: Props) {
|
// Pull [lat, lon] out of an EngineeringStructure point geometry (GeoJSON
|
||||||
|
// [lon, lat]). Non-points / missing coords → null (skip in lines).
|
||||||
|
function structureLatLon(
|
||||||
|
geom: Record<string, unknown>,
|
||||||
|
): [number, number] | null {
|
||||||
|
if (geom.type !== "Point") return null;
|
||||||
|
const coords = geom.coordinates as number[] | undefined;
|
||||||
|
if (!coords || coords.length < 2) return null;
|
||||||
|
return [coords[1], coords[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMeters(m: number): string {
|
||||||
|
if (m >= 1000) return `${(m / 1000).toFixed(2)} км`;
|
||||||
|
return `${Math.round(m)} м`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layer keys ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type LayerKey =
|
||||||
|
| "parcel"
|
||||||
|
| "competitors" // ЖК
|
||||||
|
| "pipeline" // будущие проекты
|
||||||
|
| "edu"
|
||||||
|
| "greenmetro"
|
||||||
|
| "connections"
|
||||||
|
| "zouit"
|
||||||
|
| "custompoi";
|
||||||
|
|
||||||
|
// ── Map controllers (must live inside MapContainer) ───────────────────────────
|
||||||
|
|
||||||
|
function FitToGeometry({ geom }: { geom: Geometry | null }) {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!geom) return;
|
||||||
|
const coords = flattenCoords(geom);
|
||||||
|
if (coords.length < 2) return;
|
||||||
|
let minLat = Infinity;
|
||||||
|
let maxLat = -Infinity;
|
||||||
|
let minLon = Infinity;
|
||||||
|
let maxLon = -Infinity;
|
||||||
|
for (const [lon, lat] of coords) {
|
||||||
|
if (lat < minLat) minLat = lat;
|
||||||
|
if (lat > maxLat) maxLat = lat;
|
||||||
|
if (lon < minLon) minLon = lon;
|
||||||
|
if (lon > maxLon) maxLon = lon;
|
||||||
|
}
|
||||||
|
map.fitBounds(
|
||||||
|
[
|
||||||
|
[minLat, minLon],
|
||||||
|
[maxLat, maxLon],
|
||||||
|
],
|
||||||
|
{ padding: [48, 48], maxZoom: 16, animate: false },
|
||||||
|
);
|
||||||
|
map.setZoom(Math.max(map.getZoom() - 1, 13), { animate: false });
|
||||||
|
}, [map, geom]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapClickHandler({
|
||||||
|
editMode,
|
||||||
|
onMapClick,
|
||||||
|
}: {
|
||||||
|
editMode: boolean;
|
||||||
|
onMapClick: (lat: number, lon: number) => void;
|
||||||
|
}) {
|
||||||
|
useMapEvents({
|
||||||
|
click(e) {
|
||||||
|
if (editMode) onMapClick(e.latlng.lat, e.latlng.lng);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
analysis: ParcelAnalysis;
|
||||||
|
cad: string;
|
||||||
|
connectionPoints?: ConnectionPointsResponse;
|
||||||
|
customPois?: CustomPoi[];
|
||||||
|
onOpenDrawer?: (key: DrawerKey) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PticaMapInner({
|
||||||
|
analysis,
|
||||||
|
cad,
|
||||||
|
connectionPoints,
|
||||||
|
customPois,
|
||||||
|
onOpenDrawer,
|
||||||
|
}: Props) {
|
||||||
// Fix Leaflet default icon paths broken by webpack bundler (mirror SiteMap).
|
// Fix Leaflet default icon paths broken by webpack bundler (mirror SiteMap).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void import("leaflet").then((L) => {
|
void import("leaflet").then((L) => {
|
||||||
|
|
@ -95,29 +266,525 @@ export default function PticaMapInner({ geojson }: Props) {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const geom = asGeometry(geojson);
|
const geom = useMemo(
|
||||||
|
() => asGeometry(analysis.geom_geojson),
|
||||||
|
[analysis.geom_geojson],
|
||||||
|
);
|
||||||
const center = geom ? geomCenter(geom) : EKB_CENTER;
|
const center = geom ? geomCenter(geom) : EKB_CENTER;
|
||||||
|
|
||||||
|
// ── Base layer (satellite default — matches prototype + header) ──────────────
|
||||||
|
const [base, setBase] = useState<"sat" | "vec">("sat");
|
||||||
|
|
||||||
|
// ── Layer visibility (parcel always on; isochrones is «скоро», not wired) ────
|
||||||
|
const [visible, setVisible] = useState<Set<LayerKey>>(
|
||||||
|
() =>
|
||||||
|
new Set<LayerKey>([
|
||||||
|
"parcel",
|
||||||
|
"competitors",
|
||||||
|
"pipeline",
|
||||||
|
"edu",
|
||||||
|
"greenmetro",
|
||||||
|
"connections",
|
||||||
|
"zouit",
|
||||||
|
"custompoi",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
function toggleLayer(key: LayerKey) {
|
||||||
|
setVisible((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Custom POI add/edit state + mutations ────────────────────────────────────
|
||||||
|
const [addMode, setAddMode] = useState(false);
|
||||||
|
const [pendingCoords, setPendingCoords] = useState<{
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [editingPoi, setEditingPoi] = useState<CustomPoi | null>(null);
|
||||||
|
|
||||||
|
const addMutation = useAddCustomPoi(cad);
|
||||||
|
const updateMutation = useUpdateCustomPoi(cad);
|
||||||
|
const deleteMutation = useDeleteCustomPoi(cad);
|
||||||
|
|
||||||
|
// ESC cancels add mode
|
||||||
|
const addModeRef = useRef(addMode);
|
||||||
|
addModeRef.current = addMode;
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape" && addModeRef.current) {
|
||||||
|
setAddMode(false);
|
||||||
|
setPendingCoords(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleMapClick(lat: number, lon: number) {
|
||||||
|
setPendingCoords({ lat, lon });
|
||||||
|
setAddMode(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POI markers from score_breakdown, bucketed ───────────────────────────────
|
||||||
|
const poiByBucket = useMemo(() => {
|
||||||
|
const buckets: Record<
|
||||||
|
PoiBucket,
|
||||||
|
{ lat: number; lon: number; name: string | null; distance_m: number }[]
|
||||||
|
> = {
|
||||||
|
edu: [],
|
||||||
|
greenmetro: [],
|
||||||
|
};
|
||||||
|
for (const [cat, pois] of Object.entries(analysis.score_breakdown)) {
|
||||||
|
const bucket = poiBucket(cat);
|
||||||
|
if (!bucket) continue;
|
||||||
|
for (const p of pois) {
|
||||||
|
if (p.lat === 0 && p.lon === 0) continue;
|
||||||
|
buckets[bucket].push({
|
||||||
|
lat: p.lat,
|
||||||
|
lon: p.lon,
|
||||||
|
name: p.name,
|
||||||
|
distance_m: p.distance_m,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
}, [analysis.score_breakdown]);
|
||||||
|
|
||||||
|
// ── Market data (competitors = ЖК, pipeline = будущие проекты) ───────────────
|
||||||
|
const competitorList = analysis.competitors ?? [];
|
||||||
|
const pipelineList = analysis.pipeline_24mo?.top_objects ?? [];
|
||||||
|
const competitorCount = competitorList.filter(
|
||||||
|
(c) => typeof c.lat === "number" && typeof c.lon === "number",
|
||||||
|
).length;
|
||||||
|
const pipelineCount = pipelineList.filter(
|
||||||
|
(p) => typeof p.lat === "number" && typeof p.lon === "number",
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// ── Connection points (grouped by resource category) ─────────────────────────
|
||||||
|
const cpGrouped = useMemo(
|
||||||
|
() =>
|
||||||
|
connectionPoints
|
||||||
|
? groupStructuresByCategory(connectionPoints.engineering_structures)
|
||||||
|
: new Map<CpCategory, never[]>(),
|
||||||
|
[connectionPoints],
|
||||||
|
);
|
||||||
|
const cpCount = connectionPoints?.engineering_structures.length ?? 0;
|
||||||
|
|
||||||
|
// Nearest distance per category — drives the «Точки подключения» list.
|
||||||
|
const cpNearest = useMemo(() => {
|
||||||
|
const nearest = new Map<CpCategory, number>();
|
||||||
|
if (!connectionPoints) return nearest;
|
||||||
|
for (const s of connectionPoints.engineering_structures) {
|
||||||
|
const cat = classifyStructure(s);
|
||||||
|
const cur = nearest.get(cat);
|
||||||
|
if (cur === undefined || s.distance_to_boundary_m < cur) {
|
||||||
|
nearest.set(cat, s.distance_to_boundary_m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearest;
|
||||||
|
}, [connectionPoints]);
|
||||||
|
|
||||||
|
// Connection polylines from the parcel centroid to each structure point.
|
||||||
|
const connectionLines = useMemo(() => {
|
||||||
|
if (!connectionPoints || !geom) return [];
|
||||||
|
const origin = geomCenter(geom);
|
||||||
|
const lines: {
|
||||||
|
from: [number, number];
|
||||||
|
to: [number, number];
|
||||||
|
color: string;
|
||||||
|
}[] = [];
|
||||||
|
for (const s of connectionPoints.engineering_structures) {
|
||||||
|
const latLon = structureLatLon(s.geometry_geojson);
|
||||||
|
if (!latLon) continue;
|
||||||
|
const cat = classifyStructure(s);
|
||||||
|
lines.push({
|
||||||
|
from: origin,
|
||||||
|
to: latLon,
|
||||||
|
color: CP_CATEGORY_STYLES[cat].color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}, [connectionPoints, geom]);
|
||||||
|
|
||||||
|
// ── ЗОУИТ overlaps grouped by severity ───────────────────────────────────────
|
||||||
|
const zouitGrouped = useMemo(
|
||||||
|
() => groupZouitBySeverity(analysis.nspd_zouit_overlaps ?? []),
|
||||||
|
[analysis.nspd_zouit_overlaps],
|
||||||
|
);
|
||||||
|
const zouitCount = ZOUIT_ALL_SEVERITIES.reduce(
|
||||||
|
(sum, sev) => sum + (zouitGrouped.get(sev)?.length ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const allZouitVisible: Set<ZouitSeverity> = useMemo(
|
||||||
|
() => new Set(ZOUIT_ALL_SEVERITIES),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const customPoiList = customPois ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.mapMount}>
|
<div
|
||||||
<MapContainer center={center} zoom={geom ? 15 : 11} scrollWheelZoom>
|
className={`${styles.mapMount} ${base === "sat" ? styles.baseSat : ""}`}
|
||||||
<TileLayer
|
>
|
||||||
attribution='© <a href="https://carto.com/attributions">CARTO</a> · © <a href="https://www.openstreetmap.org/copyright">OSM</a>'
|
<MapContainer
|
||||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
center={center}
|
||||||
/>
|
zoom={geom ? 15 : 11}
|
||||||
{geom && (
|
zoomControl={false}
|
||||||
|
scrollWheelZoom
|
||||||
|
style={{ cursor: addMode ? "crosshair" : "grab" }}
|
||||||
|
>
|
||||||
|
<FitToGeometry geom={geom} />
|
||||||
|
<MapClickHandler editMode={addMode} onMapClick={handleMapClick} />
|
||||||
|
|
||||||
|
{/* Base layers — only one mounted at a time (key forces tile swap). */}
|
||||||
|
{base === "sat" ? (
|
||||||
|
<TileLayer
|
||||||
|
key="sat"
|
||||||
|
attribution="© Esri · Maxar"
|
||||||
|
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||||
|
maxZoom={19}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TileLayer
|
||||||
|
key="vec"
|
||||||
|
attribution='© <a href="https://carto.com/attributions">CARTO</a> · © <a href="https://www.openstreetmap.org/copyright">OSM</a>'
|
||||||
|
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||||
|
maxZoom={19}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ЗОУИТ охранные зоны — background fill (under markers). */}
|
||||||
|
{visible.has("zouit") && zouitCount > 0 && (
|
||||||
|
<ZouitLayer
|
||||||
|
grouped={zouitGrouped}
|
||||||
|
visibleSeverities={allZouitVisible}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Competitors (ЖК) + pipeline (будущие проекты). */}
|
||||||
|
{(visible.has("competitors") || visible.has("pipeline")) && (
|
||||||
|
<MarketLayers
|
||||||
|
competitors={competitorList}
|
||||||
|
pipelineObjects={pipelineList}
|
||||||
|
riskZones={[]}
|
||||||
|
opportunityParcels={[]}
|
||||||
|
redLines={[]}
|
||||||
|
showCompetitors={visible.has("competitors")}
|
||||||
|
showPipeline={visible.has("pipeline")}
|
||||||
|
showRiskZones={false}
|
||||||
|
showOpportunity={false}
|
||||||
|
showRedLines={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* POI markers (Школы·Детсады / Парки·Метро). */}
|
||||||
|
{(["edu", "greenmetro"] as PoiBucket[]).flatMap((bucket) => {
|
||||||
|
if (!visible.has(bucket)) return [];
|
||||||
|
const style = POI_BUCKET_STYLES[bucket];
|
||||||
|
return poiByBucket[bucket].map((poi, idx) => (
|
||||||
|
<CircleMarker
|
||||||
|
key={`${bucket}-${idx}`}
|
||||||
|
center={[poi.lat, poi.lon]}
|
||||||
|
radius={style.radius}
|
||||||
|
pathOptions={{
|
||||||
|
color: style.color,
|
||||||
|
fillColor: style.color,
|
||||||
|
fillOpacity: 0.85,
|
||||||
|
weight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top">
|
||||||
|
{poi.name ?? style.label} · {formatMeters(poi.distance_m)}
|
||||||
|
</Tooltip>
|
||||||
|
</CircleMarker>
|
||||||
|
));
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Connection points + polylines to the parcel centroid. */}
|
||||||
|
{visible.has("connections") && connectionPoints && (
|
||||||
|
<>
|
||||||
|
{connectionLines.map((line, idx) => (
|
||||||
|
<Polyline
|
||||||
|
key={`cp-line-${idx}`}
|
||||||
|
positions={[line.from, line.to]}
|
||||||
|
pathOptions={{
|
||||||
|
color: line.color,
|
||||||
|
weight: 2.2,
|
||||||
|
opacity: 0.85,
|
||||||
|
dashArray: "4 6",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<ConnectionPointsLayer
|
||||||
|
grouped={cpGrouped}
|
||||||
|
visibleCategories={new Set(CP_ALL_CATEGORIES)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Parcel polygon — cyan, on top of fills so it always reads. */}
|
||||||
|
{visible.has("parcel") && geom && (
|
||||||
<GeoJSON
|
<GeoJSON
|
||||||
|
key={analysis.cad_num}
|
||||||
data={geom}
|
data={geom}
|
||||||
style={{
|
style={{
|
||||||
// = --accent-cyan (Leaflet style objects can't read CSS vars)
|
// = --accent-cyan-strong (Leaflet styles can't read CSS vars)
|
||||||
color: "#7fd0ee",
|
color: "#8ed3ff",
|
||||||
weight: 2.5,
|
weight: 2.5,
|
||||||
fillColor: "#7fd0ee",
|
fillColor: "#8ed3ff",
|
||||||
fillOpacity: 0.18,
|
fillOpacity: 0.15,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Custom POI — topmost; add/edit/delete wired to useCustomPois. */}
|
||||||
|
{visible.has("custompoi") && customPoiList.length > 0 && (
|
||||||
|
<CustomPoiLayer
|
||||||
|
pois={customPoiList}
|
||||||
|
onEdit={(poi) => setEditingPoi(poi)}
|
||||||
|
onDelete={(id) => deleteMutation.mutate(id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|
||||||
|
{/* ── Legend / layer-toggle overlay ────────────────────────────────────── */}
|
||||||
|
<div className={styles.mapOverlay}>
|
||||||
|
<h4>
|
||||||
|
<span className={styles.live} />
|
||||||
|
{base === "sat" ? "Спутник · свежий снимок" : "Схема · тёмная карта"}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className={styles.baseToggle}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={base === "sat" ? styles.baseToggleActive : ""}
|
||||||
|
onClick={() => setBase("sat")}
|
||||||
|
>
|
||||||
|
Спутник
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={base === "vec" ? styles.baseToggleActive : ""}
|
||||||
|
onClick={() => setBase("vec")}
|
||||||
|
>
|
||||||
|
Схема
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.layerList}>
|
||||||
|
<LegendRow
|
||||||
|
color="#8ed3ff"
|
||||||
|
label="Участок"
|
||||||
|
count={geom ? 1 : 0}
|
||||||
|
on={visible.has("parcel")}
|
||||||
|
onToggle={() => toggleLayer("parcel")}
|
||||||
|
/>
|
||||||
|
<LegendRow
|
||||||
|
color={MARKET_COLORS.competitor}
|
||||||
|
label="ЖК"
|
||||||
|
count={competitorCount}
|
||||||
|
on={visible.has("competitors")}
|
||||||
|
onToggle={() => toggleLayer("competitors")}
|
||||||
|
/>
|
||||||
|
<LegendRow
|
||||||
|
color={MARKET_COLORS.pipeline}
|
||||||
|
label="Будущие проекты"
|
||||||
|
count={pipelineCount}
|
||||||
|
on={visible.has("pipeline")}
|
||||||
|
onToggle={() => toggleLayer("pipeline")}
|
||||||
|
/>
|
||||||
|
<LegendRow
|
||||||
|
color={POI_BUCKET_STYLES.edu.color}
|
||||||
|
label="Школы · Дет.сады"
|
||||||
|
count={poiByBucket.edu.length}
|
||||||
|
on={visible.has("edu")}
|
||||||
|
onToggle={() => toggleLayer("edu")}
|
||||||
|
/>
|
||||||
|
<LegendRow
|
||||||
|
color={POI_BUCKET_STYLES.greenmetro.color}
|
||||||
|
label="Парки · Метро"
|
||||||
|
count={poiByBucket.greenmetro.length}
|
||||||
|
on={visible.has("greenmetro")}
|
||||||
|
onToggle={() => toggleLayer("greenmetro")}
|
||||||
|
/>
|
||||||
|
{zouitCount > 0 && (
|
||||||
|
<LegendRow
|
||||||
|
color="#d2655b"
|
||||||
|
label="ЗОУИТ"
|
||||||
|
count={zouitCount}
|
||||||
|
on={visible.has("zouit")}
|
||||||
|
onToggle={() => toggleLayer("zouit")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<LegendRow
|
||||||
|
color="#7fd0ee"
|
||||||
|
label="Точки подключения"
|
||||||
|
count={cpCount}
|
||||||
|
on={visible.has("connections")}
|
||||||
|
onToggle={() => toggleLayer("connections")}
|
||||||
|
/>
|
||||||
|
{/* Изохроны — реальный источник (ORS) пока вне /analyze: «скоро». */}
|
||||||
|
<div className={`${styles.layerRow} ${styles.layerRowSoon}`}>
|
||||||
|
<span className={styles.swatch} />
|
||||||
|
<span>Изохроны доступности</span>
|
||||||
|
<span className={styles.cnt}>скоро</span>
|
||||||
|
</div>
|
||||||
|
<LegendRow
|
||||||
|
color="#e0a93b"
|
||||||
|
label="Кастомные POI"
|
||||||
|
count={customPoiList.length}
|
||||||
|
on={visible.has("custompoi")}
|
||||||
|
onToggle={() => toggleLayer("custompoi")}
|
||||||
|
/>
|
||||||
|
{onOpenDrawer && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.layerRow} ${styles.layerRow3d}`}
|
||||||
|
onClick={() => onOpenDrawer("future3d")}
|
||||||
|
title="Открыть 3D-массу застройки"
|
||||||
|
>
|
||||||
|
<span className={`${styles.swatch} ${styles.swatch3d}`} />
|
||||||
|
<span>3D-масса застройки</span>
|
||||||
|
<span className={styles.openTag}>3D</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cpCount > 0 && (
|
||||||
|
<>
|
||||||
|
<hr className={styles.overlayDivider} />
|
||||||
|
<div className={styles.overlaySub}>Точки подключения ресурсов</div>
|
||||||
|
{CP_ALL_CATEGORIES.filter((c) => cpNearest.has(c)).map((catKey) => {
|
||||||
|
const dist = cpNearest.get(catKey);
|
||||||
|
if (dist === undefined) return null;
|
||||||
|
const style = CP_CATEGORY_STYLES[catKey];
|
||||||
|
return (
|
||||||
|
<div key={catKey} className={styles.resRow}>
|
||||||
|
<span
|
||||||
|
className={styles.dot}
|
||||||
|
style={{ background: style.color }}
|
||||||
|
/>
|
||||||
|
<span>{style.label}</span>
|
||||||
|
<b>{formatMeters(dist)}</b>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Map tools (add-POI · 3D) ─────────────────────────────────────────── */}
|
||||||
|
<div className={styles.mapTools}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.tool} ${addMode ? styles.toolActive : ""}`}
|
||||||
|
title={addMode ? "Кликните на карте" : "Добавить POI"}
|
||||||
|
aria-label="Добавить пользовательскую точку"
|
||||||
|
onClick={() => {
|
||||||
|
setAddMode((v) => !v);
|
||||||
|
setPendingCoords(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M12 21s7-6.2 7-11a7 7 0 10-14 0c0 4.8 7 11 7 11z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 7v6M9 10h6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{onOpenDrawer && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.tool}
|
||||||
|
title="3D-масса застройки"
|
||||||
|
aria-label="Открыть 3D-массу застройки"
|
||||||
|
onClick={() => onOpenDrawer("future3d")}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.4"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addMode && (
|
||||||
|
<div className={styles.mapHint}>Кликните на карте для точки</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add / edit modals (reuse the analysis-map custom-POI flow). */}
|
||||||
|
{pendingCoords && (
|
||||||
|
<CustomPoiAddModal
|
||||||
|
lat={pendingCoords.lat}
|
||||||
|
lon={pendingCoords.lon}
|
||||||
|
parcelCad={cad}
|
||||||
|
isLoading={addMutation.isPending}
|
||||||
|
onSubmit={(payload) => {
|
||||||
|
addMutation.mutate(payload, {
|
||||||
|
onSuccess: () => setPendingCoords(null),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onCancel={() => setPendingCoords(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editingPoi && (
|
||||||
|
<CustomPoiEditModal
|
||||||
|
poi={editingPoi}
|
||||||
|
isLoading={updateMutation.isPending}
|
||||||
|
onSubmit={(updateData) => {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ id: editingPoi.id, data: updateData },
|
||||||
|
{ onSuccess: () => setEditingPoi(null) },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditingPoi(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Legend row (toggle button) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function LegendRow({
|
||||||
|
color,
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
on,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
on: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.layerRow} ${on ? "" : styles.layerRowOff}`}
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-pressed={on}
|
||||||
|
>
|
||||||
|
<span className={styles.swatch} style={{ background: color }} />
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className={styles.cnt}>{count}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -426,6 +426,34 @@ export function adaptOksCard(): PticaScanCard {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инсоляция · 3D-масса — entry card for the future3d / insolation drawer (the 3D
|
||||||
|
* massing + shadow preview). Buildability % is REAL (shared gauge); the rest are
|
||||||
|
* honest placeholders until the massing volumetrics land. The card exists so the
|
||||||
|
* 3D module is discoverable from the analysis grid, not only via the map tool.
|
||||||
|
*/
|
||||||
|
export function adaptInsolationCard(a: ParcelAnalysis): PticaScanCard {
|
||||||
|
const build = adaptBuildabilityGauge(a);
|
||||||
|
return {
|
||||||
|
title: "Инсоляция · 3D-масса",
|
||||||
|
badge: "3D",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
key: "Buildability",
|
||||||
|
field:
|
||||||
|
build.value != null && build.isReal
|
||||||
|
? real(`${Math.round(build.value)}%`)
|
||||||
|
: placeholder("после расчёта потенциала"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Норматив инсоляции",
|
||||||
|
field: placeholder("после 3D-расчёта теней"),
|
||||||
|
},
|
||||||
|
{ key: "Затенённость", field: placeholder("после 3D-расчёта теней") },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
// DRAWER-LEVEL ADAPTERS (PR#4) — typed view-models, real-vs-placeholder centralized
|
// DRAWER-LEVEL ADAPTERS (PR#4) — typed view-models, real-vs-placeholder centralized
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue