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 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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='© <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"
|
||||
/>
|
||||
{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="© 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
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue