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,
|
ParcelAnalysisAirQuality,
|
||||||
ParcelAnalysisWind,
|
ParcelAnalysisWind,
|
||||||
ParcelAnalysisWeather,
|
ParcelAnalysisWeather,
|
||||||
|
ParcelLocation,
|
||||||
} from "@/types/site-finder";
|
} from "@/types/site-finder";
|
||||||
import type { FeatureCollection } from "geojson";
|
import type { FeatureCollection } from "geojson";
|
||||||
|
|
||||||
|
|
@ -16,6 +17,7 @@ import { SeasonalWeatherBlock } from "./SeasonalWeatherBlock";
|
||||||
import { HydrologyBlock } from "./HydrologyBlock";
|
import { HydrologyBlock } from "./HydrologyBlock";
|
||||||
import { GeotechRiskBlock } from "./GeotechRiskBlock";
|
import { GeotechRiskBlock } from "./GeotechRiskBlock";
|
||||||
import { IsochronesPanel } from "./IsochronesPanel";
|
import { IsochronesPanel } from "./IsochronesPanel";
|
||||||
|
import { SuccessRecommendationBlock } from "./SuccessRecommendationBlock";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: ParcelAnalysis;
|
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 ─────────────────────────────────────────────────────────────────
|
// ── ScoreCard ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ScoreCard({ data, onIsochronesResult }: Props) {
|
export function ScoreCard({ data, onIsochronesResult }: Props) {
|
||||||
|
|
@ -570,6 +610,7 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
|
||||||
{data.score_explanation}
|
{data.score_explanation}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{data.location && <CenterDistanceRow location={data.location} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -727,6 +768,20 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
|
||||||
</div>
|
</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 */}
|
||||||
{"seasonal_weather" in data && (
|
{"seasonal_weather" in data && (
|
||||||
<div
|
<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;
|
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 {
|
export interface ParcelAnalysis {
|
||||||
cad_num: string;
|
cad_num: string;
|
||||||
source: "cad_quarter" | "cad_building";
|
source: "cad_quarter" | "cad_building";
|
||||||
|
|
@ -159,6 +181,9 @@ export interface ParcelAnalysis {
|
||||||
geotech_risk?: GeotechRisk;
|
geotech_risk?: GeotechRisk;
|
||||||
geology?: ParcelAnalysisGeology;
|
geology?: ParcelAnalysisGeology;
|
||||||
isochrones_available?: boolean;
|
isochrones_available?: boolean;
|
||||||
|
score_without_center?: number;
|
||||||
|
location?: ParcelLocation;
|
||||||
|
success_recommendation?: ParcelSuccessRecommendation | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PoiCategory =
|
export type PoiCategory =
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue