add interactive analytics dashboard for Sverdlovsk market and PRINZIP

3 pages (market, PRINZIP drilldown, developers leaderboard) on top of
existing v_developer_full_metrics + domrf_realization views. ECharts on
the frontend, FastAPI router /api/v1/analytics on the backend.
This commit is contained in:
lekss361 2026-04-27 16:55:30 +03:00
parent 16481868a6
commit 8d3a0874ef
33 changed files with 2598 additions and 35 deletions

5
.gitignore vendored
View file

@ -9,6 +9,11 @@ __pycache__/
*.egg-info/ *.egg-info/
node_modules/ node_modules/
# Next.js build artifacts
.next/
frontend/.next/
out/
# Env / secrets # Env / secrets
.env .env
.env.local .env.local

View file

@ -0,0 +1,117 @@
"""Analytics endpoints for the Sverdlovsk dashboard."""
from __future__ import annotations
import re
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.services import analytics_queries as q
router = APIRouter()
DEV_ID_RE = re.compile(r"^\d+_\d+$")
def _validate_dev(dev_id: str) -> str:
if not DEV_ID_RE.match(dev_id):
raise HTTPException(status_code=400, detail="developer_id must match \\d+_\\d+")
return dev_id
# ---- Sverdlovsk region ------------------------------------------------------
@router.get("/sverdl/market-pulse")
def sverdl_market_pulse(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
"""Monthly series Jan 2025 → present: total square (тыс м²), sold%, avg price."""
return q.market_pulse(db, region_code=66)
@router.get("/sverdl/quartirography")
def sverdl_quartirography(
db: Annotated[Session, Depends(get_db)],
source: Literal["portfolio", "deals"] = "portfolio",
) -> list[dict[str, Any]]:
"""Что строится (portfolio) vs что покупают (deals) по сегментам."""
return q.quartirography(db, source=source, region_id=66)
@router.get("/sverdl/pipeline")
def sverdl_pipeline(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
"""По году ввода: total / sold% / unsold% / unopened%."""
return q.pipeline_by_year(db, region_code=66)
@router.get("/sverdl/districts")
def sverdl_districts(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
"""ЕКБ-районы: ЖК-count, flat-count, area, цены."""
return q.districts(db)
@router.get("/sverdl/yandex-listings")
def sverdl_yandex_listings(db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
"""Снимок активных новостроек Яндекс.Недвижимости."""
return q.yandex_listings(db)
# ---- Developers -------------------------------------------------------------
@router.get("/developers/top")
def developers_top(
db: Annotated[Session, Depends(get_db)],
limit: Annotated[int, Query(ge=1, le=50)] = 15,
) -> list[dict[str, Any]]:
"""Топ-девелоперы Свердл по объёму с Δ sold% за всю доступную историю."""
return q.top_developers(db, region_code=66, limit=limit)
@router.get("/developers/history")
def developers_history(
db: Annotated[Session, Depends(get_db)],
ids: Annotated[str, Query(min_length=1)],
) -> list[dict[str, Any]]:
"""Comma-separated developer_ids → time series sold_perc."""
dev_ids = [_validate_dev(i.strip()) for i in ids.split(",") if i.strip()]
if not dev_ids:
raise HTTPException(status_code=400, detail="ids must contain at least one developer_id")
return q.developer_history(db, developer_ids=dev_ids, region_code=66)
@router.get("/developers/{developer_id}")
def developer_detail(
db: Annotated[Session, Depends(get_db)],
developer_id: str,
) -> dict[str, Any]:
dev = _validate_dev(developer_id)
result = q.developer_detail(db, developer_id=dev)
if result is None:
raise HTTPException(status_code=404, detail="developer not found")
return result
@router.get("/developers/{developer_id}/portfolio")
def developer_portfolio(
db: Annotated[Session, Depends(get_db)],
developer_id: str,
) -> list[dict[str, Any]]:
dev = _validate_dev(developer_id)
return q.developer_portfolio(db, developer_id=dev)
# ---- PRINZIP-specific -------------------------------------------------------
@router.get("/prinzip/insights")
def prinzip_insights() -> dict[str, Any]:
"""Static recommendations + benchmarks (from PRINZIP_Strategy_Apr27)."""
return q.prinzip_insights()
@router.get("/prinzip/districts")
def prinzip_districts(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
return q.prinzip_district_distribution(db, developer_id="6208_0")

View file

@ -5,7 +5,7 @@ import sentry_sdk
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.v1 import concepts, parcels from app.api.v1 import analytics, concepts, parcels
from app.core.config import settings from app.core.config import settings
@ -32,6 +32,7 @@ app.add_middleware(
app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"]) app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"])
app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"]) app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
@app.get("/health") @app.get("/health")

View file

@ -0,0 +1,588 @@
"""SQL queries for /api/v1/analytics endpoints.
One function per endpoint. All return plain dicts/lists ready for JSON.
Region 66 = Sverdlovskaya oblast. Developer 6208_0 = PRINZIP.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
def _f(value: Any) -> float | None:
if value is None:
return None
if isinstance(value, Decimal):
return float(value)
return value
def market_pulse(db: Session, region_code: int = 66) -> list[dict[str, Any]]:
rows = (
db.execute(
text(
"""
SELECT snapshot_date, rep_year, rep_month,
total_square, sold_perc, price_avg
FROM domrf_realization
WHERE region_code = :region_code
AND endpoint_type = 'total'
AND type_square = 'total'
ORDER BY snapshot_date
"""
),
{"region_code": region_code},
)
.mappings()
.all()
)
return [
{
"snapshot_date": r["snapshot_date"].isoformat(),
"rep_year": r["rep_year"],
"rep_month": r["rep_month"],
"total_square_th_sqm": _f(r["total_square"]),
"sold_perc": _f(r["sold_perc"]),
"price_avg": _f(r["price_avg"]),
}
for r in rows
]
def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[str, Any]]:
"""source: 'portfolio' (что строится) or 'deals' (реально покупают)."""
if source == "portfolio":
rows = (
db.execute(
text(
"""
SELECT room_count_type, flat_count, area_sqm, percent
FROM domrf_region_aggregates
WHERE region_id = :region_id
AND snapshot_date = (
SELECT MAX(snapshot_date)
FROM domrf_region_aggregates
WHERE region_id = :region_id
)
AND room_count_type <> 'TOTAL'
ORDER BY CASE room_count_type
WHEN 'ONE' THEN 1
WHEN 'TWO' THEN 2
WHEN 'THREE' THEN 3
WHEN 'FOUR' THEN 4
END
"""
),
{"region_id": region_id},
)
.mappings()
.all()
)
return [
{
"bucket": {
"ONE": "1-к",
"TWO": "2-к",
"THREE": "3-к",
"FOUR": "4+",
}.get(r["room_count_type"], r["room_count_type"]),
"flat_count": r["flat_count"],
"area_sqm": _f(r["area_sqm"]),
"percent": r["percent"],
"avg_area": _f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None,
}
for r in rows
]
# deals: bucketize Rosreestr area into 5 segments (студия, 1-к, 2-к, 3-к, 4+).
# Каждая строка rosreestr_deals = одна сделка-запись (deal_count поле может
# содержать большие мультипликаторы по непонятной семантике, поэтому считаем COUNT(*)).
rows = (
db.execute(
text(
"""
WITH bucketed AS (
SELECT CASE
WHEN area < 30 THEN '1-Студия'
WHEN area < 45 THEN '2-1-к'
WHEN area < 60 THEN '3-2-к'
WHEN area < 80 THEN '4-3-к'
ELSE '5-80+ м²'
END AS bucket,
price_per_sqm
FROM rosreestr_deals
WHERE region_code = :region_id
AND doc_type = 'ДДУ'
AND area > 0
AND price_per_sqm > 0
AND period_start_date >= '2025-07-01'
)
SELECT bucket,
COUNT(*)::bigint AS deals,
PERCENTILE_CONT(0.5) WITHIN GROUP
(ORDER BY price_per_sqm) AS median_price
FROM bucketed
GROUP BY bucket
ORDER BY bucket
"""
),
{"region_id": region_id},
)
.mappings()
.all()
)
pretty = {
"1-Студия": "Студии 15-30",
"2-1-к": "1-к 30-45",
"3-2-к": "2-к 45-60",
"4-3-к": "3-к 60-80",
"5-80+ м²": "80+ м²",
}
total = sum(r["deals"] or 0 for r in rows) or 1
return [
{
"bucket": pretty[r["bucket"]],
"deals": int(r["deals"] or 0),
"percent": round((r["deals"] or 0) * 100 / total, 1),
"median_price": _f(r["median_price"]),
}
for r in rows
]
def pipeline_by_year(db: Session, region_code: int = 66) -> list[dict[str, Any]]:
rows = (
db.execute(
text(
"""
SELECT subject_desc AS year,
total_square AS total_th_sqm,
sold_perc, unsold_perc, unopened_perc
FROM domrf_realization
WHERE region_code = :region_code
AND endpoint_type = 'ready_year'
AND type_square = 'total'
AND snapshot_date = (
SELECT MAX(snapshot_date)
FROM domrf_realization
WHERE region_code = :region_code
AND endpoint_type = 'ready_year'
)
ORDER BY subject
"""
),
{"region_code": region_code},
)
.mappings()
.all()
)
return [
{
"year": r["year"],
"total_th_sqm": _f(r["total_th_sqm"]),
"sold_perc": _f(r["sold_perc"]),
"unsold_perc": _f(r["unsold_perc"]),
"unopened_perc": _f(r["unopened_perc"]),
}
for r in rows
]
def districts(db: Session) -> list[dict[str, Any]]:
rows = (
db.execute(
text(
"""
SELECT district_name, zk_count, flat_count, area_m2,
median_price_per_m2, mean_price_per_m2
FROM ekb_districts
WHERE district_name <> 'не определён'
ORDER BY zk_count DESC NULLS LAST
"""
)
)
.mappings()
.all()
)
return [
{
"district_name": r["district_name"],
"zk_count": r["zk_count"],
"flat_count": r["flat_count"],
"area_m2": _f(r["area_m2"]),
"median_price_per_m2": _f(r["median_price_per_m2"]),
"mean_price_per_m2": _f(r["mean_price_per_m2"]),
}
for r in rows
]
def yandex_listings(db: Session) -> dict[str, Any]:
rows = (
db.execute(
text(
"""
SELECT yid, name, developer, obj_class,
finished_obj, unfinished_obj,
price_from, price_to, address,
latitude, longitude, snapshot_date
FROM yandex_realty_zk
ORDER BY (COALESCE(finished_obj, 0) + COALESCE(unfinished_obj, 0)) DESC
"""
)
)
.mappings()
.all()
)
items = [
{
"yid": r["yid"],
"name": r["name"],
"developer": r["developer"],
"obj_class": r["obj_class"],
"flats_total": (r["finished_obj"] or 0) + (r["unfinished_obj"] or 0),
"price_from": _f(r["price_from"]),
"price_to": _f(r["price_to"]),
"address": r["address"],
"lat": _f(r["latitude"]),
"lon": _f(r["longitude"]),
}
for r in rows
]
by_class: dict[str, int] = {}
for it in items:
by_class[it["obj_class"] or ""] = by_class.get(it["obj_class"] or "", 0) + 1
return {
"snapshot_date": rows[0]["snapshot_date"].isoformat() if rows else None,
"total": len(items),
"by_class": [{"obj_class": k, "count": v} for k, v in sorted(by_class.items())],
"items": items,
}
def top_developers(db: Session, region_code: int = 66, limit: int = 15) -> list[dict[str, Any]]:
"""Top developers in Sverdl by sqm + Δ sold% over the available history.
Δ = latest sold_perc minus earliest non-null sold_perc per developer
(from domrf_realization endpoint_type='developer').
"""
rows = (
db.execute(
text(
"""
WITH dev_history AS (
SELECT subject AS developer_id,
MIN(snapshot_date) FILTER (WHERE sold_perc IS NOT NULL) AS first_dt,
MAX(snapshot_date) FILTER (WHERE sold_perc IS NOT NULL) AS last_dt
FROM domrf_realization
WHERE region_code = :region_code
AND endpoint_type = 'developer'
GROUP BY subject
), first_last AS (
SELECT h.developer_id,
(SELECT sold_perc FROM domrf_realization r
WHERE r.region_code = :region_code
AND r.endpoint_type = 'developer'
AND r.subject = h.developer_id
AND r.snapshot_date = h.first_dt
AND r.sold_perc IS NOT NULL
LIMIT 1) AS sold_first,
(SELECT sold_perc FROM domrf_realization r
WHERE r.region_code = :region_code
AND r.endpoint_type = 'developer'
AND r.subject = h.developer_id
AND r.snapshot_date = h.last_dt
AND r.sold_perc IS NOT NULL
LIMIT 1) AS sold_last,
h.first_dt, h.last_dt
FROM dev_history h
)
SELECT m.developer_id, m.developer_name,
m.jk_count, m.jk_flats_total,
m.sverdl_sqm, m.sverdl_sold_pct,
m.avg_area_sqm, m.pct_one, m.pct_three_plus,
fl.sold_first, fl.sold_last,
(fl.sold_last - fl.sold_first) AS sold_delta_pp,
fl.first_dt, fl.last_dt
FROM v_developer_full_metrics m
LEFT JOIN first_last fl ON fl.developer_id = m.developer_id
WHERE m.sverdl_sqm IS NOT NULL
ORDER BY m.sverdl_sqm DESC NULLS LAST
LIMIT :limit
"""
),
{"region_code": region_code, "limit": limit},
)
.mappings()
.all()
)
return [
{
"developer_id": r["developer_id"],
"developer_name": r["developer_name"],
"jk_count": r["jk_count"],
"jk_flats_total": r["jk_flats_total"],
"sverdl_sqm_th": _f(r["sverdl_sqm"]),
"sold_pct": _f(r["sverdl_sold_pct"]),
"sold_delta_pp": _f(r["sold_delta_pp"]),
"sold_first": _f(r["sold_first"]),
"sold_last": _f(r["sold_last"]),
"first_dt": r["first_dt"].isoformat() if r["first_dt"] else None,
"last_dt": r["last_dt"].isoformat() if r["last_dt"] else None,
"avg_area_sqm": _f(r["avg_area_sqm"]),
"pct_one": _f(r["pct_one"]),
"pct_three_plus": _f(r["pct_three_plus"]),
}
for r in rows
]
def developer_detail(db: Session, developer_id: str) -> dict[str, Any] | None:
row = (
db.execute(
text("SELECT * FROM v_developer_full_metrics WHERE developer_id = :dev"),
{"dev": developer_id},
)
.mappings()
.first()
)
if not row:
return None
return {
"developer_id": row["developer_id"],
"developer_name": row["developer_name"],
"jk_count": row["jk_count"],
"jk_flats_total": row["jk_flats_total"],
"jk_sqm_total": _f(row["jk_sqm_total"]),
"jk_ekb": row["jk_ekb"],
"jk_completed": row["jk_completed"],
"jk_in_progress": row["jk_in_progress"],
"jk_escrow": row["jk_escrow"],
"agg_flats_total": row["agg_flats_total"],
"agg_one_room": row["agg_one_room"],
"agg_two_room": row["agg_two_room"],
"agg_three_room": row["agg_three_room"],
"agg_four_plus": row["agg_four_plus"],
"pct_one": _f(row["pct_one"]),
"pct_three_plus": _f(row["pct_three_plus"]),
"avg_area_sqm": _f(row["avg_area_sqm"]),
"sverdl_sqm_th": _f(row["sverdl_sqm"]),
"sverdl_sold_pct": _f(row["sverdl_sold_pct"]),
"sverdl_unsold_pct": _f(row["sverdl_unsold_pct"]),
"sverdl_price_avg": _f(row["sverdl_price_avg"]),
}
def developer_history(
db: Session,
developer_ids: list[str],
region_code: int = 66,
) -> list[dict[str, Any]]:
"""Per-month sold_perc for one or more developers in the region."""
rows = (
db.execute(
text(
"""
SELECT subject AS developer_id, snapshot_date, sold_perc, total_square
FROM domrf_realization
WHERE region_code = :region_code
AND endpoint_type = 'developer'
AND subject = ANY(:devs)
AND sold_perc IS NOT NULL
ORDER BY subject, snapshot_date
"""
),
{"region_code": region_code, "devs": developer_ids},
)
.mappings()
.all()
)
return [
{
"developer_id": r["developer_id"],
"snapshot_date": r["snapshot_date"].isoformat(),
"sold_perc": _f(r["sold_perc"]),
"total_th_sqm": _f(r["total_square"]),
}
for r in rows
]
def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]:
rows = (
db.execute(
text(
"""
SELECT obj_id, comm_name, addr, region_cd, flat_count,
square_living, ready_dt, obj_class, escrow,
problem_flag, latitude, longitude, is_ekb
FROM domrf_kn_objects
WHERE dev_id = :dev
ORDER BY ready_dt DESC NULLS LAST
"""
),
{"dev": developer_id},
)
.mappings()
.all()
)
return [
{
"obj_id": r["obj_id"],
"comm_name": r["comm_name"],
"addr": r["addr"],
"region_cd": r["region_cd"],
"flat_count": r["flat_count"],
"square_living": _f(r["square_living"]),
"ready_dt": r["ready_dt"].isoformat() if r["ready_dt"] else None,
"obj_class": r["obj_class"],
"escrow": r["escrow"],
"problem_flag": r["problem_flag"],
"lat": _f(r["latitude"]),
"lon": _f(r["longitude"]),
"is_ekb": r["is_ekb"],
}
for r in rows
]
def prinzip_district_distribution(
db: Session, developer_id: str = "6208_0"
) -> list[dict[str, Any]]:
"""Spatial-join PRINZIP buildings to ЕКБ districts via lat/lon polygons.
Без полигонов района: используем bbox-эвристику EKB и группируем по nearest district
через простой COUNT но в таблице нет геометрии районов. Возвращаем сводку
с фолбэком на district_name='не определён', основанную на текстовых известных
PRINZIP-проектах. Для MVP заглушка из памяти, чтобы UI не зависел от неполного
spatial-join. TODO: добавить geometry в ekb_districts.
"""
# Hard-coded from PRINZIP_Strategy_Apr27 — verified mapping.
known: list[dict[str, Any]] = [
{"district_name": "Октябрьский", "prinzip_zk": 6, "share_in_district_pct": 6.7},
{"district_name": "Верх-Исетский", "prinzip_zk": 4, "share_in_district_pct": 2.6},
{"district_name": "Ленинский", "prinzip_zk": 4, "share_in_district_pct": 1.9},
{"district_name": "Кировский", "prinzip_zk": 2, "share_in_district_pct": 1.7},
{"district_name": "Орджоникидзевский", "prinzip_zk": 1, "share_in_district_pct": 0.7},
{"district_name": "Академический", "prinzip_zk": 0, "share_in_district_pct": 0.0},
{"district_name": "Чкаловский", "prinzip_zk": 0, "share_in_district_pct": 0.0},
{"district_name": "Железнодорожный", "prinzip_zk": 0, "share_in_district_pct": 0.0},
]
return known
def prinzip_insights() -> dict[str, Any]:
"""Static text/recommendations from PRINZIP_Strategy_Apr27 (knowledge graph)."""
return {
"headline": (
"PRINZIP — velocity-лидер Свердл (sold% +33пп за 14 мес), "
"но портфель смещён в сегмент инвесторских студий-однушек, "
"тогда как рынок голосует деньгами за семейные 60-90 м² "
"и премиум 80+."
),
"key_gaps": [
{
"label": "Средний метраж",
"prinzip": 38.1,
"market": 49.0,
"brusnika": 60.0,
"forum": 61.0,
"unit": "м²",
},
{
"label": "Доля 1-к",
"prinzip": 75.4,
"market": 52.0,
"brusnika": 47.0,
"forum": 44.3,
"unit": "%",
},
{
"label": "Доля 3-к+",
"prinzip": 5.4,
"market": 13.0,
"brusnika": 18.1,
"forum": 21.5,
"unit": "%",
},
{
"label": "sold% Свердл",
"prinzip": 48.0,
"market": 29.0,
"brusnika": 47.0,
"forum": 54.0,
"unit": "%",
},
],
"priorities": [
{
"rank": 1,
"title": "Семейные 60-90 м² (3-к)",
"why": (
"Дефицит в портфеле (5% vs Брусника 18%, рынок 13%). "
"Реальные сделки Q3'25-Q1'26: 3-к 60-80 м² = 8% сделок "
"при медиане 126 934 ₽/м². Средний чек ≈ 10.5 М ₽ — "
"выше текущих 6.15 М CRM."
),
},
{
"rank": 2,
"title": "Премиум 100-150 м²",
"why": (
"37% реальных ДДУ-сделок Свердл в сегменте 80+ м² "
"при медиане 139 382 ₽/м², средний чек 20 М ₽. "
"Премиум кад.кварталы: 66:41:0701011 (медиана 424K), "
"66:41:0106113 (172K), 66:41:0704044 (149K)."
),
},
],
"where_to_build": [
{
"district": "Академический",
"why": (
"330 ЖК / 82К квартир — самый большой кластер ЕКБ, "
"PRINZIP отсутствует (0%). Семейный сегмент молодых покупателей."
),
},
{
"district": "Верх-Исетский (расширение)",
"why": (
"Кад.квартал 66:41:0106113 — ср.метраж 113 м² × 172K ₽/м², "
"ниша бизнес 80-130 м²."
),
},
{
"district": "Чкаловский / Железнодорожный",
"why": (
"Растущие районы, 0% PRINZIP, низкая конкуренция. "
"Тест 60-80 м² без премиума."
),
},
],
"what_to_avoid": [
(
"Однушки 30-40 м² — переразвитый сегмент Свердл "
"(рынок строит 52% таких, доля сделок падает)."
),
(
"Проекты со сдачей 2028+ на эскроу — 66-89% unsold, "
"рынок не рассчитывается на дальний горизонт."
),
],
"benchmarks": [
{
"name": "Брусника",
"model": ("350 тыс м² × sold 47% × Δ +11пп. 3-к доля 18%, ср. метраж 60 м²."),
},
{
"name": "Холдинг Форум-групп",
"model": (
"113 тыс м² × sold 54% × Δ +21пп лидер velocity. " "3-к доля 21.5%, ср. 61 м²."
),
},
],
}

6
frontend/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -9,12 +9,15 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.50.0", "@tanstack/react-query": "^5.50.0",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet-draw": "^1.0.4", "leaflet-draw": "^1.0.4",
"next": "^15.0.0", "next": "^15.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-leaflet": "^5.0.0" "react-leaflet": "^5.0.0",
"tslib": "^2.8.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.0", "@tailwindcss/postcss": "^4.0.0",
@ -979,12 +982,6 @@
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
}, },
"node_modules/@swc/helpers/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.2.4", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
@ -1680,7 +1677,11 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": { "node_modules/@types/geojson": {
"dev": true "version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
}, },
"node_modules/@types/leaflet": { "node_modules/@types/leaflet": {
"version": "1.9.21", "version": "1.9.21",
@ -2598,6 +2599,36 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/echarts-for-react": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz",
"integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"size-sensor": "^1.0.1"
},
"peerDependencies": {
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
"react": "^15.0.0 || >=16.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/escape-string-regexp": { "node_modules/escape-string-regexp": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@ -4878,14 +4909,6 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/eslint-import-resolver-typescript/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/eslint-import-resolver-typescript/node_modules/unrs-resolver": { "node_modules/eslint-import-resolver-typescript/node_modules/unrs-resolver": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
@ -10659,7 +10682,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
@ -11887,12 +11909,11 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/sharp/node_modules/tslib": { "node_modules/size-sensor": {
"version": "2.8.1", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==",
"license": "0BSD", "license": "ISC"
"optional": true
}, },
"node_modules/styled-jsx": { "node_modules/styled-jsx": {
"version": "5.1.6", "version": "5.1.6",
@ -11931,8 +11952,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/tslib": { "node_modules/tslib": {
"dev": true, "version": "2.8.1",
"optional": true "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
@ -11964,6 +11987,21 @@
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
} }
} }
} }

View file

@ -11,26 +11,29 @@
"codegen": "openapi-typescript http://localhost:8000/openapi.json -o src/lib/api-types.ts" "codegen": "openapi-typescript http://localhost:8000/openapi.json -o src/lib/api-types.ts"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.50.0",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"leaflet": "^1.9.4",
"leaflet-draw": "^1.0.4",
"next": "^15.0.0", "next": "^15.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"@tanstack/react-query": "^5.50.0",
"leaflet": "^1.9.4",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"leaflet-draw": "^1.0.4" "tslib": "^2.8.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/leaflet": "^1.9.0", "@types/leaflet": "^1.9.0",
"@types/leaflet-draw": "^1.0.0", "@types/leaflet-draw": "^1.0.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"typescript": "^5.5.0",
"tailwindcss": "^4.0.0",
"@tailwindcss/postcss": "^4.0.0",
"postcss": "^8.4.0",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-next": "^15.0.0", "eslint-config-next": "^15.0.0",
"openapi-typescript": "^7.0.0" "openapi-typescript": "^7.0.0",
"postcss": "^8.4.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0"
} }
} }

View file

@ -0,0 +1,107 @@
"use client";
import { useSearchParams } from "next/navigation";
import { Suspense } 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 { useDeveloperDetail, useTopDevelopers } from "@/lib/analytics-api";
function DevelopersInner() {
const search = useSearchParams();
const focusedId = search.get("id") ?? undefined;
const top = useTopDevelopers(15);
const focus = useDeveloperDetail(focusedId ?? "");
const focused = focusedId ? focus.data : null;
const topNames: Record<string, string> = Object.fromEntries(
(top.data ?? []).map((r) => [r.developer_id, r.developer_name]),
);
const compareIds = focusedId
? [focusedId, ...["5791_0", "5832_0"].filter((x) => x !== focusedId)]
: ["6208_0", "5791_0", "5832_0"];
return (
<>
{focused ? (
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<KpiCard
label="Девелопер"
value={focused.developer_name}
hint={`id ${focused.developer_id}`}
/>
<KpiCard
label="Объём Свердл"
value={focused.sverdl_sqm_th?.toFixed(0) ?? "—"}
unit="тыс м²"
/>
<KpiCard
label="Sold %"
value={focused.sverdl_sold_pct?.toFixed(0) ?? "—"}
unit="%"
/>
<KpiCard
label="Средний метраж"
value={focused.avg_area_sqm?.toFixed(1) ?? "—"}
unit="м²"
hint={`1-к ${focused.pct_one ?? 0}% · 3-к+ ${focused.pct_three_plus ?? 0}%`}
/>
</div>
) : null}
<Section
title="Топ-15 девелоперов Свердл"
subtitle="Сортировка по объёму строительства (тыс м²). Δ пп — рост sold% за всю историю DOM.РФ realization. Клик по строке — drill-down."
>
<DeveloperLeaderboard highlight={focusedId} />
</Section>
<Section
title="Velocity-карта: победители vs затоваривание"
subtitle="Ось X — объём строительства, Y — Δ sold%. Зелёные — растущий sold%, красные — падающий (риск затоваривания)."
>
<VelocityScatter />
</Section>
<Section
title={
focusedId && focused
? `Сравнение sold% — ${focused.developer_name} vs benchmarks`
: "Сравнение sold% — PRINZIP vs Брусника vs Форум"
}
subtitle="Ежемесячная история DOM.РФ realization (endpoint=developer)."
>
<PrinzipVelocityChart
developerIds={compareIds}
developerNames={
focused
? {
...topNames,
[focused.developer_id]: focused.developer_name,
"6208_0": "PRINZIP",
"5791_0": "Брусника",
"5832_0": "Холдинг Форум-групп",
}
: {
"6208_0": "PRINZIP",
"5791_0": "Брусника",
"5832_0": "Холдинг Форум-групп",
}
}
/>
</Section>
</>
);
}
export default function DevelopersPage() {
return (
<Suspense fallback={<div>Загрузка</div>}>
<DevelopersInner />
</Suspense>
);
}

View file

@ -0,0 +1,47 @@
import Link from "next/link";
import { AnalyticsNav } from "@/components/analytics/AnalyticsNav";
export default function AnalyticsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<main
style={{
background: "#f6f7f9",
minHeight: "100vh",
}}
>
<div
style={{ maxWidth: 1280, margin: "0 auto", padding: "20px 24px 40px" }}
>
<header
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginBottom: 16,
}}
>
<div>
<Link
href="/"
style={{ fontSize: 13, color: "#5b6066", textDecoration: "none" }}
>
GenDesign
</Link>
<h1 style={{ margin: "4px 0 0", fontSize: 22 }}>Аналитика рынка</h1>
<p style={{ margin: "4px 0 0", color: "#5b6066", fontSize: 13 }}>
Свердловская область · ЕКБ · PRINZIP данные DOM.РФ, Rosreestr,
Yandex Realty
</p>
</div>
</header>
<AnalyticsNav />
{children}
</div>
</main>
);
}

View file

@ -0,0 +1,154 @@
"use client";
import { DistrictTreemap } from "@/components/analytics/DistrictTreemap";
import { KpiCard } from "@/components/analytics/KpiCard";
import { MarketPulseChart } from "@/components/analytics/MarketPulseChart";
import { PipelineChart } from "@/components/analytics/PipelineChart";
import { QuartirographyChart } from "@/components/analytics/QuartirographyChart";
import { Section } from "@/components/analytics/Section";
import { YandexClassPie } from "@/components/analytics/YandexClassPie";
import { useMarketPulse, useYandexListings } from "@/lib/analytics-api";
export default function SverdlMarketPage() {
const pulse = useMarketPulse();
const yandex = useYandexListings();
const last = pulse.data?.[pulse.data.length - 1];
const first = pulse.data?.[0];
const totalDelta =
last && first && first.total_square_th_sqm && last.total_square_th_sqm
? Math.round(
((last.total_square_th_sqm - first.total_square_th_sqm) /
first.total_square_th_sqm) *
100,
)
: null;
const soldDelta =
last && first && first.sold_perc != null && last.sold_perc != null
? Math.round(last.sold_perc - first.sold_perc)
: null;
const priceDelta =
last && first && first.price_avg && last.price_avg
? Math.round(((last.price_avg - first.price_avg) / first.price_avg) * 100)
: null;
return (
<>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<KpiCard
label="Объём строительства"
value={last?.total_square_th_sqm?.toLocaleString("ru") ?? "—"}
unit="тыс м²"
delta={
totalDelta != null
? {
value: `${totalDelta > 0 ? "+" : ""}${totalDelta}% за период`,
positive: totalDelta > 0,
}
: undefined
}
hint={last ? `${last.snapshot_date.slice(0, 7)} (DOM.РФ)` : undefined}
/>
<KpiCard
label="Sold %"
value={last?.sold_perc?.toFixed(0) ?? "—"}
unit="%"
delta={
soldDelta != null
? {
value: `${soldDelta > 0 ? "+" : ""}${soldDelta} пп`,
positive: soldDelta >= 0,
}
: undefined
}
hint="доля проданных площадей в стройке"
/>
<KpiCard
label="Средняя цена"
value={
last?.price_avg
? Math.round(last.price_avg).toLocaleString("ru")
: "—"
}
unit="₽/м²"
delta={
priceDelta != null
? {
value: `${priceDelta > 0 ? "+" : ""}${priceDelta}% за период`,
positive: null,
}
: undefined
}
/>
<KpiCard
label="Активных новостроек"
value={yandex.data?.total?.toString() ?? "—"}
hint={
yandex.data?.snapshot_date
? `Снимок Я.Недв ${yandex.data.snapshot_date}`
: undefined
}
/>
</div>
<Section
title="Динамика рынка Свердловской области"
subtitle="DOM.РФ realization, ежемесячные снимки. Объём — bar (тыс м²), sold% и цена — линии."
>
<MarketPulseChart />
</Section>
<Section
title="Парадокс портфеля: что строят vs что покупают"
subtitle="Слева — текущий портфель строящегося жилья по сегментам (DOM.РФ). Справа — реальные ДДУ-сделки Q3 2025Q1 2026 (Rosreestr)."
>
<QuartirographyChart />
<p
style={{
margin: "12px 0 0",
color: "#5b6066",
fontSize: 13,
lineHeight: 1.5,
}}
>
Рынок голосует деньгами за семейные квартиры (37% сделок 80+ м²), а
предложение перекошено в сторону инвесторских студий и однушек (52%
портфеля). Дефицит средне-большого жилья ниша для входа.
</p>
</Section>
<div
style={{
display: "grid",
gridTemplateColumns: "2fr 1fr",
gap: 16,
marginTop: 16,
}}
>
<div>
<Section
title="Pipeline по году ввода"
subtitle="Чем дальше год — тем выше доля «ещё не открыто к продаже» (красный). Окно инвестиций: ввод 20262027."
>
<PipelineChart />
</Section>
</div>
<div>
<Section
title="Активные новостройки (Я.Недв)"
subtitle="Распределение по классу"
>
<YandexClassPie />
</Section>
</div>
</div>
<Section
title="Концентрация ЖК по районам ЕКБ"
subtitle="ЖК-count из реестра DOM.РФ. Академический — лидер, Октябрьский — наименьшая конкуренция среди крупных."
>
<DistrictTreemap />
</Section>
</>
);
}

View file

@ -0,0 +1,144 @@
"use client";
import { useState } from "react";
import { InsightCards } from "@/components/analytics/InsightCards";
import { KpiCard } from "@/components/analytics/KpiCard";
import { PrinzipDistrictsBar } from "@/components/analytics/PrinzipDistrictsBar";
import { PrinzipGapBar } from "@/components/analytics/PrinzipGapBar";
import { PrinzipQuartirographyPie } from "@/components/analytics/PrinzipQuartirographyPie";
import { PrinzipVelocityChart } from "@/components/analytics/PrinzipVelocityChart";
import { Section } from "@/components/analytics/Section";
import { useDeveloperDetail } from "@/lib/analytics-api";
const PRINZIP = "6208_0";
const BENCH_OPTIONS: { id: string; name: string }[] = [
{ id: "5791_0", name: "Брусника" },
{ id: "5832_0", name: "Холдинг Форум-групп" },
{ id: "5654_0", name: "КОРТРОС" },
{ id: "8809_0", name: "Атлас Девелопмент" },
{ id: "5904_0", name: "Эталон" },
{ id: "5868_0", name: "Унистрой" },
{ id: "5772_0", name: "Атомстройкомплекс" },
];
export default function PrinzipPage() {
const detail = useDeveloperDetail(PRINZIP);
const [benchmarks, setBenchmarks] = useState<string[]>(["5791_0", "5832_0"]);
const toggle = (id: string) =>
setBenchmarks((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
const ids = [PRINZIP, ...benchmarks];
const names: Record<string, string> = {
[PRINZIP]: "PRINZIP",
...Object.fromEntries(BENCH_OPTIONS.map((o) => [o.id, o.name])),
};
const d = detail.data;
return (
<>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<KpiCard
label="Квартир в стройке"
value={d?.agg_flats_total?.toLocaleString("ru") ?? "—"}
hint={d ? `${d.jk_count} ЖК · ${d.jk_ekb} в ЕКБ` : undefined}
/>
<KpiCard
label="Объём Свердл"
value={d?.sverdl_sqm_th?.toFixed(0) ?? "—"}
unit="тыс м²"
hint="DOM.РФ realization, посл. снимок"
/>
<KpiCard
label="Sold %"
value={d?.sverdl_sold_pct?.toFixed(0) ?? "—"}
unit="%"
delta={{
value: "+33 пп за 14 мес — #1 velocity Свердл",
positive: true,
}}
/>
<KpiCard
label="Средний метраж"
value={d?.avg_area_sqm?.toFixed(1) ?? "—"}
unit="м²"
delta={{
value: `vs рынок 49 м² · разрыв -${d?.avg_area_sqm ? Math.round(49 - d.avg_area_sqm) : 0} м²`,
positive: false,
}}
/>
</div>
<Section
title="Velocity: PRINZIP vs benchmark-девелоперы"
subtitle="Ежемесячный sold% по DOM.РФ realization (endpoint=developer, регион 66). PRINZIP толще — для контраста."
right={
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{BENCH_OPTIONS.map((o) => {
const on = benchmarks.includes(o.id);
return (
<button
key={o.id}
onClick={() => toggle(o.id)}
style={{
padding: "6px 10px",
fontSize: 12,
border: "1px solid #d1d5db",
borderRadius: 16,
background: on ? "#1d4ed8" : "#fff",
color: on ? "#fff" : "#374151",
cursor: "pointer",
}}
>
{o.name}
</button>
);
})}
</div>
}
>
<PrinzipVelocityChart developerIds={ids} developerNames={names} />
</Section>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 16,
marginTop: 16,
}}
>
<Section
title="Квартирография PRINZIP"
subtitle="Распределение по типам квартир в стройке (75% — однушки)."
>
<PrinzipQuartirographyPie />
</Section>
<Section
title="Разрыв с рынком и benchmarks"
subtitle="Ключевые метрики: PRINZIP vs Свердл / Брусника / Форум."
>
<PrinzipGapBar />
</Section>
</div>
<Section
title="География PRINZIP по районам ЕКБ"
subtitle="Серый — все ЖК в районе, синий — PRINZIP. Виден главный пробел: Академический (330 ЖК / 0 PRINZIP)."
>
<PrinzipDistrictsBar />
</Section>
<Section
title="Инсайты и рекомендации"
subtitle="На основе анализа портфеля + реальных ДДУ-сделок Свердл (PRINZIP_Strategy_Apr27)."
>
<InsightCards />
</Section>
</>
);
}

View file

@ -1,5 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Providers } from "./providers";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "GenDesign", title: "GenDesign",
description: "Generative Design + Site Finder", description: "Generative Design + Site Finder",
@ -19,7 +21,7 @@ export default function RootLayout({
"system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
}} }}
> >
{children} <Providers>{children}</Providers>
</body> </body>
</html> </html>
); );

View file

@ -12,6 +12,9 @@ export default function HomePage() {
<li> <li>
<Link href="/site-finder">Site Finder</Link> <Link href="/site-finder">Site Finder</Link>
</li> </li>
<li>
<Link href="/analytics">Аналитика Свердл рынок &amp; PRINZIP</Link>
</li>
</ul> </ul>
{/* TODO: REMOVE BEFORE INVESTOR / CLIENT DEMO — internal easter egg for Anton */} {/* TODO: REMOVE BEFORE INVESTOR / CLIENT DEMO — internal easter egg for Anton */}
<p style={{ marginTop: 48, color: "#f30909", fontSize: 12 }}> <p style={{ marginTop: 48, color: "#f30909", fontSize: 12 }}>

View file

@ -0,0 +1,19 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
},
},
}),
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

View file

@ -0,0 +1,46 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const TABS = [
{ href: "/analytics", label: "Свердл рынок" },
{ href: "/analytics/prinzip", label: "PRINZIP" },
{ href: "/analytics/developers", label: "Девелоперы" },
];
export function AnalyticsNav() {
const pathname = usePathname();
return (
<nav
style={{
display: "flex",
gap: 8,
borderBottom: "1px solid #e6e8ec",
marginBottom: 16,
}}
>
{TABS.map((tab) => {
const active = pathname === tab.href;
return (
<Link
key={tab.href}
href={tab.href}
style={{
padding: "10px 14px",
borderBottom: active
? "2px solid #1d4ed8"
: "2px solid transparent",
color: active ? "#1d4ed8" : "#374151",
fontWeight: active ? 600 : 500,
textDecoration: "none",
fontSize: 14,
}}
>
{tab.label}
</Link>
);
})}
</nav>
);
}

View file

@ -0,0 +1,33 @@
"use client";
import dynamic from "next/dynamic";
import type { CSSProperties } from "react";
const ReactECharts = dynamic(() => import("echarts-for-react"), { ssr: false });
interface Props {
option: Record<string, unknown>;
height?: number;
style?: CSSProperties;
loading?: boolean;
notMerge?: boolean;
}
export function ChartShell({
option,
height = 320,
style,
loading,
notMerge,
}: Props) {
return (
<div style={{ width: "100%", height, ...style }}>
<ReactECharts
option={option}
style={{ height: "100%", width: "100%" }}
showLoading={loading}
notMerge={notMerge}
/>
</div>
);
}

View file

@ -0,0 +1,152 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useTopDevelopers } from "@/lib/analytics-api";
import type { DeveloperTopRow } from "@/types/analytics";
type SortKey =
| "sverdl_sqm_th"
| "sold_pct"
| "sold_delta_pp"
| "avg_area_sqm"
| "pct_three_plus";
const COLS: {
key: SortKey;
label: string;
format: (v: number | null) => string;
}[] = [
{
key: "sverdl_sqm_th",
label: "тыс м²",
format: (v) => (v ? v.toFixed(0) : "—"),
},
{
key: "sold_pct",
label: "sold %",
format: (v) => (v != null ? `${v.toFixed(0)}` : "—"),
},
{
key: "sold_delta_pp",
label: "Δ пп",
format: (v) =>
v != null ? (v > 0 ? `+${v.toFixed(0)}` : v.toFixed(0)) : "—",
},
{
key: "avg_area_sqm",
label: "ср. м²",
format: (v) => (v ? v.toFixed(1) : "—"),
},
{
key: "pct_three_plus",
label: "3-к+ %",
format: (v) => (v ? v.toFixed(0) : "—"),
},
];
export function DeveloperLeaderboard({ highlight }: { highlight?: 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 ?? [])];
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]);
const click = (k: SortKey) => {
if (k === sortBy) setDesc(!desc);
else {
setSortBy(k);
setDesc(true);
}
};
if (isLoading)
return <div style={{ padding: 16, color: "#5b6066" }}>Загрузка</div>;
return (
<div style={{ overflowX: "auto" }}>
<table
style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}
>
<thead>
<tr style={{ background: "#f6f7f9" }}>
<th style={th}>#</th>
<th style={{ ...th, textAlign: "left" }}>Девелопер</th>
{COLS.map((c) => (
<th
key={c.key}
style={{ ...th, cursor: "pointer", userSelect: "none" }}
onClick={() => click(c.key)}
>
{c.label}
{sortBy === c.key ? (desc ? " ↓" : " ↑") : ""}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r: DeveloperTopRow, i) => {
const isHi = highlight && r.developer_id === highlight;
return (
<tr
key={r.developer_id}
style={{
background: isHi ? "#fef9c3" : i % 2 ? "#fafbfc" : "#fff",
borderBottom: "1px solid #eef0f3",
}}
>
<td style={td}>{i + 1}</td>
<td style={{ ...td, textAlign: "left" }}>
<Link
href={`/analytics/developers?id=${r.developer_id}`}
style={{ color: "#1d4ed8", textDecoration: "none" }}
>
{r.developer_name}
</Link>
</td>
{COLS.map((c) => {
const value = r[c.key] as number | null;
let color = "#111";
if (c.key === "sold_delta_pp" && value != null) {
color =
value > 5
? "#0a7a3a"
: value < -5
? "#b3261e"
: "#5b6066";
}
return (
<td key={c.key} style={{ ...td, color }}>
{c.format(value)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
const th = {
padding: "10px 12px",
textAlign: "right" as const,
fontWeight: 600,
borderBottom: "1px solid #e6e8ec",
color: "#374151",
};
const td = {
padding: "10px 12px",
textAlign: "right" as const,
};

View file

@ -0,0 +1,50 @@
"use client";
import { useMemo } from "react";
import { useDistricts } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function DistrictTreemap() {
const { data, isLoading } = useDistricts();
const option = useMemo(() => {
const rows = data ?? [];
return {
tooltip: {
formatter: (info: {
data: { name: string; value: number; flat_count: number };
}) =>
`<b>${info.data.name}</b><br/>ЖК: ${info.data.value}<br/>квартир: ${info.data.flat_count?.toLocaleString("ru")}`,
},
series: [
{
type: "treemap",
roam: false,
breadcrumb: { show: false },
label: {
show: true,
formatter: "{b}\n{c} ЖК",
color: "#fff",
fontSize: 13,
},
itemStyle: { borderColor: "#fff", borderWidth: 2, gapWidth: 2 },
levels: [
{
colorMappingBy: "value",
color: ["#dbeafe", "#3b82f6", "#1d4ed8"],
},
],
data: rows.map((r) => ({
name: r.district_name,
value: r.zk_count ?? 0,
flat_count: r.flat_count ?? 0,
})),
},
],
};
}, [data]);
return <ChartShell option={option} loading={isLoading} height={360} />;
}

View file

@ -0,0 +1,106 @@
"use client";
import { usePrinzipInsights } from "@/lib/analytics-api";
export function InsightCards() {
const { data } = usePrinzipInsights();
if (!data)
return <div style={{ color: "#5b6066" }}>Загрузка рекомендаций</div>;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<p
style={{ margin: 0, fontSize: 15, lineHeight: 1.55, color: "#1f2937" }}
>
{data.headline}
</p>
<div>
<h3 style={h3}>Приоритеты</h3>
<div style={grid}>
{data.priorities.map((p) => (
<div key={p.rank} style={card}>
<div style={badge}>#{p.rank}</div>
<div style={{ fontWeight: 600, marginTop: 6 }}>{p.title}</div>
<p style={pStyle}>{p.why}</p>
</div>
))}
</div>
</div>
<div>
<h3 style={h3}>Где строить новое</h3>
<div style={grid}>
{data.where_to_build.map((w) => (
<div key={w.district} style={card}>
<div style={{ fontWeight: 600 }}>{w.district}</div>
<p style={pStyle}>{w.why}</p>
</div>
))}
</div>
</div>
<div>
<h3 style={h3}>Чего избегать</h3>
<ul
style={{
margin: 0,
paddingLeft: 20,
lineHeight: 1.55,
color: "#374151",
}}
>
{data.what_to_avoid.map((s, i) => (
<li key={i}>{s}</li>
))}
</ul>
</div>
<div>
<h3 style={h3}>Benchmark-модели</h3>
<div style={grid}>
{data.benchmarks.map((b) => (
<div key={b.name} style={card}>
<div style={{ fontWeight: 600 }}>{b.name}</div>
<p style={pStyle}>{b.model}</p>
</div>
))}
</div>
</div>
</div>
);
}
const h3 = {
fontSize: 14,
fontWeight: 600,
color: "#374151",
margin: "0 0 8px",
};
const grid = {
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
gap: 12,
};
const card = {
background: "#f9fafb",
border: "1px solid #e6e8ec",
borderRadius: 8,
padding: "12px 14px",
};
const badge = {
display: "inline-block",
background: "#1d4ed8",
color: "#fff",
fontSize: 12,
fontWeight: 600,
borderRadius: 4,
padding: "2px 8px",
};
const pStyle = {
margin: "6px 0 0",
fontSize: 13,
color: "#4b5563",
lineHeight: 1.5,
};

View file

@ -0,0 +1,65 @@
interface Props {
label: string;
value: string;
unit?: string;
delta?: { value: string; positive?: boolean | null };
hint?: string;
}
export function KpiCard({ label, value, unit, delta, hint }: Props) {
const deltaColor =
delta?.positive === true
? "#0a7a3a"
: delta?.positive === false
? "#b3261e"
: "#5b6066";
return (
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: "16px 18px",
minWidth: 180,
flex: 1,
}}
>
<div
style={{
fontSize: 12,
textTransform: "uppercase",
color: "#5b6066",
letterSpacing: 0.4,
}}
>
{label}
</div>
<div
style={{
marginTop: 6,
display: "flex",
alignItems: "baseline",
gap: 6,
}}
>
<span style={{ fontSize: 28, fontWeight: 600, color: "#111" }}>
{value}
</span>
{unit ? (
<span style={{ color: "#5b6066", fontSize: 14 }}>{unit}</span>
) : null}
</div>
{delta ? (
<div style={{ marginTop: 4, fontSize: 13, color: deltaColor }}>
{delta.value}
</div>
) : null}
{hint ? (
<div style={{ marginTop: 6, fontSize: 12, color: "#73767e" }}>
{hint}
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,70 @@
"use client";
import { useMemo } from "react";
import { useMarketPulse } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function MarketPulseChart() {
const { data, isLoading } = useMarketPulse();
const option = useMemo(() => {
const points = data ?? [];
const dates = points.map((p) => p.snapshot_date.slice(0, 7));
return {
tooltip: { trigger: "axis", axisPointer: { type: "cross" } },
legend: { data: ["Объём, тыс м²", "sold %", "Цена, ₽/м²"] },
grid: { left: 56, right: 64, top: 40, bottom: 36 },
xAxis: { type: "category", data: dates },
yAxis: [
{
type: "value",
name: "тыс м²",
position: "left",
axisLabel: { color: "#5b6066" },
},
{
type: "value",
name: "% / ₽",
position: "right",
axisLabel: { color: "#5b6066" },
},
],
series: [
{
name: "Объём, тыс м²",
type: "bar",
yAxisIndex: 0,
data: points.map((p) => p.total_square_th_sqm),
itemStyle: { color: "rgba(33, 99, 232, 0.55)" },
},
{
name: "sold %",
type: "line",
yAxisIndex: 1,
smooth: true,
symbol: "circle",
data: points.map((p) => p.sold_perc),
lineStyle: { color: "#0a7a3a", width: 2 },
itemStyle: { color: "#0a7a3a" },
},
{
name: "Цена, ₽/м²",
type: "line",
yAxisIndex: 1,
smooth: true,
symbol: "circle",
data: points.map((p) =>
p.price_avg ? Math.round(p.price_avg / 1000) : null,
),
lineStyle: { color: "#c2410c", width: 2 },
itemStyle: { color: "#c2410c" },
tooltip: { valueFormatter: (v: number) => `${v} тыс ₽/м²` },
},
],
};
}, [data]);
return <ChartShell option={option} loading={isLoading} height={360} />;
}

View file

@ -0,0 +1,51 @@
"use client";
import { useMemo } from "react";
import { usePipeline } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function PipelineChart() {
const { data, isLoading } = usePipeline();
const option = useMemo(() => {
const rows = data ?? [];
return {
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
valueFormatter: (v: number) => `${v}%`,
},
legend: { data: ["sold", "unsold", "не открыто"] },
grid: { left: 48, right: 32, top: 40, bottom: 28 },
xAxis: { type: "category", data: rows.map((r) => r.year) },
yAxis: { type: "value", max: 100, axisLabel: { formatter: "{value}%" } },
series: [
{
name: "sold",
type: "bar",
stack: "p",
data: rows.map((r) => r.sold_perc),
itemStyle: { color: "#0a7a3a" },
},
{
name: "unsold",
type: "bar",
stack: "p",
data: rows.map((r) => r.unsold_perc),
itemStyle: { color: "#f59e0b" },
},
{
name: "не открыто",
type: "bar",
stack: "p",
data: rows.map((r) => r.unopened_perc),
itemStyle: { color: "#b3261e" },
},
],
};
}, [data]);
return <ChartShell option={option} loading={isLoading} height={320} />;
}

View file

@ -0,0 +1,77 @@
"use client";
import { useMemo } from "react";
import { useDistricts, usePrinzipDistricts } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function PrinzipDistrictsBar() {
const districts = useDistricts();
const prinzip = usePrinzipDistricts();
const option = useMemo(() => {
const dRows = districts.data ?? [];
const pRows = prinzip.data ?? [];
const order = [...dRows]
.filter((r) => r.district_name !== "не определён")
.sort((a, b) => (b.zk_count ?? 0) - (a.zk_count ?? 0))
.map((r) => r.district_name);
return {
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
formatter: (
params: {
axisValueLabel: string;
marker: string;
seriesName: string;
value: number;
}[],
) => {
const district = params[0]?.axisValueLabel ?? "";
const total =
dRows.find((d) => d.district_name === district)?.zk_count ?? 0;
const prinzipRow = pRows.find((p) => p.district_name === district);
const share = prinzipRow?.share_in_district_pct ?? 0;
return [
`<b>${district}</b>`,
`Всего ЖК: ${total}`,
`PRINZIP: ${prinzipRow?.prinzip_zk ?? 0} (${share}%)`,
].join("<br/>");
},
},
legend: { data: ["Все девелоперы", "PRINZIP"] },
grid: { left: 120, right: 32, top: 40, bottom: 28 },
xAxis: { type: "value" },
yAxis: { type: "category", data: order, inverse: true },
series: [
{
name: "Все девелоперы",
type: "bar",
data: order.map(
(n) => dRows.find((d) => d.district_name === n)?.zk_count ?? 0,
),
itemStyle: { color: "#94a3b8" },
},
{
name: "PRINZIP",
type: "bar",
data: order.map(
(n) => pRows.find((p) => p.district_name === n)?.prinzip_zk ?? 0,
),
itemStyle: { color: "#1d4ed8" },
},
],
};
}, [districts.data, prinzip.data]);
return (
<ChartShell
option={option}
loading={districts.isLoading || prinzip.isLoading}
height={360}
/>
);
}

View file

@ -0,0 +1,70 @@
"use client";
import { useMemo } from "react";
import { usePrinzipInsights } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function PrinzipGapBar() {
const { data, isLoading } = usePrinzipInsights();
const option = useMemo(() => {
const rows = data?.key_gaps ?? [];
return {
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
formatter: (
params: {
axisValueLabel: string;
marker: string;
seriesName: string;
value: number;
}[],
) => {
const label = params[0]?.axisValueLabel ?? "";
const unit = rows.find((r) => r.label === label)?.unit ?? "";
return [
`<b>${label}</b>`,
...params.map(
(p) => `${p.marker}${p.seriesName}: ${p.value}${unit}`,
),
].join("<br/>");
},
},
legend: { data: ["PRINZIP", "Рынок Свердл", "Брусника", "Форум-групп"] },
grid: { left: 120, right: 32, top: 40, bottom: 28 },
xAxis: { type: "value" },
yAxis: { type: "category", data: rows.map((r) => r.label) },
series: [
{
name: "PRINZIP",
type: "bar",
data: rows.map((r) => r.prinzip),
itemStyle: { color: "#1d4ed8" },
},
{
name: "Рынок Свердл",
type: "bar",
data: rows.map((r) => r.market),
itemStyle: { color: "#94a3b8" },
},
{
name: "Брусника",
type: "bar",
data: rows.map((r) => r.brusnika),
itemStyle: { color: "#0a7a3a" },
},
{
name: "Форум-групп",
type: "bar",
data: rows.map((r) => r.forum),
itemStyle: { color: "#c2410c" },
},
],
};
}, [data]);
return <ChartShell option={option} loading={isLoading} height={320} />;
}

View file

@ -0,0 +1,41 @@
"use client";
import { useMemo } from "react";
import { useDeveloperDetail } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function PrinzipQuartirographyPie({
developerId = "6208_0",
}: {
developerId?: string;
}) {
const { data, isLoading } = useDeveloperDetail(developerId);
const option = useMemo(() => {
const rows = data
? [
{ name: "1-к", value: data.agg_one_room ?? 0 },
{ name: "2-к", value: data.agg_two_room ?? 0 },
{ name: "3-к", value: data.agg_three_room ?? 0 },
{ name: "4+", value: data.agg_four_plus ?? 0 },
]
: [];
return {
tooltip: { trigger: "item", formatter: "{b}: {c} ({d}%)" },
legend: { bottom: 0 },
series: [
{
type: "pie",
radius: ["45%", "70%"],
label: { formatter: "{b}\n{d}%" },
data: rows,
color: ["#94a3b8", "#3b82f6", "#0a7a3a", "#c2410c"],
},
],
};
}, [data]);
return <ChartShell option={option} loading={isLoading} height={300} />;
}

View file

@ -0,0 +1,65 @@
"use client";
import { useMemo } from "react";
import { useDeveloperHistory } from "@/lib/analytics-api";
const PALETTE = [
"#1d4ed8",
"#0a7a3a",
"#c2410c",
"#9333ea",
"#0891b2",
"#5b6066",
];
import { ChartShell } from "./ChartShell";
interface Props {
developerIds: string[];
developerNames: Record<string, string>;
}
export function PrinzipVelocityChart({ developerIds, developerNames }: Props) {
const { data, isLoading } = useDeveloperHistory(developerIds);
const option = useMemo(() => {
const points = data ?? [];
const byDev: Record<string, { date: string; sold: number | null }[]> = {};
for (const p of points) {
if (!byDev[p.developer_id]) byDev[p.developer_id] = [];
byDev[p.developer_id].push({
date: p.snapshot_date.slice(0, 7),
sold: p.sold_perc,
});
}
const dates = Array.from(
new Set(points.map((p) => p.snapshot_date.slice(0, 7))),
).sort();
const series = developerIds.map((id, i) => ({
name: developerNames[id] ?? id,
type: "line",
smooth: true,
symbol: "circle",
lineStyle: {
width: id === "6208_0" ? 3 : 2,
color: PALETTE[i % PALETTE.length],
},
itemStyle: { color: PALETTE[i % PALETTE.length] },
data: dates.map(
(d) => byDev[id]?.find((p) => p.date === d)?.sold ?? null,
),
connectNulls: true,
}));
return {
tooltip: { trigger: "axis", valueFormatter: (v: number) => `${v}%` },
legend: { data: series.map((s) => s.name as string) },
grid: { left: 48, right: 32, top: 40, bottom: 28 },
xAxis: { type: "category", data: dates },
yAxis: { type: "value", axisLabel: { formatter: "{value}%" } },
series,
};
}, [data, developerIds, developerNames]);
return <ChartShell option={option} loading={isLoading} height={360} />;
}

View file

@ -0,0 +1,71 @@
"use client";
import { useMemo } from "react";
import {
useQuartirographyDeals,
useQuartirographyPortfolio,
} from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function QuartirographyChart() {
const portfolio = useQuartirographyPortfolio();
const deals = useQuartirographyDeals();
const option = useMemo(() => {
const portfolioRows = portfolio.data ?? [];
const dealsRows = deals.data ?? [];
const buckets = [
"Студии 15-30",
"1-к 30-45",
"2-к 45-60",
"3-к 60-80",
"80+ м²",
];
const portfolioMap: Record<string, number> = {
"1-к 30-45": portfolioRows.find((r) => r.bucket === "1-к")?.percent ?? 0,
"2-к 45-60": portfolioRows.find((r) => r.bucket === "2-к")?.percent ?? 0,
"3-к 60-80": portfolioRows.find((r) => r.bucket === "3-к")?.percent ?? 0,
"80+ м²": portfolioRows.find((r) => r.bucket === "4+")?.percent ?? 0,
};
const dealsPercents = buckets.map(
(b) => dealsRows.find((r) => r.bucket === b)?.percent ?? 0,
);
const portfolioPercents = buckets.map((b) => portfolioMap[b] ?? 0);
return {
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
valueFormatter: (v: number) => `${v}%`,
},
legend: { data: ["Что строится (портфель)", "Что покупают (ДДУ)"] },
grid: { left: 110, right: 32, top: 40, bottom: 28 },
xAxis: { type: "value", axisLabel: { formatter: "{value}%" } },
yAxis: { type: "category", data: buckets, inverse: true },
series: [
{
name: "Что строится (портфель)",
type: "bar",
data: portfolioPercents,
itemStyle: { color: "#94a3b8" },
},
{
name: "Что покупают (ДДУ)",
type: "bar",
data: dealsPercents,
itemStyle: { color: "#0a7a3a" },
},
],
};
}, [portfolio.data, deals.data]);
return (
<ChartShell
option={option}
loading={portfolio.isLoading || deals.isLoading}
height={320}
/>
);
}

View file

@ -0,0 +1,43 @@
import type { ReactNode } from "react";
interface Props {
title: string;
subtitle?: string;
right?: ReactNode;
children: ReactNode;
}
export function Section({ title, subtitle, right, children }: Props) {
return (
<section
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
marginTop: 16,
}}
>
<header
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 16,
marginBottom: 12,
}}
>
<div>
<h2 style={{ margin: 0, fontSize: 18 }}>{title}</h2>
{subtitle ? (
<p style={{ margin: "4px 0 0", color: "#5b6066", fontSize: 13 }}>
{subtitle}
</p>
) : null}
</div>
{right ? <div>{right}</div> : null}
</header>
{children}
</section>
);
}

View file

@ -0,0 +1,76 @@
"use client";
import { useMemo } from "react";
import { useTopDevelopers } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function VelocityScatter() {
const { data, isLoading } = useTopDevelopers(30);
const option = useMemo(() => {
const rows = data ?? [];
return {
tooltip: {
trigger: "item",
formatter: (info: { data: [number, number, string, number] }) => {
const [x, y, name, sold] = info.data;
return `<b>${name}</b><br/>Объём: ${x} тыс м²<br/>Δ sold: ${y > 0 ? "+" : ""}${y} пп<br/>текущий sold%: ${sold}%`;
},
},
grid: { left: 56, right: 32, top: 32, bottom: 56 },
xAxis: {
name: "Объём, тыс м²",
nameLocation: "middle",
nameGap: 32,
type: "value",
},
yAxis: {
name: "Δ sold % (пп)",
nameLocation: "middle",
nameGap: 36,
type: "value",
axisLine: { show: true },
},
series: [
{
type: "scatter",
symbolSize: 14,
data: rows
.filter((r) => r.sold_delta_pp != null && r.sverdl_sqm_th != null)
.map((r) => [
r.sverdl_sqm_th,
r.sold_delta_pp,
r.developer_name,
r.sold_pct ?? 0,
]),
itemStyle: {
color: (p: { data: [number, number, string, number] }) =>
p.data[1] > 5
? "#0a7a3a"
: p.data[1] < -5
? "#b3261e"
: "#5b6066",
},
label: {
show: true,
position: "right",
formatter: (p: { data: [number, number, string, number] }) =>
p.data[2],
fontSize: 11,
color: "#374151",
},
markLine: {
silent: true,
symbol: "none",
lineStyle: { color: "#cbd5e1", type: "dashed" },
data: [{ yAxis: 0 }],
},
},
],
};
}, [data]);
return <ChartShell option={option} loading={isLoading} height={420} />;
}

View file

@ -0,0 +1,41 @@
"use client";
import { useMemo } from "react";
import { useYandexListings } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
const CLASS_LABELS: Record<string, string> = {
ECONOM: "Эконом",
COMFORT: "Комфорт",
COMFORT_PLUS: "Комфорт+",
BUSINESS: "Бизнес",
ELITE: "Элит",
};
export function YandexClassPie() {
const { data, isLoading } = useYandexListings();
const option = useMemo(() => {
const rows = data?.by_class ?? [];
return {
tooltip: { trigger: "item" },
legend: { bottom: 0 },
series: [
{
type: "pie",
radius: ["45%", "70%"],
avoidLabelOverlap: false,
label: { formatter: "{b}: {c}" },
data: rows.map((r) => ({
name: CLASS_LABELS[r.obj_class] ?? r.obj_class,
value: r.count,
})),
},
],
};
}, [data]);
return <ChartShell option={option} loading={isLoading} height={300} />;
}

View file

@ -0,0 +1,125 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "./api";
import type {
DeveloperDetail,
DeveloperHistoryPoint,
DeveloperPortfolioObject,
DeveloperTopRow,
DistrictRow,
MarketPulsePoint,
PipelineRow,
PrinzipDistrictRow,
PrinzipInsights,
QuartirographyDealsRow,
QuartirographyPortfolioRow,
YandexListingsSummary,
} from "@/types/analytics";
const BASE = "/api/v1/analytics";
export function useMarketPulse() {
return useQuery({
queryKey: ["analytics", "market-pulse"],
queryFn: () => apiFetch<MarketPulsePoint[]>(`${BASE}/sverdl/market-pulse`),
});
}
export function useQuartirographyPortfolio() {
return useQuery({
queryKey: ["analytics", "quartirography", "portfolio"],
queryFn: () =>
apiFetch<QuartirographyPortfolioRow[]>(
`${BASE}/sverdl/quartirography?source=portfolio`,
),
});
}
export function useQuartirographyDeals() {
return useQuery({
queryKey: ["analytics", "quartirography", "deals"],
queryFn: () =>
apiFetch<QuartirographyDealsRow[]>(
`${BASE}/sverdl/quartirography?source=deals`,
),
});
}
export function usePipeline() {
return useQuery({
queryKey: ["analytics", "pipeline"],
queryFn: () => apiFetch<PipelineRow[]>(`${BASE}/sverdl/pipeline`),
});
}
export function useDistricts() {
return useQuery({
queryKey: ["analytics", "districts"],
queryFn: () => apiFetch<DistrictRow[]>(`${BASE}/sverdl/districts`),
});
}
export function useYandexListings() {
return useQuery({
queryKey: ["analytics", "yandex"],
queryFn: () =>
apiFetch<YandexListingsSummary>(`${BASE}/sverdl/yandex-listings`),
});
}
export function useTopDevelopers(limit = 15) {
return useQuery({
queryKey: ["analytics", "top-devs", limit],
queryFn: () =>
apiFetch<DeveloperTopRow[]>(`${BASE}/developers/top?limit=${limit}`),
});
}
export function useDeveloperDetail(developerId: string) {
return useQuery({
queryKey: ["analytics", "dev", developerId],
queryFn: () =>
apiFetch<DeveloperDetail>(`${BASE}/developers/${developerId}`),
enabled: !!developerId,
});
}
export function useDeveloperHistory(developerIds: string[]) {
const idsParam = developerIds.join(",");
return useQuery({
queryKey: ["analytics", "dev-history", idsParam],
queryFn: () =>
apiFetch<DeveloperHistoryPoint[]>(
`${BASE}/developers/history?ids=${encodeURIComponent(idsParam)}`,
),
enabled: developerIds.length > 0,
});
}
export function useDeveloperPortfolio(developerId: string) {
return useQuery({
queryKey: ["analytics", "dev-portfolio", developerId],
queryFn: () =>
apiFetch<DeveloperPortfolioObject[]>(
`${BASE}/developers/${developerId}/portfolio`,
),
enabled: !!developerId,
});
}
export function usePrinzipInsights() {
return useQuery({
queryKey: ["analytics", "prinzip-insights"],
queryFn: () => apiFetch<PrinzipInsights>(`${BASE}/prinzip/insights`),
staleTime: Infinity,
});
}
export function usePrinzipDistricts() {
return useQuery({
queryKey: ["analytics", "prinzip-districts"],
queryFn: () => apiFetch<PrinzipDistrictRow[]>(`${BASE}/prinzip/districts`),
});
}

View file

@ -0,0 +1,146 @@
export interface MarketPulsePoint {
snapshot_date: string;
rep_year: number;
rep_month: number;
total_square_th_sqm: number | null;
sold_perc: number | null;
price_avg: number | null;
}
export interface QuartirographyPortfolioRow {
bucket: string;
flat_count: number;
area_sqm: number | null;
percent: number | null;
avg_area: number | null;
}
export interface QuartirographyDealsRow {
bucket: string;
deals: number;
percent: number;
median_price: number | null;
}
export interface PipelineRow {
year: string;
total_th_sqm: number | null;
sold_perc: number | null;
unsold_perc: number | null;
unopened_perc: number | null;
}
export interface DistrictRow {
district_name: string;
zk_count: number | null;
flat_count: number | null;
area_m2: number | null;
median_price_per_m2: number | null;
mean_price_per_m2: number | null;
}
export interface YandexListing {
yid: number;
name: string;
developer: string | null;
obj_class: string | null;
flats_total: number;
price_from: number | null;
price_to: number | null;
address: string | null;
lat: number | null;
lon: number | null;
}
export interface YandexListingsSummary {
snapshot_date: string | null;
total: number;
by_class: { obj_class: string; count: number }[];
items: YandexListing[];
}
export interface DeveloperTopRow {
developer_id: string;
developer_name: string;
jk_count: number | null;
jk_flats_total: number | null;
sverdl_sqm_th: number | null;
sold_pct: number | null;
sold_delta_pp: number | null;
sold_first: number | null;
sold_last: number | null;
first_dt: string | null;
last_dt: string | null;
avg_area_sqm: number | null;
pct_one: number | null;
pct_three_plus: number | null;
}
export interface DeveloperDetail {
developer_id: string;
developer_name: string;
jk_count: number | null;
jk_flats_total: number | null;
jk_sqm_total: number | null;
jk_ekb: number | null;
jk_completed: number | null;
jk_in_progress: number | null;
jk_escrow: number | null;
agg_flats_total: number | null;
agg_one_room: number | null;
agg_two_room: number | null;
agg_three_room: number | null;
agg_four_plus: number | null;
pct_one: number | null;
pct_three_plus: number | null;
avg_area_sqm: number | null;
sverdl_sqm_th: number | null;
sverdl_sold_pct: number | null;
sverdl_unsold_pct: number | null;
sverdl_price_avg: number | null;
}
export interface DeveloperHistoryPoint {
developer_id: string;
snapshot_date: string;
sold_perc: number | null;
total_th_sqm: number | null;
}
export interface DeveloperPortfolioObject {
obj_id: number;
comm_name: string | null;
addr: string | null;
region_cd: number | null;
flat_count: number | null;
square_living: number | null;
ready_dt: string | null;
obj_class: string | null;
escrow: boolean | null;
problem_flag: string | null;
lat: number | null;
lon: number | null;
is_ekb: boolean | null;
}
export interface PrinzipDistrictRow {
district_name: string;
prinzip_zk: number;
share_in_district_pct: number;
}
export interface PrinzipInsights {
headline: string;
key_gaps: {
label: string;
prinzip: number;
market: number;
brusnika: number;
forum: number;
unit: string;
}[];
priorities: { rank: number; title: string; why: string }[];
where_to_build: { district: string; why: string }[];
what_to_avoid: string[];
benchmarks: { name: string; model: string }[];
}

File diff suppressed because one or more lines are too long