feat(site-finder): center distance bonus display + success_recommendation block
This commit is contained in:
parent
ab0647e4d5
commit
2e83d62237
3 changed files with 274 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue