diff --git a/frontend/src/components/site-finder/MarketTab.tsx b/frontend/src/components/site-finder/MarketTab.tsx
index 0ce61c46..4b408e1b 100644
--- a/frontend/src/components/site-finder/MarketTab.tsx
+++ b/frontend/src/components/site-finder/MarketTab.tsx
@@ -7,6 +7,7 @@ import { MarketTrendBlock } from "./MarketTrendBlock";
import { CompetitorTable } from "./CompetitorTable";
import { Pipeline24moBlock } from "./Pipeline24moBlock";
import { SuccessRecommendationBlock } from "./SuccessRecommendationBlock";
+import { VelocityBlock } from "./VelocityBlock";
interface Props {
data: ParcelAnalysis;
@@ -16,8 +17,13 @@ export function MarketTab({ data }: Props) {
const hasTrend = "market_trend" in data;
const hasRecommendation = "success_recommendation" in data;
const hasPipeline = data.pipeline_24mo !== undefined;
+ const hasVelocity = "velocity" in data;
const hasAny =
- hasTrend || hasRecommendation || hasPipeline || data.competitors.length > 0;
+ hasTrend ||
+ hasRecommendation ||
+ hasPipeline ||
+ hasVelocity ||
+ data.competitors.length > 0;
return (
@@ -38,6 +44,20 @@ export function MarketTab({ data }: Props) {
)}
+ {/* D2 (#34) — Velocity-score */}
+ {hasVelocity && (
+
+
+
+ )}
+
{/* Competitors */}
{data.competitors.length > 0 && (
diff --git a/frontend/src/components/site-finder/VelocityBlock.tsx b/frontend/src/components/site-finder/VelocityBlock.tsx
new file mode 100644
index 00000000..268f8587
--- /dev/null
+++ b/frontend/src/components/site-finder/VelocityBlock.tsx
@@ -0,0 +1,218 @@
+"use client";
+import type { Velocity } from "@/types/site-finder";
+import { SectionLabel } from "@/components/ui/SectionLabel";
+import { EmptyState } from "@/components/ui/EmptyState";
+
+interface VelocityBlockProps {
+ velocity: Velocity | null | undefined;
+}
+
+const CONFIDENCE_COLOR: Record<
+ Velocity["confidence"],
+ { bg: string; fg: string }
+> = {
+ high: { bg: "#dcfce7", fg: "#166534" },
+ medium: { bg: "#fef3c7", fg: "#854d0e" },
+ low: { bg: "#fee2e2", fg: "#991b1b" },
+};
+
+const CONFIDENCE_LABEL: Record
= {
+ high: "Высокая",
+ medium: "Средняя",
+ low: "Низкая",
+};
+
+function formatPercent(score: number): string {
+ return `${Math.round(score * 100)}%`;
+}
+
+export function VelocityBlock({ velocity }: VelocityBlockProps) {
+ if (!velocity) {
+ return (
+
+
+ Темп продаж конкурентов
+
+
+
+ );
+ }
+
+ const confColor = CONFIDENCE_COLOR[velocity.confidence];
+ const scorePct = formatPercent(velocity.velocity_score);
+ const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm;
+
+ return (
+
+
+ Темп продаж конкурентов
+
+ {CONFIDENCE_LABEL[velocity.confidence]}
+
+
+
+ {/* Score gauge */}
+
+
+ Velocity-score
+ {scorePct}
+
+
+
= 0.66
+ ? "#10b981"
+ : velocity.velocity_score >= 0.33
+ ? "#f59e0b"
+ : "#ef4444",
+ width: `${velocity.velocity_score * 100}%`,
+ height: "100%",
+ }}
+ />
+
+
+ {Math.round(velocity.monthly_velocity_sqm)} м²/мес vs{" "}
+ {Math.round(velocity.ekb_median_sqm)} м²/мес (медиана ЕКБ) ·{" "}
+ {ratio >= 1
+ ? `x${ratio.toFixed(1)} выше`
+ : `${formatPercent(ratio)} от среднего`}
+
+
+
+ {/* Period + competitors meta */}
+
+ В радиусе 3 км: {velocity.competitors_count} ЖК · период{" "}
+
+ {velocity.period.start} → {velocity.period.end}
+ {" "}
+ ({velocity.months_observed} мес)
+
+
+ {/* Top competitors */}
+ {velocity.sample_competitors.length > 0 && (
+
+
Топ продавцов
+
+
+
+ |
+ Название
+ |
+
+ Расст.
+ |
+
+ Продано м²
+ |
+
+
+
+ {velocity.sample_competitors.map((c) => (
+
+ |
+ {c.name ?? `obj#${c.obj_id}`}
+ {c.class && (
+
+ {c.class}
+
+ )}
+ |
+
+ {Math.round(c.distance_m)}м
+ |
+
+ {Math.round(c.total_sqm).toLocaleString("ru")}
+ |
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts
index 8674699b..7245c602 100644
--- a/frontend/src/types/site-finder.ts
+++ b/frontend/src/types/site-finder.ts
@@ -204,6 +204,31 @@ export interface Pipeline24mo {
note?: string;
}
+// D2 (#34) — velocity-score: темп продаж конкурентов
+export interface VelocityCompetitor {
+ obj_id: number;
+ name: string | null;
+ class: string | null;
+ distance_m: number;
+ total_sqm: number;
+}
+
+export interface VelocityPeriod {
+ start: string; // YYYY-MM
+ end: string;
+}
+
+export interface Velocity {
+ competitors_count: number;
+ monthly_velocity_sqm: number;
+ ekb_median_sqm: number;
+ velocity_score: number; // 0..1
+ confidence: "high" | "medium" | "low";
+ months_observed: number;
+ period: VelocityPeriod;
+ sample_competitors: VelocityCompetitor[];
+}
+
// G5 (#32) — Gate verdict: can_build_mkd
export type GateVerdictSource = "nspd_dump" | "nspd_dump_partial" | "no_data";
@@ -342,6 +367,8 @@ export interface ParcelAnalysis {
pipeline_24mo?: Pipeline24mo;
// G5 (#32) — gate verdict: МКД buildability
gate_verdict?: GateVerdict;
+ // D2 (#34) — velocity-score: темп продаж конкурентов
+ velocity?: Velocity | null;
}
export type PoiCategory =