Merge pull request 'fix(ptica): populate scan cards with real /analyze data + product tabs + map zoom' (#1857) from fix/ptica-parity-data-mapping into main
This commit is contained in:
commit
ea20d8688a
6 changed files with 264 additions and 17 deletions
|
|
@ -520,6 +520,39 @@
|
||||||
.mapMount :global(.leaflet-control-attribution a) {
|
.mapMount :global(.leaflet-control-attribution a) {
|
||||||
color: var(--text-muted);
|
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 {
|
.mapLoading {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
@ -788,11 +821,16 @@
|
||||||
}
|
}
|
||||||
.kvrow .k {
|
.kvrow .k {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.kvrow .v {
|
.kvrow .v {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
/* Long values (e.g. «Стагнация — рынок остыл») wrap gracefully instead of
|
||||||
|
overflowing the card. */
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.kvrowPlaceholder .v {
|
.kvrowPlaceholder .v {
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
|
|
@ -1198,6 +1236,34 @@
|
||||||
color: var(--text);
|
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 (квартирография) */
|
/* horizontal bars (квартирография) */
|
||||||
.hbars {
|
.hbars {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ export function DevelopmentScan({ analysis, onOpenDrawer }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScanCard
|
<ScanCard
|
||||||
card={adaptEnvironmentCard()}
|
card={adaptEnvironmentCard(analysis)}
|
||||||
drawerKey="environment"
|
drawerKey="environment"
|
||||||
onOpen={onOpenDrawer}
|
onOpen={onOpenDrawer}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ import {
|
||||||
Popup,
|
Popup,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
AttributionControl,
|
AttributionControl,
|
||||||
|
ZoomControl,
|
||||||
|
ScaleControl,
|
||||||
useMap,
|
useMap,
|
||||||
useMapEvents,
|
useMapEvents,
|
||||||
} from "react-leaflet";
|
} 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
|
{/* Drop the Leaflet 1.9 flag/«Leaflet» prefix but KEEP the per-tile data
|
||||||
attribution (© Esri · Maxar / CARTO · OSM) required for licensing. */}
|
attribution (© Esri · Maxar / CARTO · OSM) required for licensing. */}
|
||||||
<AttributionControl prefix={false} position="bottomright" />
|
<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} />
|
<FitToGeometry geom={geom} />
|
||||||
<MapClickHandler editMode={addMode} onMapClick={handleMapClick} />
|
<MapClickHandler editMode={addMode} onMapClick={handleMapClick} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
* honest «после прогноза» placeholder. "Подробнее" opens the product drawer.
|
* honest «после прогноза» placeholder. "Подробнее" opens the product drawer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||||
import type {
|
import type {
|
||||||
ForecastReport,
|
ForecastReport,
|
||||||
|
|
@ -48,16 +50,30 @@ interface MixBar {
|
||||||
tone: "good" | "warn" | "bad" | "neutral";
|
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[];
|
bars: MixBar[];
|
||||||
byShare: boolean;
|
byShare: boolean;
|
||||||
} {
|
} {
|
||||||
// §22 mix can carry buckets across MULTIPLE obj_class values (e.g. 3 classes ×
|
// §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.
|
// single honest квартирография (≈5 rows); if no class is set, keep all.
|
||||||
const classMix: ProductMixEntry[] =
|
const classMix: ProductMixEntry[] =
|
||||||
tz.obj_class != null
|
selectedClass != null
|
||||||
? tz.mix.filter((m) => m.obj_class === tz.obj_class)
|
? tz.mix.filter((m) => m.obj_class === selectedClass)
|
||||||
: tz.mix;
|
: tz.mix;
|
||||||
const rows = classMix.filter(
|
const rows = classMix.filter(
|
||||||
(m) => m.pct != null || m.deficit_index != null,
|
(m) => m.pct != null || m.deficit_index != null,
|
||||||
|
|
@ -98,8 +114,20 @@ export function RecommendedProductCard({
|
||||||
const tz = forecastReport?.product_tz;
|
const tz = forecastReport?.product_tz;
|
||||||
const hasProduct =
|
const hasProduct =
|
||||||
tz != null && (tz.obj_class != null || tz.mix.length > 0 || !!tz.summary);
|
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
|
const { bars: allBars, byShare } = tz
|
||||||
? buildBars(tz)
|
? buildBars(tz, activeClass)
|
||||||
: { bars: [], byShare: false };
|
: { bars: [], byShare: false };
|
||||||
|
|
||||||
// Degenerate signal: when bars are deficit-driven (not explicit доля) and every
|
// Degenerate signal: when bars are deficit-driven (not explicit доля) and every
|
||||||
|
|
@ -120,11 +148,32 @@ export function RecommendedProductCard({
|
||||||
Recommended Product
|
Recommended Product
|
||||||
<span className={styles.cardTitleSub}>продуктовая стратегия</span>
|
<span className={styles.cardTitleSub}>продуктовая стратегия</span>
|
||||||
</h3>
|
</h3>
|
||||||
{hasProduct && tz?.obj_class && (
|
{hasProduct && activeClass != null && (
|
||||||
<span className={styles.tag}>{capitalize(tz.obj_class)}</span>
|
<span className={styles.tag}>{capitalize(activeClass)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 ? (
|
{hasProduct && bars.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className={styles.mixHead}>
|
<div className={styles.mixHead}>
|
||||||
|
|
|
||||||
|
|
@ -354,6 +354,7 @@ export function adaptUrbanCard(a: ParcelAnalysis): PticaScanCard {
|
||||||
const zone = regulationZone(z);
|
const zone = regulationZone(z);
|
||||||
const maxFar = z?.max_far;
|
const maxFar = z?.max_far;
|
||||||
const maxHeight = z?.max_height_m;
|
const maxHeight = z?.max_height_m;
|
||||||
|
const buildingPct = z?.max_building_pct;
|
||||||
const areaM2 = parcelAreaM2(a);
|
const areaM2 = parcelAreaM2(a);
|
||||||
|
|
||||||
// Плотность (КСИТ) ёмкость = площадь × КСИТ (м²), real only with both inputs.
|
// Плотность (КСИТ) ёмкость = площадь × КСИТ (м²), real only with both inputs.
|
||||||
|
|
@ -368,6 +369,18 @@ export function adaptUrbanCard(a: ParcelAnalysis): PticaScanCard {
|
||||||
: { value: formatFar(maxFar), isReal: true, caption: "КСИТ · НСПД" }
|
: { value: formatFar(maxFar), isReal: true, caption: "КСИТ · НСПД" }
|
||||||
: placeholder(SRC_NSPD_ZONING);
|
: 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 {
|
return {
|
||||||
title: "Градостроительство",
|
title: "Градостроительство",
|
||||||
rows: [
|
rows: [
|
||||||
|
|
@ -390,7 +403,7 @@ export function adaptUrbanCard(a: ParcelAnalysis): PticaScanCard {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "Пятно застройки",
|
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 {
|
export function adaptMarketCard(a: ParcelAnalysis): PticaScanCard {
|
||||||
const median = a.district?.median_price_per_m2;
|
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 newProjects = a.pipeline_24mo?.objects_count;
|
||||||
|
const demandLabel = a.market_trend?.label;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: "Рынок",
|
title: "Рынок",
|
||||||
rows: [
|
rows: [
|
||||||
|
|
@ -453,6 +478,24 @@ export function adaptMarketCard(a: ParcelAnalysis): PticaScanCard {
|
||||||
? real(formatRubPerM2(median))
|
? real(formatRubPerM2(median))
|
||||||
: placeholder("нет данных района"),
|
: placeholder("нет данных района"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "Поглощение",
|
||||||
|
field:
|
||||||
|
velocity != null
|
||||||
|
? {
|
||||||
|
value: `${fmtNum(velocity, 1)} м²/мес`,
|
||||||
|
isReal: true,
|
||||||
|
caption: "средний темп поглощения · рынок",
|
||||||
|
}
|
||||||
|
: placeholder("нет данных рынка"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Конкурентов",
|
||||||
|
field:
|
||||||
|
competitors != null
|
||||||
|
? real(formatInt(competitors))
|
||||||
|
: placeholder("нет данных рынка"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "Новых проектов",
|
key: "Новых проектов",
|
||||||
field:
|
field:
|
||||||
|
|
@ -460,8 +503,12 @@ export function adaptMarketCard(a: ParcelAnalysis): PticaScanCard {
|
||||||
? real(formatInt(newProjects))
|
? real(formatInt(newProjects))
|
||||||
: placeholder("нет пайплайна"),
|
: 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 {
|
return {
|
||||||
title: "Среда",
|
title: "Среда",
|
||||||
badge: "экология",
|
badge: "экология",
|
||||||
rows: [
|
rows: [
|
||||||
{ key: "Шум", field: placeholder("после атмосферного слоя") },
|
{ key: "Шум", field: noiseField },
|
||||||
{ key: "Воздух (AQI)", field: placeholder("после атмосферного слоя") },
|
{ key: "Воздух (PM2.5)", field: airField },
|
||||||
{ key: "Зелёных зон", field: placeholder("после атмосферного слоя") },
|
{ key: "Ветер", field: windField },
|
||||||
|
{
|
||||||
|
key: "Водоёмы",
|
||||||
|
field:
|
||||||
|
nearestWater != null
|
||||||
|
? real(`${formatInt(nearestWater)} м`)
|
||||||
|
: placeholder("нет ближайшего водоёма"),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,38 @@ export interface MarketTrend {
|
||||||
radius_km: number;
|
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
|
// D4 (#36) — 24-month project pipeline competition
|
||||||
export interface PipelineQuarterSlot {
|
export interface PipelineQuarterSlot {
|
||||||
quarter: string;
|
quarter: string;
|
||||||
|
|
@ -373,6 +405,10 @@ export interface ParcelAnalysis {
|
||||||
score_max_reference?: number;
|
score_max_reference?: number;
|
||||||
score_explanation?: string;
|
score_explanation?: string;
|
||||||
market_trend?: MarketTrend | null;
|
market_trend?: MarketTrend | null;
|
||||||
|
// Абсорбция + число конкурентов из рыночного слоя /analyze.
|
||||||
|
market_pulse?: MarketPulse | null;
|
||||||
|
// Распределение цен сделок района (median и т.п.).
|
||||||
|
market_price?: MarketPrice | null;
|
||||||
score_breakdown: Record<string, ParcelAnalysisPoi[]>;
|
score_breakdown: Record<string, ParcelAnalysisPoi[]>;
|
||||||
score_breakdown_detailed?: FactorContribution[];
|
score_breakdown_detailed?: FactorContribution[];
|
||||||
score_top_3_positives?: FactorContribution[];
|
score_top_3_positives?: FactorContribution[];
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue