Compare commits
No commits in common. "11a7018e78f1b6c293ca533e2e05a05454446831" and "4863e67f27cb3487a5efab686c96a3e42e0bc410" have entirely different histories.
11a7018e78
...
4863e67f27
5 changed files with 13 additions and 150 deletions
|
|
@ -49,10 +49,6 @@ export default function SaleSharePage() {
|
||||||
const [yearMax, setYearMax] = useState("");
|
const [yearMax, setYearMax] = useState("");
|
||||||
const [houseType, setHouseType] = useState("");
|
const [houseType, setHouseType] = useState("");
|
||||||
const [sort, setSort] = useState<SaleShareSort>("share_desc");
|
const [sort, setSort] = useState<SaleShareSort>("share_desc");
|
||||||
// Окно метрики: «сейчас» (активные сейчас) или «за 45 дней».
|
|
||||||
const [window, setWindow] = useState<"now" | "45d">("now");
|
|
||||||
// Клиентский фильтр «мин. объявлений в доме» (см. filteredBuildings ниже).
|
|
||||||
const [minCount, setMinCount] = useState<number>(0);
|
|
||||||
|
|
||||||
// Выбранный дом (drawer) + наведённый дом (sync со списком/картой).
|
// Выбранный дом (drawer) + наведённый дом (sync со списком/картой).
|
||||||
const [selected, setSelected] = useState<BuildingSaleShare | null>(null);
|
const [selected, setSelected] = useState<BuildingSaleShare | null>(null);
|
||||||
|
|
@ -66,15 +62,6 @@ export default function SaleSharePage() {
|
||||||
const debYearMin = useDebouncedValue(yearMin, 400);
|
const debYearMin = useDebouncedValue(yearMin, 400);
|
||||||
const debYearMax = useDebouncedValue(yearMax, 400);
|
const debYearMax = useDebouncedValue(yearMax, 400);
|
||||||
|
|
||||||
// Сорт, уходящий в API, зависит от окна: при «45 дней» share/active мапятся
|
|
||||||
// на 45-дневные метрики (остальные — цена/экспозиция — без изменений).
|
|
||||||
const apiSort: SaleShareSort = useMemo(() => {
|
|
||||||
if (window !== "45d") return sort;
|
|
||||||
if (sort === "share_desc") return "share_45d_desc";
|
|
||||||
if (sort === "active_desc") return "count_45d_desc";
|
|
||||||
return sort;
|
|
||||||
}, [sort, window]);
|
|
||||||
|
|
||||||
const query: SaleShareQuery = useMemo(
|
const query: SaleShareQuery = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
min_pct: debMinPct,
|
min_pct: debMinPct,
|
||||||
|
|
@ -84,30 +71,19 @@ export default function SaleSharePage() {
|
||||||
year_min: toNumOrNull(debYearMin),
|
year_min: toNumOrNull(debYearMin),
|
||||||
year_max: toNumOrNull(debYearMax),
|
year_max: toNumOrNull(debYearMax),
|
||||||
house_type: houseType || null,
|
house_type: houseType || null,
|
||||||
sort: apiSort,
|
sort,
|
||||||
limit: 200,
|
limit: 200,
|
||||||
}),
|
}),
|
||||||
[debMinPct, debCity, debPriceMin, debPriceMax, debYearMin, debYearMax, houseType, apiSort],
|
[debMinPct, debCity, debPriceMin, debPriceMax, debYearMin, debYearMax, houseType, sort],
|
||||||
);
|
);
|
||||||
|
|
||||||
const summaryQ = useSaleShareSummary();
|
const summaryQ = useSaleShareSummary();
|
||||||
const buildingsQ = useSaleShareBuildings(query);
|
const buildingsQ = useSaleShareBuildings(query);
|
||||||
const buildings = useMemo(() => buildingsQ.data ?? [], [buildingsQ.data]);
|
const buildings = useMemo(() => buildingsQ.data ?? [], [buildingsQ.data]);
|
||||||
|
|
||||||
// Клиентский фильтр «мин. объявлений в доме» — намеренно НЕ серверный:
|
|
||||||
// не трогаем query-key/бэкенд, фильтруем уже загруженный список по окну.
|
|
||||||
// Счётчик null трактуем как 0; minCount === 0 → фильтр выключен.
|
|
||||||
const filteredBuildings = useMemo(() => {
|
|
||||||
if (minCount <= 0) return buildings;
|
|
||||||
return buildings.filter((b) => {
|
|
||||||
const count = (window === "45d" ? b.listings_45d : b.active_secondary) ?? 0;
|
|
||||||
return count >= minCount;
|
|
||||||
});
|
|
||||||
}, [buildings, minCount, window]);
|
|
||||||
|
|
||||||
// Стабильные колбэки (маркеры карты замыкают их при отрисовке).
|
// Стабильные колбэки (маркеры карты замыкают их при отрисовке).
|
||||||
const buildingsRef = useRef<BuildingSaleShare[]>(filteredBuildings);
|
const buildingsRef = useRef<BuildingSaleShare[]>(buildings);
|
||||||
buildingsRef.current = filteredBuildings;
|
buildingsRef.current = buildings;
|
||||||
const handleSelect = useCallback((houseId: number) => {
|
const handleSelect = useCallback((houseId: number) => {
|
||||||
setSelected(buildingsRef.current.find((b) => b.house_id === houseId) ?? null);
|
setSelected(buildingsRef.current.find((b) => b.house_id === houseId) ?? null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -188,10 +164,6 @@ export default function SaleSharePage() {
|
||||||
houseTypeOptions={houseTypeOptions}
|
houseTypeOptions={houseTypeOptions}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
onSort={setSort}
|
onSort={setSort}
|
||||||
window={window}
|
|
||||||
onWindow={setWindow}
|
|
||||||
minCount={minCount}
|
|
||||||
onMinCount={setMinCount}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<article className="card">
|
<article className="card">
|
||||||
|
|
@ -209,7 +181,7 @@ export default function SaleSharePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SaleShareMap
|
<SaleShareMap
|
||||||
buildings={filteredBuildings}
|
buildings={buildings}
|
||||||
selectedHouseId={selectedHouseId}
|
selectedHouseId={selectedHouseId}
|
||||||
hoveredHouseId={hoveredHouseId}
|
hoveredHouseId={hoveredHouseId}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
|
@ -218,7 +190,7 @@ export default function SaleSharePage() {
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<SaleShareList
|
<SaleShareList
|
||||||
buildings={filteredBuildings}
|
buildings={buildings}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
onSort={setSort}
|
onSort={setSort}
|
||||||
selectedHouseId={selectedHouseId}
|
selectedHouseId={selectedHouseId}
|
||||||
|
|
@ -228,7 +200,6 @@ export default function SaleSharePage() {
|
||||||
isLoading={buildingsQ.isPending}
|
isLoading={buildingsQ.isPending}
|
||||||
isError={buildingsQ.isError}
|
isError={buildingsQ.isError}
|
||||||
minPct={minPct}
|
minPct={minPct}
|
||||||
window={window}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -81,10 +81,6 @@ interface Props {
|
||||||
houseTypeOptions: string[];
|
houseTypeOptions: string[];
|
||||||
sort: SaleShareSort;
|
sort: SaleShareSort;
|
||||||
onSort: (v: SaleShareSort) => void;
|
onSort: (v: SaleShareSort) => void;
|
||||||
window: "now" | "45d";
|
|
||||||
onWindow: (v: "now" | "45d") => void;
|
|
||||||
minCount: number;
|
|
||||||
onMinCount: (v: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SaleShareControls({
|
export function SaleShareControls({
|
||||||
|
|
@ -106,10 +102,6 @@ export function SaleShareControls({
|
||||||
houseTypeOptions,
|
houseTypeOptions,
|
||||||
sort,
|
sort,
|
||||||
onSort,
|
onSort,
|
||||||
window,
|
|
||||||
onWindow,
|
|
||||||
minCount,
|
|
||||||
onMinCount,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const sliderMax =
|
const sliderMax =
|
||||||
summary && summary.max_pct != null
|
summary && summary.max_pct != null
|
||||||
|
|
@ -126,30 +118,6 @@ export function SaleShareControls({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="ss-window-row">
|
|
||||||
<span id="ss-window-label" className="ss-window-label">
|
|
||||||
Окно метрики
|
|
||||||
</span>
|
|
||||||
<div className="ss-seg" role="group" aria-labelledby="ss-window-label">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`ss-seg-btn${window === "now" ? " is-active" : ""}`}
|
|
||||||
aria-pressed={window === "now"}
|
|
||||||
onClick={() => onWindow("now")}
|
|
||||||
>
|
|
||||||
Сейчас
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`ss-seg-btn${window === "45d" ? " is-active" : ""}`}
|
|
||||||
aria-pressed={window === "45d"}
|
|
||||||
onClick={() => onWindow("45d")}
|
|
||||||
>
|
|
||||||
За 45 дней
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Histogram summary={summary} minPct={minPct} />
|
<Histogram summary={summary} minPct={minPct} />
|
||||||
|
|
||||||
<div className="ss-slider-row">
|
<div className="ss-slider-row">
|
||||||
|
|
@ -248,22 +216,6 @@ export function SaleShareControls({
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="ss-field">
|
|
||||||
<label htmlFor="ss-min-count">Мин. объявлений в доме</label>
|
|
||||||
<input
|
|
||||||
id="ss-min-count"
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
value={minCount === 0 ? "" : minCount}
|
|
||||||
onChange={(e) => {
|
|
||||||
const n = Math.floor(Number(e.target.value));
|
|
||||||
onMinCount(Number.isFinite(n) && n > 0 ? n : 0);
|
|
||||||
}}
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="ss-field">
|
<div className="ss-field">
|
||||||
<label htmlFor="ss-sort">Сортировка</label>
|
<label htmlFor="ss-sort">Сортировка</label>
|
||||||
<select
|
<select
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ interface Props {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
minPct: number;
|
minPct: number;
|
||||||
window: "now" | "45d";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Стрелка-индикатор активной сортировки. */
|
/** Стрелка-индикатор активной сортировки. */
|
||||||
|
|
@ -46,7 +45,6 @@ export function SaleShareList({
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
minPct,
|
minPct,
|
||||||
window,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const togglePrice = () =>
|
const togglePrice = () =>
|
||||||
onSort(sort === "price_asc" ? "price_desc" : "price_asc");
|
onSort(sort === "price_asc" ? "price_desc" : "price_asc");
|
||||||
|
|
@ -142,7 +140,6 @@ export function SaleShareList({
|
||||||
<BuildingRow
|
<BuildingRow
|
||||||
key={b.house_id}
|
key={b.house_id}
|
||||||
b={b}
|
b={b}
|
||||||
window={window}
|
|
||||||
selected={b.house_id === selectedHouseId}
|
selected={b.house_id === selectedHouseId}
|
||||||
hovered={b.house_id === hoveredHouseId}
|
hovered={b.house_id === hoveredHouseId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
|
|
@ -159,30 +156,22 @@ export function SaleShareList({
|
||||||
|
|
||||||
function BuildingRow({
|
function BuildingRow({
|
||||||
b,
|
b,
|
||||||
window,
|
|
||||||
selected,
|
selected,
|
||||||
hovered,
|
hovered,
|
||||||
onSelect,
|
onSelect,
|
||||||
onHover,
|
onHover,
|
||||||
}: {
|
}: {
|
||||||
b: BuildingSaleShare;
|
b: BuildingSaleShare;
|
||||||
window: "now" | "45d";
|
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
hovered: boolean;
|
hovered: boolean;
|
||||||
onSelect: (houseId: number) => void;
|
onSelect: (houseId: number) => void;
|
||||||
onHover: (houseId: number | null) => void;
|
onHover: (houseId: number | null) => void;
|
||||||
}) {
|
}) {
|
||||||
// Метрики окна: при «45 дней» — 45-дневные поля (с фолбэком на «сейчас»),
|
|
||||||
// иначе текущие. Цвет/процент/бейдж «>100%» считаются по выбранному окну.
|
|
||||||
const pct = window === "45d" ? (b.sale_share_pct_45d ?? b.sale_share_pct) : b.sale_share_pct;
|
|
||||||
const count = window === "45d" ? b.listings_45d : b.active_secondary;
|
|
||||||
const overHundred = pct !== null && pct > 100;
|
|
||||||
|
|
||||||
const denom =
|
const denom =
|
||||||
count !== null && b.flat_count_effective !== null
|
b.active_secondary !== null && b.flat_count_effective !== null
|
||||||
? `${count} из ${b.flat_count_effective}`
|
? `${b.active_secondary} из ${b.flat_count_effective}`
|
||||||
: count !== null
|
: b.active_secondary !== null
|
||||||
? `${count}`
|
? `${b.active_secondary}`
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
const houseMeta = [
|
const houseMeta = [
|
||||||
|
|
@ -206,7 +195,7 @@ function BuildingRow({
|
||||||
<span className="a-main">{b.address ?? "Адрес не указан"}</span>
|
<span className="a-main">{b.address ?? "Адрес не указан"}</span>
|
||||||
<span className="a-sub" style={{ display: "inline-flex", gap: 6, flexWrap: "wrap" }}>
|
<span className="a-sub" style={{ display: "inline-flex", gap: 6, flexWrap: "wrap" }}>
|
||||||
{b.is_emergency && <span className="ss-badge ss-badge--danger">аварийный</span>}
|
{b.is_emergency && <span className="ss-badge ss-badge--danger">аварийный</span>}
|
||||||
{overHundred && (
|
{b.over_100 && (
|
||||||
<span className="ss-badge ss-badge--warn">возможно, несколько корпусов</span>
|
<span className="ss-badge ss-badge--warn">возможно, несколько корпусов</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -215,9 +204,9 @@ function BuildingRow({
|
||||||
<td className="num">
|
<td className="num">
|
||||||
<span
|
<span
|
||||||
className="ss-pct"
|
className="ss-pct"
|
||||||
style={{ color: "#fff", background: heatColor(pct) }}
|
style={{ color: "#fff", background: heatColor(b.sale_share_pct) }}
|
||||||
>
|
>
|
||||||
{fmtPct(pct)}
|
{fmtPct(b.sale_share_pct)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="num">{denom}</td>
|
<td className="num">{denom}</td>
|
||||||
|
|
|
||||||
|
|
@ -2400,49 +2400,6 @@ html, body { overflow-x: clip; }
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Window toggle (сейчас / за 45 дней) */
|
|
||||||
.ss-window-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.ss-window-label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--fg-2);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.ss-seg {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 4px;
|
|
||||||
background: var(--surface-2);
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.ss-seg-btn {
|
|
||||||
padding: 7px 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: calc(var(--radius) - 4px);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--fg-2);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
|
||||||
}
|
|
||||||
.ss-seg-btn:hover { background: var(--surface); }
|
|
||||||
.ss-seg-btn.is-active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--surface);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
.ss-seg-btn:focus-visible {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slider */
|
/* Slider */
|
||||||
.ss-slider-row {
|
.ss-slider-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@
|
||||||
export type SaleShareSort =
|
export type SaleShareSort =
|
||||||
| "share_desc"
|
| "share_desc"
|
||||||
| "active_desc"
|
| "active_desc"
|
||||||
| "share_45d_desc"
|
|
||||||
| "count_45d_desc"
|
|
||||||
| "exposure_desc"
|
| "exposure_desc"
|
||||||
| "price_asc"
|
| "price_asc"
|
||||||
| "price_desc";
|
| "price_desc";
|
||||||
|
|
@ -21,10 +19,6 @@ export interface BuildingSaleShare {
|
||||||
// Доля квартир дома, выставленных на вторичную продажу. null, если по дому
|
// Доля квартир дома, выставленных на вторичную продажу. null, если по дому
|
||||||
// ещё не загружен реестр квартир (ГАР) → знаменатель неизвестен.
|
// ещё не загружен реестр квартир (ГАР) → знаменатель неизвестен.
|
||||||
sale_share_pct: number | null;
|
sale_share_pct: number | null;
|
||||||
// Те же метрики, но за окно последних 45 дней (доля квартир, которые были
|
|
||||||
// в продаже хотя бы раз за 45д, и число таких объявлений). null — нет данных.
|
|
||||||
sale_share_pct_45d: number | null;
|
|
||||||
listings_45d: number | null;
|
|
||||||
// active_secondary > знаменателя — implausible-матч (коллизия адреса при
|
// active_secondary > знаменателя — implausible-матч (коллизия адреса при
|
||||||
// ГАР-матче). Не прячем, а маркируем бейджем «возможно, несколько корпусов».
|
// ГАР-матче). Не прячем, а маркируем бейджем «возможно, несколько корпусов».
|
||||||
over_100: boolean;
|
over_100: boolean;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue