fix(ptica): populate scan cards with real /analyze data + product tabs + map zoom #1857
6 changed files with 264 additions and 17 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export function DevelopmentScan({ analysis, onOpenDrawer }: Props) {
|
|||
</div>
|
||||
|
||||
<ScanCard
|
||||
card={adaptEnvironmentCard()}
|
||||
card={adaptEnvironmentCard(analysis)}
|
||||
drawerKey="environment"
|
||||
onOpen={onOpenDrawer}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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("нет ближайшего водоёма"),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue