fix(ptica): populate scan cards with real /analyze data + product tabs + map zoom #1857

Merged
bot-backend merged 1 commit from fix/ptica-parity-data-mapping into main 2026-06-20 21:18:46 +00:00
6 changed files with 264 additions and 17 deletions

View file

@ -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;

View file

@ -83,7 +83,7 @@ export function DevelopmentScan({ analysis, onOpenDrawer }: Props) {
</div>
<ScanCard
card={adaptEnvironmentCard()}
card={adaptEnvironmentCard(analysis)}
drawerKey="environment"
onOpen={onOpenDrawer}
/>

View file

@ -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. */}
<AttributionControl prefix={false} position="bottomright" />
{/* Dark-styled zoom +/- (single instance MapContainer zoomControl is
false so this is the only one) and a metric scale bar. */}
<ZoomControl position="topright" />
<ScaleControl position="bottomleft" imperial={false} />
<FitToGeometry geom={geom} />
<MapClickHandler editMode={addMode} onMapClick={handleMapClick} />

View file

@ -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<string | null>(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
<span className={styles.cardTitleSub}>продуктовая стратегия</span>
</h3>
{hasProduct && tz?.obj_class && (
<span className={styles.tag}>{capitalize(tz.obj_class)}</span>
{hasProduct && activeClass != null && (
<span className={styles.tag}>{capitalize(activeClass)}</span>
)}
</div>
{/* 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 && (
<div className={styles.productTabs} role="tablist">
{classes.map((cls) => (
<button
key={cls}
type="button"
role="tab"
aria-selected={cls === activeClass}
className={`${styles.ptab} ${
cls === activeClass ? styles.ptabActive : ""
}`}
onClick={() => setSelectedClass(cls)}
>
{capitalize(cls)}
</button>
))}
</div>
)}
{hasProduct && bars.length > 0 ? (
<>
<div className={styles.mixHead}>

View file

@ -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("нет ближайшего водоёма"),
},
],
};
}

View file

@ -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<string, ParcelAnalysisPoi[]>;
score_breakdown_detailed?: FactorContribution[];
score_top_3_positives?: FactorContribution[];