From ba05a4cb624cc96b39d3084b7407e9067df7801d Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 21 Jun 2026 00:04:00 +0500 Subject: [PATCH] =?UTF-8?q?feat(ptica):=20rich=20dark=20cockpit=20map=20+?= =?UTF-8?q?=20=D0=9F=D0=A2=D0=98=D0=A6=D0=90=20as=20the=20default=20analys?= =?UTF-8?q?is=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app/site-finder/analysis/[cad]/page.tsx | 26 +- .../site-finder/analysis/[cad]/ptica/page.tsx | 57 +- .../analysis/[cad]/ptica/ptica.module.css | 302 ++++++++ .../site-finder/ptica/DevelopmentScan.tsx | 8 + .../site-finder/ptica/PticaHero.tsx | 2 +- .../components/site-finder/ptica/PticaMap.tsx | 32 +- .../site-finder/ptica/PticaMapInner.tsx | 715 +++++++++++++++++- .../site-finder/ptica/ptica-adapt.ts | 28 + 8 files changed, 1084 insertions(+), 86 deletions(-) diff --git a/frontend/src/app/site-finder/analysis/[cad]/page.tsx b/frontend/src/app/site-finder/analysis/[cad]/page.tsx index 6a82c8da..c2ceefa2 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/page.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/page.tsx @@ -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) { - Загрузка анализа… + Загрузка ПТИЦА… } > - + ); } diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/page.tsx b/frontend/src/app/site-finder/analysis/[cad]/ptica/page.tsx index 75f42f58..8845a982 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/page.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/page.tsx @@ -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 { +// 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 ( - - Загрузка ПТИЦА… - - } - > - - - ); + // `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}`); } diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css index 55a0413e..58f46369 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css @@ -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); +} diff --git a/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx b/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx index a10b99a6..54a4d9f2 100644 --- a/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx +++ b/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx @@ -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} /> + {/* Инсоляция · 3D-масса — opens the future3d/insolation drawer (3D + massing + shadow preview) so the 3D module is discoverable here. */} + ); diff --git a/frontend/src/components/site-finder/ptica/PticaHero.tsx b/frontend/src/components/site-finder/ptica/PticaHero.tsx index 71d8c032..5647d2d7 100644 --- a/frontend/src/components/site-finder/ptica/PticaHero.tsx +++ b/frontend/src/components/site-finder/ptica/PticaHero.tsx @@ -31,7 +31,7 @@ export function PticaHero({ analysis, forecastReport, onOpenDrawer }: Props) { return (
- + diff --git a/frontend/src/components/site-finder/ptica/PticaMap.tsx b/frontend/src/components/site-finder/ptica/PticaMap.tsx index 79ad46a6..f7d8ad93 100644 --- a/frontend/src/components/site-finder/ptica/PticaMap.tsx +++ b/frontend/src/components/site-finder/ptica/PticaMap.tsx @@ -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 (
- +
); } diff --git a/frontend/src/components/site-finder/ptica/PticaMapInner.tsx b/frontend/src/components/site-finder/ptica/PticaMapInner.tsx index eace3f14..783e465e 100644 --- a/frontend/src/components/site-finder/ptica/PticaMapInner.tsx +++ b/frontend/src/components/site-finder/ptica/PticaMapInner.tsx @@ -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 = { + 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, +): [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>( + () => + new Set([ + "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(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(), + [connectionPoints], + ); + const cpCount = connectionPoints?.engineering_structures.length ?? 0; + + // Nearest distance per category — drives the «Точки подключения» list. + const cpNearest = useMemo(() => { + const nearest = new Map(); + 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 = useMemo( + () => new Set(ZOUIT_ALL_SEVERITIES), + [], + ); + + const customPoiList = customPois ?? []; + return ( -
- - - {geom && ( +
+ + + + + {/* Base layers — only one mounted at a time (key forces tile swap). */} + {base === "sat" ? ( + + ) : ( + + )} + + {/* ЗОУИТ охранные зоны — background fill (under markers). */} + {visible.has("zouit") && zouitCount > 0 && ( + + )} + + {/* Competitors (ЖК) + pipeline (будущие проекты). */} + {(visible.has("competitors") || visible.has("pipeline")) && ( + + )} + + {/* 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) => ( + + + {poi.name ?? style.label} · {formatMeters(poi.distance_m)} + + + )); + })} + + {/* Connection points + polylines to the parcel centroid. */} + {visible.has("connections") && connectionPoints && ( + <> + {connectionLines.map((line, idx) => ( + + ))} + + + )} + + {/* Parcel polygon — cyan, on top of fills so it always reads. */} + {visible.has("parcel") && geom && ( )} + + {/* Custom POI — topmost; add/edit/delete wired to useCustomPois. */} + {visible.has("custompoi") && customPoiList.length > 0 && ( + setEditingPoi(poi)} + onDelete={(id) => deleteMutation.mutate(id)} + /> + )} + + {/* ── Legend / layer-toggle overlay ────────────────────────────────────── */} +
+

+ + {base === "sat" ? "Спутник · свежий снимок" : "Схема · тёмная карта"} +

+ +
+ + +
+ +
+ toggleLayer("parcel")} + /> + toggleLayer("competitors")} + /> + toggleLayer("pipeline")} + /> + toggleLayer("edu")} + /> + toggleLayer("greenmetro")} + /> + {zouitCount > 0 && ( + toggleLayer("zouit")} + /> + )} + toggleLayer("connections")} + /> + {/* Изохроны — реальный источник (ORS) пока вне /analyze: «скоро». */} +
+ + Изохроны доступности + скоро +
+ toggleLayer("custompoi")} + /> + {onOpenDrawer && ( + + )} +
+ + {cpCount > 0 && ( + <> +
+
Точки подключения ресурсов
+ {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 ( +
+ + {style.label} + {formatMeters(dist)} +
+ ); + })} + + )} +
+ + {/* ── Map tools (add-POI · 3D) ─────────────────────────────────────────── */} +
+ + {onOpenDrawer && ( + + )} +
+ + {addMode && ( +
Кликните на карте для точки
+ )} + + {/* Add / edit modals (reuse the analysis-map custom-POI flow). */} + {pendingCoords && ( + { + addMutation.mutate(payload, { + onSuccess: () => setPendingCoords(null), + }); + }} + onCancel={() => setPendingCoords(null)} + /> + )} + {editingPoi && ( + { + updateMutation.mutate( + { id: editingPoi.id, data: updateData }, + { onSuccess: () => setEditingPoi(null) }, + ); + }} + onCancel={() => setEditingPoi(null)} + /> + )}
); } + +// ── Legend row (toggle button) ───────────────────────────────────────────────── + +function LegendRow({ + color, + label, + count, + on, + onToggle, +}: { + color: string; + label: string; + count: number; + on: boolean; + onToggle: () => void; +}) { + return ( + + ); +} diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts index d36512ad..8ed506ac 100644 --- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts +++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts @@ -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 // ══════════════════════════════════════════════════════════════════════════════