feat(ptica): rich dark cockpit map + ПТИЦА as the default analysis route
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 49s
CI / openapi-codegen-check (pull_request) Successful in 1m48s

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:
Light1YT 2026-06-21 00:04:00 +05:00
parent 09656077c1
commit ba05a4cb62
8 changed files with 1084 additions and 86 deletions

View file

@ -1,7 +1,7 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { AnalysisPageContent } from "./AnalysisPageContent";
import { PticaPageContent } from "./ptica/PticaPageContent";
// ── Metadata ──────────────────────────────────────────────────────────────────
@ -21,13 +21,18 @@ export async function generateMetadata({
const { cad: cadRaw } = await params;
const cad = decodeURIComponent(cadRaw);
return {
title: `Анализ ${cad} — Site Finder`,
description: `Анализ инвестиционного участка ${cad}`,
title: `ПТИЦА · ${cad}`,
description: `Когнитивный анализ участка ${cad} — платформа ПТИЦА`,
};
}
// ── 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) {
const { cad: cadRaw } = await params;
const cad = decodeURIComponent(cadRaw);
@ -36,18 +41,23 @@ export default async function AnalysisPage({ params }: PageProps) {
<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={{
padding: "40px 24px",
textAlign: "center",
color: "var(--fg-tertiary, #73767E)",
minHeight: "100vh",
display: "grid",
placeItems: "center",
background: "#060f16",
color: "#8ba6b3",
fontSize: 14,
}}
>
Загрузка анализа
Загрузка ПТИЦА
</div>
}
>
<AnalysisPageContent cad={cad} />
<PticaPageContent cad={cad} />
</Suspense>
);
}

View file

@ -1,55 +1,18 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { PticaPageContent } from "./PticaPageContent";
// ── Metadata ──────────────────────────────────────────────────────────────────
// ── Page ──────────────────────────────────────────────────────────────────────
interface PageProps {
params: Promise<{ cad: string }>;
}
// Next.js delivers `cad` URL-encoded (":" -> "%3A"); decode once at the page
// boundary so the cockpit + hooks see the canonical "66:41:0204016:10" (and
// re-encode exactly once when building API URLs). Mirrors the sibling page.tsx.
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
// The ПТИЦА cockpit is now rendered by the parent `[cad]/page.tsx` route, so
// this subroute is redundant — it permanently redirects to the parent to avoid
// two divergent copies of PticaPageContent. Old `/site-finder/analysis/{cad}/
// ptica` links (and any cached bookmarks) keep working via this hop.
export default async function PticaSubroutePage({ params }: PageProps) {
const { cad: cadRaw } = await params;
const cad = decodeURIComponent(cadRaw);
return {
title: `ПТИЦА · ${cad}`,
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>
);
// `cad` arrives URL-encoded; keep it encoded for the redirect target so the
// canonical decode happens once in the parent page boundary.
redirect(`/site-finder/analysis/${cadRaw}`);
}

View file

@ -33,6 +33,14 @@
--accent-purple: #9b80d6;
--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;
/* dark surface palette */
@ -56,6 +64,7 @@
--map-bg: #0a1820;
--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-strong: rgba(110, 200, 240, 0.55);
--shadow: 0 18px 70px rgba(0, 0, 0, 0.5);
@ -363,6 +372,55 @@
.mapMount :global(.leaflet-tile-pane) {
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) {
background: rgba(8, 19, 28, 0.55);
color: var(--text-soft);
@ -1865,3 +1923,247 @@
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);
}

View file

@ -21,6 +21,7 @@ import {
adaptEconomyCard,
adaptEnvironmentCard,
adaptOksCard,
adaptInsolationCard,
adaptRiskGauge,
} from "@/components/site-finder/ptica/ptica-adapt";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
@ -89,6 +90,13 @@ export function DevelopmentScan({ analysis, onOpenDrawer }: Props) {
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>
</>
);

View file

@ -31,7 +31,7 @@ export function PticaHero({ analysis, forecastReport, onOpenDrawer }: Props) {
return (
<section id="passport" className={styles.hero}>
<PticaMap geojson={analysis.geom_geojson} />
<PticaMap analysis={analysis} onOpenDrawer={onOpenDrawer} />
<ParcelPassportCard analysis={analysis} onOpenDrawer={onOpenDrawer} />

View file

@ -1,14 +1,23 @@
"use client";
/**
* PticaMap SSR-safe wrapper around the Leaflet cockpit map. Leaflet touches
* `window`, so PticaMapInner is loaded via dynamic(ssr:false) with a dark
* loading placeholder (never imported into a server component).
* PticaMap SSR-safe wrapper around the rich Leaflet cockpit map. Leaflet
* touches `window`, so PticaMapInner is loaded via dynamic(ssr:false) with a
* 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 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(
() => import("@/components/site-finder/ptica/PticaMapInner"),
@ -19,13 +28,24 @@ const PticaMapInner = dynamic(
);
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 (
<div className={styles.mapCard}>
<PticaMapInner geojson={geojson} />
<PticaMapInner
analysis={analysis}
cad={cad}
connectionPoints={connectionPoints}
customPois={customPois}
onOpenDrawer={onOpenDrawer}
/>
</div>
);
}

View file

@ -1,25 +1,107 @@
"use client";
/**
* PticaMapInner react-leaflet map for the cockpit hero (client-only; loaded
* via dynamic(ssr:false) from PticaMap). CartoDB dark_all tiles + the parcel
* polygon in cyan, centered on the geometry centroid (or EKB when absent).
* PticaMapInner the rich dark cockpit map (client-only; loaded via
* dynamic(ssr:false) from PticaMap). Prototype parity (ptica-redesign/app.js):
*
* Mirrors the SiteMap pattern: react-leaflet MapContainer + the Leaflet default-
* icon webpack fix. `geojson` arrives as `unknown` (the wire type of
* ParcelAnalysis.geom_geojson) and is narrowed to a GeoJSON Geometry here.
* - Two base layers, toggled by the «Спутник | Схема» segmented control: Esri
* World Imagery (default, `.baseSat` darker --map-filter-sat) and CartoDB
* 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 { MapContainer, TileLayer, GeoJSON } from "react-leaflet";
import { useEffect, useMemo, useRef, useState } from "react";
import {
MapContainer,
TileLayer,
GeoJSON,
CircleMarker,
Polyline,
Popup,
Tooltip,
useMap,
useMapEvents,
} from "react-leaflet";
import type { Geometry, Position } from "geojson";
import "leaflet/dist/leaflet.css";
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import { EKB_CENTER } from "@/components/site-finder/ptica/ptica-adapt";
import type { ParcelAnalysis } from "@/types/site-finder";
import type { 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 {
geojson: unknown;
// ── POI legend buckets (prototype: ЖК / Школы·Детсады / Парки·Метро) ───────────
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 ─────────────────────────────────────
@ -42,7 +124,7 @@ function asGeometry(value: unknown): Geometry | null {
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[] {
switch (geom.type) {
@ -79,7 +161,96 @@ function geomCenter(geom: Geometry): [number, number] {
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).
useEffect(() => {
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;
// ── 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 (
<div className={styles.mapMount}>
<MapContainer center={center} zoom={geom ? 15 : 11} scrollWheelZoom>
<TileLayer
attribution='&copy; <a href="https://carto.com/attributions">CARTO</a> · &copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>'
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
/>
{geom && (
<div
className={`${styles.mapMount} ${base === "sat" ? styles.baseSat : ""}`}
>
<MapContainer
center={center}
zoom={geom ? 15 : 11}
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="&copy; Esri · Maxar"
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
maxZoom={19}
/>
) : (
<TileLayer
key="vec"
attribution='&copy; <a href="https://carto.com/attributions">CARTO</a> · &copy; <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
key={analysis.cad_num}
data={geom}
style={{
// = --accent-cyan (Leaflet style objects can't read CSS vars)
color: "#7fd0ee",
// = --accent-cyan-strong (Leaflet styles can't read CSS vars)
color: "#8ed3ff",
weight: 2.5,
fillColor: "#7fd0ee",
fillOpacity: 0.18,
fillColor: "#8ed3ff",
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>
{/* ── 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>
);
}
// ── 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>
);
}

View file

@ -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
// ══════════════════════════════════════════════════════════════════════════════