feat(site-finder): center distance bonus display + success_recommendation block

This commit is contained in:
lekss361 2026-05-11 22:40:46 +03:00
parent ab0647e4d5
commit 2e83d62237
3 changed files with 274 additions and 0 deletions

View file

@ -7,6 +7,7 @@ import type {
ParcelAnalysisAirQuality,
ParcelAnalysisWind,
ParcelAnalysisWeather,
ParcelLocation,
} from "@/types/site-finder";
import type { FeatureCollection } from "geojson";
@ -16,6 +17,7 @@ import { SeasonalWeatherBlock } from "./SeasonalWeatherBlock";
import { HydrologyBlock } from "./HydrologyBlock";
import { GeotechRiskBlock } from "./GeotechRiskBlock";
import { IsochronesPanel } from "./IsochronesPanel";
import { SuccessRecommendationBlock } from "./SuccessRecommendationBlock";
interface Props {
data: ParcelAnalysis;
@ -481,6 +483,44 @@ function WeatherBlock({
);
}
// ── Center distance row ───────────────────────────────────────────────────────
function centerBonusColor(bonus: number): string {
if (bonus >= 3.0) return "#16a34a";
if (bonus >= 1.5) return "#1d4ed8";
return "#6b7280";
}
function CenterDistanceRow({ location }: { location: ParcelLocation }) {
const color = centerBonusColor(location.center_bonus);
return (
<div
title={location.note}
style={{
fontSize: 12,
color: "#6b7280",
display: "flex",
alignItems: "center",
gap: 4,
cursor: "help",
}}
>
<span>📍</span>
<span>
До центра ЕКБ:{" "}
<strong style={{ color: "#374151" }}>
{location.distance_to_center_km.toFixed(1)} км
</strong>
</span>
{location.center_bonus > 0 && (
<span style={{ color, fontWeight: 600 }}>
(+{location.center_bonus.toFixed(1)} к score)
</span>
)}
</div>
);
}
// ── ScoreCard ─────────────────────────────────────────────────────────────────
export function ScoreCard({ data, onIsochronesResult }: Props) {
@ -570,6 +610,7 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
{data.score_explanation}
</div>
)}
{data.location && <CenterDistanceRow location={data.location} />}
</div>
</div>
@ -727,6 +768,20 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
</div>
)}
{/* Success recommendation */}
{"success_recommendation" in data && (
<div
style={{
padding: "12px 24px 16px",
borderTop: "1px solid #e5e7eb",
}}
>
<SuccessRecommendationBlock
recommendation={data.success_recommendation}
/>
</div>
)}
{/* Seasonal weather */}
{"seasonal_weather" in data && (
<div

View file

@ -0,0 +1,194 @@
"use client";
import type {
ParcelSuccessRecommendation,
SuccessRankingBucket,
} from "@/types/site-finder";
interface Props {
recommendation: ParcelSuccessRecommendation | null | undefined;
}
function successColor(score: number): string {
if (score > 0.5) return "#16a34a";
if (score >= 0) return "#d97706";
return "#6b7280";
}
function successBg(score: number): string {
if (score > 0.5) return "#dcfce7";
if (score >= 0) return "#fef3c7";
return "#f3f4f6";
}
function formatPrice(v: number | null): string {
if (v == null) return "—";
return (v / 1000).toFixed(0) + " тыс";
}
function formatArea(v: number | null): string {
if (v == null) return "—";
return v.toFixed(0) + " м²";
}
function SuccessBadge({ bucket }: { bucket: SuccessRankingBucket }) {
const color = successColor(bucket.success_score);
const bg = successBg(bucket.success_score);
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
borderRadius: 6,
padding: "2px 8px",
background: bg,
border: `1px solid ${color}44`,
}}
>
<div
style={{
width: 36,
height: 8,
borderRadius: 4,
background: "#e5e7eb",
overflow: "hidden",
position: "relative",
}}
>
<div
style={{
position: "absolute",
left: 0,
top: 0,
height: "100%",
width: `${Math.max(0, Math.min(1, bucket.success_score)) * 100}%`,
background: color,
borderRadius: 4,
}}
/>
</div>
<span style={{ fontSize: 12, fontWeight: 600, color }}>
{bucket.success_score.toFixed(2)}
</span>
</div>
);
}
export function SuccessRecommendationBlock({ recommendation }: Props) {
if (!recommendation || recommendation.ranking.length === 0) {
return (
<div
style={{
borderRadius: 10,
padding: "14px 16px",
background: "#f3f4f6",
color: "#6b7280",
fontSize: 13,
}}
>
Недостаточно сделок в районе для рейтинга (мин 30)
</div>
);
}
const { district, ranking, top_bucket, note } = recommendation;
const top5 = ranking.slice(0, 5);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: "#111827" }}>
Что хорошо продаётся в районе {district}
</div>
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 13,
}}
>
<thead>
<tr>
{[
"Бакет",
"Сделок",
"Ср. цена ₽/м²",
"Ср. площадь м²",
"Успех",
].map((h) => (
<th
key={h}
style={{
textAlign: "left",
fontSize: 11,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.04em",
paddingBottom: 6,
paddingRight: 12,
whiteSpace: "nowrap",
borderBottom: "1px solid #e5e7eb",
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{top5.map((row) => {
const isTop = row.bucket === top_bucket.bucket;
return (
<tr key={row.bucket}>
<td
style={{
padding: "6px 12px 6px 0",
color: "#111827",
fontWeight: isTop ? 600 : 400,
whiteSpace: "nowrap",
}}
>
{isTop ? "⭐ " : ""}
{row.bucket}
</td>
<td
style={{
padding: "6px 12px 6px 0",
color: "#374151",
}}
>
{row.n_deals}
</td>
<td
style={{
padding: "6px 12px 6px 0",
color: "#374151",
}}
>
{formatPrice(row.avg_price_per_m2)}
</td>
<td
style={{
padding: "6px 12px 6px 0",
color: "#374151",
}}
>
{formatArea(row.avg_area_m2)}
</td>
<td style={{ padding: "6px 0 6px 0" }}>
<SuccessBadge bucket={row} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div style={{ fontSize: 11, color: "#9ca3af" }}>{note}</div>
</div>
);
}

View file

@ -137,6 +137,28 @@ export interface MarketTrend {
radius_km: number;
}
export interface ParcelLocation {
distance_to_center_km: number;
center_bonus: number;
ekb_center: { lat: number; lon: number };
note: string;
}
export interface SuccessRankingBucket {
bucket: string;
success_score: number;
n_deals: number;
avg_price_per_m2: number | null;
avg_area_m2: number | null;
}
export interface ParcelSuccessRecommendation {
district: string;
ranking: SuccessRankingBucket[];
top_bucket: SuccessRankingBucket;
note: string;
}
export interface ParcelAnalysis {
cad_num: string;
source: "cad_quarter" | "cad_building";
@ -159,6 +181,9 @@ export interface ParcelAnalysis {
geotech_risk?: GeotechRisk;
geology?: ParcelAnalysisGeology;
isochrones_available?: boolean;
score_without_center?: number;
location?: ParcelLocation;
success_recommendation?: ParcelSuccessRecommendation | null;
}
export type PoiCategory =