(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[];