From 5587154327f1330f52d66b4d4539b808e255a36a Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 21 Jun 2026 02:18:20 +0500 Subject: [PATCH] fix(ptica): populate scan cards with real /analyze data + product tabs + map zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against a real captured /analyze+/forecast fixture rendered locally: much of the cockpit's «—» was unmapped data, not missing data. - РЫНОК: map Поглощение (market_pulse.avg_velocity_m2), add Конкурентов (market_pulse.competitors_total), Уровень спроса (market_trend.label); keep Медиана + Новых проектов. Prototype row order. - СРЕДА (adaptEnvironmentCard now takes analysis): Шум (noise.estimated_db), Воздух PM2.5 (air_quality.pm2_5), Ветер (wind dir + max_speed), Водоёмы (hydrology nearest). Honest «—» where genuinely absent. - ГРАДОСТРОИТЕЛЬСТВО: Пятно застройки = area × max_building_pct/100 (real). - RECOMMENDED PRODUCT: prototype class tabs (обж_class present in mix), default = recommended class; per-class honest empty-state preserved. - MAP: dark-styled ZoomControl (+/-) + ScaleControl, matching prototype. - types: optional MarketPulse/MarketPrice on ParcelAnalysis (no any). Economy/Investment-clearance stay «после финмодели §23» (not built); the 0%/«нельзя»/all-«избегать» verdict is the parcel's real result, not a bug. --- .../analysis/[cad]/ptica/ptica.module.css | 66 +++++++++++ .../site-finder/ptica/DevelopmentScan.tsx | 2 +- .../site-finder/ptica/PticaMapInner.tsx | 6 + .../ptica/cockpit/RecommendedProductCard.tsx | 63 ++++++++-- .../site-finder/ptica/ptica-adapt.ts | 108 ++++++++++++++++-- frontend/src/types/site-finder.ts | 36 ++++++ 6 files changed, 264 insertions(+), 17 deletions(-) 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 81df944d..7f58998c 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 @@ -520,6 +520,39 @@ .mapMount :global(.leaflet-control-attribution a) { color: var(--text-muted); } +/* Dark-styled zoom +/- control (prototype map controls). */ +.mapMount :global(.leaflet-control-zoom) { + border: var(--line) solid var(--border-strong); + border-radius: var(--radius-xs); + overflow: hidden; + box-shadow: 0 0 12px var(--glow); +} +.mapMount :global(.leaflet-control-zoom a) { + background: var(--surface-strong); + color: var(--text); + border-bottom: var(--line) solid var(--border); + width: 28px; + height: 28px; + line-height: 28px; +} +.mapMount :global(.leaflet-control-zoom a:hover) { + background: var(--surface-muted); + color: var(--accent-cyan); +} +.mapMount :global(.leaflet-control-zoom a.leaflet-disabled) { + background: var(--surface-strong); + color: var(--text-soft); +} +/* Metric scale bar. */ +.mapMount :global(.leaflet-control-scale-line) { + background: rgba(8, 19, 28, 0.55); + color: var(--text-soft); + border: var(--line) solid var(--border); + border-top: none; + font-size: 9px; + padding: 1px 6px; + backdrop-filter: blur(4px); +} .mapLoading { position: absolute; inset: 0; @@ -788,11 +821,16 @@ } .kvrow .k { color: var(--text-muted); + flex-shrink: 0; } .kvrow .v { font-weight: 600; color: var(--text); text-align: right; + /* Long values (e.g. «Стагнация — рынок остыл») wrap gracefully instead of + overflowing the card. */ + min-width: 0; + overflow-wrap: anywhere; } .kvrowPlaceholder .v { color: var(--text-soft); @@ -1198,6 +1236,34 @@ color: var(--text); } +/* Recommended Product class-tabs (prototype .product-tabs / .ptab / .active) */ +.productTabs { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 12px; +} +.ptab { + padding: 4px 12px; + border: var(--line) solid var(--border); + background: transparent; + color: var(--text-muted); + border-radius: var(--radius-xs); + font-size: 10px; + cursor: pointer; +} +.ptab:hover { + color: var(--text); + border-color: var(--border-strong); +} +.ptabActive, +.ptabActive:hover { + background: var(--accent-blue); + color: var(--text); + border-color: var(--accent-blue); + box-shadow: 0 0 14px var(--glow); +} + /* horizontal bars (квартирография) */ .hbars { display: grid; diff --git a/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx b/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx index 1f7aac47..3ffaea0f 100644 --- a/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx +++ b/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx @@ -83,7 +83,7 @@ export function DevelopmentScan({ analysis, onOpenDrawer }: Props) { diff --git a/frontend/src/components/site-finder/ptica/PticaMapInner.tsx b/frontend/src/components/site-finder/ptica/PticaMapInner.tsx index 312756cb..e8218092 100644 --- a/frontend/src/components/site-finder/ptica/PticaMapInner.tsx +++ b/frontend/src/components/site-finder/ptica/PticaMapInner.tsx @@ -36,6 +36,8 @@ import { Popup, Tooltip, AttributionControl, + ZoomControl, + ScaleControl, useMap, useMapEvents, } from "react-leaflet"; @@ -442,6 +444,10 @@ export default function PticaMapInner({ {/* Drop the Leaflet 1.9 flag/«Leaflet» prefix but KEEP the per-tile data attribution (© Esri · Maxar / CARTO · OSM) required for licensing. */} + {/* Dark-styled zoom +/- (single instance — MapContainer zoomControl is + false so this is the only one) and a metric scale bar. */} + + diff --git a/frontend/src/components/site-finder/ptica/cockpit/RecommendedProductCard.tsx b/frontend/src/components/site-finder/ptica/cockpit/RecommendedProductCard.tsx index 5829ba7e..cfd9a834 100644 --- a/frontend/src/components/site-finder/ptica/cockpit/RecommendedProductCard.tsx +++ b/frontend/src/components/site-finder/ptica/cockpit/RecommendedProductCard.tsx @@ -9,6 +9,8 @@ * honest «после прогноза» placeholder. "Подробнее" opens the product drawer. */ +import { useMemo, useState } from "react"; + import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; import type { ForecastReport, @@ -48,16 +50,30 @@ interface MixBar { tone: "good" | "warn" | "bad" | "neutral"; } -function buildBars(tz: ReportProductTz): { +/** Distinct obj_class values present in the mix, in first-seen order. */ +function classesInMix(tz: ReportProductTz): string[] { + const seen: string[] = []; + for (const m of tz.mix) { + if (m.obj_class != null && !seen.includes(m.obj_class)) { + seen.push(m.obj_class); + } + } + return seen; +} + +function buildBars( + tz: ReportProductTz, + selectedClass: string | null, +): { bars: MixBar[]; byShare: boolean; } { // §22 mix can carry buckets across MULTIPLE obj_class values (e.g. 3 classes × - // 5 buckets = 15 rows). Keep ONLY the recommended class so the card shows the + // 5 buckets = 15 rows). Keep ONLY the selected class so the card shows the // single honest квартирография (≈5 rows); if no class is set, keep all. const classMix: ProductMixEntry[] = - tz.obj_class != null - ? tz.mix.filter((m) => m.obj_class === tz.obj_class) + selectedClass != null + ? tz.mix.filter((m) => m.obj_class === selectedClass) : tz.mix; const rows = classMix.filter( (m) => m.pct != null || m.deficit_index != null, @@ -98,8 +114,20 @@ export function RecommendedProductCard({ const tz = forecastReport?.product_tz; const hasProduct = tz != null && (tz.obj_class != null || tz.mix.length > 0 || !!tz.summary); + + // Classes present in the mix (segmented tabs). Default to the recommended + // class (tz.obj_class) when it is one of them, else the first present class. + const classes = useMemo(() => (tz ? classesInMix(tz) : []), [tz]); + const [selectedClass, setSelectedClass] = useState(null); + const activeClass = + selectedClass != null && classes.includes(selectedClass) + ? selectedClass + : tz?.obj_class != null && classes.includes(tz.obj_class) + ? tz.obj_class + : (classes[0] ?? null); + const { bars: allBars, byShare } = tz - ? buildBars(tz) + ? buildBars(tz, activeClass) : { bars: [], byShare: false }; // Degenerate signal: when bars are deficit-driven (not explicit доля) and every @@ -120,11 +148,32 @@ export function RecommendedProductCard({ Recommended Product продуктовая стратегия - {hasProduct && tz?.obj_class && ( - {capitalize(tz.obj_class)} + {hasProduct && activeClass != null && ( + {capitalize(activeClass)} )} + {/* Segmented class-tabs (prototype .product-tabs > .ptab). One tab per + obj_class present in the mix; the recommended class is default-active. */} + {hasProduct && classes.length > 1 && ( +
+ {classes.map((cls) => ( + + ))} +
+ )} + {hasProduct && bars.length > 0 ? ( <>
diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts index b97b27aa..f772e2fc 100644 --- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts +++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts @@ -354,6 +354,7 @@ export function adaptUrbanCard(a: ParcelAnalysis): PticaScanCard { const zone = regulationZone(z); const maxFar = z?.max_far; const maxHeight = z?.max_height_m; + const buildingPct = z?.max_building_pct; const areaM2 = parcelAreaM2(a); // Плотность (КСИТ) ёмкость = площадь × КСИТ (м²), real only with both inputs. @@ -368,6 +369,18 @@ export function adaptUrbanCard(a: ParcelAnalysis): PticaScanCard { : { value: formatFar(maxFar), isReal: true, caption: "КСИТ · НСПД" } : placeholder(SRC_NSPD_ZONING); + // Пятно застройки = площадь участка × коэф. застройки (max_building_pct). + // Reuses parcelAreaM2 (area_ha × 10000) — the same area КСИТ uses, and the same + // computation as the DEVELOPMENT POTENTIAL card (adaptPotentialDrawer). + const spotField: PticaField = + areaM2 != null && buildingPct != null + ? { + value: `${formatInt(areaM2 * (buildingPct / 100))} м²`, + isReal: true, + caption: `${formatInt(buildingPct)} % площади · НСПД`, + } + : placeholder(SRC_NSPD_ZONING); + return { title: "Градостроительство", rows: [ @@ -390,7 +403,7 @@ export function adaptUrbanCard(a: ParcelAnalysis): PticaScanCard { }, { key: "Пятно застройки", - field: placeholder("после расчёта потенциала"), + field: spotField, }, ], }; @@ -439,10 +452,22 @@ export function adaptEngineeringCard(a: ParcelAnalysis): PticaScanCard { }; } -/** Рынок — median price + pipeline count are REAL; demand/absorption placeholder. */ +/** + * Рынок — REAL data from the market layer of /analyze. Median (district), + * absorption (market_pulse.avg_velocity_m2), competitor count + * (market_pulse.competitors_total), pipeline объектов (pipeline_24mo) and the + * demand-trend label (market_trend.label). Honest «—» where a field is absent. + * Prototype row order: Медиана цены / Поглощение / Конкурентов / Новых проектов / + * Уровень спроса. + */ export function adaptMarketCard(a: ParcelAnalysis): PticaScanCard { const median = a.district?.median_price_per_m2; + const pulse = a.market_pulse; + const velocity = pulse?.avg_velocity_m2; + const competitors = pulse?.competitors_total; const newProjects = a.pipeline_24mo?.objects_count; + const demandLabel = a.market_trend?.label; + return { title: "Рынок", rows: [ @@ -453,6 +478,24 @@ export function adaptMarketCard(a: ParcelAnalysis): PticaScanCard { ? real(formatRubPerM2(median)) : placeholder("нет данных района"), }, + { + key: "Поглощение", + field: + velocity != null + ? { + value: `${fmtNum(velocity, 1)} м²/мес`, + isReal: true, + caption: "средний темп поглощения · рынок", + } + : placeholder("нет данных рынка"), + }, + { + key: "Конкурентов", + field: + competitors != null + ? real(formatInt(competitors)) + : placeholder("нет данных рынка"), + }, { key: "Новых проектов", field: @@ -460,8 +503,12 @@ export function adaptMarketCard(a: ParcelAnalysis): PticaScanCard { ? real(formatInt(newProjects)) : placeholder("нет пайплайна"), }, - { key: "Поглощение", field: placeholder("после прогноза") }, - { key: "Уровень спроса", field: placeholder("после прогноза") }, + { + key: "Уровень спроса", + field: demandLabel + ? { value: demandLabel, isReal: true, caption: "тренд цен · рынок" } + : placeholder("нет тренда рынка"), + }, ], }; } @@ -480,15 +527,58 @@ export function adaptEconomyCard(): PticaScanCard { }; } -/** Среда · экология — PLACEHOLDER for INCREMENT 1. */ -export function adaptEnvironmentCard(): PticaScanCard { +/** + * Среда · экология — REAL atmospheric layer from /analyze. Шум (noise.estimated_db + * + level), Воздух (air_quality.pm2_5 — honest PM2.5 reading, NOT a fabricated AQI + * index), Ветер (wind dominant direction + max speed), Водоёмы (hydrology nearest + * water distance). Honest «—» where a field is absent. + */ +export function adaptEnvironmentCard(a: ParcelAnalysis): PticaScanCard { + const noise = a.noise; + const aq = a.air_quality; + const wind = a.wind; + const hydro = a.hydrology; + const nearestWater = hydro?.nearest?.[0]?.distance_m; + + const noiseField: PticaField = noise + ? { + value: `${fmtNum(noise.estimated_db, 1)} дБ`, + isReal: true, + caption: `уровень: ${noise.level}`, + } + : placeholder("нет данных шума"); + + // Воздух — честное прямое чтение PM2.5 (не выдуманный AQI-индекс). + const airField: PticaField = aq + ? { + value: `PM2.5 ${fmtNum(aq.pm2_5, 1)}`, + isReal: true, + caption: "мкг/м³ · атмосферный слой", + } + : placeholder("нет данных AQI"); + + const windField: PticaField = wind + ? wind.max_speed_m_s != null + ? real( + `${wind.dominant_direction_label} · ${fmtNum(wind.max_speed_m_s, 1)} м/с`, + ) + : real(wind.dominant_direction_label) + : placeholder("нет данных ветра"); + return { title: "Среда", badge: "экология", rows: [ - { key: "Шум", field: placeholder("после атмосферного слоя") }, - { key: "Воздух (AQI)", field: placeholder("после атмосферного слоя") }, - { key: "Зелёных зон", field: placeholder("после атмосферного слоя") }, + { key: "Шум", field: noiseField }, + { key: "Воздух (PM2.5)", field: airField }, + { key: "Ветер", field: windField }, + { + key: "Водоёмы", + field: + nearestWater != null + ? real(`${formatInt(nearestWater)} м`) + : placeholder("нет ближайшего водоёма"), + }, ], }; } diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 40ea5c4f..d792a391 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -185,6 +185,38 @@ export interface MarketTrend { radius_km: number; } +// Market pulse — district absorption + competitor counts (/analyze market layer). +export interface MarketPulseTopSeller { + obj_id: number; + comm_name: string | null; + dev_name: string | null; + units_sold: number | null; + avg_price_per_m2_rub: number | null; +} + +export interface MarketPulse { + avg_velocity_m2: number; // средний темп поглощения, м²/мес + market_avg_price_per_m2: number; + competitors_total: number; // всего конкурентов в выборке + competitors_with_price: number; + coverage_pct: number; + top_sellers?: MarketPulseTopSeller[]; +} + +// Market price distribution (district deals) — used for the median row. +export interface MarketPrice { + p25: number | null; + median: number | null; + p75: number | null; + mean: number | null; + deals_count: number | null; + median_6m: number | null; + median_12m: number | null; + median_24m: number | null; + last_deal_date: string | null; + source: string; +} + // D4 (#36) — 24-month project pipeline competition export interface PipelineQuarterSlot { quarter: string; @@ -373,6 +405,10 @@ export interface ParcelAnalysis { score_max_reference?: number; score_explanation?: string; market_trend?: MarketTrend | null; + // Абсорбция + число конкурентов из рыночного слоя /analyze. + market_pulse?: MarketPulse | null; + // Распределение цен сделок района (median и т.п.). + market_price?: MarketPrice | null; score_breakdown: Record; score_breakdown_detailed?: FactorContribution[]; score_top_3_positives?: FactorContribution[];