feat(site-finder): multi-cad сравнение (#50) + analytics поиск/cross-links (#65)
All checks were successful
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Successful in 47s
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 47s
All checks were successful
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Successful in 47s
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 47s
#50: /site-finder/compare — shortlist (localStorage, SSR-guard, cap 2-5) + CompareTable (per-row winner-highlight, reuse useParcelAnalyzeQuery per cad, hook-order stable в .map, cache-shared). Cross-link с site-finder. #65: debounced search (300ms, client-side filter top-15) в DeveloperLeaderboard + cross-link объект→Site Finder analyze. Existing developers compare не дублирован. Closes #50 Closes #65
This commit is contained in:
parent
3bbbf25412
commit
7d69364e5f
8 changed files with 839 additions and 5 deletions
|
|
@ -1,13 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { Search } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { Suspense, useState } from "react";
|
||||
|
||||
import { DeveloperLeaderboard } from "@/components/analytics/DeveloperLeaderboard";
|
||||
import { KpiCard } from "@/components/analytics/KpiCard";
|
||||
import { PrinzipVelocityChart } from "@/components/analytics/PrinzipVelocityChart";
|
||||
import { Section } from "@/components/analytics/Section";
|
||||
import { VelocityScatter } from "@/components/analytics/VelocityScatter";
|
||||
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
||||
import { useDeveloperDetail, useTopDevelopers } from "@/lib/analytics-api";
|
||||
|
||||
function DevelopersInner() {
|
||||
|
|
@ -16,6 +18,11 @@ function DevelopersInner() {
|
|||
const top = useTopDevelopers(15);
|
||||
const focus = useDeveloperDetail(focusedId ?? "");
|
||||
|
||||
// Debounced 300ms search over the leaderboard (client-side filter — the
|
||||
// top-15 list is already in memory, no extra request).
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(searchInput, 300);
|
||||
|
||||
const focused = focusedId ? focus.data : null;
|
||||
const topNames: Record<string, string> = Object.fromEntries(
|
||||
(top.data ?? []).map((r) => [r.developer_id, r.developer_name]),
|
||||
|
|
@ -67,8 +74,43 @@ function DevelopersInner() {
|
|||
<Section
|
||||
title="Топ-15 девелоперов Свердл"
|
||||
subtitle="Сортировка по объёму строительства (тыс м²). Δ пп — рост sold% за всю историю DOM.РФ realization. Клик по строке — drill-down."
|
||||
right={
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
border: "1px solid var(--border-strong)",
|
||||
borderRadius: 8,
|
||||
padding: "6px 10px",
|
||||
background: "var(--bg-card)",
|
||||
}}
|
||||
>
|
||||
<Search
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
color="var(--fg-tertiary)"
|
||||
aria-hidden
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Найти девелопера…"
|
||||
aria-label="Поиск девелопера по названию"
|
||||
style={{
|
||||
border: "none",
|
||||
outline: "none",
|
||||
fontSize: 14,
|
||||
width: 200,
|
||||
background: "transparent",
|
||||
color: "var(--fg-primary)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DeveloperLeaderboard highlight={focusedId} />
|
||||
<DeveloperLeaderboard highlight={focusedId} query={debouncedSearch} />
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
|
|
@ -90,6 +91,13 @@ export default function ObjectDrillInPage() {
|
|||
|
||||
const d = fullDetail.data;
|
||||
|
||||
// Cross-link target (#65): first building's cadastral number — a real parcel
|
||||
// Site Finder can analyze. Объект сам по себе не несёт cad_quarter, но корпуса
|
||||
// из NSPD несут cad_num, что и есть точка входа в анализ участка.
|
||||
const siteFinderCad = buildings.data?.find(
|
||||
(b) => b.cad_num != null && b.cad_num !== "",
|
||||
)?.cad_num;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
|
|
@ -331,6 +339,27 @@ export default function ObjectDrillInPage() {
|
|||
<Section
|
||||
title="Корпуса ЖК"
|
||||
subtitle="Данные из NSPD: кадастровый номер, этажность, площадь, назначение."
|
||||
right={
|
||||
siteFinderCad ? (
|
||||
<Link
|
||||
href={`/site-finder/analysis/${encodeURIComponent(siteFinderCad)}`}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: "#1d4ed8",
|
||||
textDecoration: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={`Анализ участка ${siteFinderCad} в Site Finder`}
|
||||
>
|
||||
Посмотреть участок в Site Finder
|
||||
<ArrowUpRight size={16} strokeWidth={1.5} />
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table
|
||||
|
|
|
|||
257
frontend/src/app/site-finder/compare/page.tsx
Normal file
257
frontend/src/app/site-finder/compare/page.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { CompareTable } from "@/components/site-finder/compare/CompareTable";
|
||||
import type { CompareColumn } from "@/components/site-finder/compare/CompareTable";
|
||||
import { ParcelCompareLoader } from "@/components/site-finder/compare/ParcelCompareLoader";
|
||||
import {
|
||||
COMPARE_MAX,
|
||||
COMPARE_MIN,
|
||||
getShortlist,
|
||||
isValidCad,
|
||||
saveShortlist,
|
||||
} from "@/lib/compare-shortlist";
|
||||
|
||||
export default function ComparePage() {
|
||||
// Shortlist of cads. Hydrated from localStorage after mount (SSR-safe: the
|
||||
// first client render matches the server's empty list, then the effect fills
|
||||
// it — avoids a hydration mismatch).
|
||||
const [shortlist, setShortlist] = useState<string[]>([]);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const [inputError, setInputError] = useState<string | null>(null);
|
||||
|
||||
// Per-cad column state, keyed by cad. Lifted up from each ParcelCompareLoader.
|
||||
const [columns, setColumns] = useState<Record<string, CompareColumn>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setShortlist(getShortlist());
|
||||
setHydrated(true);
|
||||
}, []);
|
||||
|
||||
// Persist to localStorage on every change (after hydration so the initial
|
||||
// empty state never clobbers a saved list).
|
||||
useEffect(() => {
|
||||
if (hydrated) saveShortlist(shortlist);
|
||||
}, [shortlist, hydrated]);
|
||||
|
||||
const handleResult = useCallback((col: CompareColumn) => {
|
||||
setColumns((prev) => {
|
||||
const existing = prev[col.cad];
|
||||
// Avoid a needless state update (and re-render loop) when nothing changed.
|
||||
if (
|
||||
existing &&
|
||||
existing.status === col.status &&
|
||||
existing.score === col.score &&
|
||||
existing.verdictLabel === col.verdictLabel &&
|
||||
existing.velocityScore === col.velocityScore &&
|
||||
existing.pipeline24mo === col.pipeline24mo &&
|
||||
existing.confidenceValue === col.confidenceValue
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, [col.cad]: col };
|
||||
});
|
||||
}, []);
|
||||
|
||||
function addCad(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const cad = input.trim();
|
||||
if (!isValidCad(cad)) {
|
||||
setInputError("Неверный формат. Ожидается 66:41:0204016:10 или 66:41:0204016.");
|
||||
return;
|
||||
}
|
||||
if (shortlist.includes(cad)) {
|
||||
setInputError("Этот участок уже в сравнении.");
|
||||
return;
|
||||
}
|
||||
if (shortlist.length >= COMPARE_MAX) {
|
||||
setInputError(`Можно сравнить не более ${COMPARE_MAX} участков.`);
|
||||
return;
|
||||
}
|
||||
setShortlist((prev) => [...prev, cad]);
|
||||
setInput("");
|
||||
setInputError(null);
|
||||
}
|
||||
|
||||
const removeCad = useCallback((cad: string) => {
|
||||
setShortlist((prev) => prev.filter((c) => c !== cad));
|
||||
setColumns((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[cad];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Build ordered columns from the shortlist; default to a loading column so
|
||||
// the table renders immediately while each loader resolves.
|
||||
const orderedColumns: CompareColumn[] = useMemo(
|
||||
() =>
|
||||
shortlist.map(
|
||||
(cad) => columns[cad] ?? { cad, status: "loading" as const },
|
||||
),
|
||||
[shortlist, columns],
|
||||
);
|
||||
|
||||
const canCompare = shortlist.length >= COMPARE_MIN;
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
background: "var(--bg-app)",
|
||||
padding: "24px 20px",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 1280, margin: "0 auto" }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: "var(--fg-secondary)",
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href="/site-finder"
|
||||
style={{
|
||||
color: "var(--accent)",
|
||||
textDecoration: "none",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Site Finder
|
||||
</Link>
|
||||
<span style={{ color: "var(--fg-tertiary)" }}>/</span>
|
||||
<span style={{ color: "var(--fg-tertiary)" }}>сравнение участков</span>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
style={{
|
||||
margin: "8px 0 4px",
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
color: "var(--fg-primary)",
|
||||
}}
|
||||
>
|
||||
Сравнение участков
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
margin: "0 0 20px",
|
||||
color: "var(--fg-secondary)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Добавьте от {COMPARE_MIN} до {COMPARE_MAX} кадастровых номеров — метрики
|
||||
анализа (вердикт МКД, инвест-балл, скорость продаж, цена, пайплайн)
|
||||
встанут рядом. Лучшее значение в строке подсвечено. Список сохраняется
|
||||
между сессиями.
|
||||
</p>
|
||||
|
||||
{/* Add form */}
|
||||
<form
|
||||
onSubmit={addCad}
|
||||
style={{ display: "flex", gap: 8, alignItems: "flex-start" }}
|
||||
>
|
||||
<div style={{ flex: 1, maxWidth: 420 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setInputError(null);
|
||||
}}
|
||||
placeholder="66:41:0204016:10"
|
||||
aria-label="Кадастровый номер для сравнения"
|
||||
disabled={shortlist.length >= COMPARE_MAX}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
fontSize: 14,
|
||||
fontFamily: "monospace",
|
||||
border: inputError
|
||||
? "1px solid var(--danger)"
|
||||
: "1px solid var(--border-strong)",
|
||||
borderRadius: 8,
|
||||
outline: "none",
|
||||
background: "var(--bg-card)",
|
||||
}}
|
||||
/>
|
||||
{inputError ? (
|
||||
<p
|
||||
style={{
|
||||
margin: "6px 0 0",
|
||||
fontSize: 12,
|
||||
color: "var(--danger)",
|
||||
}}
|
||||
>
|
||||
{inputError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || shortlist.length >= COMPARE_MAX}
|
||||
style={{
|
||||
padding: "8px 18px",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
background:
|
||||
!input.trim() || shortlist.length >= COMPARE_MAX
|
||||
? "var(--fg-tertiary)"
|
||||
: "var(--accent)",
|
||||
color: "var(--fg-on-dark)",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
cursor:
|
||||
!input.trim() || shortlist.length >= COMPARE_MAX
|
||||
? "default"
|
||||
: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Headless loaders — one per cad, lift analyze metrics up */}
|
||||
{shortlist.map((cad) => (
|
||||
<ParcelCompareLoader key={cad} cad={cad} onResult={handleResult} />
|
||||
))}
|
||||
|
||||
{/* Body */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
background: "var(--bg-card)",
|
||||
border: "1px solid var(--border-card)",
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
}}
|
||||
>
|
||||
{!canCompare ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "32px 24px",
|
||||
textAlign: "center",
|
||||
color: "var(--fg-tertiary)",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{shortlist.length === 0
|
||||
? "Список пуст. Добавьте участки выше, чтобы начать сравнение."
|
||||
: `Добавьте ещё минимум ${COMPARE_MIN - shortlist.length} участок для сравнения.`}
|
||||
</div>
|
||||
) : (
|
||||
<CompareTable columns={orderedColumns} onRemove={removeCad} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -146,6 +146,18 @@ export default function SiteFinderPage() {
|
|||
>
|
||||
SiteFinder · карта участков
|
||||
</h1>
|
||||
<Link
|
||||
href="/site-finder/compare"
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
fontSize: 13,
|
||||
color: "var(--accent)",
|
||||
textDecoration: "none",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Сравнить участки
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{/* Filter bar */}
|
||||
|
|
|
|||
|
|
@ -46,20 +46,30 @@ const COLS: {
|
|||
},
|
||||
];
|
||||
|
||||
export function DeveloperLeaderboard({ highlight }: { highlight?: string }) {
|
||||
export function DeveloperLeaderboard({
|
||||
highlight,
|
||||
query,
|
||||
}: {
|
||||
highlight?: string;
|
||||
/** Case-insensitive substring filter on developer_name (debounced upstream). */
|
||||
query?: string;
|
||||
}) {
|
||||
const { data, isLoading } = useTopDevelopers(15);
|
||||
const [sortBy, setSortBy] = useState<SortKey>("sverdl_sqm_th");
|
||||
const [desc, setDesc] = useState(true);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const arr = [...(data ?? [])];
|
||||
const needle = (query ?? "").trim().toLocaleLowerCase("ru");
|
||||
const arr = (data ?? []).filter((r) =>
|
||||
needle ? r.developer_name.toLocaleLowerCase("ru").includes(needle) : true,
|
||||
);
|
||||
arr.sort((a, b) => {
|
||||
const av = (a[sortBy] ?? -Infinity) as number;
|
||||
const bv = (b[sortBy] ?? -Infinity) as number;
|
||||
return desc ? bv - av : av - bv;
|
||||
});
|
||||
return arr;
|
||||
}, [data, sortBy, desc]);
|
||||
}, [data, sortBy, desc, query]);
|
||||
|
||||
const click = (k: SortKey) => {
|
||||
if (k === sortBy) setDesc(!desc);
|
||||
|
|
@ -72,6 +82,15 @@ export function DeveloperLeaderboard({ highlight }: { highlight?: string }) {
|
|||
if (isLoading)
|
||||
return <div style={{ padding: 16, color: "#5b6066" }}>Загрузка…</div>;
|
||||
|
||||
if (rows.length === 0)
|
||||
return (
|
||||
<div style={{ padding: 16, color: "#5b6066", fontSize: 14 }}>
|
||||
{query?.trim()
|
||||
? `Ничего не найдено по запросу «${query.trim()}».`
|
||||
: "Данные пока недоступны."}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table
|
||||
|
|
|
|||
351
frontend/src/components/site-finder/compare/CompareTable.tsx
Normal file
351
frontend/src/components/site-finder/compare/CompareTable.tsx
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { BadgeVariant } from "@/components/ui/Badge";
|
||||
import type { GateVerdictLabel } from "@/types/site-finder";
|
||||
|
||||
// ── Per-column state ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One parcel column. `status` mirrors the TanStack Query lifecycle of the
|
||||
* per-cad `useParcelAnalyzeQuery` lifted up by the page. Metrics are flattened
|
||||
* to primitives here so CompareTable stays a pure presentational component.
|
||||
*/
|
||||
export interface CompareColumn {
|
||||
cad: string;
|
||||
status: "loading" | "error" | "ready";
|
||||
errorMsg?: string;
|
||||
districtName?: string | null;
|
||||
verdictLabel?: GateVerdictLabel | null;
|
||||
score?: number | null;
|
||||
scoreLabel?: string | null;
|
||||
velocityScore?: number | null; // 0..1
|
||||
medianPricePerM2?: number | null;
|
||||
pipeline24mo?: number | null; // total flats in 24-month pipeline
|
||||
confidenceLabel?: "high" | "medium" | "low" | null;
|
||||
confidenceValue?: number | null; // 0..1
|
||||
}
|
||||
|
||||
interface Props {
|
||||
columns: CompareColumn[];
|
||||
onRemove: (cad: string) => void;
|
||||
}
|
||||
|
||||
// ── Metric row definitions ──────────────────────────────────────────────────
|
||||
|
||||
type WinnerDir = "high" | "low" | "none";
|
||||
|
||||
interface MetricRow {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Which direction wins, for per-row highlight. "none" → not comparable. */
|
||||
winner: WinnerDir;
|
||||
/** Numeric value used for winner comparison (null = no data, never wins). */
|
||||
value: (c: CompareColumn) => number | null;
|
||||
/** Rendered cell content. */
|
||||
render: (c: CompareColumn) => React.ReactNode;
|
||||
}
|
||||
|
||||
const VERDICT_BADGE: Record<GateVerdictLabel, BadgeVariant> = {
|
||||
Можно: "success",
|
||||
Нельзя: "danger",
|
||||
"С ограничениями": "warning",
|
||||
"Нужна проверка": "neutral",
|
||||
};
|
||||
|
||||
const CONFIDENCE_RU: Record<"high" | "medium" | "low", string> = {
|
||||
high: "высокая",
|
||||
medium: "средняя",
|
||||
low: "низкая",
|
||||
};
|
||||
|
||||
const DASH = "—";
|
||||
|
||||
function fmtScore(v: number | null | undefined): string {
|
||||
return v != null && Number.isFinite(v) ? v.toFixed(1) : DASH;
|
||||
}
|
||||
|
||||
function fmtPrice(v: number | null | undefined): string {
|
||||
// Median ₽/м² — точное число с разделителями тысяч (ru), e.g. «145 320 ₽».
|
||||
return v != null && Number.isFinite(v)
|
||||
? `${Math.round(v).toLocaleString("ru")} ₽`
|
||||
: DASH;
|
||||
}
|
||||
|
||||
function fmtPipeline(v: number | null | undefined): string {
|
||||
return v != null && Number.isFinite(v)
|
||||
? `${Math.round(v).toLocaleString("ru")} кв.`
|
||||
: DASH;
|
||||
}
|
||||
|
||||
const METRIC_ROWS: MetricRow[] = [
|
||||
{
|
||||
key: "verdict",
|
||||
label: "Вердикт МКД",
|
||||
winner: "none",
|
||||
value: () => null,
|
||||
render: (c) =>
|
||||
c.verdictLabel ? (
|
||||
<Badge variant={VERDICT_BADGE[c.verdictLabel]} size="sm">
|
||||
{c.verdictLabel}
|
||||
</Badge>
|
||||
) : (
|
||||
DASH
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "score",
|
||||
label: "Инвест-балл",
|
||||
winner: "high",
|
||||
value: (c) => c.score ?? null,
|
||||
render: (c) => (
|
||||
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{fmtScore(c.score)}
|
||||
{c.scoreLabel ? (
|
||||
<span style={{ color: "var(--fg-tertiary)", fontSize: 12 }}>
|
||||
{" "}
|
||||
· {c.scoreLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "velocity",
|
||||
label: "Скорость продаж",
|
||||
winner: "high",
|
||||
value: (c) => c.velocityScore ?? null,
|
||||
render: (c) =>
|
||||
c.velocityScore != null && Number.isFinite(c.velocityScore) ? (
|
||||
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{Math.round(c.velocityScore * 100)}
|
||||
<span style={{ color: "var(--fg-tertiary)", fontSize: 12 }}>
|
||||
{" "}
|
||||
/ 100
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
DASH
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "price",
|
||||
label: "Медиана ₽ / м²",
|
||||
// Lower median price = cheaper land context. Valence is ambiguous for a
|
||||
// developer (cheaper district ≠ better), so it is NOT highlighted.
|
||||
winner: "none",
|
||||
value: (c) => c.medianPricePerM2 ?? null,
|
||||
render: (c) => (
|
||||
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{fmtPrice(c.medianPricePerM2)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "pipeline",
|
||||
label: "Пайплайн 24 мес",
|
||||
// More competing pipeline = more future supply = worse. Lower wins.
|
||||
winner: "low",
|
||||
value: (c) => c.pipeline24mo ?? null,
|
||||
render: (c) => (
|
||||
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{fmtPipeline(c.pipeline24mo)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "confidence",
|
||||
label: "Достоверность",
|
||||
winner: "high",
|
||||
value: (c) => c.confidenceValue ?? null,
|
||||
render: (c) =>
|
||||
c.confidenceLabel ? (
|
||||
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{c.confidenceValue != null
|
||||
? `${Math.round(c.confidenceValue * 100)}% · `
|
||||
: ""}
|
||||
{CONFIDENCE_RU[c.confidenceLabel]}
|
||||
</span>
|
||||
) : (
|
||||
DASH
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns the set of column cads that win a given row, or an empty set when the
|
||||
* metric is not comparable / has fewer than two data points. Ties win jointly.
|
||||
*/
|
||||
function winnersFor(row: MetricRow, columns: CompareColumn[]): Set<string> {
|
||||
if (row.winner === "none") return new Set();
|
||||
const vals = columns
|
||||
.map((c) => ({ cad: c.cad, v: row.value(c) }))
|
||||
.filter((x): x is { cad: string; v: number } => x.v != null);
|
||||
if (vals.length < 2) return new Set();
|
||||
const best =
|
||||
row.winner === "high"
|
||||
? Math.max(...vals.map((x) => x.v))
|
||||
: Math.min(...vals.map((x) => x.v));
|
||||
return new Set(vals.filter((x) => x.v === best).map((x) => x.cad));
|
||||
}
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function CompareTable({ columns, onRemove }: Props) {
|
||||
const winnerByRow = new Map<string, Set<string>>(
|
||||
METRIC_ROWS.map((row) => [row.key, winnersFor(row, columns)]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: 14,
|
||||
minWidth: 120 + columns.length * 200,
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={cornerTh}>Фактор</th>
|
||||
{columns.map((c) => (
|
||||
<th key={c.cad} style={colTh}>
|
||||
<div style={colHeadInner}>
|
||||
<Link
|
||||
href={`/site-finder/analysis/${encodeURIComponent(c.cad)}`}
|
||||
style={{
|
||||
color: "var(--accent)",
|
||||
textDecoration: "none",
|
||||
fontFamily: "monospace",
|
||||
fontSize: 13,
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
title="Открыть полный анализ участка"
|
||||
>
|
||||
{c.cad}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(c.cad)}
|
||||
aria-label={`Убрать ${c.cad} из сравнения`}
|
||||
style={removeBtn}
|
||||
>
|
||||
<X size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
{c.districtName && c.districtName !== DASH ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 400,
|
||||
color: "var(--fg-tertiary)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{c.districtName}
|
||||
</div>
|
||||
) : null}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{METRIC_ROWS.map((row) => {
|
||||
const winners = winnerByRow.get(row.key) ?? new Set<string>();
|
||||
return (
|
||||
<tr key={row.key} style={{ borderTop: "1px solid var(--border-soft)" }}>
|
||||
<th style={rowTh}>{row.label}</th>
|
||||
{columns.map((c) => {
|
||||
const isWinner = winners.has(c.cad);
|
||||
return (
|
||||
<td
|
||||
key={c.cad}
|
||||
style={{
|
||||
...cell,
|
||||
background: isWinner ? "var(--success-soft)" : undefined,
|
||||
color: isWinner ? "var(--success)" : "var(--fg-primary)",
|
||||
fontWeight: isWinner ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{c.status === "loading" ? (
|
||||
<span style={{ color: "var(--fg-tertiary)" }}>…</span>
|
||||
) : c.status === "error" ? (
|
||||
<span
|
||||
style={{ color: "var(--danger)", fontSize: 12 }}
|
||||
title={c.errorMsg ?? "Ошибка анализа"}
|
||||
>
|
||||
ошибка
|
||||
</span>
|
||||
) : (
|
||||
row.render(c)
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Styles ────────────────────────────────────────────────────────────────
|
||||
|
||||
const baseTh: React.CSSProperties = {
|
||||
padding: "10px 12px",
|
||||
borderBottom: "1px solid var(--border-card)",
|
||||
fontWeight: 600,
|
||||
verticalAlign: "top",
|
||||
};
|
||||
|
||||
const cornerTh: React.CSSProperties = {
|
||||
...baseTh,
|
||||
textAlign: "left",
|
||||
background: "var(--bg-app)",
|
||||
width: 180,
|
||||
minWidth: 160,
|
||||
};
|
||||
|
||||
const colTh: React.CSSProperties = {
|
||||
...baseTh,
|
||||
textAlign: "left",
|
||||
background: "var(--bg-app)",
|
||||
};
|
||||
|
||||
const colHeadInner: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
const rowTh: React.CSSProperties = {
|
||||
padding: "10px 12px",
|
||||
textAlign: "left",
|
||||
fontWeight: 500,
|
||||
color: "var(--fg-secondary)",
|
||||
background: "var(--bg-card-alt)",
|
||||
};
|
||||
|
||||
const cell: React.CSSProperties = {
|
||||
padding: "10px 12px",
|
||||
textAlign: "left",
|
||||
};
|
||||
|
||||
const removeBtn: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 2,
|
||||
cursor: "pointer",
|
||||
color: "var(--fg-tertiary)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useParcelAnalyzeQuery } from "@/lib/site-finder-api";
|
||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import type { CompareColumn } from "./CompareTable";
|
||||
|
||||
interface Props {
|
||||
cad: string;
|
||||
onResult: (col: CompareColumn) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless loader: runs `useParcelAnalyzeQuery` for a single cad and lifts the
|
||||
* flattened metrics up to the compare page via `onResult`. Rendered one-per-cad
|
||||
* inside a `.map`, so each instance owns a stable hook order (rules-of-hooks
|
||||
* safe) — the page itself never calls the hook in a loop. Reuses the exact same
|
||||
* analyze endpoint + 202-poll logic as the single-parcel analysis page, so a
|
||||
* parcel already analyzed there is served warm from the shared query cache.
|
||||
*
|
||||
* Renders nothing; `useEffect` here only mirrors query state into parent state,
|
||||
* it does not perform HTTP (the hook does, via TanStack Query).
|
||||
*/
|
||||
export function ParcelCompareLoader({ cad, onResult }: Props) {
|
||||
const { data, isLoading, error } = useParcelAnalyzeQuery(cad);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
onResult({ cad, status: "loading" });
|
||||
return;
|
||||
}
|
||||
if (error || !data) {
|
||||
onResult({
|
||||
cad,
|
||||
status: "error",
|
||||
errorMsg:
|
||||
error instanceof Error ? error.message : "Ошибка загрузки анализа",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ParcelAnalyzeResponse is a structural subset of ParcelAnalysis (same
|
||||
// /analyze endpoint); the richer fields (velocity, gate_verdict, confidence,
|
||||
// success_recommendation) are optional and may be absent on thin parcels.
|
||||
const a = data as unknown as ParcelAnalysis;
|
||||
|
||||
onResult({
|
||||
cad,
|
||||
status: "ready",
|
||||
districtName: a.district?.district_name ?? null,
|
||||
verdictLabel: a.gate_verdict?.verdict_label ?? null,
|
||||
score: a.score ?? null,
|
||||
scoreLabel: a.score_label ?? null,
|
||||
velocityScore: a.velocity?.velocity_score ?? null,
|
||||
medianPricePerM2: a.district?.median_price_per_m2 ?? null,
|
||||
pipeline24mo: a.pipeline_24mo?.flats_total ?? null,
|
||||
confidenceLabel: a.confidence_label ?? null,
|
||||
confidenceValue: a.confidence ?? null,
|
||||
});
|
||||
}, [cad, data, isLoading, error, onResult]);
|
||||
|
||||
return null;
|
||||
}
|
||||
60
frontend/src/lib/compare-shortlist.ts
Normal file
60
frontend/src/lib/compare-shortlist.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Site Finder multi-cad compare — localStorage-backed shortlist (issue #50).
|
||||
*
|
||||
* The compare page lets a developer pin 2–5 cadastral numbers and view their
|
||||
* analyze metrics side-by-side. The shortlist survives reload via localStorage
|
||||
* (per browser, not per server session) — mirroring `gd_recent_parcels` in
|
||||
* site-finder-api.ts. All access is SSR-guarded (`typeof window`).
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "gd_compare_shortlist";
|
||||
|
||||
/** Hard cap from the issue: batch analyze runs max 5 parcels in parallel. */
|
||||
export const COMPARE_MAX = 5;
|
||||
/** Need at least two parcels for a meaningful side-by-side. */
|
||||
export const COMPARE_MIN = 2;
|
||||
|
||||
/**
|
||||
* Validates cadastral number formats (same regex as CadInput):
|
||||
* 3-part: 66:41:0204016 (quarter)
|
||||
* 4-part: 66:41:0204016:10 (parcel)
|
||||
*/
|
||||
const CAD_REGEX = /^\d+:\d+:\d+(?::\d+)?$/;
|
||||
|
||||
export function isValidCad(cad: string): boolean {
|
||||
return CAD_REGEX.test(cad.trim());
|
||||
}
|
||||
|
||||
export function getShortlist(): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
// Narrow to a clean, de-duplicated, capped string[] — defends against a
|
||||
// hand-edited / stale localStorage payload.
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const item of parsed) {
|
||||
if (typeof item !== "string") continue;
|
||||
const cad = item.trim();
|
||||
if (!isValidCad(cad) || seen.has(cad)) continue;
|
||||
seen.add(cad);
|
||||
out.push(cad);
|
||||
if (out.length >= COMPARE_MAX) break;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveShortlist(cads: string[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(cads.slice(0, COMPARE_MAX)));
|
||||
} catch {
|
||||
// ignore quota / disabled-storage errors — shortlist is best-effort
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue