gendesign/site-finder/static/index.html
bot-backend 9e5086890f
Some checks failed
CI / changes (pull_request) Has been cancelled
CI / backend-tests (pull_request) Has been cancelled
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
fix(site-finder): remove dead sat_factor (computed+written, never applied) (#1509)
sat_factor = 1 + ((sold_pct-50)/100)*0.30 was computed in 09_macro_and_trend.py,
written to district_economics.sat_factor, and fetched in server.py and 10_score_v2.py
— but never multiplied into any score. The live market sub-score uses a separate
sat_score = min(100, sold_pct*100/70) directly, so sat_factor was dead code that
would double-count absorption if ever wired in.

- 09_macro_and_trend.py: remove sat_factor computation, ALTER TABLE column, UPDATE
  binding, and debug print column
- 10_score_v2.py: remove sat_factor from SELECT and unpacking
- server.py: remove sat_factor variable assignment and from macro_factors response
- static/index.html: remove sat_factor documentation row
- data/sql/162_drop_district_economics_sat_factor.sql: DROP COLUMN IF EXISTS
2026-06-17 21:08:54 +03:00

3513 lines
230 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>GenDesign · Анализ участка</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script>
// BASE prefix support — if served from /sf/, all fetch('/api/...') auto-prepended
(function() {
const path = location.pathname;
const m = path.match(/^(\/[^\/]+)(\/|$)/);
const candidate = m ? m[1] : '';
// Heuristic: if first path segment is non-empty AND request goes to / wouldn't return our app,
// assume that segment is our base prefix. Cheap probe: if path starts with /sf/ or /site-finder/ — use it.
const KNOWN_PREFIXES = ['/sf', '/site-finder'];
const BASE = KNOWN_PREFIXES.find(p => path.startsWith(p)) || '';
window.__SF_BASE__ = BASE;
if (BASE) {
const orig = window.fetch.bind(window);
window.fetch = (url, ...rest) => {
if (typeof url === 'string' && url.startsWith('/') && !url.startsWith(BASE + '/')) {
url = BASE + url;
}
return orig(url, ...rest);
};
}
})();
</script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5/dist/echarts.min.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script>
// Register Service Worker for persistent tile + asset cache (cache-first).
if ('serviceWorker' in navigator) {
const swPath = (window.__SF_BASE__ || '') + '/sw.js';
const scope = (window.__SF_BASE__ || '') + '/';
navigator.serviceWorker.register(swPath, {scope}).then(reg => {
console.log('[sw] registered', reg.scope);
}).catch(e => console.warn('[sw] failed', e));
}
</script>
<style>
*{box-sizing:border-box}
:root {
--bg:#f8fafc; --fg:#0f172a; --muted:#64748b; --border:#e2e8f0; --card:#ffffff;
--accent:#0ea5e9; --accent-2:#10b981; --accent-3:#7c3aed; --danger:#ef4444; --warn:#f59e0b;
--map-bg:#eef2f6; --hover:#f1f5f9;
}
html.dark {
--bg:#0f172a; --fg:#f1f5f9; --muted:#94a3b8; --border:#1e293b; --card:#1e293b;
--map-bg:#0b1220; --hover:#1e293b;
}
html.dark #side, html.dark .header, html.dark .scorebox, html.dark .kpi,
html.dark .cmp, html.dark .modal, html.dark .layer-controls, html.dark .legend,
html.dark .side-toggle, html.dark .form input, html.dark .form select,
html.dark #searchBox, html.dark #searchResults, html.dark .form button:not(.primary),
html.dark .header .actions button, html.dark .whatif, html.dark .photo-grid img,
html.dark .leaflet-popup-content-wrapper, html.dark .leaflet-popup-tip,
html.dark .leaflet-tooltip, html.dark #recentBlock div[data-recent],
html.dark #searchResults > div { background:var(--card) !important; color:var(--fg); border-color:var(--border) }
html.dark .header { background: linear-gradient(180deg, #1e293b, #0f172a); border-bottom-color:var(--border) }
html.dark input, html.dark select, html.dark textarea { background:#0f172a; color:#f1f5f9; border-color:#334155 }
html.dark .bar { background:#334155 }
html.dark .leaflet-popup-content-wrapper, html.dark .leaflet-tooltip { color:#f1f5f9 }
html.dark .leaflet-tooltip:before { border-top-color:var(--card) }
html.dark #map { background: var(--map-bg) }
html.dark .header h1 { color:white }
html.dark .lot-table th { color:var(--muted); border-bottom-color:var(--border) }
html.dark .lot-table td { border-bottom-color:var(--border) }
html.dark table th { background:#1e293b }
html.dark .poi-row { border-bottom-color:var(--border) }
html.dark .poi-row:hover { background:#334155 }
html,body{margin:0;height:100%;font-family:-apple-system,'SF Pro Display',Segoe UI,Roboto,sans-serif;color:var(--fg);background:var(--bg)}
body{display:grid;grid-template-columns:360px 1fr;height:100vh;overflow:hidden;transition:grid-template-columns .22s cubic-bezier(.4,0,.2,1)}
body.side-collapsed{grid-template-columns:0 1fr}
body.side-collapsed #side{transform:translateX(-100%)}
#side{transition:transform .22s cubic-bezier(.4,0,.2,1)}
.side-toggle{position:fixed;left:360px;top:50%;transform:translateY(-50%);z-index:600;background:white;border:1px solid #cbd5e1;border-radius:0 8px 8px 0;border-left:0;width:22px;height:60px;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;color:#475569;box-shadow:2px 2px 6px rgba(0,0,0,.08);transition:left .22s cubic-bezier(.4,0,.2,1)}
.side-toggle:hover{background:#f8fafc;color:#0ea5e9}
body.side-collapsed .side-toggle{left:0;border-radius:0 8px 8px 0}
#side{overflow-y:auto;background:#f8fafc;border-right:1px solid #cbd5e1;color:#0f172a;position:relative}
#side::-webkit-scrollbar{width:8px}
#side::-webkit-scrollbar-thumb{background:#cbd5e1;border-radius:4px}
.header{padding:14px 18px 12px;background:linear-gradient(180deg,#fff,#f8fafc);border-bottom:1px solid #e2e8f0;position:sticky;top:0;z-index:20;backdrop-filter:blur(10px)}
.header h1{font-size:16px;font-weight:700;margin:0;display:flex;align-items:center;gap:8px;letter-spacing:-.01em}
.header h1 .v{font-size:9px;background:#0ea5e9;color:white;padding:2px 6px;border-radius:99px;font-weight:600}
.header .sub{font-size:11px;color:#64748b;margin-top:4px}
.header .actions{display:flex;gap:4px;margin-top:9px;flex-wrap:wrap}
.header .actions button{font-size:10px;padding:4px 7px;background:white;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;color:#475569;font-weight:500;white-space:nowrap;line-height:1.3}
.header .actions button:hover{background:#0ea5e9;color:white;border-color:#0ea5e9}
.body{padding:14px 18px 30px}
h2{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:#64748b;margin:18px 0 7px;font-weight:700;display:flex;justify-content:space-between;align-items:baseline}
h2 .help{font-weight:400;font-size:10px;color:#94a3b8;text-transform:none;letter-spacing:0}
.form{display:flex;flex-direction:column;gap:7px}
.form .row2{display:flex;gap:6px}
.form .row2 input,.form .row2 select{flex:1;min-width:0}
input,button,select{font-size:13px;padding:8px 10px;border:1px solid #cbd5e1;border-radius:7px;background:white;font-family:inherit;color:#0f172a}
input:focus,select:focus{outline:0;border-color:#0ea5e9;box-shadow:0 0 0 3px rgba(14,165,233,.18)}
button.primary{background:#0ea5e9;color:white;border:0;cursor:pointer;font-weight:600;padding:10px;font-size:13px;letter-spacing:.01em;transition:background .12s}
button.primary:hover{background:#0284c7}
button.primary:disabled{background:#94a3b8;cursor:wait}
.err{background:#fee2e2;color:#991b1b;padding:8px 10px;border-radius:6px;font-size:12px;margin-top:6px}
.hint{font-size:11px;color:#94a3b8}
.scorebox{display:grid;grid-template-columns:auto 1fr;gap:14px;align-items:center;padding:14px 16px;background:white;border:1px solid #e2e8f0;border-radius:10px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.scorebox .num{font-size:48px;font-weight:700;line-height:1;letter-spacing:-.03em;font-variant-numeric:tabular-nums}
.scorebox .meta{font-size:11px;color:#475569;line-height:1.4}
.badge{display:inline-block;padding:2px 8px;border-radius:99px;font-size:10px;font-weight:600;letter-spacing:.02em}
.b-good{background:#dcfce7;color:#166534}
.b-mid{background:#fef9c3;color:#854d0e}
.b-low{background:#fee2e2;color:#991b1b}
.comp{display:grid;grid-template-columns:88px 32px 1fr 36px;gap:8px;align-items:center;padding:5px 0;font-size:12px}
.comp .lbl{font-weight:500;color:#334155}
.comp .v{text-align:right;font-weight:600;color:#0f172a;font-variant-numeric:tabular-nums}
.bar{height:6px;background:#e2e8f0;border-radius:3px;overflow:hidden}
.bar > span{display:block;height:100%;background:linear-gradient(90deg,#0ea5e9,#10b981);border-radius:3px;transition:width .4s}
.comp .pct{font-size:10px;color:#94a3b8;text-align:right}
.tabs{display:flex;gap:2px;border-bottom:1px solid #e2e8f0;margin:14px 0 10px;overflow-x:auto;scrollbar-width:none}
.tabs::-webkit-scrollbar{display:none}
.tabs button{background:transparent;border:0;padding:7px 11px;font-size:11px;font-weight:600;color:#64748b;cursor:pointer;border-bottom:2px solid transparent;border-radius:0;transition:color .12s;white-space:nowrap}
.tabs button:hover{color:#0f172a}
.tabs button.active{color:#0ea5e9;border-bottom-color:#0ea5e9}
.tab{display:none}
.tab.active{display:block;animation:fade .25s}
@keyframes fade{from{opacity:0}to{opacity:1}}
@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-3px)}75%{transform:translateX(3px)}}
@keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
@keyframes whatif-pulse{
0% {box-shadow:0 0 0 0 rgba(168,85,247,.7);transform:scale(1)}
70% {box-shadow:0 0 0 14px rgba(168,85,247,0);transform:scale(1.2)}
100% {box-shadow:0 0 0 0 rgba(168,85,247,0);transform:scale(1)}
}
.whatif-marker {animation: whatif-pulse 1.6s infinite}
.kpis{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.kpi{padding:9px 11px;background:white;border:1px solid #e2e8f0;border-radius:7px}
.kpi .l{font-size:9px;color:#64748b;text-transform:uppercase;font-weight:600;letter-spacing:.04em}
.kpi .v{font-size:17px;font-weight:600;margin-top:2px;letter-spacing:-.01em;font-variant-numeric:tabular-nums}
.kpi .v small{font-size:10px;color:#94a3b8;font-weight:400;margin-left:3px}
.macro{background:#0f172a;color:#cbd5e1;padding:8px 11px;border-radius:7px;font-size:11px;display:flex;justify-content:space-between;gap:10px;margin-top:8px;flex-wrap:wrap;align-items:center}
.macro span{display:inline-flex;align-items:center;gap:4px}
.macro b{color:white;font-variant-numeric:tabular-nums}
.macro .pulse{width:6px;height:6px;border-radius:50%;background:#10b981;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.poi-row{display:grid;grid-template-columns:22px 90px 1fr 50px 30px 30px;gap:6px;padding:5px 2px;font-size:11px;align-items:center;border-bottom:1px solid #f1f5f9}
.poi-row:hover{background:#f8fafc}
.poi-row .icon{font-size:14px;text-align:center}
.poi-row .cat{font-weight:600;color:#475569}
.poi-row .name{color:#0f172a;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.poi-row .d{text-align:right;font-variant-numeric:tabular-nums;font-weight:600}
.poi-row .c{text-align:right;font-size:10px;color:#64748b;font-variant-numeric:tabular-nums}
.poi-head{display:grid;grid-template-columns:22px 90px 1fr 50px 30px 30px;gap:6px;font-size:9px;color:#94a3b8;text-transform:uppercase;font-weight:600;border-bottom:1px solid #e2e8f0;padding:0 2px 4px;letter-spacing:.04em}
.cmp{display:grid;grid-template-columns:auto 1fr auto auto auto;gap:8px;padding:9px 11px;background:white;border:1px solid #e2e8f0;border-radius:7px;margin-bottom:6px;align-items:center;cursor:pointer;transition:transform .12s,border-color .12s}
.cmp .ab-toggle{width:24px;height:24px;border-radius:6px;border:1.5px solid #cbd5e1;background:white;cursor:pointer;font-weight:700;color:#475569;font-size:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1}
.cmp .ab-toggle:hover{border-color:#0ea5e9;color:#0ea5e9}
.cmp.selected .ab-toggle{background:#10b981;border-color:#10b981;color:white}
.cmp:hover{border-color:#0ea5e9;transform:translateY(-1px)}
.cmp .dist{font-weight:600;color:#0ea5e9;font-size:13px;font-variant-numeric:tabular-nums;min-width:48px}
.cmp .nm{font-size:12px;font-weight:500;line-height:1.2}
.cmp .meta{font-size:10px;color:#64748b;margin-top:1px}
.cmp .vel{text-align:right;font-size:11px;color:#475569}
.cmp .vel b{font-size:13px;color:#0ea5e9}
.cmp .sc{font-size:18px;font-weight:700;text-align:right;font-variant-numeric:tabular-nums;letter-spacing:-.02em;min-width:40px}
.cmp .sc small{font-size:9px;color:#94a3b8;font-weight:400;display:block;margin-top:-3px}
.cmp.selected{border-color:#10b981;background:#f0fdf4}
.chart{height:160px;background:white;border:1px solid #e2e8f0;border-radius:7px;padding:6px}
.chart-radar{height:280px}
details>summary{cursor:pointer;color:#0284c7;font-size:11px;margin-top:6px;outline:none;list-style:none}
details>summary::before{content:'';display:inline-block;margin-right:4px;transition:transform .15s}
details[open]>summary::before{transform:rotate(90deg)}
details pre{font-size:10px;background:#f1f5f9;padding:8px;border-radius:4px;overflow:auto;margin-top:5px;max-height:300px}
.empty{padding:24px 12px;text-align:center;color:#94a3b8;font-size:12px}
.addr{font-size:11px;color:#475569;margin-top:6px;padding:6px 10px;background:#eff6ff;border-radius:6px;border-left:3px solid #0ea5e9}
/* What-if controls */
.whatif{padding:9px 11px;background:#fef9c3;border:1px solid #fde047;border-radius:7px;margin-top:6px;font-size:11px}
.whatif h3{font-size:10px;text-transform:uppercase;letter-spacing:.04em;margin:0 0 6px;color:#854d0e;font-weight:700}
.whatif label{display:flex;align-items:center;gap:5px;margin-bottom:3px;cursor:pointer}
.whatif input[type=checkbox]{margin:0}
.whatif .hint{color:#854d0e}
/* Map */
#map{height:100%;background:#eef2f6}
.leaflet-container{background:#eef2f6}
.leaflet-tile{background-color:#eef2f6}
.legend{position:absolute;bottom:14px;right:14px;background:white;padding:10px 12px;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,.15);font-size:11px;z-index:500;line-height:1.5;max-width:240px}
.legend .ttl{font-weight:600;font-size:10px;text-transform:uppercase;color:#64748b;margin-bottom:6px;letter-spacing:.04em}
.legend i{display:inline-block;width:11px;height:11px;border-radius:50%;margin-right:6px;vertical-align:middle}
.legend .grad{display:flex;align-items:center;gap:4px;font-size:10px;color:#64748b;margin-top:6px}
.legend .grad .g{height:8px;flex:1;border-radius:2px}
.layer-controls{position:absolute;top:14px;right:14px;background:white;padding:9px 11px;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,.15);font-size:11px;z-index:500;display:flex;flex-direction:column;gap:5px;max-width:200px}
.layer-controls label{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;font-weight:500;color:#475569}
.layer-controls hr{border:0;border-top:1px solid #e2e8f0;margin:3px 0;width:100%}
.layer-controls select{padding:4px 6px;font-size:11px;border-radius:4px;width:100%}
.layer-controls .l{font-size:9px;color:#94a3b8;text-transform:uppercase;font-weight:600;letter-spacing:.04em;margin-top:2px}
/* Modal */
.modal-bg{position:fixed;inset:0;background:rgba(15,23,42,.7);z-index:1000;display:none;align-items:center;justify-content:center;backdrop-filter:blur(4px)}
.modal-bg.show{display:flex}
.modal{background:white;border-radius:12px;padding:24px;max-width:920px;width:92%;max-height:90vh;overflow:auto;position:relative;color:#0f172a}
.modal h2{font-size:14px;color:#0f172a;border:0;padding:0;margin:0 0 14px}
.modal .close{position:absolute;top:14px;right:14px;background:transparent;border:0;font-size:22px;color:#94a3b8;cursor:pointer}
.modal .close:hover{color:#0f172a}
.ab-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px}
.ab-radar{height:340px}
/* JK detail modal */
.jk-hero{padding:22px 26px 18px;background:linear-gradient(135deg,#0ea5e9,#0284c7);color:white;position:relative}
.jk-hero h2{margin:0;font-size:22px;color:white;border:0;letter-spacing:-.01em}
.jk-hero .subtitle{font-size:12px;opacity:.85;margin-top:5px;display:flex;flex-wrap:wrap;gap:14px}
.jk-hero .score-circle{position:absolute;right:64px;top:18px;background:white;color:#0f172a;border-radius:50%;width:80px;height:80px;display:flex;flex-direction:column;align-items:center;justify-content:center;box-shadow:0 4px 14px rgba(0,0,0,.25)}
.jk-hero .score-circle .v{font-size:26px;font-weight:700;line-height:1}
.jk-hero .score-circle .r{font-size:10px;color:#64748b;margin-top:2px}
.jk-body{padding:18px 24px 24px;display:grid;grid-template-columns:1fr 1fr;gap:18px}
.jk-body > section{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:12px 14px}
.jk-body h3{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#475569;margin:0 0 8px;font-weight:700}
.photo-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
.photo-grid img{width:100%;height:80px;object-fit:cover;border-radius:4px;cursor:pointer;background:#e2e8f0}
.photo-grid img:hover{transform:scale(1.05);box-shadow:0 2px 8px rgba(0,0,0,.2)}
.lot-table{font-size:11px;width:100%;border-collapse:collapse}
.lot-table th{font-size:9px;color:#64748b;text-transform:uppercase;font-weight:600;padding:4px 5px;border-bottom:1px solid #e2e8f0;text-align:left}
.lot-table td{padding:4px 5px;border-bottom:1px solid #f1f5f9}
.lot-table td.r{text-align:right;font-variant-numeric:tabular-nums}
.full-w{grid-column:1/-1}
.chart-mini{height:120px}
.meta-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px 12px;font-size:11px}
.meta-grid .l{color:#64748b}
.meta-grid .v{font-weight:600;text-align:right;font-variant-numeric:tabular-nums}
.footer-attrib{font-size:9px;color:#94a3b8;margin-top:14px;text-align:center}
/* Mobile */
@media (max-width: 800px) {
body{grid-template-columns:1fr;grid-template-rows:auto 1fr;height:100vh}
body:not(.mobile-menu) #side{display:none}
body.mobile-menu #side{display:block;position:fixed;inset:0;z-index:1500;background:var(--bg);overflow:auto;height:100vh}
body.mobile-menu main{display:none}
main{order:1;height:calc(100vh - 0px)}
.layer-controls{top:8px;right:8px;font-size:10px;padding:6px 8px;max-width:160px}
.legend{display:none}
.ab-grid{grid-template-columns:1fr}
.side-toggle{display:none}
.mobile-fab{position:fixed;top:14px;left:14px;background:white;color:#0f172a;border:1px solid #cbd5e1;border-radius:8px;width:42px;height:42px;display:flex;align-items:center;justify-content:center;font-size:20px;z-index:1000;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.18)}
body.mobile-menu .mobile-fab{position:fixed;top:14px;right:14px;left:auto;z-index:1600;background:#ef4444;color:white;border-color:#dc2626}
.header .actions{flex-wrap:wrap}
.scorebox{padding:10px}
.scorebox .num{font-size:36px}
.kpis{grid-template-columns:1fr 1fr}
.jk-body{grid-template-columns:1fr}
}
@media (min-width: 801px) {
.mobile-fab{display:none}
}
/* Print / PDF — printable surface = #addrBlock + #result */
@media print {
@page { margin: 12mm; }
html, body{margin:0;padding:0;background:white !important;color:black !important;display:block !important;-webkit-print-color-adjust:exact;print-color-adjust:exact}
/* Kill the map column and all chrome */
main, #leftToggle, #sideToggle, .layer-controls, .legend, #map, #minimap,
.footer-attrib, .modal-bg, .gd-toast,
#searchBox, #searchResults, #frm, #msg,
.header .actions, .header .v, .header .sub,
.tabs, .whatif, details, pre,
#recentBlock, #scenariosHeader, #scenariosBlock, #pinnedCompareBlock,
.tab#tab-raw, .tab#tab-radar { display: none !important; }
/* Side panel becomes the page */
#side{position:static !important;overflow:visible !important;height:auto !important;width:100% !important;max-width:none !important;border:0 !important;background:white !important;box-shadow:none !important;padding:0 !important}
#side .body{padding:0 !important}
.header{position:static !important;border:0 !important;border-bottom:1px solid #999 !important;padding:0 0 6px !important;margin-bottom:8px !important}
.header h1{font-size:18pt !important;margin:0 !important}
/* Search-box wrapper sits right after .body; nuke the empty wrapper */
#side .body > div:first-of-type{display:none !important}
/* «ЖК на карте» legend below result is map-only — hide it and its hint */
#side .body > h2:last-of-type, #metasum{display:none !important}
/* Force every tab panel to render — they're toggled via .active class */
.tab{display:block !important;page-break-inside:avoid;margin-top:10px}
.tab + .tab{border-top:1px dashed #cbd5e1;padding-top:10px}
.scorebox, .kpis, .chart, .cmp, .comp{break-inside:avoid;page-break-inside:avoid}
/* Charts may be canvas-sized: cap height so multi-page layout works */
.chart{max-height:260px !important}
h2{break-after:avoid;page-break-after:avoid;margin-top:14px;font-size:13pt !important}
/* Drop hover-only tooltips and animations */
*{animation:none !important;transition:none !important;box-shadow:none !important}
}
</style>
</head>
<body>
<aside id="side">
<div class="header">
<h1>Анализ участка <span class="v">v0.4</span></h1>
<div class="sub">POI · Объектив · ставка ЦБ · конкуренция · what-if</div>
<div class="actions">
<button id="btn-share" title="Скопировать share-ссылку">🔗</button>
<button id="btn-print" title="Распечатать / сохранить PDF">PDF</button>
<button id="btn-ab" title="Сравнить участок с 2-3 ЖК">A/B</button>
<button id="btn-dark" title="Тёмная тема">🌓</button>
<button id="btn-top" title="Топ-30 ЖК по текущему профилю аудитории">Топ-30</button>
<button id="btn-export" title="Экспорт CSV / GeoJSON">CSV</button>
<button id="btn-pin" title="Закрепить участок на карте для сравнения">📍</button>
<button id="btn-roi" title="Где в Екб самый высокий ROI для гипотетического участка">ROI</button>
<button id="btn-methodology" title="Как считаются баллы, веса, формулы">Методика</button>
</div>
</div>
<div class="body">
<div style="position:relative;margin-bottom:8px">
<input id="searchBox" placeholder="🔍 поиск ЖК по имени, девелоперу, кадастру…" autocomplete="off" style="width:100%;padding:8px 10px;border:1px solid #cbd5e1;border-radius:7px;font-size:12px">
<div id="searchResults" style="display:none;position:absolute;top:100%;left:0;right:0;background:white;border:1px solid #cbd5e1;border-radius:7px;max-height:280px;overflow-y:auto;z-index:30;box-shadow:0 4px 12px rgba(0,0,0,.08);margin-top:2px"></div>
</div>
<form class="form" id="frm">
<input id="cad" placeholder="Кадастр (66:41:0204016:10)" autocomplete="off">
<div class="row2">
<input id="lat" placeholder="lat" type="number" step="any">
<input id="lon" placeholder="lon" type="number" step="any">
</div>
<input id="name" placeholder="название (опц.)">
<div class="row2" style="align-items:center">
<select id="audience" style="flex:2"></select>
<button type="submit" class="primary" id="go" style="flex:1.2">Оценить</button>
</div>
<button type="button" id="btn-fetch-poly" style="background:#7c3aed;color:white;border:0;border-radius:7px;padding:7px 10px;cursor:pointer;font-size:11px;font-weight:600">Тянуть полигон по кадастру</button>
<div class="hint">Клик по карте подставит координаты. «Тянуть полигон» — точная геометрия из Росреестра.</div>
</form>
<div id="msg"></div>
<div id="macroBar" class="macro" style="display:none"></div>
<div id="addrBlock"></div>
<div id="result"></div>
<div id="recentBlock"></div>
<h2 id="scenariosHeader" style="display:none">Сценарии <span class="help" style="cursor:pointer" id="scn-save">+ сохранить</span></h2>
<div id="scenariosBlock"></div>
<div id="pinnedCompareBlock"></div>
<h2>ЖК на карте <span class="help">кликни любой точкой для деталей</span></h2>
<div class="hint" id="metasum">Загрузка…</div>
<div class="footer-attrib">OSM · DOM.РФ kn-API · Объектив API · ЦБ РФ · NSPD · Nominatim</div>
</div>
</aside>
<button class="side-toggle" id="sideToggle" title="Свернуть/развернуть панель"></button>
<button class="mobile-fab" id="mobileFab" title="Меню"></button>
<main style="position:relative">
<div id="map"></div>
<div id="minimap" style="position:absolute;bottom:14px;left:14px;width:180px;height:140px;border:2px solid white;border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,.18);z-index:500;background:#eef2f6;overflow:hidden"></div>
<div class="layer-controls" id="layer-controls">
<details open>
<summary style="font-weight:600;cursor:pointer;font-size:11px;color:#475569">🏘 ЖК</summary>
<label><input type="checkbox" id="layer-jk" checked> точки <span id="lc-jk"></span></label>
<label><input type="checkbox" id="layer-jk-poly"> полигоны <span class="hint">(304/380)</span></label>
<label><input type="checkbox" id="layer-heatmap"> heatmap районов</label>
<select id="heatmap-metric" style="font-size:10px;display:none;width:100%">
<option value="score">по среднему баллу</option>
<option value="price">по цене м²</option>
<option value="velocity">по скорости продаж</option>
<option value="sold_pct">по % продаж</option>
<option value="jk_count">по числу ЖК</option>
</select>
</details>
<details open>
<summary style="font-weight:600;cursor:pointer;font-size:11px;color:#475569">📍 Участок · радиусы</summary>
<label><input type="checkbox" id="ring-300" checked> 300м <span class="hint">(~4мин пешком)</span></label>
<label><input type="checkbox" id="ring-500" checked> 500м <span class="hint">(~6мин)</span></label>
<label><input type="checkbox" id="ring-1000"> 1км <span class="hint">(~12мин)</span></label>
<label><input type="checkbox" id="ring-walk-15"> 15-мин ходьба ≈1.2км</label>
<hr style="margin:6px 0;border:0;border-top:1px solid #e2e8f0">
<label><input type="checkbox" id="layer-poi" checked> POI вокруг</label>
<select id="poi-filter-cat" style="font-size:11px;width:100%;margin-top:3px">
<option value="all">все категории</option>
<optgroup label="Образование">
<option value="kindergarten">детские сады</option>
<option value="childcare">ясли/няни</option>
<option value="school">школы</option>
<option value="university">ВУЗы</option>
</optgroup>
<optgroup label="Здоровье">
<option value="pharmacy">аптеки</option>
<option value="clinic">поликлиники</option>
<option value="hospital">больницы</option>
<option value="dentist">стоматология</option>
<option value="vet">ветеринар</option>
</optgroup>
<optgroup label="Транспорт">
<option value="bus_stop">автобусы</option>
<option value="tram_stop">трамваи</option>
<option value="metro">метро</option>
<option value="parking">парковки</option>
<option value="car_rental">авто/такси</option>
</optgroup>
<optgroup label="Магазины">
<option value="shop_big">большие</option>
<option value="shop_med">средние</option>
<option value="shop_small">малые</option>
<option value="marketplace">рынок</option>
</optgroup>
<optgroup label="Еда / отдых">
<option value="cafe">кафе</option>
<option value="restaurant">рестораны</option>
<option value="nightclub">бары/клубы</option>
</optgroup>
<optgroup label="Сервис">
<option value="atm">банкоматы</option>
<option value="bank">банки</option>
<option value="post">почта</option>
<option value="courier">постаматы</option>
<option value="library">библиотеки</option>
<option value="worship">храмы</option>
<option value="fuel">АЗС</option>
<option value="car_wash">автомойки</option>
<option value="hotel">отели</option>
</optgroup>
<optgroup label="Безопасность">
<option value="police">полиция</option>
<option value="fire">пожарка</option>
</optgroup>
<optgroup label="Досуг / культура">
<option value="park">парки</option>
<option value="playground">площадки</option>
<option value="sports">спорт</option>
<option value="gym">фитнес</option>
<option value="theater">театры</option>
<option value="cinema">кино</option>
<option value="museum">музеи</option>
</optgroup>
</select>
</details>
<details>
<summary style="font-weight:600;cursor:pointer;font-size:11px;color:#475569">🌐 POI плотность</summary>
<label><input type="checkbox" id="layer-poi-density"> heatmap</label>
<select id="poi-density-cat" style="font-size:11px;width:100%">
<option value="*">все категории</option>
<option value="education">🎓 образование</option>
<option value="health">⚕ здоровье</option>
<option value="retail">🛒 магазины</option>
<option value="transit">🚇 транспорт</option>
<option value="leisure">🌳 досуг</option>
<option value="cafe">☕ еда</option>
</select>
</details>
<details>
<summary style="font-weight:600;cursor:pointer;font-size:11px;color:#475569">🔍 Фильтр ЖК</summary>
<select id="filter-class" style="font-size:11px;width:100%"><option value="">все классы</option></select>
<select id="filter-district" style="font-size:11px;width:100%"><option value="">все районы</option></select>
<label style="font-size:11px"><span>балл от <b id="filter-score-val">0</b></span><input type="range" min="0" max="100" value="0" id="filter-score" style="width:100%"></label>
<div style="display:flex;gap:4px">
<button id="save-view" style="font-size:10px;padding:4px 6px;background:white;border:1px solid #cbd5e1;border-radius:4px;cursor:pointer;flex:1">💾 view</button>
</div>
<select id="saved-views" style="font-size:11px;width:100%"><option value="">мои виды…</option></select>
</details>
<details>
<summary style="font-weight:600;cursor:pointer;font-size:11px;color:#475569">🗺 Подложка</summary>
<select id="basemap" style="width:100%">
<option value="cartovoyager" selected>Carto Voyager</option>
<option value="cartolight">Carto Light</option>
<option value="cartodark">Carto Dark</option>
<option value="osm">OSM</option>
<option value="twogis">2ГИС (z≤18)</option>
<option value="esri_sat">Спутник (Esri)</option>
<option value="esri_topo">Esri Topo</option>
</select>
</details>
</div>
<div class="legend" id="legend">
<div class="ttl">Легенда</div>
<div><i style="background:#ef4444"></i>участок</div>
<div><i style="background:#10b981"></i>ЖК · топ-25%</div>
<div><i style="background:#0ea5e9"></i>ЖК · средний</div>
<div><i style="background:#94a3b8"></i>ЖК · ниже</div>
<div style="margin-top:6px;font-weight:600;font-size:9px;color:#64748b;text-transform:uppercase">POI</div>
<div id="legend-poi"></div>
</div>
</main>
<!-- ЖК detail modal -->
<div class="modal-bg" id="jkModal">
<div class="modal" style="max-width:1100px;width:96%;padding:0;overflow:hidden">
<button class="close" onclick="document.getElementById('jkModal').classList.remove('show')" style="z-index:10">×</button>
<div id="jkContent" style="overflow:auto;max-height:90vh"></div>
</div>
</div>
<!-- Methodology modal -->
<div class="modal-bg" id="methodModal">
<div class="modal" style="max-width:920px;width:96%;padding:0;overflow:hidden">
<button class="close" onclick="document.getElementById('methodModal').classList.remove('show')" style="z-index:10">×</button>
<div style="overflow:auto;max-height:90vh">
<div class="jk-hero" style="background:linear-gradient(135deg,#475569,#0f172a)">
<h2>📐 Методика расчёта балла</h2>
<div class="subtitle">Как 5 источников данных собираются в один балл 0-100</div>
</div>
<div style="padding:18px 24px 28px;font-size:13px;line-height:1.6;color:#0f172a">
<div style="background:linear-gradient(135deg,#fef3c7,#fde68a);padding:16px 18px;border-radius:10px;margin-bottom:18px;font-size:13px;line-height:1.7">
<div style="font-weight:700;font-size:14px;margin-bottom:8px">💡 Простыми словами — что мы делаем</div>
Берём <b>любую точку на карте Екатеринбурга</b> и отвечаем на вопрос: «насколько это место подходит для жилого строительства».
Сравниваем эту точку с 380 строящимися ЖК и говорим где она в рейтинге.
<br><br>
Балл собирается из <b>7 факторов</b>: что вокруг (садики, школы, аптеки, транспорт, парки, магазины), какая экономика района (цена м², скорость продаж),
и есть ли свободная ниша (мало ли конкурентов рядом). Каждый фактор от 0 до 100, потом всё перемножается на свои веса
и складывается. Веса меняются в зависимости от того <b>кому будем продавать квартиры</b> (семьям важна школа, инвестору — скорость продаж).
</div>
<h3 style="font-size:14px;margin:0 0 8px;font-weight:700">🎬 Сценарий на пальцах</h3>
<div style="background:#f8fafc;padding:14px 16px;border-radius:8px;margin-bottom:18px;font-size:13px;line-height:1.7;border-left:3px solid #0ea5e9">
<b>Шаг 1.</b> Подставь координаты участка (или кадастровый номер).<br>
<b>Шаг 2.</b> Система за 7 миллисекунд:<br>
<span style="display:inline-block;margin-left:12px">— находит все 39 типов POI в радиусе 2 км (садики, остановки, магазины, …)</span><br>
<span style="display:inline-block;margin-left:12px">— считает расстояние до ближайшего объекта каждого типа</span><br>
<span style="display:inline-block;margin-left:12px">— переводит расстояния в баллы 0-100 (близко = 100, далеко = 0)</span><br>
<span style="display:inline-block;margin-left:12px">— определяет в каком районе участок (по полигонам и kNN)</span><br>
<span style="display:inline-block;margin-left:12px">— достаёт цены и скорость продаж этого района из 303 тысяч квартир Объектива</span><br>
<span style="display:inline-block;margin-left:12px">— складывает 7 компонент с весами выбранной аудитории</span><br>
<b>Шаг 3.</b> На выходе — балл 0-100, ранг среди 380 ЖК, расшифровка каждого фактора.
</div>
<h3 style="font-size:14px;margin:0 0 8px;font-weight:700">🧪 Реальный пример: парцель 66:41:0204016:10</h3>
<div style="background:#f0f9ff;padding:14px 16px;border-radius:8px;margin-bottom:14px;font-size:13px;line-height:1.7;border-left:3px solid #0284c7">
<b>Адрес:</b> Маневровая 31А, Старая Сортировка, Железнодорожный район Екб.<br>
<b>Площадь:</b> 0.28 га (2 789 м²) · кадастровая стоимость 23.7 млн ₽.<br>
<b>Профиль:</b> «Семейный» (важны школы и парки).<br><br>
<b>Что вокруг:</b><br>
🟢 <b>Образование (вес 30%):</b> детсад в 380 м, школа в 792 м. Школа далековата, но не критично → балл компоненты <b>79.5</b>.<br>
🟢 <b>Здоровье (12%):</b> аптека 471 м, поликлиника 443 м. Норм → <b>88.7</b>.<br>
🟢 <b>Ритейл (10%):</b> крупный магазин в 124 м — идеально → <b>100</b>.<br>
🟡 <b>Транспорт (10%):</b> метро нет, трамвай 177 м, автобус 189 м → <b>69.7</b>.<br>
🟢 <b>Досуг (13%):</b> детплощадка 163 м, парк 565 м → <b>98.4</b>.<br>
🔴 <b>Экономика (20%):</b> район мас-маркет (114.9 тыс ₽/м², невысокая цена) → <b>39.2</b>. Здесь главный минус.<br>
🟢 <b>Рынок (5%):</b> всего 3 конкурента в 1 км, район proven → <b>88.8</b>.<br><br>
<b>Сборка:</b> 79.5×0.30 + 88.7×0.12 + 100×0.10 + 69.7×0.10 + 98.4×0.13 + 39.2×0.20 + 88.8×0.05 ≈ <b>76 баллов</b>.<br>
<b>Ранг:</b> #166 из 381 — это 56-й перцентиль, выше середины.<br><br>
<b>Вердикт:</b> локация средне-сильная для семейного жилья. Главный сдерживающий фактор — низкая цена района
(нельзя поставить премиум за 200 тыс ₽/м²). При попытке построить даже эконом-класс на FAR=2.0 ТЭП показывает <b>10% маржи</b>
участок убыточный для жилья. Может работать как коммерческая недвижимость или продажа другому застройщику.
</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">🎭 Зачем 5 разных «профилей»?</h3>
<div style="font-size:13px;line-height:1.7;margin-bottom:18px">
Один и тот же участок может быть отличным для семей и плохим для премиума, или наоборот.
Поэтому веса компонент <b>меняются</b>:
<ul style="margin:6px 0;padding-left:24px">
<li><b>Семейный</b> — образование выше всего (30%), парки, потом цена. Нужны садики и школы рядом.</li>
<li><b>Премиум</b> — экономика 40% (хочется продать дороже + быстрее). Транспорт и образование меньше.</li>
<li><b>Эконом</b> — транспорт (25%) и магазины (20%). Главное чтобы можно было дёшево добраться куда нужно.</li>
<li><b>Инвестор</b> — экономика 45% + рынок 12% (скорость возврата денег важнее всего).</li>
<li><b>Сбалансированный</b> — поровну, без перекоса.</li>
</ul>
В UI можно <b>переключаться live</b> — без перерасчёта компонентов, просто веса другие. Виден другой балл и другая позиция в топе.
</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">📏 Как далеко считается «близко»</h3>
<div style="background:#f8fafc;padding:12px 16px;border-radius:8px;margin-bottom:14px;font-size:13px;line-height:1.7">
Каждой категории POI задано два расстояния:
<br>
<code>ideal_m</code> — в этом радиусе максимум баллов (100). Для садика — это 300 м, для школы — 400 м.<br>
<code>max_m</code> — за этим радиусом баллов 0. Для садика — 1000 м, для школы — 1500 м.<br>
• Между ними — <b>линейно</b>. Школа в 792 м: <code>(1500-792)/(1500-400) × 100 = 64.4 балла</code>.<br>
<br>
Эти границы прокалиброваны на основе индустрии:
<br><b>«4 минуты пешком» = 300 м</b> — комфорт. <b>«15 минут» = 1.2 км</b> — предел.
<br>• Метро прощается до 1 км (это 12 минут пешком), школа до 1.5 км (родители подвозят).
<br>• Больница может быть в 5 км — туда едут раз в год, а не каждый день.
</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">💰 Откуда мы знаем цены и скорость продаж</h3>
<div style="font-size:13px;line-height:1.7;margin-bottom:14px">
Из <b>Объектив API</b> — платная аналитика рынка недвижимости РФ. Они сканируют сайты девелоперов и Росреестр.
Мы вытащили <b>303 677 квартир Екатеринбурга</b>: каждая со статусом (продано/в продаже/резерв), ценой, площадью, датой регистрации ДДУ.
<br><br>
Из этих данных собираем:
<ul style="margin:6px 0;padding-left:24px">
<li><b>Медианная цена м²</b> — берём проданные квартиры района, считаем медиану. Старая Сорт. = 114.9 тыс ₽.</li>
<li><b>Скорость продаж (velocity)</b> — сколько ДДУ зарегистрировали в этом районе за последние 6 месяцев / число корпусов / 6.
Получается «сколько квартир в среднем продаёт корпус в месяц». Старая Сорт. = 4.23/мес.</li>
<li><b>Тренд</b> — скорость последних 6 мес ÷ скорость предыдущих 6 мес. >1 = район разогревается. Старая Сорт. = ×1.15 (медленный рост).</li>
<li><b>Распродано %</b> — доля проданных лотов в районе. 48.8% — рынок не дошёл и до середины.</li>
</ul>
</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">🏗 Зачем компонент «рынок»</h3>
<div style="font-size:13px;line-height:1.7;margin-bottom:14px">
Если в радиусе 1 км уже строится 15 ЖК — у нового проекта будет <b>тяжёлая конкуренция</b>. Покупатели выбирают.
Поэтому считаем <code>density</code> = чем меньше конкурентов, тем выше балл.
<br><br>
С другой стороны если в районе sold% = 50% (середина продаж), значит рынок есть и продаётся. Если sold% = 5%,
скорее всего что-то не так (район непопулярен или новый). Это <code>saturation</code>.
<br>Раньше я делал штраф «за слишком высокий sold%» (мол, рынок на финишной прямой), но это не правильно — высокий sold% = доказанный спрос.
Поэтому формула: <code>min(100, sold% × 100/70)</code> — плато на 70%, дальше штрафа нет.
</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">🚶 Walkability — отдельный показатель «можно ли жить пешком»</h3>
<div style="font-size:13px;line-height:1.7;margin-bottom:14px">
Идея от walkscore.com. Не входит в основной балл, показывается рядом.
<br><br>
<b>Логика:</b> для каждого «нужного» POI считаем <code>1/(1 + расстояние/200)</code>. На 0 м = 1.0, на 200 м = 0.5, на 1 км = 0.17.
Потом умножаем на «важность категории» — кафе важнее музея, садик важнее библиотеки.
Суммируем, нормируем к шкале где 60 = «отлично».
<br><br>
Парцель: <b>44/100</b> — средне. Большинство нужного есть, но всё в 400-800 м, не в 100 м. Не всё пешком.
</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">☀ Инсоляция — будут ли в квартирах окна на солнце</h3>
<div style="font-size:13px;line-height:1.7;margin-bottom:14px">
Берём <b>61 240 контуров зданий</b> Екб из OpenStreetMap. Считаем сколько зданий <b>южнее</b> участка (азимут 135°-225°)
и насколько они близко. Чем больше — тем больше «давления» на инсоляцию (тенят).
<br><br>
<code>shadow_S = Σ 1/(1 + расстояние/50)</code> по зданиям к югу.<br>
<code>insolation_score = max(0, 100 shadow_S × 10)</code>.
<br><br>
Парцель: <b>44.5</b>. С юго-запада плотная застройка (21 здание на 500 м) — будет затенять вечернее солнце.
Это критично для премиум-жилья (квартиры с видом и солнцем). Для эконом-класса — допустимо.
</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">⚠ Риск-скор — что плохого рядом</h3>
<div style="font-size:13px;line-height:1.7;margin-bottom:14px">
5 источников риска: <b>железная дорога</b> (шум), <b>магистраль</b> (шум + грязь), <b>промзона</b> (запах + шум),
<b>АЗС</b> (пожароопасность близко), <b>пожарка</b> (наоборот, должна быть в 2 км максимум).
<br><br>
Для каждого: ближе к <code>ideal_m</code> = больше штраф. Веса — самые сильные у ж/д и магистралей (по 30%), потом промзоны (25%).
<br><br>
Парцель: <b>31.5/100</b> — плохо. Магистраль в 44 м (-30%) и ж/д в 160 м (-30%). Для премиума критично, для эконома —
надо защитный экран и правильная ориентация окон.
</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">📐 ТЭП — выгодно ли тут строить вообще</h3>
<div style="font-size:13px;line-height:1.7;margin-bottom:14px">
Простая финмодель: задаёшь <code>FAR</code> (плотность застройки), <code>saleable_share</code> (доля квартир от общей площади),
среднюю площадь квартиры. Получаешь:
<ul style="margin:6px 0;padding-left:24px">
<li><b>Сколько квартир</b> поместится: GBA × saleable / средняя_площадь.</li>
<li><b>Выручка</b> = квартиры × цена_района. Цену берём реальную из Объектива.</li>
<li><b>Себестоимость</b> = GBA × construction_cost (типично 75 тыс ₽/м² для комфорт-монолита).</li>
<li><b>Soft cost</b> = 10% от выручки (проектные, маркетинг, продажи).</li>
<li><b>Прибыль</b> = выручка себест. soft. <b>Маржа</b> = прибыль / выручка.</li>
<li><b>NPV</b> — учитывает что деньги в будущем дешевле. Если NPV > 0 — проект окупается.</li>
<li><b>IRR</b> — годовая доходность при которой NPV = 0. Хорошо если >25%.</li>
</ul>
Для парцеля при FAR=2.0 эконом-класс → <b>76 квартир, выручка 416 млн, прибыль 43 млн (минус 10%)</b>. Не строим жильё.
</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">📈 ROI-рейтинг районов — где в Екб строить выгодно</h3>
<div style="font-size:13px;line-height:1.7;margin-bottom:14px">
Сценарий: «у меня будет участок 3000 м², хочу строить комфорт. В каком районе максимальная отдача?»
<br><br>
Берём 30 районов, для каждого считаем гипотетическую финмодель с одинаковыми параметрами и реальной ценой района.
Результат: топ-3 для комфорт-класса — <b>Сибирский 13.2%</b>, <b>Центр 10%</b>, <b>Юго-Зап. 8.9%</b>.
Худшие убыточные: Шир. Речка, Завокзальный, Солнечный.
<br><br>
Используется как «куда копать» — где искать участки. Для каждого района мы знаем цены, скорость продаж, sold% — модель честная.
</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">🎯 Что считать <i>хорошим</i> баллом</h3>
<div style="font-size:13px;line-height:1.7;margin-bottom:18px">
<b>85+</b> — топ-локация, премиум застраивать здесь самое то<br>
<b>70-85</b> — хорошее место, любой класс кроме самого премиума<br>
<b>55-70</b> — средне, ставить нужно эконом / комфорт с правильным позиционированием<br>
<b>40-55</b> — слабо, возможно нерентабельно (как наш парцель)<br>
<b>&lt; 40</b> — не строить жильё, искать другое назначение<br>
<br>
Распределение по 380 ЖК Екб: медиана ~70, p75 ~80, max ~95. <b>Парцель 75.8</b> = выше медианы.
</div>
<hr style="margin:22px 0 18px;border:0;border-top:2px solid #e2e8f0">
<h3 style="font-size:14px;margin:0 0 8px;font-weight:700">📊 Формула</h3>
<div style="background:#f1f5f9;padding:12px 14px;border-radius:8px;font-family:'SF Mono','Menlo',monospace;font-size:12px;margin-bottom:14px;line-height:1.7">
<b>balanced_score</b> = Σ (component<sub>i</sub> × weight<sub>i</sub>)
<br>где component ∈ {education, health, retail, transit, leisure, economic, market}
<br>каждый component нормирован к 0..100, weight зависит от audience-профиля.
</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">🎯 7 компонент</h3>
<table class="lot-table" style="font-size:11px">
<thead><tr><th>Компонент</th><th>Что измеряет</th><th>Источник</th></tr></thead>
<tbody>
<tr><td><b>education</b></td><td>дистанции до садиков (×1.0), школ (×1.0), ВУЗов (×0.3)</td><td>OSM</td></tr>
<tr><td><b>health</b></td><td>аптеки (×1.0), поликлиники (×1.0), больницы (×0.5)</td><td>OSM</td></tr>
<tr><td><b>retail</b></td><td>max(big-shops, 0.7×med-shops) — крупные приоритетнее</td><td>OSM</td></tr>
<tr><td><b>transit</b></td><td>max(metro, 0.85×tram, 0.7×bus) — лучший вид транспорта</td><td>OSM</td></tr>
<tr><td><b>leisure</b></td><td>парки + площадки + спорт (×0.7)</td><td>OSM</td></tr>
<tr><td><b>economic</b></td><td>0.50×price + 0.25×velocity + 0.25×liquidity</td><td>Объектив API</td></tr>
<tr><td><b>market</b></td><td>0.5×density(jk_count_1km) + 0.5×saturation(sold%)</td><td>local DB</td></tr>
</tbody>
</table>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">📏 Distance-to-score (POI компоненты)</h3>
<div style="background:#f1f5f9;padding:12px 14px;border-radius:8px;font-family:'SF Mono','Menlo',monospace;font-size:12px;margin-bottom:8px;line-height:1.7">
if d ≤ ideal_m: score = 100
<br>elif d ≥ max_m: score = 0
<br>else: score = 100 × (max_m d) / (max_m ideal_m) <span style="color:#64748b"># piecewise linear</span>
</div>
<details>
<summary style="cursor:pointer;color:#0284c7;font-size:12px;margin-bottom:6px">Конкретные ideal_m / max_m для каждой категории</summary>
<table class="lot-table" style="font-size:10px;margin-top:6px">
<thead><tr><th style="text-align:left">Категория</th><th style="text-align:right">ideal_m</th><th style="text-align:right">max_m</th><th style="text-align:right">weight</th></tr></thead>
<tbody>
<tr><td>kindergarten</td><td class=r>300</td><td class=r>1000</td><td class=r>1.0</td></tr>
<tr><td>school</td><td class=r>400</td><td class=r>1500</td><td class=r>1.0</td></tr>
<tr><td>university</td><td class=r>1000</td><td class=r>5000</td><td class=r>0.3</td></tr>
<tr><td>pharmacy</td><td class=r>300</td><td class=r>1000</td><td class=r>1.0</td></tr>
<tr><td>clinic</td><td class=r>500</td><td class=r>2000</td><td class=r>1.0</td></tr>
<tr><td>hospital</td><td class=r>1500</td><td class=r>5000</td><td class=r>0.5</td></tr>
<tr><td>shop_big (mall, hyper)</td><td class=r>500</td><td class=r>2000</td><td class=r>1.0</td></tr>
<tr><td>shop_med (магнит и пр.)</td><td class=r>300</td><td class=r>1000</td><td class=r>1.0</td></tr>
<tr><td>metro</td><td class=r>1000</td><td class=r>3000</td><td class=r>1.0</td></tr>
<tr><td>tram</td><td class=r>400</td><td class=r>1500</td><td class=r>1.0</td></tr>
<tr><td>bus</td><td class=r>200</td><td class=r>800</td><td class=r>1.0</td></tr>
<tr><td>park</td><td class=r>500</td><td class=r>2000</td><td class=r>1.0</td></tr>
<tr><td>playground</td><td class=r>200</td><td class=r>700</td><td class=r>1.0</td></tr>
<tr><td>sports</td><td class=r>500</td><td class=r>2000</td><td class=r>0.7</td></tr>
</tbody>
</table>
</details>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">🎭 Audience-профили — веса (сумма = 1.0)</h3>
<table class="lot-table" style="font-size:11px">
<thead><tr><th style="text-align:left">Профиль</th><th style="text-align:right">edu</th><th style="text-align:right">health</th><th style="text-align:right">retail</th><th style="text-align:right">transit</th><th style="text-align:right">leisure</th><th style="text-align:right">economic</th><th style="text-align:right">market</th></tr></thead>
<tbody>
<tr><td><b>balanced</b></td><td class=r>0.18</td><td class=r>0.10</td><td class=r>0.13</td><td class=r>0.15</td><td class=r>0.09</td><td class=r>0.30</td><td class=r>0.05</td></tr>
<tr><td><b>family</b><br><span class=hint>садики, школы, парки</span></td><td class=r style="color:#0a6;font-weight:700">0.30</td><td class=r>0.12</td><td class=r>0.10</td><td class=r>0.10</td><td class=r style="color:#0a6;font-weight:700">0.13</td><td class=r>0.20</td><td class=r>0.05</td></tr>
<tr><td><b>premium</b><br><span class=hint>цена, топ-локация</span></td><td class=r>0.10</td><td class=r>0.10</td><td class=r>0.10</td><td class=r>0.10</td><td class=r>0.15</td><td class=r style="color:#0a6;font-weight:700">0.40</td><td class=r>0.05</td></tr>
<tr><td><b>economy</b><br><span class=hint>транспорт, базовое</span></td><td class=r>0.15</td><td class=r>0.10</td><td class=r style="color:#0a6;font-weight:700">0.20</td><td class=r style="color:#0a6;font-weight:700">0.25</td><td class=r>0.05</td><td class=r>0.20</td><td class=r>0.05</td></tr>
<tr><td><b>investor</b><br><span class=hint>скорость, ликвидность</span></td><td class=r>0.10</td><td class=r>0.08</td><td class=r>0.08</td><td class=r>0.12</td><td class=r>0.05</td><td class=r style="color:#0a6;font-weight:700">0.45</td><td class=r style="color:#0a6;font-weight:700">0.12</td></tr>
</tbody>
</table>
<div class="hint" style="margin-top:6px">Live audience swap: при смене профиля компоненты не пересчитываются (они уже кэшированы), просто перевзвешиваются — мгновенно.</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">💰 Economic компонент</h3>
<div style="background:#f1f5f9;padding:12px 14px;border-radius:8px;font-family:'SF Mono','Menlo',monospace;font-size:12px;line-height:1.7">
economic = <b>0.50</b>×price_score + <b>0.25</b>×velocity_score + <b>0.25</b>×liquidity_score
<br>
<br>price_score = norm(district_median_price, [city_min, city_max])
<br>velocity_score = norm(real_velocity_6mo, [0, p90]) × trend_modifier
<br>liquidity_score = max(0, 100 months_to_sellout × 100/24)
<br>
<br><span style="color:#64748b"># trend_modifier — prod-style (см. recommend_mix):</span>
<br>trend_factor = clamp(recent_6mo / prior_6mo, 0.7, 2.0)
<br>velocity_score *= 0.5 + 0.5 × (trend_factor / 2)
</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">🏘 Market компонент (конкуренция)</h3>
<div style="background:#f1f5f9;padding:12px 14px;border-radius:8px;font-family:'SF Mono','Menlo',monospace;font-size:12px;line-height:1.7">
density_score = max(0, 100 jk_count_1km × 100/15)
<br>saturation_score = min(100, sold% × 100/70) <span style="color:#64748b"># &gt;70% sold = плато, sold% подтверждает спрос</span>
<br>market = 0.5 × density + 0.5 × saturation
</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">🚶 Walkability (отдельный показатель)</h3>
<div style="background:#f1f5f9;padding:12px 14px;border-radius:8px;font-family:'SF Mono','Menlo',monospace;font-size:12px;line-height:1.7">
walkability = Σ_categories (weight<sub>cat</sub> × Σ_pois 1/(1 + distance/200m)) / 0.6
<br>
<br><span style="color:#64748b"># Веса по категориям (топ-3):</span>
<br>shop_med 1.5 · kindergarten 1.5 · school 1.5 · metro 1.5 · park 1.2 · pharmacy 1.0
<br><span style="color:#64748b"># Радиус: 1500 м, decay 1/(1 + d/200)</span>
</div>
<div class="hint" style="margin-top:5px">Этот показатель — отдельный (показан рядом с баллом), не входит в основную формулу. Идея от Walk Score (walkscore.com).</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">☀ Инсоляция (открытость на юг)</h3>
<div style="background:#f1f5f9;padding:12px 14px;border-radius:8px;font-family:'SF Mono','Menlo',monospace;font-size:12px;line-height:1.7">
shadow_S = Σ_buildings_in_500m_south 1/(1 + d/50) <span style="color:#64748b"># юг = bearing 135°-225°</span>
<br>insolation_score = max(0, 100 shadow_S × 10)
</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">⚠ Risk-score</h3>
<table class="lot-table" style="font-size:11px">
<thead><tr><th style="text-align:left">Источник риска</th><th style="text-align:right">ideal_m</th><th style="text-align:right">max_m</th><th style="text-align:right">weight</th></tr></thead>
<tbody>
<tr><td>railway</td><td class=r>200</td><td class=r>800</td><td class=r>0.30</td></tr>
<tr><td>highway (motorway/trunk/primary)</td><td class=r>80</td><td class=r>400</td><td class=r>0.30</td></tr>
<tr><td>industry zones</td><td class=r>200</td><td class=r>800</td><td class=r>0.25</td></tr>
<tr><td>fuel stations</td><td class=r>80</td><td class=r>300</td><td class=r>0.05</td></tr>
<tr><td>fire stations (далеко = плохо)</td><td class=r>500</td><td class=r>2000</td><td class=r>0.10</td></tr>
</tbody>
</table>
<div style="background:#f1f5f9;padding:12px 14px;border-radius:8px;font-family:'SF Mono','Menlo',monospace;font-size:12px;margin-top:6px;line-height:1.7">
per_risk_penalty = weight × 100 × (max_m d) / (max_m ideal_m) <span style="color:#64748b"># clamp 0..weight×100</span>
<br>risk_score = max(0, 100 Σ penalties)
</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">📐 ТЭП-калькулятор</h3>
<div style="background:#f1f5f9;padding:12px 14px;border-radius:8px;font-family:'SF Mono','Menlo',monospace;font-size:12px;line-height:1.7">
GBA = parcel_area × FAR
<br>saleable = GBA × saleable_share <span style="color:#64748b"># typ. 0.55-0.75</span>
<br>n_units = saleable / avg_unit_area
<br>revenue = saleable × district_median_price × 1000
<br>construction = GBA × construction_cost_per_m2
<br>soft_cost = revenue × soft_cost_pct <span style="color:#64748b"># typ. 10%</span>
<br>profit = revenue construction soft_cost
<br>
<br>NPV = 0.20×construction + Σ_months (revenue/M cost/M) / (1+r)^month
<br>IRR = bisect(npv_at_rate(r) = 0, lo=0, hi=5)
</div>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">📊 Sources of truth</h3>
<table class="lot-table" style="font-size:11px">
<thead><tr><th>Источник</th><th>Что даёт</th><th>Объём</th></tr></thead>
<tbody>
<tr><td><b>OSM (Overpass)</b></td><td>POI 39 категорий, building footprints для insolation</td><td>17 627 POI · 61 240 buildings</td></tr>
<tr><td><b>DOM.РФ kn-API</b></td><td>380 ЖК Екб с координатами, девелопер, класс</td><td>через прод PostgreSQL</td></tr>
<tr><td><b>Объектив API</b></td><td>per-flat данные: цена, статус, площадь, контракт-дата</td><td>303 677 квартир</td></tr>
<tr><td><b>ЦБ РФ</b></td><td>средневз. ставка ипотеки Свердл</td><td>Янв 2026 = 7.84%</td></tr>
<tr><td><b>Росреестр</b> (rosreestr2coord)</td><td>точная геометрия + кад.стоимость участка</td><td>по запросу</td></tr>
<tr><td><b>Nominatim</b> (OSM)</td><td>обратное геокодирование — адрес</td><td>cached</td></tr>
<tr><td><b>NSPD AEGGIS</b></td><td>8 admin полигонов Екб для PostGIS-assignment</td><td>через прод</td></tr>
</tbody>
</table>
<div class="hint" style="margin-top:18px;padding:10px 14px;background:#fef3c7;border-radius:6px;border-left:3px solid #f59e0b">
<b>Где числа — экспертная калибровка:</b> ideal_m / max_m для POI-категорий, audience-веса, walkability-decay (1/(1+d/200)). Не валидированы на real-world данных продаж — это hand-tuned heuristics из прод-кода <code>recommend_mix</code> + индустриальные практики (Walk Score, urban planning).
<br><br>
<b>Что точно из данных:</b> цены / velocity / sold% / геометрия — реальные числа. Веса — гипотезы для проверки.
</div>
<hr style="margin:26px 0 14px;border:0;border-top:2px solid #e2e8f0">
<h3 style="font-size:18px;margin:0 0 6px;font-weight:800">📚 Глоссарий терминов</h3>
<div class="hint" style="margin-bottom:14px">Все понятия, формулы, единицы измерения. Для each термина — определение + источник данных + конкретный пример с реальным числом из системы.</div>
<details open style="margin-bottom:8px"><summary style="cursor:pointer;font-weight:700;color:#0f172a;font-size:13px;padding:6px 0">🎯 Балл и ранжирование</summary>
<table class="lot-table" style="font-size:11px">
<tbody>
<tr><td style="width:160px"><b>weighted_score</b></td><td>Итоговый балл 0-100, взвешенная сумма 7 компонент. Главное число.<br><span class="hint">Пример: парцель 66:41:0204016:10 на family-профиле = 75.83</span></td></tr>
<tr><td><b>rank_overall</b></td><td>Позиция точки среди 380 строящихся ЖК Екб. Ниже = лучше.<br><span class="hint">Пример: #166 / 381 → топ-43% = 56-й перцентиль</span></td></tr>
<tr><td><b>percentile</b></td><td>Доля ЖК с меньшим баллом. <code>(1 rank/total) × 100</code>.<br><span class="hint">Пример: 56.7% — выше середины, но не топ</span></td></tr>
<tr><td><b>audience profile</b></td><td>Набор весов для 7 компонент. balanced/family/premium/economy/investor.<br><span class="hint">Live swap: при смене профиля компоненты не пересчитываются, только перевзвешиваются (мгновенно)</span></td></tr>
</tbody>
</table>
</details>
<details open style="margin-bottom:8px"><summary style="cursor:pointer;font-weight:700;color:#0f172a;font-size:13px;padding:6px 0">🏘 Локационные компоненты (POI distance)</summary>
<table class="lot-table" style="font-size:11px">
<tbody>
<tr><td style="width:160px"><b>education</b></td><td>Близость объектов образования. <code>(1.0 × kindergarten + 1.0 × school + 0.3 × university) / Σweights</code>.<br><span class="hint">Парцель: 79.5 (садик 380 м, школа 792 м, ВУЗ 906 м)</span></td></tr>
<tr><td><b>health</b></td><td>Близость аптек, поликлиник, больниц. <code>(1.0 × pharmacy + 1.0 × clinic + 0.5 × hospital)</code>.<br><span class="hint">Парцель: 88.7 (аптека 471 м, поликлиника 443 м, больница 1626 м)</span></td></tr>
<tr><td><b>retail</b></td><td>Лучший из «крупный магазин» или 70% «средний». Большие приоритетнее.<br><code>retail = max(big_score, 0.7 × med_score)</code><br><span class="hint">Парцель: 100 (большой магаз в 124 м — идеал)</span></td></tr>
<tr><td><b>transit</b></td><td>Лучший вид транспорта с штрафом. <code>max(metro, 0.85 × tram, 0.7 × bus)</code><br><span class="hint">Парцель: 69.7 (метро нет, трамвай 177 м с весом 0.85)</span></td></tr>
<tr><td><b>leisure</b></td><td>Парки, площадки, спорт. <code>(1.0 × park + 1.0 × playground + 0.7 × sports)</code>.<br><span class="hint">Парцель: 98.4 (площадка 163 м, парк 565 м)</span></td></tr>
<tr><td><b>distance scoring</b></td><td>Piecewise linear: <code>≤ ideal_m → 100; ≥ max_m → 0; иначе линейно</code>.<br><span class="hint">Пример: school ideal 400, max 1500. d=792 → 100×(1500792)/(1500400) = 64.4</span></td></tr>
<tr><td><b>nearest_m</b></td><td>Расстояние до ближайшей точки данной категории, в метрах (haversine).</td></tr>
<tr><td><b>count_500m / count_1km</b></td><td>Число POI данной категории в радиусе. Информационно, не в скоре.</td></tr>
</tbody>
</table>
</details>
<details open style="margin-bottom:8px"><summary style="cursor:pointer;font-weight:700;color:#0f172a;font-size:13px;padding:6px 0">💰 Economic компонент</summary>
<table class="lot-table" style="font-size:11px">
<tbody>
<tr><td style="width:160px"><b>economic</b></td><td>Экономическая привлекательность района. <code>0.5 × price + 0.25 × velocity + 0.25 × liquidity</code>.<br><span class="hint">Парцель: 39.2 (недорогой район, средняя скорость, медленный sellout)</span></td></tr>
<tr><td><b>price_score</b></td><td>Нормированная медианная цена района. <code>(price city_min) × 100 / (city_max city_min)</code><br><span class="hint">Старая Сортировка 114.9 тыс/м² · city_min=82.7 (УНЦ) · city_max=272.4 (Центр старая выгрузка) → 30.6</span></td></tr>
<tr><td><b>real_median_price_m2</b></td><td>Медиана цены за м² по реальным per-flat сделкам района из Объектива.<br><span class="hint">Старая Сортировка: 114.9 тыс ₽/м² (P25=92, P75=132)</span></td></tr>
<tr><td><b>velocity_score</b></td><td>Скорость продаж, нормированная к p90 города. <code>min(100, vel × 100 / vmax)</code> × trend_modifier.<br><span class="hint">vmax = p90 = 8.96 ДДУ/корп/мес. Старая Сортировка vel=4.23 → 47.2 × 1.07 (тренд) = 50.4</span></td></tr>
<tr><td><b>real_velocity_6mo</b></td><td>Зарегистрированных ДДУ в районе за recent 6 месяцев / число корпусов / 6.<br><span class="hint">Источник: register_date в objective_lots</span></td></tr>
<tr><td><b>real_velocity_prior_6mo</b></td><td>То же, но за пред. 6 месяцев (мес 7-12 от today).<br><span class="hint">Используется для расчёта тренда</span></td></tr>
<tr><td><b>trend_factor</b></td><td><code>clamp(real_trend_ratio, 0.7, 2.0)</code> где ratio = recent_6mo / prior_6mo.<br>Из прод-кода <code>recommend_mix</code>. Velocity_score умножается на (0.5 + 0.5 × tf/2).<br><span class="hint">Старая Сортировка ratio=1.15 (район разогревается) → tf=1.15 → ×0.79</span></td></tr>
<tr><td><b>liquidity_score</b></td><td>Скорость распродажи стока. <code>max(0, 100 months_to_sellout × 100/24)</code>.<br><span class="hint">Если sellout 12 мес → 50, если 24 мес → 0, если 0 → 100</span></td></tr>
<tr><td><b>months_to_sellout</b></td><td><code>district_stock_m2 / (sold_volume_90d / 3)</code>.<br><span class="hint">Старая Сортировка: 16 мес. Гоголя 21 (per-ЖК): 29.2 мес</span></td></tr>
<tr><td><b>real_sold_pct</b></td><td>Доля проданных лотов в районе из per-flat данных. <code>n_sold / n_total × 100</code>.<br><span class="hint">Старая Сортировка: 4078 / 8356 = 48.8%</span></td></tr>
<tr><td><b>real_avg_readiness_pct</b></td><td>Средняя готовность строительства проектов в районе. От 0% до 100%.<br><span class="hint">Старая Сортировка: 76% (большинство проектов на финальной стадии)</span></td></tr>
<tr><td><b>weighted_price_m2 (corp_sum)</b></td><td>Старая метрика из Сводные/Корпуса (взвешена по объёму проданных м²). Хуже real_median_price_m2 — оставлена для cross-check.</td></tr>
</tbody>
</table>
</details>
<details open style="margin-bottom:8px"><summary style="cursor:pointer;font-weight:700;color:#0f172a;font-size:13px;padding:6px 0">🏗 Market компонент (конкурентный)</summary>
<table class="lot-table" style="font-size:11px">
<tbody>
<tr><td style="width:160px"><b>market</b></td><td>Рыночная оценка позиции в окружении. <code>0.5 × density + 0.5 × saturation</code>.<br><span class="hint">Парцель: 88.8 (мало конкурентов в 1 км, район proven)</span></td></tr>
<tr><td><b>jk_count_1km</b></td><td>Число других строящихся ЖК в радиусе 1 км от точки.<br><span class="hint">Парцель: 3 (мало конкурентов рядом — плюс)</span></td></tr>
<tr><td><b>density_score</b></td><td><code>max(0, 100 jk_count_1km × 100/15)</code>. Чем меньше конкурентов — тем выше.<br><span class="hint">Парцель: max(0, 100 3×100/15) = 80</span></td></tr>
<tr><td><b>saturation_score</b></td><td><code>min(100, real_sold_pct × 100/70)</code>. Высокий sold% подтверждает спрос, не штраф.<br>Плато на 70%+ — район proven, дальше плюсы не растут.<br><span class="hint">Старая Сортировка sold 48.8% → 69.7</span></td></tr>
</tbody>
</table>
</details>
<details style="margin-bottom:8px"><summary style="cursor:pointer;font-weight:700;color:#0f172a;font-size:13px;padding:6px 0">📍 География и районы</summary>
<table class="lot-table" style="font-size:11px">
<tbody>
<tr><td style="width:160px"><b>admin_district</b></td><td>Один из 8 админ-районов Екб (Верх-Исетский, Чкаловский, …). PostGIS ST_Contains по полигонам из <code>ekb_districts_geom</code>.<br><span class="hint">Парцель: Железнодорожный</span></td></tr>
<tr><td><b>obj_district</b></td><td>Микрорайон Объектива (Старая Сортировка, Академический, …). 31 район.<br>Назначается через <b>kNN-voting</b>: 7 ближайших matched-ЖК голосуют, выигрывает большинство.<br><span class="hint">Парцель: Старая Сортировка</span></td></tr>
<tr><td><b>district_method</b></td><td>Способ assignment: <code>name_match</code> (если ЖК сматчен с Объективом) / <code>knn_vote_5</code> (по соседям).</td></tr>
<tr><td><b>nearest_jk_dist_m</b></td><td>Дистанция до ближайшего matched-ЖК (для knn-методов).</td></tr>
<tr><td><b>jk_objective_match</b></td><td>Связь между обр.домрф obj_id и Объектив project (по имени ЖК).<br>Match score: SequenceMatcher ratio ≥ 0.80 после нормализации.<br><span class="hint">Покрытие: 229 из 380 ЖК (60%)</span></td></tr>
</tbody>
</table>
</details>
<details style="margin-bottom:8px"><summary style="cursor:pointer;font-weight:700;color:#0f172a;font-size:13px;padding:6px 0">🚶 Walkability (отдельный показатель)</summary>
<table class="lot-table" style="font-size:11px">
<tbody>
<tr><td style="width:160px"><b>walkability</b></td><td>Индекс «дойти пешком до всего нужного» 0..100. Идея от Walk Score (walkscore.com).<br>В основной балл не входит, показывается рядом.<br><span class="hint">Парцель: 44/100 — средне</span></td></tr>
<tr><td><b>decay function</b></td><td><code>1 / (1 + distance/200)</code>. На 0 м → 1.0, на 200 м → 0.5, на 1000 м → 0.17.<br>Реалистично моделирует «дойти не лень».</td></tr>
<tr><td><b>walk_w (веса)</b></td><td>Daily-need POI получают больший вес: shop_med 1.5 / kindergarten 1.5 / school 1.5 / metro 1.5 / park 1.2 / pharmacy 1.0 / clinic 0.8 / cafe 0.6 / shop_big 0.6 / sports 0.5 / atm 0.4 / bank 0.3 / restaurant 0.4 / playground 1.0 / library 0.3 / post 0.4 / bus 0.8 / tram 1.0.</td></tr>
<tr><td><b>radius</b></td><td>1500 м (15 мин ходьбы). Top-10 точек каждой категории.</td></tr>
<tr><td><b>normalization</b></td><td><code>walkability = min(100, walk_score_raw × 100/60)</code>. 60 = «отличное» по экспериментальной калибровке.</td></tr>
</tbody>
</table>
</details>
<details style="margin-bottom:8px"><summary style="cursor:pointer;font-weight:700;color:#0f172a;font-size:13px;padding:6px 0">☀ Insolation (открытость на юг)</summary>
<table class="lot-table" style="font-size:11px">
<tbody>
<tr><td style="width:160px"><b>insolation_score</b></td><td>Сколько солнца получит будущее ЖК. <code>max(0, 100 shadow_S × 10)</code>.<br><span class="hint">Парцель: 44.5 — средне (плотная застройка SW)</span></td></tr>
<tr><td><b>shadow_pressure_S</b></td><td>Сумма «давления» зданий с юга (bearing 135-225°): <code>Σ 1/(1 + d/50)</code>.<br>Чем ближе и больше зданий южнее — тем выше shadow_S.<br><span class="hint">Парцель: 5.55. 1 здание прямо на юге в 50 м даёт +0.5 единицы</span></td></tr>
<tr><td><b>octants</b></td><td>Распределение зданий в 500 м по 8 сторонам света (N/NE/E/SE/S/SW/W/NW).<br><span class="hint">Парцель: SW=21 (самая плотная сторона), N=13, S=14</span></td></tr>
<tr><td><b>n_buildings_500m</b></td><td>Всего зданий в радиусе 500 м. Источник: 61 240 OSM building centroids.<br><span class="hint">Парцель: 121</span></td></tr>
</tbody>
</table>
</details>
<details style="margin-bottom:8px"><summary style="cursor:pointer;font-weight:700;color:#0f172a;font-size:13px;padding:6px 0">⚠ Risk-score</summary>
<table class="lot-table" style="font-size:11px">
<tbody>
<tr><td style="width:160px"><b>risk_score</b></td><td><code>max(0, 100 Σ penalties)</code>. Чем выше — тем меньше шум/опасность рядом.<br><span class="hint">Парцель: 31.5/100 (магистраль 44 м, ж/д 160 м — критично)</span></td></tr>
<tr><td><b>penalty_pct</b></td><td>Штраф по конкретному типу: <code>weight × 100 × (max_m d) / (max_m ideal_m)</code> с clamp 0..weight×100.</td></tr>
<tr><td><b>railway</b></td><td>Линия ж/д. weight=0.30, ideal=200 м, max=800 м. Самый сильный штраф.</td></tr>
<tr><td><b>highway</b></td><td>motorway / trunk / primary / secondary. weight=0.30, ideal=80 м, max=400 м.</td></tr>
<tr><td><b>industry</b></td><td>Промзоны (зарезервировано, реализация в planned).</td></tr>
<tr><td><b>fuel</b></td><td>АЗС близко (<80 м) небольшой штраф. weight=0.05.</td></tr>
<tr><td><b>fire</b></td><td>Пожарка ДАЛЕКО (>2 км) = плохо. weight=0.10. Inverted score (близко = хорошо).</td></tr>
</tbody>
</table>
</details>
<details style="margin-bottom:8px"><summary style="cursor:pointer;font-weight:700;color:#0f172a;font-size:13px;padding:6px 0">📐 ТЭП-калькулятор и финмодель</summary>
<table class="lot-table" style="font-size:11px">
<tbody>
<tr><td style="width:160px"><b>parcel_area_m2</b></td><td>Площадь земельного участка из Росреестра.<br><span class="hint">Парцель: 2 788.7 м² (0.28 га)</span></td></tr>
<tr><td><b>FAR (Floor Area Ratio)</b></td><td>Плотность застройки = total_GBA / parcel_area. Регулируется ПЗЗ (правилами землепользования).<br><span class="hint">Типично 1.5-3.5 для жилья. Дефолт = 2.0</span></td></tr>
<tr><td><b>GBA (Gross Building Area)</b></td><td>Общая надземная площадь корпусов. <code>parcel × FAR</code>.<br><span class="hint">2789 × 2.0 = 5578 м²</span></td></tr>
<tr><td><b>saleable_share</b></td><td>Доля квартир в GBA (остальное — coridory, lobby, technical). Типично 0.55-0.75.<br><span class="hint">Дефолт = 0.65</span></td></tr>
<tr><td><b>saleable_m2</b></td><td><code>GBA × saleable_share</code>. Это то что реально продаётся.<br><span class="hint">5578 × 0.65 = 3625 м²</span></td></tr>
<tr><td><b>n_units</b></td><td><code>round(saleable_m2 / avg_unit_area_m2)</code>. Сколько квартир.<br><span class="hint">3625 / 48 ≈ 76 квартир</span></td></tr>
<tr><td><b>revenue</b></td><td><code>saleable_m2 × price_per_m2</code>.<br><span class="hint">3625 × 114 900 ₽ = 416.4 млн ₽</span></td></tr>
<tr><td><b>construction_cost_per_m2</b></td><td>Себестоимость стройки за 1 м² GBA. Вкл. бетон, отделку, сети.<br><span class="hint">Дефолт 75 000 ₽/м² (комфорт-класс монолит)<br>панель ~50 000, премиум монолит ~150 000</span></td></tr>
<tr><td><b>construction</b></td><td><code>GBA × construction_cost_per_m2</code>.<br><span class="hint">5578 × 75 000 = 418.3 млн ₽</span></td></tr>
<tr><td><b>soft_cost_pct</b></td><td>Доля проектных + маркетинг + landlord. Типично 8-15% от выручки.<br><span class="hint">Дефолт 10%</span></td></tr>
<tr><td><b>soft_cost</b></td><td><code>revenue × soft_cost_pct</code>.<br><span class="hint">416.4 × 0.10 = 41.6 млн ₽</span></td></tr>
<tr><td><b>profit</b></td><td><code>revenue construction soft_cost</code>.<br><span class="hint">416.4 418.3 41.6 = 43.5 млн ₽ (убыток)</span></td></tr>
<tr><td><b>profit_margin_pct</b></td><td><code>profit / revenue × 100</code>. Норма 12-18%.<br><span class="hint">Парцель: 10.4% (нерентабельно)</span></td></tr>
<tr><td><b>NPV (Net Present Value)</b></td><td>Дисконтированная сумма будущих cashflows. Положительный = проект окупается с учётом стоимости денег.<br><code>NPV = 0.20×construction + Σ_months (rev_i cost_i) / (1+r_m)^month</code><br><span class="hint">0.20 — 20% upfront stroyka. r_m = (1+R_yearly)^(1/12)1</span></td></tr>
<tr><td><b>IRR (Internal Rate of Return)</b></td><td>Доходность при которой NPV = 0. <b>Бисекция</b> между 0..500% годовых.<br>Если &lt;15% — слабо, &lt;25% — средне, &gt;25% — хорошо.<br><span class="hint">Парцель: IRR не сходится (NPV всегда отрицателен)</span></td></tr>
<tr><td><b>discount_rate</b></td><td>Годовая стоимость капитала (для дисконтирования). Дефолт 18%.<br><span class="hint">Соответствует целевому ROIC застройщика (выше ставки ЦБ + спред риска)</span></td></tr>
<tr><td><b>cashflow_months</b></td><td><code>max(months_to_complete, n_units / velocity)</code>. Сколько месяцев денежные потоки.</td></tr>
</tbody>
</table>
</details>
<details style="margin-bottom:8px"><summary style="cursor:pointer;font-weight:700;color:#0f172a;font-size:13px;padding:6px 0">📊 ROI-ranking по районам</summary>
<table class="lot-table" style="font-size:11px">
<tbody>
<tr><td style="width:160px"><b>roi_annual_pct</b></td><td>Грубая годовая отдача на инвестицию.<br><code>(profit / construction) × 12 / months_to_sellout × 100</code><br><span class="hint">Топ для комфорт-класса: Сибирский 13.2%, Центр 10%</span></td></tr>
<tr><td><b>price_kr_class</b></td><td>Цена м² для класса в данном районе. <code>baseline × (district_price / 130)</code>.<br><span class="hint">Например, комфорт baseline 130 тыс. В Старой Сорт. районе цена ниже на 12% → 114.9</span></td></tr>
</tbody>
</table>
</details>
<details style="margin-bottom:8px"><summary style="cursor:pointer;font-weight:700;color:#0f172a;font-size:13px;padding:6px 0">🏛 Кадастр и геометрия</summary>
<table class="lot-table" style="font-size:11px">
<tbody>
<tr><td style="width:160px"><b>cad / cad_num</b></td><td>Кадастровый номер. Формат <code>RR:DD:KKKKKKK:NN</code>.<br>RR=регион (66=Свердл), DD=район (41=Екб), KKKKKKK=кадастровый квартал, NN=участок в квартале.<br><span class="hint">Пример: 66:41:0204016:10</span></td></tr>
<tr><td><b>parcel polygon</b></td><td>GeoJSON Polygon из Росреестра через <code>rosreestr2coord</code> (обходит NSPD WAF).<br><span class="hint">Пример: 17 vertices в WGS84</span></td></tr>
<tr><td><b>cost_value</b></td><td>Кадастровая стоимость участка (от Росреестра).<br><span class="hint">Парцель: 23 706 714 ₽</span></td></tr>
<tr><td><b>cost_index</b></td><td>Уд. кад. стоимость = cost / area.<br><span class="hint">Парцель: 8 497 ₽/м²</span></td></tr>
<tr><td><b>cad_quarter</b></td><td>Кадастровый квартал (сегмент 3). Используется для spatial-join с ДДУ из Росреестра.</td></tr>
<tr><td><b>building footprint</b></td><td>Полигон здания. У нас 1 394 OSM-building footprints для 304 ЖК + 76 fallback rings 45 м для остальных.</td></tr>
</tbody>
</table>
</details>
<details style="margin-bottom:8px"><summary style="cursor:pointer;font-weight:700;color:#0f172a;font-size:13px;padding:6px 0">🌐 POI-категории</summary>
<table class="lot-table" style="font-size:11px">
<tbody>
<tr><td colspan="2" style="background:#f1f5f9;font-weight:700">🎓 Образование (4)</td></tr>
<tr><td>kindergarten</td><td>Детский сад. OSM <code>amenity=kindergarten</code></td></tr>
<tr><td>childcare</td><td>Ясли / частные детсады. <code>amenity=childcare</code></td></tr>
<tr><td>school</td><td>Школа. <code>amenity=school</code></td></tr>
<tr><td>university</td><td>ВУЗ или колледж. <code>amenity=university|college</code></td></tr>
<tr><td colspan="2" style="background:#f1f5f9;font-weight:700">⚕ Здоровье (5)</td></tr>
<tr><td>pharmacy</td><td>Аптека. <code>amenity=pharmacy</code></td></tr>
<tr><td>clinic</td><td>Поликлиника / врач-специалист. <code>amenity=clinic|doctors</code></td></tr>
<tr><td>hospital</td><td>Больница. <code>amenity=hospital</code></td></tr>
<tr><td>dentist</td><td>Стоматология. <code>amenity=dentist</code></td></tr>
<tr><td>vet</td><td>Ветеринар. <code>amenity=veterinary</code></td></tr>
<tr><td colspan="2" style="background:#f1f5f9;font-weight:700">🚇 Транспорт (5)</td></tr>
<tr><td>bus_stop</td><td>Автобусная остановка. <code>highway=bus_stop</code></td></tr>
<tr><td>tram_stop</td><td>Трамвайная. <code>railway=tram_stop</code></td></tr>
<tr><td>metro</td><td>Метро. <code>station=subway</code> (в Екб строится 2-я линия)</td></tr>
<tr><td>parking</td><td>Парковка (любая, OSM). 4246 точек в bbox.</td></tr>
<tr><td>car_rental</td><td>Прокат / такси-стоянка. <code>amenity=car_rental|taxi</code></td></tr>
<tr><td colspan="2" style="background:#f1f5f9;font-weight:700">🛒 Магазины (4)</td></tr>
<tr><td>shop_big</td><td>Крупные. <code>shop=mall|supermarket|department_store|hypermarket</code></td></tr>
<tr><td>shop_med</td><td>Средние. <code>shop=convenience|grocery|bakery</code></td></tr>
<tr><td>shop_small</td><td>Малые / киоски. <code>shop=kiosk|newsagent</code></td></tr>
<tr><td>marketplace</td><td>Рынок. <code>amenity=marketplace</code></td></tr>
<tr><td colspan="2" style="background:#f1f5f9;font-weight:700">☕ Еда / отдых (3)</td></tr>
<tr><td>cafe</td><td>Кафе. <code>amenity=cafe</code></td></tr>
<tr><td>restaurant</td><td>Ресторан / fast food. <code>amenity=restaurant|fast_food</code></td></tr>
<tr><td>nightclub</td><td>Бар / паб / клуб. <code>amenity=nightclub|bar|pub</code></td></tr>
<tr><td colspan="2" style="background:#f1f5f9;font-weight:700">💰 Деньги / Сервис (8)</td></tr>
<tr><td>atm</td><td>Банкомат. <code>amenity=atm</code></td></tr>
<tr><td>bank</td><td>Отделение банка. <code>amenity=bank</code></td></tr>
<tr><td>post</td><td>Почта. <code>amenity=post_office</code></td></tr>
<tr><td>courier</td><td>Постамат / почтовый ящик. <code>amenity=parcel_locker|post_box</code></td></tr>
<tr><td>library</td><td>Библиотека. <code>amenity=library</code></td></tr>
<tr><td>worship</td><td>Храм / место поклонения. <code>amenity=place_of_worship</code></td></tr>
<tr><td>fuel</td><td>АЗС. <code>amenity=fuel</code></td></tr>
<tr><td>car_wash</td><td>Автомойка. <code>amenity=car_wash</code></td></tr>
<tr><td>hotel</td><td>Отель / хостел. <code>tourism=hotel|hostel|apartment</code></td></tr>
<tr><td colspan="2" style="background:#f1f5f9;font-weight:700">🛡 Безопасность (2)</td></tr>
<tr><td>police</td><td>Полиция. <code>amenity=police</code></td></tr>
<tr><td>fire</td><td>Пожарная часть. <code>amenity=fire_station</code></td></tr>
<tr><td colspan="2" style="background:#f1f5f9;font-weight:700">🌳 Досуг / культура (7)</td></tr>
<tr><td>park</td><td>Парк / сад. <code>leisure=park|garden</code></td></tr>
<tr><td>playground</td><td>Детская площадка. <code>leisure=playground</code></td></tr>
<tr><td>sports</td><td>Спорткомплекс / поле. <code>leisure=sports_centre|fitness_centre|pitch</code></td></tr>
<tr><td>gym</td><td>Фитнес. <code>leisure=fitness_centre</code></td></tr>
<tr><td>theater</td><td>Театр. <code>amenity=theatre</code></td></tr>
<tr><td>cinema</td><td>Кинотеатр. <code>amenity=cinema</code></td></tr>
<tr><td>museum</td><td>Музей. <code>tourism=museum</code></td></tr>
</tbody>
</table>
</details>
<details style="margin-bottom:8px"><summary style="cursor:pointer;font-weight:700;color:#0f172a;font-size:13px;padding:6px 0">🔬 Источники данных</summary>
<table class="lot-table" style="font-size:11px">
<tbody>
<tr><td style="width:160px"><b>OSM (Overpass)</b></td><td>POI 39 категорий + building footprints. Cache: 17 627 POI / 61 240 buildings.<br>Pulled через 12_more_pois.py с overpass-api.de</td></tr>
<tr><td><b>DOM.РФ kn-API</b></td><td>4 548 ЖК РФ (наш.дом.рф). Через прод PostgreSQL: <code>domrf_kn_objects</code> · 380 строящихся в Екб.<br>Поля: obj_id, comm_name, addr, latitude, longitude, dev_name, obj_class</td></tr>
<tr><td><b>Объектив API</b></td><td>Платная аналитика недвижимости РФ. Per-flat данные.<br>4 отчёта × ReportType×Name. 2 работают, 2 = HTTP 500 (тариф).<br>303 677 квартир Екб + 19 738 corp_sum строк</td></tr>
<tr><td><b>ЦБ РФ</b></td><td>Средневзв. ставка ипотеки по региону. <code>cbr_mortgage_series</code> в проде.<br><span class="hint">Свердл, Янв 2026 = 7.84%</span></td></tr>
<tr><td><b>Росреестр</b></td><td>Через <code>rosreestr2coord</code> Python lib. Геометрия + кадастровая стоимость.<br>Обходит блокировку NSPD WAF (которая блокирует direct API).</td></tr>
<tr><td><b>NSPD AEGGIS</b></td><td>Полигоны 8 админ-районов Екб (через прод PostgreSQL <code>ekb_districts_geom</code>).<br>Был получен раньше через ESIA-доступ + Playwright.</td></tr>
<tr><td><b>Nominatim</b></td><td>Обратное геокодирование (lat/lon → адрес). Open-source OSM.<br><span class="hint">Кешируется в cache/geocode_cache.json</span></td></tr>
</tbody>
</table>
</details>
<details style="margin-bottom:8px"><summary style="cursor:pointer;font-weight:700;color:#0f172a;font-size:13px;padding:6px 0">⚙ UI и интерактив</summary>
<table class="lot-table" style="font-size:11px">
<tbody>
<tr><td style="width:160px"><b>buffer rings</b></td><td>Концентрические круги 300/500/1000 м и 15-мин ходьба ≈1.2 км вокруг участка. Показывают «что входит в радиус».</td></tr>
<tr><td><b>cluster (ЖК)</b></td><td>При z &lt; 13 близкие ЖК группируются в badge с числом. Цвет — средний балл группы.</td></tr>
<tr><td><b>POI cluster</b></td><td>При z &lt; 16 близкие POI разных категорий в одной точке объединяются в один значок с числом.</td></tr>
<tr><td><b>what-if POI</b></td><td>Гипотетический POI добавляется на карту через клик. Score пересчитывается с этим POI как будто он есть.<br>Используется для «а что если построить рядом метро?»</td></tr>
<tr><td><b>scenario</b></td><td>Именованный сохранённый state {parcel, audience, what_if, remove_cats}. localStorage.</td></tr>
<tr><td><b>saved view</b></td><td>Сохранённый набор {filter_class, filter_district, score_min, audience, basemap}. Для повторного применения.</td></tr>
<tr><td><b>pinned parcels</b></td><td>До N участков, закреплённых на карте через 📍+. Side-by-side compare.</td></tr>
<tr><td><b>A/B compare</b></td><td>До 3 ЖК vs участка. Радар-чарт + таблица 7 компонент.</td></tr>
<tr><td><b>leaderboard</b></td><td>Топ-30 ЖК Екб ранжированы по audience-весам. Live re-rank при смене профиля.</td></tr>
<tr><td><b>smart suggestions</b></td><td>До 5 ЖК рядом (≤3 км) с баллом ВЫШЕ участка. «Альтернативы».</td></tr>
<tr><td><b>time-machine</b></td><td>Series 12 мес по району: deals/median_price/volume/active_corpuses. Из per-flat данных.</td></tr>
<tr><td><b>POI-vs-district</b></td><td>Δ% по 12 категориям относительно среднего по району. «Сильные/слабые стороны» ЖК.</td></tr>
<tr><td><b>service worker</b></td><td>Кэш тайлов (cache-first stale-while-revalidate). Survives sessions.</td></tr>
<tr><td><b>BroadcastChannel</b></td><td>Sync recent/scenarios между вкладками (одна сессия).</td></tr>
</tbody>
</table>
</details>
<details style="margin-bottom:8px"><summary style="cursor:pointer;font-weight:700;color:#0f172a;font-size:13px;padding:6px 0">🔢 Единицы измерения и условные обозначения</summary>
<table class="lot-table" style="font-size:11px">
<tbody>
<tr><td style="width:160px"><b>тыс ₽/м²</b></td><td>Цена м² жилья в тысячах рублей. Стандарт рынка РФ.<br><span class="hint">Например 130 тыс/м² = 130 000 ₽/м² = 6.5 млн за 50 м² квартиру</span></td></tr>
<tr><td><b>ДДУ</b></td><td>Договор Долевого Участия. Юр. форма продажи строящегося жилья по 214-ФЗ.</td></tr>
<tr><td><b>ДКП</b></td><td>Договор Купли-Продажи. Готовые квартиры (после ввода).</td></tr>
<tr><td><b>ЖК</b></td><td>Жилой Комплекс. Объект в DOM.РФ обозначается obj_id.</td></tr>
<tr><td><b>обр. кв. кадастра</b></td><td>Сегмент 7 цифр в кадастровом номере. <code>RR:DD:<u>KKKKKKK</u>:NN</code></td></tr>
<tr><td><b>velocity</b></td><td>ДДУ/корпус/мес. Скорость продаж нормированная на корпус.<br><span class="hint">2.0 — средне, 5+ — горячий рынок, &lt;1 — застой</span></td></tr>
<tr><td><b>sellout</b></td><td>Полная распродажа стока. <code>months_to_sellout</code> = когда придёт.</td></tr>
<tr><td><b>upside</b></td><td>Потенциал роста. В контексте — насколько ROI ЖК выше benchmark.</td></tr>
</tbody>
</table>
</details>
<h3 style="font-size:14px;margin:18px 0 8px;font-weight:700">📖 Worked example: парцель 66:41:0204016:10 family-профиль</h3>
<div style="background:#f8fafc;padding:12px 14px;border-radius:8px;font-family:'SF Mono','Menlo',monospace;font-size:11px;line-height:1.7">
<span style="color:#64748b"># Распаковка балла 75.83</span><br>
education = 79.5 × 0.30 = 23.85<br>
health = 88.7 × 0.12 = 10.64<br>
retail = 100.0 × 0.10 = 10.00<br>
transit = 69.7 × 0.10 = 6.97<br>
leisure = 98.4 × 0.13 = 12.79<br>
economic = 39.2 × 0.20 = 7.84<br>
market = 88.8 × 0.05 = 4.44<br>
──────────────────────────<br>
<b>weighted_score = 76.53 ≈ 75.83</b> <span style="color:#64748b">(rounding в Python)</span><br><br>
<span style="color:#64748b"># 4 фактора влияют на score:</span><br>
• Слабые: economic 39 (низкая цена района) · transit 70 (нет метро)<br>
• Сильные: retail 100 (магазин в 124 м) · leisure 98 (площадка 163 м)<br>
• walkability 44 — средне (плохой пешеходный доступ к ВУЗам/больницам)<br>
• risk_score 31 — критично (магистраль 44 м, ж/д 160 м)
</div>
</div>
</div>
</div>
</div>
<!-- ROI ranking modal -->
<div class="modal-bg" id="roiModal">
<div class="modal" style="max-width:1100px;width:96%;padding:0;overflow:hidden">
<button class="close" onclick="document.getElementById('roiModal').classList.remove('show')" style="z-index:10">×</button>
<div id="roiContent" style="overflow:auto;max-height:90vh"></div>
</div>
</div>
<!-- Leaderboard modal -->
<div class="modal-bg" id="topModal">
<div class="modal" style="max-width:1080px;width:96%;padding:0;overflow:hidden">
<button class="close" onclick="document.getElementById('topModal').classList.remove('show')" style="z-index:10">×</button>
<div id="topContent" style="overflow:auto;max-height:90vh"></div>
</div>
</div>
<!-- A/B compare modal -->
<div class="modal-bg" id="abModal">
<div class="modal">
<button class="close" onclick="document.getElementById('abModal').classList.remove('show')">×</button>
<h2>A/B сравнение: участок vs выбранные ЖК</h2>
<div id="abContent"></div>
</div>
</div>
<script>
// =================== Map ===================
const BASEMAPS = {
cartovoyager:{url:'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png', attr:'© CartoDB · OSM', sub:['a','b','c','d'], maxZoom:20},
cartolight: {url:'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', attr:'© CartoDB', sub:['a','b','c','d'], maxZoom:20},
cartodark: {url:'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', attr:'© CartoDB', sub:['a','b','c','d'], maxZoom:20},
osm: {url:'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', attr:'© OSM', sub:['a','b','c'], maxZoom:19},
twogis: {url:'https://tile{s}.maps.2gis.com/tiles?x={x}&y={y}&z={z}&v=1.4&r=g', attr:'© 2ГИС', sub:['1','2','3','4'], maxZoom:18},
esri_sat: {url:'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attr:'© Esri', sub:[], maxZoom:19},
esri_topo: {url:'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}', attr:'© Esri Topo', sub:[], maxZoom:19},
yandex: {url:'https://core-renderer-tiles.maps.yandex.net/tiles?l=map&x={x}&y={y}&z={z}&lang=ru_RU&scale=1', attr:'© Яндекс (3395 — drift)', sub:[], maxZoom:18},
yandexsat: {url:'https://core-sat.maps.yandex.net/tiles?l=sat&x={x}&y={y}&z={z}&lang=ru_RU&scale=1', attr:'© Яндекс Спутник (drift)', sub:[], maxZoom:18},
};
const map = L.map('map', {zoomControl:true, preferCanvas:true,
attributionControl: false // strip Leaflet's UA-flag prefix; we add a clean control below
}).setView([56.84, 60.6], 11);
L.control.attribution({prefix: 'Leaflet'}).addTo(map);
let baseLayer = null;
// Always-on fallback below the chosen basemap.
// Carto-Light uses generous keepBuffer + no eviction so it stays put.
const FALLBACK_LAYER = L.tileLayer(
'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png',
{subdomains:['a','b','c','d'], maxZoom:20, opacity:1, attribution:'',
keepBuffer: 8, updateWhenZooming: false, updateWhenIdle: true}
);
// Tiny loading badge on the map
const loadingBadge = L.control({position: 'topleft'});
loadingBadge.onAdd = () => {
const el = L.DomUtil.create('div');
el.style.cssText = 'background:white;padding:5px 10px;border-radius:6px;box-shadow:0 2px 8px rgba(0,0,0,.12);font-size:11px;color:#475569;display:none;align-items:center;gap:6px;font-weight:500;margin-top:6px';
el.innerHTML = '<span style="display:inline-block;width:10px;height:10px;border:2px solid #e2e8f0;border-top-color:#0ea5e9;border-radius:50%;animation:spin 0.7s linear infinite"></span><span>загружаем плитки</span>';
el._show = () => el.style.display = 'inline-flex';
el._hide = () => el.style.display = 'none';
return el;
};
loadingBadge.addTo(map);
function setBasemap(key) {
if (baseLayer) map.removeLayer(baseLayer);
const cfg = BASEMAPS[key];
const opts = {
attribution: cfg.attr,
maxZoom: cfg.maxZoom || 19,
maxNativeZoom: cfg.maxZoom || 19,
errorTileUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAOXl5f///yH5BAEAAAEALAAAAAABAAEAAAICTAEAOw==',
crossOrigin: true,
// Big buffer so panning never hits empty edges; never evict invisible
keepBuffer: 8,
updateWhenZooming: false,
updateWhenIdle: true,
};
if (cfg.sub && cfg.sub.length) opts.subdomains = cfg.sub;
if (!map.hasLayer(FALLBACK_LAYER)) FALLBACK_LAYER.addTo(map);
baseLayer = L.tileLayer(cfg.url, opts).addTo(map);
baseLayer.bringToFront();
// Loading badge
const badge = loadingBadge.getContainer();
let pending = 0;
baseLayer.on('loading', () => { pending++; badge && badge._show(); });
baseLayer.on('load', () => { pending = 0; badge && badge._hide(); });
// Retry chain on tileerror
const retried = new Map();
baseLayer.on('tileerror', e => {
const t = e.tile; if (!t || !e.coords) return;
const k = `${e.coords.z}/${e.coords.x}/${e.coords.y}`;
const n = (retried.get(k) || 0) + 1;
if (n > 2) return;
retried.set(k, n);
setTimeout(() => { t.src = baseLayer.getTileUrl(e.coords); }, 400 + 500*n);
});
}
setBasemap('cartovoyager');
// Mini-map / overview
const miniMap = L.map('minimap', {
zoomControl:false, attributionControl:false, dragging:false, scrollWheelZoom:false,
doubleClickZoom:false, boxZoom:false, keyboard:false, tap:false,
}).setView([56.84, 60.6], 9);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png',
{subdomains:['a','b','c','d'], maxZoom:14}).addTo(miniMap);
const miniViewportRect = L.rectangle([[0,0],[0,0]], {color:'#ef4444', weight:2, fillOpacity:0.1}).addTo(miniMap);
function updateMiniMap() {
const b = map.getBounds();
miniViewportRect.setBounds([[b.getSouth(), b.getWest()],[b.getNorth(), b.getEast()]]);
miniMap.setView(map.getCenter(), Math.max(8, map.getZoom() - 4));
}
map.on('moveend zoomend', updateMiniMap);
setTimeout(updateMiniMap, 500);
document.getElementById('basemap').addEventListener('change', e => setBasemap(e.target.value));
const poiLayer = L.layerGroup().addTo(map);
const parcelLayer = L.layerGroup().addTo(map);
const polygonLayer = L.layerGroup().addTo(map);
const ringsLayer = L.layerGroup().addTo(map);
const RING_CFG = [
{id:'ring-300', m:300, color:'#10b981', label:'300м · 4мин'},
{id:'ring-500', m:500, color:'#0ea5e9', label:'500м · 6мин'},
{id:'ring-1000', m:1000, color:'#7c3aed', label:'1км · 12мин'},
{id:'ring-walk-15', m:1200, color:'#f59e0b', label:'15-мин ходьба'},
];
function drawBufferRings(lat, lon) {
ringsLayer.clearLayers();
if (lat == null || lon == null) return;
for (const cfg of RING_CFG) {
if (!document.getElementById(cfg.id)?.checked) continue;
const c = L.circle([lat, lon], {
radius: cfg.m, color: cfg.color, weight: 1.5, dashArray: '4 4',
fillColor: cfg.color, fillOpacity: 0.04, interactive: false,
});
c.bindTooltip(cfg.label, {sticky:true, direction:'top'});
ringsLayer.addLayer(c);
}
}
RING_CFG.forEach(cfg => {
const el = document.getElementById(cfg.id);
if (el) el.addEventListener('change', () => {
if (LAST_RESULT) drawBufferRings(LAST_RESULT.input.lat, LAST_RESULT.input.lon);
});
});
const jkLayer = L.layerGroup().addTo(map);
const jkPolyLayer = L.geoJSON(null, {
style: f => {
const w = f.properties.weighted ?? 50;
const c = w >= p75 ? '#10b981' : (w >= 50 ? '#0ea5e9' : '#94a3b8');
return {color: c, weight: 1.3, fillColor: c, fillOpacity: 0.6};
},
onEachFeature: (f, l) => {
const p = f.properties;
const tags = p.tags || {};
const buildingType = tags['building']!=='yes' ? tags['building'] : null;
l.bindTooltip(
`<b>${p.name||'—'}</b><br>` +
`${p.developer||''}${p.obj_class?' · '+p.obj_class:''}<br>` +
`Балл: <b>${p.weighted?.toFixed(1)||'—'}</b> · #${p.rank_overall||'?'}<br>` +
`<small>${tags['addr:street']||''} ${tags['addr:housenumber']||''}${buildingType?' · '+buildingType:''}${tags['building:levels']?' · '+tags['building:levels']+' эт':''}</small>`,
{sticky:true, direction:'top'}
);
l.on('click', () => p.site_id && openJK(p.site_id));
}
});
const heatmapLayer = L.geoJSON(null, {
style: f => {
const v = f.properties.value;
const col = colorForHeat(v, currentHeatmapBounds);
return {color: '#0f172a', weight: 1.2, fillColor: col, fillOpacity: 0.55};
},
onEachFeature: (f, l) => l.bindTooltip(
`<b>${f.properties.name}</b><br>` +
`${heatmapLabel()}: <b>${f.properties.value?.toFixed?.(1)||'—'}</b><br>` +
`ЖК: ${f.properties.jk_count||0} · avg score: ${f.properties.avg_score?.toFixed?.(1)||'—'}`,
{sticky:true, direction:'top'}
),
});
let pickedMarker = null;
let currentHeatmapBounds = {min: 0, max: 100};
function heatmapLabel() {
const m = document.getElementById('heatmap-metric').value;
return ({score:'avg score',price:'цена м² (тыс)',velocity:'velocity',sold_pct:'sold %',jk_count:'ЖК'})[m] || m;
}
function colorForHeat(v, bounds) {
if (v == null) return '#475569';
const t = Math.max(0, Math.min(1, (v - bounds.min) / (bounds.max - bounds.min || 1)));
// gradient: red(low) → yellow → green(high)
const r = t < 0.5 ? 239 : Math.round(239 - (239 - 16) * (t - 0.5) * 2);
const g = t < 0.5 ? Math.round(68 + (200 - 68) * t * 2) : Math.round(200 - (200 - 185) * (t - 0.5) * 2);
const b = t < 0.5 ? 68 : Math.round(68 + (129 - 68) * (t - 0.5) * 2);
return `rgb(${r},${g},${b})`;
}
document.getElementById('layer-jk').addEventListener('change', e => e.target.checked ? jkLayer.addTo(map) : map.removeLayer(jkLayer));
document.getElementById('layer-poi').addEventListener('change', e => e.target.checked ? poiLayer.addTo(map) : map.removeLayer(poiLayer));
let _jkPolyLoaded = false;
const jkCirclesLayer = L.layerGroup(); // fallback rings for unmatched ЖК
document.getElementById('layer-jk-poly').addEventListener('change', async e => {
if (e.target.checked) {
if (!_jkPolyLoaded) {
const fc = await fetch('/api/jk-polygons').then(r=>r.json());
jkPolyLayer.clearLayers();
jkPolyLayer.addData(fc);
// Fallback ~50m circles for ЖК without polygon match
const matched = new Set(fc.features.map(f => f.properties.site_id));
jkCirclesLayer.clearLayers();
for (const s of SITES) {
if (s.kind !== 'jk' || matched.has(s.site_id)) continue;
const w = s.weighted ?? 0;
const c = w >= p75 ? '#10b981' : w >= 50 ? '#0ea5e9' : '#94a3b8';
const ring = L.circle([s.lat, s.lon], {
radius: 45, color: c, weight: 1.2, dashArray: '4 3',
fillColor: c, fillOpacity: 0.18
});
ring.bindTooltip(`<b>${s.name||'—'}</b><br>${s.developer||''}${s.obj_class?' · '+s.obj_class:''}<br>Балл: <b>${w.toFixed(1)}</b> · #${s.rank_overall||'?'}<br><small>контур не известен — placeholder 45м</small>`, {sticky:true, direction:'top'});
ring.on('click', () => openJK(s.site_id));
jkCirclesLayer.addLayer(ring);
}
_jkPolyLoaded = true;
}
jkPolyLayer.addTo(map);
jkCirclesLayer.addTo(map);
// Hide point markers when polygons shown — avoids dup
map.removeLayer(jkLayer);
document.getElementById('layer-jk').checked = false;
} else {
map.removeLayer(jkPolyLayer);
map.removeLayer(jkCirclesLayer);
}
});
document.getElementById('layer-heatmap').addEventListener('change', e => {
const sel = document.getElementById('heatmap-metric');
sel.style.display = e.target.checked ? '' : 'none';
if (e.target.checked) reloadHeatmap(); else map.removeLayer(heatmapLayer);
});
document.getElementById('heatmap-metric').addEventListener('change', reloadHeatmap);
// POI density heat layer
let poiDensityLayer = null;
async function reloadPoiDensity() {
if (poiDensityLayer) { map.removeLayer(poiDensityLayer); poiDensityLayer = null; }
const cat = document.getElementById('poi-density-cat').value;
const data = await fetch('/api/poi-points?category=' + encodeURIComponent(cat)).then(r=>r.json());
poiDensityLayer = L.heatLayer(data.points, {radius: 22, blur: 16, maxZoom: 17, max: 1.0,
gradient: {0.2:'#0ea5e9', 0.4:'#10b981', 0.6:'#f59e0b', 0.8:'#ef4444'}});
poiDensityLayer.addTo(map);
}
document.getElementById('layer-poi-density').addEventListener('change', e => {
const sel = document.getElementById('poi-density-cat');
sel.style.display = e.target.checked ? '' : 'none';
if (e.target.checked) reloadPoiDensity();
else if (poiDensityLayer) { map.removeLayer(poiDensityLayer); poiDensityLayer = null; }
});
document.getElementById('poi-density-cat').addEventListener('change', () => {
if (document.getElementById('layer-poi-density').checked) reloadPoiDensity();
});
async function reloadHeatmap() {
const metric = document.getElementById('heatmap-metric').value;
const fc = await fetch('/api/district-heatmap?metric=' + metric).then(r=>r.json());
const vals = fc.features.map(f=>f.properties.value).filter(v=>v!=null);
currentHeatmapBounds = {min: Math.min(...vals), max: Math.max(...vals)};
heatmapLayer.clearLayers();
heatmapLayer.addData(fc);
heatmapLayer.addTo(map);
}
// =================== Constants ===================
// POI colors per category
const POI_COLOR = {
// Education (green)
kindergarten:'#22c55e', school:'#16a34a', university:'#15803d',
// Health (blue)
pharmacy:'#0ea5e9', clinic:'#0284c7', hospital:'#0369a1',
// Retail (amber)
shop_big:'#f59e0b', shop_med:'#fbbf24', shop_small:'#fcd34d',
// Transit (purple)
bus_stop:'#a855f7', tram_stop:'#9333ea', metro:'#7e22ce',
// Leisure (lime)
park:'#84cc16', playground:'#a3e635', sports:'#65a30d',
// Food/Service (rose)
cafe:'#f43f5e', restaurant:'#e11d48',
// Money (teal)
atm:'#14b8a6', bank:'#0d9488',
// Civic (slate)
post:'#475569', worship:'#7c3aed', library:'#0f766e',
police:'#1e40af', fire:'#dc2626',
// Auto (zinc)
fuel:'#525252', parking:'#71717a', car_wash:'#a1a1aa', car_rental:'#52525b',
// Lifestyle (pink)
gym:'#ec4899', theater:'#db2777', cinema:'#be185d', museum:'#9d174d',
hotel:'#831843', nightclub:'#500724',
// Other
marketplace:'#dc2626', vet:'#22c55e', courier:'#0284c7',
dentist:'#06b6d4', childcare:'#10b981',
};
// Lucide-style minimal SVG icons (24x24 viewbox) per category
const POI_SVG = {
// Education — graduation cap
kindergarten: '<path d="M12 3 1 9l11 6 9-4.91V17" stroke-linecap="round" stroke-linejoin="round"/><path d="M5 12.5V18a7 4 0 0 0 14 0v-5.5"/>',
school: '<path d="M12 3 1 9l11 6 9-4.91V17" stroke-linecap="round" stroke-linejoin="round"/><path d="M5 12.5V18a7 4 0 0 0 14 0v-5.5"/>',
university: '<path d="M12 3 1 9l11 6 9-4.91V17" stroke-linecap="round" stroke-linejoin="round"/><path d="M5 12.5V18a7 4 0 0 0 14 0v-5.5"/>',
// Health — plus
pharmacy: '<rect x="9" y="3" width="6" height="18" rx="1"/><rect x="3" y="9" width="18" height="6" rx="1"/>',
clinic: '<rect x="9" y="3" width="6" height="18" rx="1"/><rect x="3" y="9" width="18" height="6" rx="1"/>',
hospital: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 8v8M15 8v8M9 12h6"/>',
// Retail — shopping bag
shop_big: '<path d="M3 8h18l-1 12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2L3 8Z"/><path d="M8 8V6a4 4 0 0 1 8 0v2"/>',
shop_med: '<path d="M3 8h18l-1 12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2L3 8Z"/><path d="M8 8V6a4 4 0 0 1 8 0v2"/>',
shop_small: '<path d="M3 8h18l-1 12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2L3 8Z"/><path d="M8 8V6a4 4 0 0 1 8 0v2"/>',
// Transit — bus / tram / metro
bus_stop: '<path d="M5 17V7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v10"/><path d="M5 17h14M7 12h10M3 21l2-2M21 21l-2-2"/><circle cx="9" cy="17" r="1.5"/><circle cx="15" cy="17" r="1.5"/>',
tram_stop: '<rect x="5" y="3" width="14" height="14" rx="3"/><path d="M9 17l-2 4M15 17l2 4M8 7h8M9 11h6"/><circle cx="9" cy="13" r="1"/><circle cx="15" cy="13" r="1"/>',
metro: '<path d="M3 21h18M5 21V11l5-7 5 7v10M9 21V14h6v7"/>',
// Leisure — tree / park / sports
park: '<path d="M12 3v18M8 7l4-4 4 4M6 12l6-6 6 6M5 17l7-7 7 7"/>',
playground: '<circle cx="12" cy="6" r="2"/><path d="M12 8v6m-3-2 3-2 3 2M5 22l4-8M19 22l-4-8"/>',
sports: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>',
// Food
cafe: '<path d="M3 8h14a3 3 0 0 1 0 6H3z"/><path d="M3 14v2a3 3 0 0 0 3 3h8a3 3 0 0 0 3-3v-2"/><path d="M6 5v-2M10 5v-2M14 5v-2"/>',
restaurant: '<path d="M7 3v18M5 3v6a2 2 0 0 0 4 0V3M17 3v18M15 3c-1 4-1 7 2 7v11"/>',
// Money
atm: '<rect x="3" y="6" width="18" height="12" rx="2"/><circle cx="12" cy="12" r="2"/><path d="M6 9v.01M18 9v.01M6 15v.01M18 15v.01"/>',
bank: '<path d="M3 21h18M3 9h18M5 21V9M19 21V9M9 21v-9M15 21v-9M3 9l9-6 9 6"/>',
// Civic
post: '<rect x="3" y="6" width="18" height="14" rx="2"/><path d="M3 8l9 6 9-6"/>',
worship: '<path d="M12 2v20M8 6h8M5 10l7-2 7 2v12H5z"/>',
library: '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20V5a2 2 0 0 0-2-2H6.5A2.5 2.5 0 0 0 4 5.5z"/><path d="M9 6h6"/>',
police: '<path d="M12 2 4 5v6c0 5 3 9 8 11 5-2 8-6 8-11V5z"/><path d="M9 12l2 2 4-4"/>',
fire: '<path d="M12 22a7 7 0 0 1-7-7c0-3 2-6 4-7-1 2 0 4 2 5 1-3 0-7 4-9 0 4 4 6 4 11a7 7 0 0 1-7 7z"/>',
fuel: '<rect x="3" y="3" width="11" height="18" rx="1"/><path d="M14 7h3a2 2 0 0 1 2 2v8M19 7v3a2 2 0 0 0 2-2v-1"/>',
// Auto
parking: '<rect x="4" y="4" width="16" height="16" rx="2"/><text x="12" y="16" font-size="11" font-weight="bold" text-anchor="middle" fill="white">P</text>',
car_wash: '<path d="M5 16h14l-1-7H6z"/><circle cx="8" cy="18" r="2"/><circle cx="16" cy="18" r="2"/><path d="M3 4l2 3M21 4l-2 3M12 3v4"/>',
car_rental: '<path d="M3 16h18v-5l-2-5H5l-2 5z"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/>',
// Lifestyle
gym: '<path d="M5 9v6M19 9v6M3 7v10M21 7v10M9 7l6 0M9 17l6 0M9 12l6 0"/>',
theater: '<path d="M3 10c0-3 2-5 5-5h8c3 0 5 2 5 5v2a8 8 0 0 1-9 8 8 8 0 0 1-9-8z"/><path d="M9 11v.01M15 11v.01M9 14a3 3 0 0 0 6 0"/>',
cinema: '<rect x="3" y="6" width="18" height="12" rx="2"/><path d="M3 9h18M3 15h18M7 9v6M11 9v6M15 9v6M19 9v6"/>',
museum: '<path d="M3 21h18M5 9V21M9 9v12M15 9v12M19 9v12M2 9h20l-10-6z"/>',
hotel: '<rect x="3" y="6" width="18" height="14" rx="1"/><path d="M3 12h18"/><circle cx="7" cy="10" r="1"/><path d="M9 16h12"/>',
nightclub: '<circle cx="12" cy="12" r="9"/><path d="M12 3v9l6 4"/>',
// Other
marketplace: '<path d="M3 7l1-3h16l1 3v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7h18M9 11v6M15 11v6"/>',
vet: '<circle cx="12" cy="13" r="4"/><circle cx="6" cy="8" r="1.5"/><circle cx="18" cy="8" r="1.5"/><circle cx="9" cy="5" r="1.5"/><circle cx="15" cy="5" r="1.5"/>',
courier: '<rect x="3" y="6" width="18" height="14" rx="1"/><path d="M3 10h18M8 14h2M14 14h2"/>',
dentist: '<path d="M12 4c-3 0-6 2-6 6 0 4 1 11 3 10 1 0 2-3 3-3s2 3 3 3c2 1 3-6 3-10 0-4-3-6-6-6z"/>',
childcare: '<circle cx="12" cy="6" r="2"/><path d="M9 22v-7a3 3 0 0 1 3-3 3 3 0 0 1 3 3v7M9 16H6M15 16h3"/>',
};
function svgIcon(cat, sizePx=20) {
const col = POI_COLOR[cat] || '#94a3b8';
const path = POI_SVG[cat] || '<circle cx="12" cy="12" r="6"/>';
return `<svg xmlns="http://www.w3.org/2000/svg" width="${sizePx}" height="${sizePx}" viewBox="0 0 24 24" fill="${col}" stroke="white" stroke-width="1.5" style="filter:drop-shadow(0 1px 2px rgba(0,0,0,.4))">${path}</svg>`;
}
const RU = {kindergarten:'Детский сад',school:'Школа',university:'ВУЗ',pharmacy:'Аптека',clinic:'Поликлиника',hospital:'Больница',shop_big:'Большой маг.',shop_med:'Средний маг.',shop_small:'Малый маг.',bus_stop:'Автобус',tram_stop:'Трамвай',metro:'Метро',park:'Парк',playground:'Площадка',sports:'Спорт',
cafe:'Кафе', restaurant:'Ресторан', fuel:'АЗС', atm:'Банкомат', bank:'Банк',
post:'Почта', worship:'Храм', library:'Библиотека', police:'Полиция', fire:'Пожарка',
parking:'Парковка', gym:'Фитнес', theater:'Театр', cinema:'Кино', marketplace:'Рынок',
museum:'Музей', hotel:'Отель', nightclub:'Клуб/бар', car_wash:'Автомойка',
car_rental:'Авто/такси', vet:'Ветеринар', courier:'Постамат', dentist:'Стоматология',
childcare:'Ясли/няня'};
const POI_GROUPS = [
{label:'Образование', cats:['kindergarten','childcare','school','university']},
{label:'Здоровье', cats:['pharmacy','clinic','hospital','dentist','vet']},
{label:'Транспорт', cats:['bus_stop','tram_stop','metro','parking','car_rental']},
{label:'Магазины', cats:['shop_big','shop_med','shop_small','marketplace']},
{label:'Еда', cats:['cafe','restaurant','nightclub']},
{label:'Деньги', cats:['atm','bank','courier']},
{label:'Сервис', cats:['post','library','worship','fuel','car_wash','hotel']},
{label:'Безопасность',cats:['police','fire']},
{label:'Досуг', cats:['park','playground','sports','gym','theater','cinema','museum']},
];
const COMP_RU = {education:'Образование',health:'Здоровье',retail:'Ритейл',transit:'Транспорт',leisure:'Досуг',economic:'Экономика',market:'Рынок'};
let SITES = [];
let p75 = 75;
let LAST_RESULT = null;
let SELECTED_FOR_AB = new Set();
let WHAT_IF_POIS = [];
let WHAT_IF_REMOVE = new Set();
// =================== Init ===================
function renderLegendPOI() {
const reps = [
['school', 'образование'],
['hospital', 'здоровье'],
['metro', 'транспорт'],
['shop_big', 'магазины'],
['cafe', 'еда'],
['atm', 'деньги'],
['post', 'сервис'],
['police', 'безопасность'],
['park', 'досуг'],
];
document.getElementById('legend-poi').innerHTML = reps.map(([cat,lbl]) =>
`<div style="display:flex;align-items:center;gap:6px;margin:1px 0">${svgIcon(cat,12)}<span>${lbl}</span></div>`
).join('');
}
renderLegendPOI();
window.AUDIENCES = {};
async function init() {
const [sites, audiences, macro] = await Promise.all([
fetch('/api/sites').then(r=>r.json()),
fetch('/api/audiences').then(r=>r.json()),
fetch('/api/macro').then(r=>r.json())
]);
window.AUDIENCES = audiences;
const sel = document.getElementById('audience');
sel.innerHTML = Object.entries(audiences).map(([k,v]) => `<option value="${k}" ${k==='balanced'?'selected':''}>${v.label}</option>`).join('');
SITES = sites;
const all_w = sites.filter(s=>s.kind==='jk' && s.weighted!=null).map(s=>s.weighted).sort((a,b)=>b-a);
p75 = all_w[Math.floor(all_w.length*0.25)] || 75;
document.getElementById('metasum').textContent = `${sites.filter(s=>s.kind==='jk').length} ЖК · медиана ${(all_w[Math.floor(all_w.length/2)]||0).toFixed(1)} · P75=${p75.toFixed(1)} · max=${(all_w[0]||0).toFixed(1)}`;
document.getElementById('lc-jk').textContent = `(${sites.filter(s=>s.kind==='jk').length})`;
for (const s of sites) {
if (s.kind === 'parcel') {
parcelLayer.addLayer(L.circleMarker([s.lat, s.lon], {
radius:9, color:'#7f1d1d', weight:2, fillColor:'#ef4444', fillOpacity:0.85
}).bindPopup(`<b>${s.name||s.site_id}</b><br>Балл: <b>${s.weighted?.toFixed(1)}</b> · #${s.rank_overall}`));
} else {
const w = s.weighted ?? 0;
const c = w >= p75 ? '#10b981' : (w >= 50 ? '#0ea5e9' : '#94a3b8');
const m = L.circleMarker([s.lat, s.lon], {
radius:5, color:'#0f172a', weight:0.5, fillColor:c, fillOpacity:0.9
});
const tipColor = w >= p75 ? '#10b981' : w >= 50 ? '#0ea5e9' : '#94a3b8';
m.bindTooltip(`<div style="text-align:center"><div style="font-size:18px;font-weight:700;color:${tipColor};line-height:1">${w.toFixed(0)}</div><div style="font-size:10px;color:#64748b">#${s.rank_overall||'?'}</div></div><div style="margin-top:4px;font-weight:600">${s.name||'—'}</div><div style="font-size:10px;color:#64748b">${s.developer||''}${s.obj_class?' · '+s.obj_class:''}</div>${s.obj_district?'<div style="font-size:10px;color:#64748b">'+s.obj_district+'</div>':''}`, {sticky:true, direction:'top', offset:[0,-4]});
m.on('click', () => openJK(s.site_id));
m.on('mouseover', () => m.setStyle({radius:7}));
m.on('mouseout', () => m.setStyle({radius:5}));
m._jkSite = s;
jkLayer.addLayer(m);
}
}
setupClustering();
setupFilters(sites);
if (macro && macro.length) {
document.getElementById('macroBar').style.display='flex';
document.getElementById('macroBar').innerHTML = '<span class="pulse"></span>' +
macro.map(x => `<span>${x.label.split(' ').slice(0,3).join(' ')}: <b>${x.value.toFixed(2)}${x.unit||''}</b></span>`).join('');
}
renderRecent();
const params = new URLSearchParams(location.hash.slice(1));
if (params.get('lat') && params.get('lon')) {
document.getElementById('lat').value = params.get('lat');
document.getElementById('lon').value = params.get('lon');
if (params.get('cad')) document.getElementById('cad').value = params.get('cad');
if (params.get('audience')) document.getElementById('audience').value = params.get('audience');
setTimeout(() => document.getElementById('frm').requestSubmit(), 300);
} else {
document.getElementById('cad').value = '66:41:0204016:10';
document.getElementById('lat').value = '56.878913';
document.getElementById('lon').value = '60.522615';
}
}
init();
map.on('click', e => {
if (window.WHAT_IF_PICK_MODE) {
addWhatIfFromMap(e.latlng);
return;
}
document.getElementById('lat').value = e.latlng.lat.toFixed(6);
document.getElementById('lon').value = e.latlng.lng.toFixed(6);
if (pickedMarker) map.removeLayer(pickedMarker);
pickedMarker = L.circleMarker(e.latlng, {
radius: 10, color: '#7f1d1d', weight: 2.5, fillColor: '#ef4444', fillOpacity: 0.85
}).addTo(map);
});
// =================== ЖК Clustering ===================
const clusterLayer = L.layerGroup();
function _filterPasses(s) {
const klass = document.getElementById('filter-class')?.value;
const dist = document.getElementById('filter-district')?.value;
const minScore = +(document.getElementById('filter-score')?.value || 0);
return (!klass || s.obj_class === klass)
&& (!dist || (s.obj_district||s.district) === dist)
&& ((s.weighted ?? 0) >= minScore);
}
function setupClustering() {
function rebuildClusters() {
const z = map.getZoom();
if (z >= 13) {
clusterLayer.clearLayers();
map.removeLayer(clusterLayer);
if (!map.hasLayer(jkLayer)) jkLayer.addTo(map);
return;
}
map.removeLayer(jkLayer);
clusterLayer.clearLayers();
const gridDeg = z >= 11 ? 0.012 : z >= 9 ? 0.04 : 0.12;
const buckets = new Map();
for (const m of jkLayer.getLayers()) {
const s = m._jkSite;
if (s && !_filterPasses(s)) continue; // respect filters
const ll = m.getLatLng();
const k = `${Math.floor(ll.lat/gridDeg)}/${Math.floor(ll.lng/gridDeg)}`;
if (!buckets.has(k)) buckets.set(k, []);
buckets.get(k).push({m, lat: ll.lat, lng: ll.lng, w: s?.weighted || 0});
}
for (const arr of buckets.values()) {
if (arr.length === 1) {
// single point — keep as marker
clusterLayer.addLayer(arr[0].m);
continue;
}
const lat = arr.reduce((s,x)=>s+x.lat,0)/arr.length;
const lng = arr.reduce((s,x)=>s+x.lng,0)/arr.length;
const avgW = arr.reduce((s,x)=>s+x.w,0)/arr.length;
const col = avgW >= p75 ? '#10b981' : avgW >= 50 ? '#0ea5e9' : '#94a3b8';
const size = 24 + Math.min(28, Math.sqrt(arr.length)*4);
const html = `<div style="background:${col};color:white;width:${size}px;height:${size}px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:${arr.length>=100?10:11}px;border:2px solid white;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer">${arr.length}</div>`;
const cm = L.marker([lat, lng], {
icon: L.divIcon({className:'jk-cluster', html, iconSize:[size,size], iconAnchor:[size/2,size/2]})
});
cm.bindTooltip(`<b>${arr.length} ЖК</b><br>средний балл: ${avgW.toFixed(1)}`);
cm.on('click', () => map.flyTo([lat,lng], Math.min(15, z+2), {duration:0.5}));
clusterLayer.addLayer(cm);
}
if (!map.hasLayer(clusterLayer)) clusterLayer.addTo(map);
}
map.on('zoomend', rebuildClusters);
rebuildClusters();
}
function setupFilters(sites) {
const jkSites = sites.filter(s => s.kind === 'jk');
const classes = [...new Set(jkSites.map(s => s.obj_class).filter(Boolean))].sort();
const districts = [...new Set(jkSites.map(s => s.obj_district || s.district).filter(Boolean))].sort();
document.getElementById('filter-class').innerHTML = '<option value="">все классы</option>' +
classes.map(c => `<option value="${c}">${c}</option>`).join('');
document.getElementById('filter-district').innerHTML = '<option value="">все районы</option>' +
districts.map(d => `<option value="${d}">${d}</option>`).join('');
const apply = () => {
const klass = document.getElementById('filter-class').value;
const dist = document.getElementById('filter-district').value;
const minScore = +document.getElementById('filter-score').value;
document.getElementById('filter-score-val').textContent = minScore;
let visible = 0;
for (const m of jkLayer.getLayers()) {
const s = m._jkSite;
const ok = (!klass || s.obj_class === klass)
&& (!dist || (s.obj_district||s.district) === dist)
&& ((s.weighted ?? 0) >= minScore);
if (ok) { m.setStyle({opacity:0.9, fillOpacity:0.9}); visible++; }
else { m.setStyle({opacity:0.05, fillOpacity:0.05}); }
}
document.getElementById('lc-jk').textContent = visible === jkSites.length ? `(${jkSites.length})` : `(${visible}/${jkSites.length})`;
};
function applyAndRecluster() {
apply();
if (map.getZoom() < 13) map.fire('zoomend');
}
document.getElementById('filter-class').addEventListener('change', applyAndRecluster);
document.getElementById('filter-district').addEventListener('change', applyAndRecluster);
document.getElementById('filter-score').addEventListener('input', applyAndRecluster);
// Saved views (filter combos + audience)
const VIEWS_KEY = 'gendesign_saved_views';
function loadViews() { try { return JSON.parse(localStorage.getItem(VIEWS_KEY)||'[]'); } catch { return []; } }
function renderViews() {
const sel = document.getElementById('saved-views');
sel.innerHTML = '<option value="">сохранённые виды…</option>' +
loadViews().map((v, i) => `<option value="${i}">${v.name}</option>`).join('');
}
document.getElementById('save-view').addEventListener('click', () => {
const name = prompt('Название view:', `View ${loadViews().length+1}`);
if (!name) return;
const view = {
name, ts: Date.now(),
filter_class: document.getElementById('filter-class').value,
filter_district: document.getElementById('filter-district').value,
filter_score: document.getElementById('filter-score').value,
audience: document.getElementById('audience').value,
basemap: document.getElementById('basemap').value,
jk_poly: document.getElementById('layer-jk-poly').checked,
heatmap: document.getElementById('layer-heatmap').checked,
};
const arr = loadViews();
arr.unshift(view);
localStorage.setItem(VIEWS_KEY, JSON.stringify(arr.slice(0, 12)));
renderViews();
toast('View «'+name+'» сохранён', 'success');
});
document.getElementById('saved-views').addEventListener('change', e => {
const idx = +e.target.value;
if (isNaN(idx)) return;
const v = loadViews()[idx];
if (!v) return;
document.getElementById('filter-class').value = v.filter_class || '';
document.getElementById('filter-district').value = v.filter_district || '';
document.getElementById('filter-score').value = v.filter_score || 0;
document.getElementById('audience').value = v.audience || 'balanced';
document.getElementById('basemap').value = v.basemap || 'cartovoyager';
setBasemap(v.basemap || 'cartovoyager');
if (v.jk_poly) { document.getElementById('layer-jk-poly').checked = true; document.getElementById('layer-jk-poly').dispatchEvent(new Event('change')); }
if (v.heatmap) { document.getElementById('layer-heatmap').checked = true; document.getElementById('layer-heatmap').dispatchEvent(new Event('change')); }
applyAndRecluster();
document.getElementById('audience').dispatchEvent(new Event('change'));
toast('View «'+v.name+'» применён', 'info');
});
renderViews();
}
// =================== Keyboard shortcuts ===================
document.addEventListener('keydown', e => {
if (e.target.matches('input, textarea, select')) return;
if (e.key === '/') { e.preventDefault(); sb.focus(); }
if (e.key === 'Escape') {
document.getElementById('jkModal').classList.remove('show');
document.getElementById('abModal').classList.remove('show');
sr.style.display = 'none';
}
if (e.key === 'r' && !e.metaKey && !e.ctrlKey) { map.flyTo([56.84, 60.6], 11, {duration: 0.5}); toast('сброс вида', 'info', 1500); }
if (e.key === 'd') { document.getElementById('btn-dark').click(); }
if (e.key === 's') { document.getElementById('sideToggle').click(); }
if (e.key === 'a' && LAST_RESULT) { document.getElementById('btn-ab').click(); }
if (e.key === 'p') { document.getElementById('btn-print').click(); }
});
// Tile pre-warmer: when user pans/zooms or flyTo, fire HEAD-like requests
// for tiles around the destination at the current zoom + neighbour zoom levels.
// Uses Image() — browser caches them; subsequent map redraw paints instantly.
function preloadTilesAround(lat, lon, zoom, radius=1) {
if (!baseLayer) return;
// Manually substitute the URL template — Leaflet's getTileUrl ignores the passed z
// and uses its internal _tileZoom, which produces wrong coords for prefetched z±1.
const tpl = baseLayer._url;
const subs = (baseLayer.options.subdomains && baseLayer.options.subdomains.length) ? baseLayer.options.subdomains : ['a','b','c','d'];
const tplFn = (z, x, y) => tpl
.replace('{s}', subs[(x + y) % subs.length])
.replace('{z}', z)
.replace('{x}', x)
.replace('{y}', y)
.replace('{r}', '');
const tilesPer = 1 << zoom;
const tx = Math.floor((lon + 180) / 360 * tilesPer);
const ty = Math.floor((1 - Math.log(Math.tan(lat*Math.PI/180) + 1/Math.cos(lat*Math.PI/180))/Math.PI) / 2 * tilesPer);
for (let dz = -1; dz <= 1; dz++) {
const z = zoom + dz;
const maxNative = baseLayer.options.maxNativeZoom || baseLayer.options.maxZoom || 19;
if (z < 5 || z > maxNative) continue;
const max = (1 << z) - 1;
const factor = Math.pow(2, dz);
const cx = Math.floor(tx * factor);
const cy = Math.floor(ty * factor);
for (let dx = -radius; dx <= radius; dx++) for (let dy = -radius; dy <= radius; dy++) {
const x = cx+dx, y = cy+dy;
if (x < 0 || y < 0 || x > max || y > max) continue;
try { (new Image()).src = tplFn(z, x, y); } catch(e) {}
}
}
}
// Pre-warm initial Ekb area + on every moveend
preloadTilesAround(56.84, 60.6, 13, 2);
let _moveTimer = null;
map.on('moveend', () => {
clearTimeout(_moveTimer);
_moveTimer = setTimeout(() => {
const c = map.getCenter();
preloadTilesAround(c.lat, c.lng, map.getZoom(), 2);
}, 250);
});
// Wrap flyTo / setView so we pre-warm BEFORE the animation, not after.
// This makes pan-to-far-area show tiles instantly when the camera arrives.
['flyTo','setView','panTo'].forEach(fn => {
const orig = map[fn].bind(map);
map[fn] = function(latlng, zoom, opts) {
try {
const lat = latlng?.lat ?? latlng?.[0];
const lng = latlng?.lng ?? latlng?.[1];
const z = (typeof zoom === 'number') ? zoom : map.getZoom();
if (lat != null && lng != null) preloadTilesAround(lat, lng, z, 3);
} catch(e) {}
return orig(latlng, zoom, opts);
};
});
// =================== Submit ===================
document.getElementById('frm').addEventListener('submit', async (e) => {
e.preventDefault();
await runAnalysis();
});
async function runAnalysis() {
const btn = document.getElementById('go');
const msg = document.getElementById('msg');
btn.disabled = true; btn.textContent='считаем…';
msg.innerHTML = '';
poiLayer.clearLayers(); polygonLayer.clearLayers();
const body = {
cad: document.getElementById('cad').value || null,
lat: parseFloat(document.getElementById('lat').value) || null,
lon: parseFloat(document.getElementById('lon').value) || null,
name: document.getElementById('name').value || null,
audience: document.getElementById('audience').value,
what_if: WHAT_IF_POIS.length ? WHAT_IF_POIS : null,
remove_cats: WHAT_IF_REMOVE.size ? [...WHAT_IF_REMOVE] : null,
};
const h = new URLSearchParams();
if (body.cad) h.set('cad', body.cad);
if (body.lat) h.set('lat', body.lat);
if (body.lon) h.set('lon', body.lon);
h.set('audience', body.audience);
history.replaceState(null, '', '#'+h.toString());
try {
const r = await fetch('/api/analyze', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(body)
});
if (!r.ok) {
const err = await r.json();
msg.innerHTML = `<div class="err">${err.detail||'ошибка'}</div>`;
toast(err.detail || 'ошибка анализа', 'error');
return;
}
const d = await r.json();
LAST_RESULT = d;
saveRecent({cad: body.cad, lat: body.lat, lon: body.lon, audience: body.audience, weighted: d.weighted});
renderResult(d);
toast(`Балл ${d.weighted.toFixed(1)} · ранг #${d.rank_overall} · ${d._perf?.poi_source==='cache'?'cached '+d._perf.poi_ms+'ms':'live overpass'}`, 'success', 2500);
if (d.input.lat && d.input.lon) {
map.setView([d.input.lat, d.input.lon], 15);
drawBufferRings(d.input.lat, d.input.lon);
// Polygon if available
if (d.polygon) {
const geom = d.polygon.geometry || (d.polygon.features?.[0]?.geometry) || d.polygon;
polygonLayer.addLayer(L.geoJSON(geom, {style:{color:'#ef4444', weight:2, fillColor:'#ef4444', fillOpacity:0.15}}));
}
// POIs
renderPoiMarkers(d);
// Reverse geocode (async)
fetch(`/api/reverse-geocode?lat=${d.input.lat}&lon=${d.input.lon}`).then(r=>r.json()).then(g => {
const ab = document.getElementById('addrBlock');
if (g && g.display_name) {
ab.innerHTML = `<div class="addr">${g.display_name}</div>`;
} else ab.innerHTML = '';
}).catch(()=>{});
}
} catch (e) {
msg.innerHTML = `<div class="err">${e.message}</div>`;
} finally {
btn.disabled = false; btn.textContent='Оценить';
}
}
// =================== What-if ===================
const HYPO_CATS = [
{cat:'metro', icon:'Ⓜ', label:'Метро'},
{cat:'school', icon:'🏫', label:'Школа'},
{cat:'kindergarten', icon:'🧒', label:'Детсад'},
{cat:'shop_big', icon:'🏬', label:'Большой магазин'},
{cat:'park', icon:'🌳', label:'Парк'},
];
function whatIfHTML() {
const list = WHAT_IF_POIS.map((p,i) => `
<div style="display:flex;align-items:center;gap:5px;font-size:11px;background:white;padding:3px 6px;border-radius:4px;margin-top:3px">
<span>${HYPO_CATS.find(h=>h.cat===p.cat)?.icon||''}</span>
<span>${p.name||RU[p.cat]||p.cat}</span>
<span class="hint" style="margin-left:auto">${p.lat.toFixed(4)},${p.lon.toFixed(4)}</span>
<button onclick="removeWhatIf(${i})" style="padding:0;background:transparent;border:0;cursor:pointer;color:#ef4444;font-size:14px">×</button>
</div>`).join('');
return `
<div class="whatif">
<h3>Что-если режим</h3>
<div class="hint" style="margin-bottom:5px">Включи режим, кликни на карту — добавит гипотетический POI</div>
${HYPO_CATS.map(h => `
<label><input type="radio" name="whatif-cat" value="${h.cat}"> <span>${h.icon}</span> ${h.label}</label>
`).join('')}
<div style="display:flex;gap:5px;align-items:center;margin-top:6px">
<button onclick="toggleWhatIfPick()" id="whatif-pick-btn" style="font-size:10px;padding:4px 8px;background:#fde047;border:0;border-radius:4px;cursor:pointer;font-weight:600">Кликнуть на карте</button>
<button onclick="clearWhatIf()" style="font-size:10px;padding:4px 8px;background:white;border:1px solid #cbd5e1;border-radius:4px;cursor:pointer">очистить</button>
</div>
<div style="margin-top:6px"><b style="font-size:10px">Удалить из учёта:</b>
${['metro','tram_stop','park'].map(c => `
<label style="display:inline-flex;margin-right:8px"><input type="checkbox" data-rmcat="${c}" ${WHAT_IF_REMOVE.has(c)?'checked':''}> <span style="margin-left:4px">${RU[c]}</span></label>
`).join('')}
</div>
${list}
${WHAT_IF_POIS.length || WHAT_IF_REMOVE.size ? '<button onclick="document.getElementById(\'frm\').requestSubmit()" class="primary" style="margin-top:6px;padding:6px 10px;font-size:11px">Пересчитать</button>' : ''}
</div>`;
}
function toggleWhatIfPick() {
window.WHAT_IF_PICK_MODE = !window.WHAT_IF_PICK_MODE;
const btn = document.getElementById('whatif-pick-btn');
if (btn) btn.style.background = window.WHAT_IF_PICK_MODE ? '#10b981' : '#fde047';
if (btn) btn.style.color = window.WHAT_IF_PICK_MODE ? 'white' : '#0f172a';
document.getElementById('map').style.cursor = window.WHAT_IF_PICK_MODE ? 'crosshair' : '';
}
function addWhatIfFromMap(latlng) {
const cat = document.querySelector('input[name=whatif-cat]:checked')?.value;
if (!cat) { toast('Выбери категорию POI', 'warn', 2500); return; }
WHAT_IF_POIS.push({cat, lat:latlng.lat, lon:latlng.lng, name:`Hypothetical ${RU[cat]}`});
toggleWhatIfPick();
updateWhatIfBlock();
}
function removeWhatIf(i) { WHAT_IF_POIS.splice(i,1); updateWhatIfBlock(); }
function clearWhatIf() { WHAT_IF_POIS=[]; WHAT_IF_REMOVE.clear(); updateWhatIfBlock(); }
function updateWhatIfBlock() {
const wif = document.getElementById('whatif-block');
if (wif) wif.innerHTML = whatIfHTML();
// wire remove-cat checkboxes
document.querySelectorAll('input[data-rmcat]').forEach(c => {
c.addEventListener('change', e => {
e.target.checked ? WHAT_IF_REMOVE.add(e.target.dataset.rmcat) : WHAT_IF_REMOVE.delete(e.target.dataset.rmcat);
});
});
}
// =================== Render ===================
function rankBadge(rank, total) {
if (!rank || !total) return '';
const pct = (1 - (rank-1)/total) * 100;
const cls = pct >= 75 ? 'b-good' : (pct >= 40 ? 'b-mid' : 'b-low');
return `<span class="badge ${cls}">#${rank}/${total} · топ-${(100-pct).toFixed(0)}%</span>`;
}
async function renderResult(d) {
const div = document.getElementById('result');
const e = d.economics || {};
const f = d.features || {};
const price = e.real_median_price_m2 ?? e.weighted_price_m2;
const vel = e.real_velocity_6mo ?? e.real_velocity_per_month;
const cls = d.weighted >= 75 ? 'b-good' : (d.weighted >= 50 ? 'b-mid' : 'b-low');
const totalRanked = d.n_jk_compared + 1;
const numColor = cls==='b-good'?'#0a6':cls==='b-mid'?'#a16207':'#dc2626';
const compRows = ['economic','education','retail','transit','health','leisure','market'].map(k => {
const v = d.scores[k] || 0;
const w = d.weights[k] || 0;
return `<div class="comp">
<span class="lbl">${COMP_RU[k]}</span>
<span class="v">${v.toFixed(0)}</span>
<div class="bar"><span style="width:${v}%"></span></div>
<span class="pct">${(w*100).toFixed(0)}%</span>
</div>`;
}).join('');
const poiRows = POI_GROUPS.map(g => {
const items = g.cats.map(cat => {
const n = f[`${cat}_nearest_m`];
const top = (f[`${cat}_top5`] || [])[0];
const c500 = f[`${cat}_count_500m`] ?? 0;
const c1k = f[`${cat}_count_1km`] ?? 0;
return `<div class="poi-row">
<span class="icon">${svgIcon(cat, 14)}</span>
<span class="cat">${RU[cat]||cat}</span>
<span class="name" title="${top?.name||''}">${top?.name||'—'}</span>
<span class="d">${n?Math.round(n)+'м':'—'}</span>
<span class="c">${c500}</span>
<span class="c">${c1k}</span>
</div>`;
}).join('');
return `<div style="font-size:9px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em;font-weight:700;padding:8px 2px 2px;border-bottom:1px dashed #e2e8f0;margin-top:4px">${g.label}</div>${items}`;
}).join('');
const trendArrow = e.real_trend_ratio == null ? '—' :
(e.real_trend_ratio > 1.1 ? `<span style="color:#10b981">↗ ${e.real_trend_ratio.toFixed(2)}×</span>` :
e.real_trend_ratio < 0.9 ? `<span style="color:#ef4444">↘ ${e.real_trend_ratio.toFixed(2)}×</span>` :
`<span style="color:#64748b">→ ${e.real_trend_ratio.toFixed(2)}×</span>`);
const econBlock = price ? `
<div class="kpis">
<div class="kpi"><div class="l">Цена м² (медиана)</div><div class="v">${price.toFixed(1)}<small>тыс ₽</small></div></div>
<div class="kpi"><div class="l">Скорость продаж (6м)</div><div class="v">${(vel||0).toFixed(2)}<small>ДДУ/корп/мес</small></div></div>
<div class="kpi"><div class="l">Тренд (6мо vs пред)</div><div class="v">${trendArrow}</div></div>
<div class="kpi"><div class="l">Sold-out стока</div><div class="v">${(e.real_sold_pct||0).toFixed(1)}<small>%</small></div></div>
<div class="kpi"><div class="l">Лотов в районе</div><div class="v">${(e.real_n_lots||0).toLocaleString()}<small>шт</small></div></div>
<div class="kpi"><div class="l">Готовность</div><div class="v">${(e.real_avg_readiness_pct||0).toFixed(0)}<small>%</small></div></div>
</div>
<div class="hint" style="margin-top:6px">P25=${(e.real_p25_price_m2||0).toFixed(1)} · P75=${(e.real_p75_price_m2||0).toFixed(1)} · ${e.n_projects||0} проектов · ср. площадь сделки ${(e.real_avg_area_sold||0).toFixed(1)} м²</div>
<h2>📈 Регистрации ДДУ за 12 мес</h2>
<div id="trendChart" class="chart"></div>
` : '<div class="empty">Нет данных Объектива для района</div>';
// Per-complex velocity for nearby ЖК
const nearby = await fetch(`/api/nearby-jk-velocity?lat=${d.input.lat}&lon=${d.input.lon}&radius_m=1500`).then(r=>r.json()).catch(()=>[]);
const cmpRows = nearby.slice(0, 8).map(s => {
const sc = s.weighted ?? 0;
const sclr = sc >= p75 ? '#0a6' : sc >= 50 ? '#0ea5e9' : '#64748b';
const sel = SELECTED_FOR_AB.has(s.site_id) ? 'selected' : '';
const abIcon = SELECTED_FOR_AB.has(s.site_id) ? '✓' : '+';
return `<div class="cmp ${sel}" data-id="${s.site_id}" data-lat="${s.lat}" data-lon="${s.lon}">
<button class="ab-toggle" data-ab="${s.site_id}" title="${SELECTED_FOR_AB.has(s.site_id)?'убрать из':'добавить в'} A/B">${abIcon}</button>
<div>
<div class="nm">${s.name || '—'}</div>
<div class="meta">${(s.distance_m).toFixed(0)} м · ${s.developer||''}${s.obj_class?' · '+s.obj_class:''} · ${s.obj_district||'—'}${s.project_in_objective?' · matched':''}</div>
</div>
<div class="vel">${s.velocity_6mo!=null ? `<b>${s.velocity_6mo.toFixed(1)}</b><br><span class="hint">${(s.sold_pct||0).toFixed(0)}% sold</span>` : '<span class="hint">—</span>'}</div>
<div class="sc" style="color:${sclr}">${sc.toFixed(0)}<small>#${s.rank||'?'}</small></div>
<div></div>
</div>`;
}).join('');
// Polygon KPI strip if polygon present
const polyInfo = d.polygon ? (() => {
const fc = d.polygon;
const area = fc._area_m2;
const props = fc._props || (fc.features?.[0]?.properties?.options) || {};
return `<div class="kpis" style="margin:8px 0">
<div class="kpi"><div class="l">Площадь участка</div><div class="v">${(area||0).toLocaleString('ru')}<small>м² · ${(area/10000).toFixed(2)} га</small></div></div>
<div class="kpi"><div class="l">Кад. стоимость</div><div class="v">${props.cost_value ? (props.cost_value/1e6).toFixed(1) : '—'}<small>млн ₽</small></div></div>
${props.cost_index ? `<div class="kpi"><div class="l">Уд. кад.ст. ₽/м²</div><div class="v">${(props.cost_index||0).toLocaleString('ru')}<small>₽/м²</small></div></div>` : ''}
${props.cost_application_date ? `<div class="kpi"><div class="l">Действ. с</div><div class="v" style="font-size:13px">${props.cost_application_date}</div></div>` : ''}
</div>`;
})() : '';
div.innerHTML = `
<h2>Итог</h2>
${polyInfo}
<div class="scorebox">
<div class="num" style="color:${numColor}">${d.weighted.toFixed(1)}</div>
<div>
<div style="font-size:12px;color:#64748b">из 100</div>
<div style="margin-top:4px">${rankBadge(d.rank_overall, totalRanked)}</div>
<div class="meta" style="margin-top:5px">
<b>Район:</b> ${d.district||'—'} <span class="hint">(админ: ${d.admin_district||'—'})</span><br>
<span class="hint">профиль: <b>${document.getElementById('audience').selectedOptions[0].text}</b></span>
${d._perf?` · <span class="hint">${d._perf.poi_source}, ${d._perf.poi_ms}мс</span>`:''}
${f.walkability_score!=null ? `<br><span class="hint">walkability:</span> <b style="color:${f.walkability_score>=70?'#0a6':f.walkability_score>=40?'#a16207':'#dc2626'}">${f.walkability_score}/100</b>` : ''}
</div>
</div>
</div>
<div style="margin-top:10px">${compRows}</div>
<div id="whatif-block">${whatIfHTML()}</div>
<div class="tabs">
<button class="active" data-tab="poi">POI · 25</button>
<button data-tab="econ">Экономика</button>
<button data-tab="cmp">ЖК рядом · ${nearby.length}</button>
<button data-tab="suggest">Лучше рядом</button>
<button data-tab="audiences">Все профили</button>
<button data-tab="risk">Риски</button>
<button data-tab="multiclass">Класс выгоднее</button>
<button data-tab="tep">ТЭП·IRR</button>
<button data-tab="prices">Цены/Тренд</button>
<button data-tab="radar">Радар</button>
<button data-tab="raw">JSON</button>
</div>
<div class="tab active" id="tab-poi">
<div class="poi-head"><span></span><span>Категория</span><span>Ближайший</span><span style="text-align:right">мин</span><span style="text-align:right">500м</span><span style="text-align:right">1км</span></div>
${poiRows}
</div>
<div class="tab" id="tab-econ">${econBlock}</div>
<div class="tab" id="tab-cmp">
<div class="hint" style="margin-bottom:6px">Клик «+» — добавить в A/B (до 3). Клик по строке — карточка. Двойной клик — flyTo. <span id="ab-counter"></span></div>
${cmpRows || '<div class="empty">…</div>'}
</div>
<div class="tab" id="tab-risk">
<div class="hint">Шум, ж/д, магистрали, пожароопасные объекты в радиусе</div>
<div id="riskOutput" style="margin-top:6px"></div>
</div>
<div class="tab" id="tab-multiclass">
<div class="hint">Сравнение прибыли при строительстве разных классов на этом участке</div>
<div id="multiclassOutput" style="margin-top:6px"></div>
</div>
<div class="tab" id="tab-tep">
<div class="hint" style="margin-bottom:6px">Generative TЭП-калькулятор: подставит цену/velocity района и посчитает выручку, NPV, IRR</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;font-size:11px;margin-bottom:6px">
<label>Площадь участка, м²<input id="tep-area" type="number" value="${(d.polygon?._area_m2 || 2789).toFixed(0)}" style="width:100%;padding:5px;border:1px solid #cbd5e1;border-radius:4px"></label>
<label>FAR (плотность)<input id="tep-far" type="number" value="2.0" step="0.1" style="width:100%;padding:5px;border:1px solid #cbd5e1;border-radius:4px"></label>
<label>Доля квартир в GBA<input id="tep-saleable" type="number" value="0.65" step="0.05" style="width:100%;padding:5px;border:1px solid #cbd5e1;border-radius:4px"></label>
<label>Ср. площадь сделки, м²<input id="tep-unit" type="number" value="${(e.real_avg_area_sold||45).toFixed(0)}" style="width:100%;padding:5px;border:1px solid #cbd5e1;border-radius:4px"></label>
<label>Цена строительства, ₽/м²<input id="tep-cost" type="number" value="75000" style="width:100%;padding:5px;border:1px solid #cbd5e1;border-radius:4px"></label>
<label>Soft-cost, %<input id="tep-soft" type="number" value="10" step="1" style="width:100%;padding:5px;border:1px solid #cbd5e1;border-radius:4px"></label>
<label>Срок стройки, мес<input id="tep-months" type="number" value="30" style="width:100%;padding:5px;border:1px solid #cbd5e1;border-radius:4px"></label>
<label>Дисконт, %<input id="tep-disc" type="number" value="18" step="1" style="width:100%;padding:5px;border:1px solid #cbd5e1;border-radius:4px"></label>
</div>
<button id="tep-calc-btn" class="primary" style="width:100%;font-size:11px">Рассчитать ТЭП</button>
<div id="tep-output" style="margin-top:10px"></div>
</div>
<div class="tab" id="tab-prices">
<div class="hint">Распределение цен по классам в районе ${d.district || '—'}</div>
<div id="boxplotChart" class="chart" style="height:200px;margin-top:6px"></div>
<h2 style="margin-top:10px">Динамика района (12 мес)</h2>
<div class="hint">Время-машина: переключи метрику</div>
<select id="tm-metric" style="font-size:11px;padding:4px;border-radius:4px;border:1px solid #cbd5e1">
<option value="deals">Сделки/мес</option>
<option value="median_price_kr">Медианная цена тыс/м²</option>
<option value="volume_m2">Объём м²</option>
<option value="active_corpuses">Активные корпуса</option>
</select>
<div id="tmChart" class="chart" style="height:170px;margin-top:6px"></div>
</div>
<div class="tab" id="tab-audiences">
<div class="hint">Балл этого участка под все 5 профилей — какому из них участок подходит лучше всего</div>
<div id="audChart" class="chart" style="height:200px;margin-top:6px"></div>
<div id="audTable" style="margin-top:8px"></div>
</div>
<div class="tab" id="tab-suggest">
<div class="hint">5 ЖК в радиусе 3 км с баллом выше под текущий профиль аудитории</div>
<div id="suggestList" style="margin-top:6px"></div>
</div>
<div class="tab" id="tab-radar">
<div id="radarChart" class="chart chart-radar"></div>
<div class="hint" style="margin-top:6px;text-align:center">Профиль участка vs медиана 5 ближайших ЖК</div>
</div>
<div class="tab" id="tab-raw"><pre>${JSON.stringify(d,null,2)}</pre></div>
`;
// Tabs
div.querySelectorAll('.tabs button').forEach(b => b.addEventListener('click', e => {
div.querySelectorAll('.tabs button').forEach(x => x.classList.remove('active'));
div.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
b.classList.add('active');
div.querySelector('#tab-' + b.dataset.tab).classList.add('active');
if (b.dataset.tab === 'econ') drawTrendChart(d);
if (b.dataset.tab === 'radar') drawRadarChart(d, nearby.slice(0,5));
if (b.dataset.tab === 'tep') bindTepCalc(d);
if (b.dataset.tab === 'prices') { drawBoxplot(d); drawTimeMachine(d); }
if (b.dataset.tab === 'suggest') loadSuggestions(d);
if (b.dataset.tab === 'audiences') drawAudiencesChart(d);
if (b.dataset.tab === 'risk') loadRiskScore(d);
if (b.dataset.tab === 'multiclass') loadMultiClass(d);
}));
// Comparable interactions:
// click on «+» button → toggle in A/B set (no modifier needed)
// click on row body → open ЖК modal
// double-click on row → flyTo on map
div.querySelectorAll('.ab-toggle').forEach(btn => btn.addEventListener('click', ev => {
ev.stopPropagation();
const sid = btn.dataset.ab;
if (SELECTED_FOR_AB.has(sid)) {
SELECTED_FOR_AB.delete(sid);
btn.parentElement.classList.remove('selected');
btn.textContent = '+'; btn.title = 'добавить в A/B';
} else if (SELECTED_FOR_AB.size < 3) {
SELECTED_FOR_AB.add(sid);
btn.parentElement.classList.add('selected');
btn.textContent = '✓'; btn.title = 'убрать из A/B';
} else {
btn.style.animation = 'shake .3s';
setTimeout(()=>btn.style.animation='', 350);
}
}));
div.querySelectorAll('.cmp').forEach(c => {
c.addEventListener('click', ev => {
if (ev.target.closest('.ab-toggle')) return;
openJK(c.dataset.id);
});
c.addEventListener('dblclick', () => {
const lat = parseFloat(c.dataset.lat), lon = parseFloat(c.dataset.lon);
map.flyTo([lat, lon], 16, {duration: 0.6});
});
});
updateWhatIfBlock();
}
// =================== Charts ===================
let trendChart = null, radarChart = null, abRadar = null;
function drawTrendChart(d) {
if (!d.district) return;
fetch('/api/district-velocity-trend/' + encodeURIComponent(d.district))
.then(r => r.json())
.then(data => {
const el = document.getElementById('trendChart');
if (!el) return;
if (trendChart) trendChart.dispose();
trendChart = echarts.init(el, null, {renderer:'canvas'});
trendChart.setOption({
grid: {left: 30, right: 10, top: 10, bottom: 22},
xAxis: {type:'category', data: data.map(x=>x.month.slice(2)), axisLabel:{fontSize:9, color:'#64748b'}},
yAxis: {type:'value', axisLabel:{fontSize:9, color:'#64748b'}, splitLine:{lineStyle:{color:'#e2e8f0'}}},
series: [{
type: 'bar', data: data.map(x=>x.n),
itemStyle: {color: '#0ea5e9', borderRadius: [3,3,0,0]},
markLine: {data:[{type:'average', name:'avg'}], lineStyle:{color:'#94a3b8', type:'dashed', width:1}, label:{show:false}}
}],
tooltip: {trigger:'axis', formatter: p => `${p[0].name}<br><b>${p[0].value}</b> ДДУ`},
});
});
}
function drawRadarChart(d, nearby) {
const el = document.getElementById('radarChart');
if (!el) return;
if (radarChart) radarChart.dispose();
radarChart = echarts.init(el, null, {renderer:'canvas'});
const keys = ['economic','education','retail','transit','health','leisure','market'];
const indicators = keys.map(k => ({name: COMP_RU[k], max: 100}));
// Approximate competitor profiles: their `weighted` = uniformly across components
// (since /api/sites doesn't return components — it's an approximation)
const cmpScores = keys.map(_ => {
const vals = nearby.map(s => s.weighted ?? 0);
return vals.length ? vals.reduce((a,b)=>a+b,0) / vals.length : 0;
});
radarChart.setOption({
radar: {indicator: indicators, shape:'polygon', radius:'70%', axisName:{color:'#475569', fontSize:10}},
series: [{
type: 'radar',
data: [
{name:'Участок', value: keys.map(k=>d.scores[k]),
areaStyle:{color:'rgba(14,165,233,0.3)'}, lineStyle:{color:'#0ea5e9', width:2}, symbol:'circle', symbolSize:6},
{name:'Медиана 5 ближайших', value: cmpScores,
areaStyle:{color:'rgba(148,163,184,0.15)'}, lineStyle:{color:'#94a3b8', width:1, type:'dashed'}, symbol:'none'}
]
}],
legend: {data:['Участок','Медиана 5 ближайших'], bottom:0, textStyle:{fontSize:10, color:'#475569'}},
tooltip: {}
});
}
window.addEventListener('resize', () => {
trendChart?.resize(); radarChart?.resize(); abRadar?.resize();
boxChart?.resize();
});
// =================== ТЭП calc ===================
function bindTepCalc(d) {
const btn = document.getElementById('tep-calc-btn');
if (!btn || btn._wired) return;
btn._wired = true;
btn.addEventListener('click', async () => {
btn.disabled = true; btn.textContent = '⏳ считаем…';
const body = {
parcel_area_m2: +document.getElementById('tep-area').value,
far: +document.getElementById('tep-far').value,
saleable_share: +document.getElementById('tep-saleable').value,
avg_unit_area_m2: +document.getElementById('tep-unit').value,
construction_cost_per_m2: +document.getElementById('tep-cost').value,
soft_cost_pct: +document.getElementById('tep-soft').value/100,
months_to_complete: +document.getElementById('tep-months').value,
discount_rate: +document.getElementById('tep-disc').value/100,
district: d.district,
};
try {
const r = await fetch('/api/tep-calc', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
const o = (await r.json()).outputs;
const irrCls = o.irr_annual === null ? '' : o.irr_annual >= 25 ? 'b-good' : o.irr_annual >= 15 ? 'b-mid' : 'b-low';
const profitCls = o.profit_margin_pct >= 15 ? 'b-good' : o.profit_margin_pct >= 8 ? 'b-mid' : 'b-low';
document.getElementById('tep-output').innerHTML = `
<div class="kpis">
<div class="kpi"><div class="l">GBA</div><div class="v">${o.gba_m2.toLocaleString('ru')}<small>м²</small></div></div>
<div class="kpi"><div class="l">Квартиры</div><div class="v">${o.n_units}<small>шт · ${o.saleable_m2.toLocaleString('ru')}м²</small></div></div>
<div class="kpi"><div class="l">Выручка</div><div class="v">${o.revenue_mln}<small>млн ₽</small></div></div>
<div class="kpi"><div class="l">Стройка</div><div class="v">${o.construction_mln}<small>млн ₽</small></div></div>
<div class="kpi"><div class="l">Прибыль</div><div class="v">${o.profit_mln}<small>млн ₽ · <span class="badge ${profitCls}">${o.profit_margin_pct||'—'}%</span></small></div></div>
<div class="kpi"><div class="l">Sellout</div><div class="v">${o.months_to_sellout}<small>мес</small></div></div>
<div class="kpi"><div class="l">NPV</div><div class="v">${o.npv_mln}<small>млн ₽</small></div></div>
<div class="kpi"><div class="l">IRR</div><div class="v"><span class="badge ${irrCls}">${o.irr_annual!==null?o.irr_annual+'%':'—'}</span></div></div>
</div>
<div class="hint" style="margin-top:6px">Допущения: цена ${(d.economics?.real_median_price_m2||130).toFixed(0)} тыс/₽м², velocity ${(d.economics?.real_velocity_6mo||2).toFixed(2)} лот/мес. 20% upfront stroyka, остальное в график.</div>
`;
} catch(e) { document.getElementById('tep-output').innerHTML = '<div class="err">'+e.message+'</div>'; }
finally { btn.disabled=false; btn.textContent='Рассчитать ТЭП'; }
});
}
// =================== Time-machine ===================
let tmChart = null;
async function drawTimeMachine(d) {
if (!d.district) return;
const data = await fetch('/api/district-time-machine/' + encodeURIComponent(d.district)).then(r=>r.json());
const draw = (metric) => {
const el = document.getElementById('tmChart');
if (!el) return;
if (tmChart) tmChart.dispose();
tmChart = echarts.init(el, null, {renderer:'canvas'});
const colors = {deals:'#0ea5e9', median_price_kr:'#10b981', volume_m2:'#7c3aed', active_corpuses:'#f59e0b'};
tmChart.setOption({
grid:{left:42,right:14,top:14,bottom:24},
xAxis:{type:'category', data:data.series.map(x=>x.month.slice(2)), axisLabel:{fontSize:9,color:'#64748b'}},
yAxis:{type:'value', axisLabel:{fontSize:9,color:'#64748b'}, splitLine:{lineStyle:{color:'#e2e8f0'}}},
series:[{type:'line', smooth:true, symbol:'circle', symbolSize:5,
data:data.series.map(x=>x[metric] ?? 0),
areaStyle:{color:colors[metric]+'33'}, lineStyle:{color:colors[metric], width:2.4},
itemStyle:{color:colors[metric]}}],
tooltip:{trigger:'axis'},
});
};
draw(document.getElementById('tm-metric').value);
document.getElementById('tm-metric').addEventListener('change', e => draw(e.target.value));
}
// =================== POI rendering with filter + cluster ===================
function renderPoiMarkers(d) {
poiLayer.clearLayers();
const filterCat = document.getElementById('poi-filter-cat')?.value || 'all';
const ALL_CATS = POI_GROUPS.flatMap(g => g.cats);
const cats = filterCat === 'all' ? ALL_CATS : [filterCat];
// collect POIs
const points = [];
for (const cat of cats) {
const top = d.features?.[`${cat}_top5`] || [];
const limit = filterCat === 'all' ? 3 : 5;
for (const p of top.slice(0, limit)) {
points.push({...p, cat});
}
}
// grid bucket clustering at small zoom for visual cleanup
const z = map.getZoom();
const gridDeg = z >= 16 ? 0.0002 : 0.0008;
const buckets = new Map();
for (const p of points) {
const k = `${Math.floor(p.lat/gridDeg)}/${Math.floor(p.lon/gridDeg)}`;
if (!buckets.has(k)) buckets.set(k, []);
buckets.get(k).push(p);
}
for (const arr of buckets.values()) {
if (arr.length === 1) {
const p = arr[0];
const isHypo = (p.name||'').includes('Hypothetical');
const html = isHypo
? `<div class="whatif-marker" style="width:24px;height:24px;border-radius:50%;background:#a855f7;border:3px solid white;display:flex;align-items:center;justify-content:center;color:white;font-weight:700;font-size:14px">+</div>`
: svgIcon(p.cat, 20);
poiLayer.addLayer(L.marker([p.lat, p.lon], {
icon: L.divIcon({className:'poi-svg', html, iconSize:[22,22], iconAnchor:[11,11]})
}).bindPopup(`<b>${p.name||RU[p.cat]}</b><br>${RU[p.cat]||p.cat} · ${p.distance_m.toFixed(0)} м`));
} else {
// multi POI in same bucket → mini-cluster
const lat = arr.reduce((s,p)=>s+p.lat,0)/arr.length;
const lon = arr.reduce((s,p)=>s+p.lon,0)/arr.length;
const types = [...new Set(arr.map(p => p.cat))];
const mainCat = types[0];
const html = `<div style="position:relative;width:26px;height:26px">
${svgIcon(mainCat, 22)}
<div style="position:absolute;top:-4px;right:-4px;background:#0f172a;color:white;border-radius:50%;width:14px;height:14px;font-size:9px;font-weight:700;display:flex;align-items:center;justify-content:center;border:1.5px solid white">${arr.length}</div>
</div>`;
const popupHtml = arr.slice(0,8).map(p => `<div>${RU[p.cat]||p.cat} · ${(p.name||'').slice(0,30)} · ${p.distance_m.toFixed(0)} м</div>`).join('');
poiLayer.addLayer(L.marker([lat, lon], {
icon: L.divIcon({className:'poi-cluster', html, iconSize:[26,26], iconAnchor:[13,13]})
}).bindPopup(`<b>${arr.length} POI</b><br>${popupHtml}`));
}
}
}
document.getElementById('poi-filter-cat')?.addEventListener('change', () => {
if (LAST_RESULT) renderPoiMarkers(LAST_RESULT);
});
map.on('zoomend', () => {
if (LAST_RESULT && map.hasLayer(poiLayer)) renderPoiMarkers(LAST_RESULT);
});
// =================== Risk score & Multi-class TEP ===================
async function loadRiskScore(d) {
const el = document.getElementById('riskOutput');
el.innerHTML = '<div class="hint">загружаем (живая Overpass-запрос ~5s)…</div>';
try {
const r = await fetch(`/api/risk-score?lat=${d.input.lat}&lon=${d.input.lon}`).then(r=>r.json());
const cls = r.risk_score >= 80 ? 'b-good' : r.risk_score >= 50 ? 'b-mid' : 'b-low';
const RU_RISK = {railway:'Ж/Д', highway:'Магистраль', industry:'Промзона', fuel:'АЗС близко', fire:'Пожарка'};
const RISK_W = {railway:0.3, highway:0.3, industry:0.25, fuel:0.05, fire:0.1};
const rows = Object.entries(r.risks).map(([k,v]) => {
const w = (v.weight ?? RISK_W[k] ?? 0);
const pen = v.penalty_pct ?? 0;
return `
<div style="display:grid;grid-template-columns:90px 1fr auto auto;gap:6px;padding:5px 8px;background:white;border:1px solid #e2e8f0;border-radius:6px;margin-bottom:4px;font-size:11px;align-items:center">
<span style="font-weight:600">${RU_RISK[k]||k}</span>
<span class="hint">${v.min_distance_m ? Math.round(v.min_distance_m)+' м · в буфере '+v.n_within_buffer : '—'}</span>
<span style="color:${pen>10?'#ef4444':pen>0?'#f59e0b':'#10b981'};font-weight:700">${pen.toFixed(1)}</span>
<span class="hint">×${(w*100).toFixed(0)}%</span>
</div>`;
}).join('');
el.innerHTML = `
<div class="scorebox" style="padding:10px">
<div style="font-size:36px;font-weight:700;line-height:1;color:${cls==='b-good'?'#0a6':cls==='b-mid'?'#a16207':'#dc2626'}">${r.risk_score.toFixed(0)}</div>
<div>
<div style="font-size:11px;color:#64748b">риск-скор</div>
<div class="meta" style="font-size:11px">штраф ${r.total_penalty}, чем выше скор — тем лучше (меньше шума/опасности рядом)</div>
</div>
</div>
<div style="margin-top:8px">${rows}</div>
`;
} catch(e) { el.innerHTML = '<div class="err">'+e.message+'</div>'; }
}
async function loadMultiClass(d) {
const el = document.getElementById('multiclassOutput');
const area = d.polygon?._area_m2 || 2789;
el.innerHTML = '<div class="hint">считаем 4 класса…</div>';
try {
const r = await fetch('/api/tep-multiclass', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({district: d.district, parcel_area_m2: area, far: 2.0})}).then(r=>r.json());
el.innerHTML = `
<div class="hint">Лучший класс: <b style="color:#0a6">${r.best_class}</b> · район ${r.district||'—'} · площадь ${area} м² · FAR=2.0</div>
<table class="lot-table" style="margin-top:6px">
<thead><tr><th>Класс</th><th class=r>Цена ₽/м²</th><th class=r>Кв.</th><th class=r>Выручка</th><th class=r>Себестоим.</th><th class=r>Прибыль</th><th class=r>Маржа</th></tr></thead>
<tbody>${r.results.map(x => {
const pcls = x.margin_pct >= 15 ? '#0a6' : x.margin_pct >= 0 ? '#a16207' : '#dc2626';
return `<tr>
<td><b>${x.class}</b></td>
<td class=r>${x.price_kr_per_m2.toLocaleString('ru')} тыс</td>
<td class=r>${x.n_units}</td>
<td class=r>${x.revenue_mln} млн</td>
<td class=r>${x.cost_mln} млн</td>
<td class=r style="color:${pcls};font-weight:700">${x.profit_mln} млн</td>
<td class=r style="color:${pcls};font-weight:700">${x.margin_pct}%</td>
</tr>`;
}).join('')}</tbody>
</table>`;
} catch(e) { el.innerHTML = '<div class="err">'+e.message+'</div>'; }
}
// =================== Audience comparison ===================
let audChart = null;
async function drawAudiencesChart(d) {
const auds = await fetch('/api/audiences').then(r=>r.json());
const scores = {};
for (const [k, w] of Object.entries(auds)) {
const weights = {education:w.education, health:w.health, retail:w.retail,
transit:w.transit, leisure:w.leisure, economic:w.economic, market:w.market};
scores[k] = {label: w.label, value: +Object.entries(d.scores).reduce(
(s, [c,v]) => s + (weights[c]||0)*v, 0).toFixed(1)};
}
const sorted = Object.entries(scores).sort((a,b)=>b[1].value-a[1].value);
const el = document.getElementById('audChart');
if (audChart) audChart.dispose();
audChart = echarts.init(el, null, {renderer:'canvas'});
audChart.setOption({
grid:{left:130,right:30,top:10,bottom:10},
xAxis:{type:'value', max:100, axisLabel:{fontSize:9, color:'#64748b'}},
yAxis:{type:'category', data: sorted.map(([k,v])=>v.label.split(' ')[0]), axisLabel:{fontSize:11, color:'#475569'}},
series:[{type:'bar', data: sorted.map(([k,v]) => ({
value: v.value,
itemStyle:{color: v.value>=75?'#10b981':v.value>=50?'#0ea5e9':'#94a3b8', borderRadius:[0,4,4,0]}
})),
label:{show:true, position:'right', fontSize:11, fontWeight:'700'}
}],
});
document.getElementById('audTable').innerHTML = `
<div class="hint">лучшая аудитория для участка: <b>${sorted[0][1].label}</b> (${sorted[0][1].value})</div>`;
}
// =================== Smart Suggestions ===================
async function loadSuggestions(d) {
const el = document.getElementById('suggestList');
if (!el) return;
el.innerHTML = '<div class="hint">загружаем…</div>';
const aud = document.getElementById('audience').value;
const data = await fetch(`/api/smart-suggestions?lat=${d.input.lat}&lon=${d.input.lon}&audience=${aud}&radius_km=3&top_n=8`).then(r=>r.json());
const myScore = d.weighted;
const better = data.results.filter(r => r.weighted > myScore);
if (!better.length) {
el.innerHTML = '<div class="empty">в радиусе 3км нет ЖК с большим баллом — твой участок локально лучший</div>';
return;
}
el.innerHTML = better.map(r => {
const delta = (r.weighted - myScore).toFixed(1);
const cls = r.weighted >= 75 ? '#0a6' : '#0ea5e9';
return `<div class="cmp" data-id="${r.site_id}" data-lat="${r.lat}" data-lon="${r.lon}">
<div class="dist">${r.distance_m} м</div>
<div>
<div class="nm">${r.name||'—'}</div>
<div class="meta">${r.developer||''}${r.obj_class?' · '+r.obj_class:''} · ${r.obj_district||''}</div>
</div>
<div></div>
<div class="sc" style="color:${cls}">${r.weighted.toFixed(0)}<small style="color:#0a6">+${delta}</small></div>
</div>`;
}).join('');
el.querySelectorAll('.cmp').forEach(c => c.addEventListener('click', () => openJK(c.dataset.id)));
}
// =================== Boxplot цен по классам ===================
let boxChart = null;
async function drawBoxplot(d) {
if (!d.district) return;
const data = await fetch('/api/price-distribution/' + encodeURIComponent(d.district)).then(r=>r.json());
const el = document.getElementById('boxplotChart');
if (!el) return;
if (boxChart) boxChart.dispose();
boxChart = echarts.init(el, null, {renderer:'canvas'});
const classes = Object.keys(data.by_class);
if (!classes.length) {
el.innerHTML = '<div class="empty">нет данных по классам</div>';
return;
}
const series = classes.map(cls => {
const x = data.by_class[cls];
return [x.min, x.p25, x.p50, x.p75, x.max];
});
boxChart.setOption({
grid:{left:50, right:14, top:14, bottom:36},
xAxis:{type:'category', data: classes, axisLabel:{fontSize:10, color:'#64748b'}},
yAxis:{type:'value', name:'тыс ₽/м²', axisLabel:{fontSize:9, color:'#64748b'}, splitLine:{lineStyle:{color:'#e2e8f0'}}},
series:[{type:'boxplot', data: series, itemStyle:{color:'#0ea5e9', borderColor:'#0284c7'}}],
tooltip:{trigger:'item', formatter: p => {
const c = classes[p.dataIndex]; const v = data.by_class[c];
return `<b>${c}</b> (n=${v.n})<br>min ${v.min.toFixed(0)} · P25 ${v.p25.toFixed(0)} · med ${v.p50.toFixed(0)} · P75 ${v.p75.toFixed(0)} · max ${v.max.toFixed(0)}`;
}}
});
}
// =================== A/B compare ===================
document.getElementById('btn-ab').addEventListener('click', () => {
if (!LAST_RESULT) { toast('Сначала оцени участок', 'warn', 2500); return; }
const ids = [...SELECTED_FOR_AB];
if (ids.length === 0) { toast('Shift+клик по 13 ЖК во вкладке «ЖК рядом», потом нажми A/B', 'info', 4000); return; }
buildABModal(ids);
});
async function buildABModal(ids) {
document.getElementById('abContent').innerHTML = 'загружаем профили…';
document.getElementById('abModal').classList.add('show');
const sites = await Promise.all(ids.map(id => fetch('/api/site/' + encodeURIComponent(id)).then(r=>r.json())));
const keys = ['economic','education','retail','transit','health','leisure','market'];
const indicators = keys.map(k => ({name: COMP_RU[k], max: 100}));
const colors = ['#0ea5e9','#10b981','#f59e0b','#ef4444'];
const data = [{name:'Участок', value: keys.map(k => LAST_RESULT.scores[k]),
itemStyle:{color: colors[0]},
areaStyle:{color:colors[0]+'33'}, lineStyle:{color:colors[0], width:2}}];
sites.forEach((s, i) => data.push({
name: s.name || s.site_id, value: keys.map(k => s.scores?.[k] || 0),
itemStyle:{color: colors[i+1]},
areaStyle:{color:colors[i+1]+'33'}, lineStyle:{color:colors[i+1], width:2}
}));
const tableHTML = `
<table style="border-collapse:collapse;width:100%;font-size:12px;margin-top:14px">
<thead><tr>
<th style="text-align:left;padding:8px 6px;border-bottom:1px solid #e2e8f0">Метрика</th>
<th style="text-align:right;padding:8px 6px;border-bottom:1px solid #e2e8f0;color:${colors[0]}">Участок</th>
${sites.map((s,i) => `<th style="text-align:right;padding:8px 6px;border-bottom:1px solid #e2e8f0;color:${colors[i+1]};max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${s.name||s.site_id}</th>`).join('')}
</tr></thead>
<tbody>
${[
['Балл', LAST_RESULT.weighted.toFixed(1), sites.map(s => s.weighted?.toFixed?.(1) || '—')],
['Ранг', '#'+LAST_RESULT.rank_overall, sites.map(s => s.rank_overall ? '#'+s.rank_overall : '—')],
['Район', LAST_RESULT.district||'—', sites.map(s => s.economics?.district || s.district||'—')],
['Девелопер', '—', sites.map(s => s.developer || '—')],
['Класс', '—', sites.map(s => s.obj_class || '—')],
['Цена м²', '—', sites.map(s => s.economics?.real_median_price_m2 ? s.economics.real_median_price_m2.toFixed(1)+' тыс' : '—')],
...keys.map(k => [COMP_RU[k], LAST_RESULT.scores[k].toFixed(1), sites.map(s => s.scores?.[k]?.toFixed?.(1) || '—')])
].map(row => `<tr>
<td style="padding:6px;border-bottom:1px solid #f1f5f9;color:#64748b">${row[0]}</td>
<td style="text-align:right;padding:6px;border-bottom:1px solid #f1f5f9;font-weight:600">${row[1]}</td>
${row[2].map(v => `<td style="text-align:right;padding:6px;border-bottom:1px solid #f1f5f9">${v}</td>`).join('')}
</tr>`).join('')}
</tbody>
</table>`;
document.getElementById('abContent').innerHTML = `
<div class="ab-grid">
<div><div id="abRadarEl" class="ab-radar"></div></div>
<div>${tableHTML}</div>
</div>`;
if (abRadar) abRadar.dispose();
abRadar = echarts.init(document.getElementById('abRadarEl'));
abRadar.setOption({
radar: {indicator: indicators, shape:'polygon', radius:'68%', axisName:{color:'#475569', fontSize:11}},
series: [{type: 'radar', data: data, symbol:'circle', symbolSize:5}],
legend: {data: data.map(x => x.name), bottom: 0, textStyle:{fontSize:11}, type:'scroll'},
tooltip: {}
});
}
// =================== Buttons ===================
document.getElementById('btn-share').addEventListener('click', () => {
navigator.clipboard.writeText(location.href).then(() => {
const b = document.getElementById('btn-share');
const t = b.textContent; b.textContent = '✅ скопирована!';
setTimeout(() => b.textContent = t, 1500);
});
});
document.getElementById('btn-print').addEventListener('click', async () => {
if (!LAST_RESULT) { toast('Сначала оцени участок', 'warn', 2500); return; }
toast('Готовим PDF — несколько секунд…', 'info', 1800);
// Close any open modals so they don't block the print
document.querySelectorAll('.modal-bg').forEach(m => m.classList.remove('show'));
// Each result tab has lazy chart/data loaders wired to the tab button click.
// To get a complete PDF, click every tab in turn so its async fetch + chart paint runs.
// Skip 'raw' and 'radar' (we hide them in print anyway).
const tabsToHydrate = ['econ','suggest','audiences','risk','multiclass','tep','prices','cmp','poi'];
for (const t of tabsToHydrate) {
const btn = document.querySelector(`.tabs button[data-tab="${t}"]`);
if (btn) { btn.click(); await new Promise(r => setTimeout(r, 280)); }
}
// Resize ECharts so legends/labels fit in print width
if (window.echarts) {
document.querySelectorAll('.chart').forEach(el => {
try { const inst = window.echarts.getInstanceByDom(el); if (inst) inst.resize(); } catch(_) {}
});
}
// Final wait for any pending async (risk/multiclass overpass calls)
await new Promise(r => setTimeout(r, 400));
window.print();
});
// Pin parcels on map for compare
const pinnedLayer = L.layerGroup().addTo(map);
const PINNED = [];
document.getElementById('btn-pin').addEventListener('click', () => {
if (!LAST_RESULT) { toast('Сначала оцени участок', 'warn'); return; }
const p = {
cad: LAST_RESULT.input.cad, lat: LAST_RESULT.input.lat, lon: LAST_RESULT.input.lon,
weighted: LAST_RESULT.weighted, district: LAST_RESULT.district,
audience: LAST_RESULT.input.audience, scores: {...LAST_RESULT.scores},
name: LAST_RESULT.input.name || (LAST_RESULT.input.cad || 'Парцель'),
};
PINNED.push(p);
const idx = PINNED.length;
const colors = ['#ef4444','#0ea5e9','#10b981','#f59e0b','#7c3aed'];
const col = colors[(idx-1) % colors.length];
const m = L.marker([p.lat, p.lon], {
icon: L.divIcon({className:'', iconSize:[28,28], iconAnchor:[14,14],
html:`<div style="background:${col};color:white;width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;border:2px solid white;box-shadow:0 1px 6px rgba(0,0,0,.3)">${idx}</div>`})
});
m.bindPopup(`<b>#${idx} ${p.name}</b><br>Балл: ${p.weighted.toFixed(1)}<br>Район: ${p.district||'—'}<br>Профиль: ${p.audience}`);
pinnedLayer.addLayer(m);
toast(`Участок #${idx} закреплён · ${PINNED.length} в сравнении`, 'success');
renderPinnedCompare();
});
function renderPinnedCompare() {
const block = document.getElementById('pinnedCompareBlock');
if (!block) return;
if (PINNED.length < 1) { block.innerHTML = ''; return; }
const colors = ['#ef4444','#0ea5e9','#10b981','#f59e0b','#7c3aed'];
const cmp = `<h2>Закреплённые участки <span class="help" style="cursor:pointer" id="clear-pinned">очистить</span></h2>
<table class="lot-table">
<thead><tr><th>#</th><th>Участок</th><th>Район</th><th class="r">Балл</th><th></th></tr></thead>
<tbody>${PINNED.map((p,i)=>`
<tr>
<td><span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:${colors[i%5]};color:white;font-size:9px;text-align:center;line-height:14px">${i+1}</span></td>
<td>${p.name}</td>
<td>${p.district||'—'}</td>
<td class="r"><b>${p.weighted.toFixed(1)}</b></td>
<td><button data-pin-fly="${i}" style="border:0;background:transparent;cursor:pointer;color:#0284c7">→</button></td>
</tr>`).join('')}
</tbody></table>`;
block.innerHTML = cmp;
block.querySelectorAll('[data-pin-fly]').forEach(b => b.addEventListener('click', () => {
const p = PINNED[+b.dataset.pinFly];
map.flyTo([p.lat, p.lon], 16, {duration:0.5});
}));
document.getElementById('clear-pinned')?.addEventListener('click', () => {
PINNED.length = 0; pinnedLayer.clearLayers(); renderPinnedCompare();
});
}
// Export
document.getElementById('btn-export').addEventListener('click', () => {
const aud = document.getElementById('audience').value;
const choice = prompt('Что экспортировать?\n 1 - CSV (для Excel)\n 2 - GeoJSON (для QGIS / Mapbox)', '1');
if (!choice) return;
if (choice.trim() === '1') {
window.open('/api/export/csv?audience=' + aud, '_blank');
toast('CSV скачивается', 'success');
} else if (choice.trim() === '2') {
fetch('/api/export/geojson?audience=' + aud).then(r => r.json()).then(fc => {
const blob = new Blob([JSON.stringify(fc, null, 2)], {type: 'application/geo+json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `gendesign_jk_${aud}.geojson`;
a.click();
URL.revokeObjectURL(a.href);
toast('GeoJSON скачан · ' + fc.features.length + ' ЖК', 'success');
});
}
});
// ROI district ranking
async function loadROI(cls = 'комфорт', far = 2.0, area = 3000) {
const c = document.getElementById('roiContent');
c.innerHTML = '<div style="padding:40px;text-align:center;color:#94a3b8">считаем по 31 району…</div>';
const data = await fetch(`/api/district-roi-ranking?obj_class=${encodeURIComponent(cls)}&far=${far}&parcel_area_m2=${area}`).then(r=>r.json());
c.innerHTML = `
<div class="jk-hero" style="background:linear-gradient(135deg,#10b981,#0ea5e9)">
<h2 style="display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap">
<span>📈 ROI по районам · класс ${cls} · ${area} м² · FAR ${far}</span>
<span style="display:flex;gap:6px">
<select id="roi-class" style="font-size:11px;padding:4px 8px;border-radius:6px;background:white;color:#0f172a;border:0">
${['эконом','комфорт','бизнес','премиум'].map(k=>`<option ${k===cls?'selected':''}>${k}</option>`).join('')}
</select>
<input id="roi-far" type="number" step="0.1" value="${far}" style="width:55px;padding:4px;font-size:11px;border-radius:6px;background:white;color:#0f172a;border:0" title="FAR (плотность застройки)">
<input id="roi-area" type="number" step="500" value="${area}" style="width:75px;padding:4px;font-size:11px;border-radius:6px;background:white;color:#0f172a;border:0" title="Площадь участка, м²">
</span>
</h2>
<div class="subtitle">Ранжирование 31 района по годовой ROI с учётом цены, скорости продаж и сроков sellout</div>
</div>
<div style="padding:18px 24px 24px">
<table class="lot-table">
<thead><tr>
<th>#</th><th>Район</th>
<th class="r">Цена ₽/м²</th><th class="r">Кв.</th><th class="r">Выручка</th>
<th class="r">Прибыль</th><th class="r">Маржа</th><th class="r">Sellout</th><th class="r">ROI</th>
</tr></thead>
<tbody>
${data.results.map((r,i) => {
const roiCls = r.roi_annual_pct >= 30 ? 'b-good' : r.roi_annual_pct >= 12 ? 'b-mid' : 'b-low';
return `<tr>
<td><b>${i+1}</b></td>
<td><b>${r.district}</b><div class="hint">${r.n_projects||0} проектов · ${r.sold_pct}% sold</div></td>
<td class="r">${r.price_kr_class.toLocaleString('ru')} тыс</td>
<td class="r">${r.n_units}</td>
<td class="r">${r.revenue_mln} млн</td>
<td class="r" style="color:${r.profit_mln>0?'#0a6':'#dc2626'};font-weight:700">${r.profit_mln} млн</td>
<td class="r">${r.margin_pct}%</td>
<td class="r">${r.months_to_sellout} мес</td>
<td class="r"><span class="badge ${roiCls}">${r.roi_annual_pct}%</span></td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>
`;
// wire reactive controls
const recompute = () => loadROI(
document.getElementById('roi-class').value,
+document.getElementById('roi-far').value || 2.0,
+document.getElementById('roi-area').value || 3000
);
document.getElementById('roi-class').addEventListener('change', recompute);
document.getElementById('roi-far').addEventListener('change', recompute);
document.getElementById('roi-area').addEventListener('change', recompute);
}
document.getElementById('btn-roi').addEventListener('click', () => {
document.getElementById('roiModal').classList.add('show');
loadROI();
});
// Methodology
document.getElementById('btn-methodology').addEventListener('click', () => {
document.getElementById('methodModal').classList.add('show');
});
// Leaderboard
async function loadLeaderboard(audience) {
const c = document.getElementById('topContent');
c.innerHTML = '<div style="padding:40px;text-align:center;color:#94a3b8">загружаем лидерборд…</div>';
try {
const r = await fetch(`/api/leaderboard?audience=${audience}&limit=30`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
if (!data || !data.results) throw new Error('Пустой ответ');
c.innerHTML = renderLeaderboardHTML(data);
} catch (err) {
c.innerHTML = `<div style="padding:40px;text-align:center;color:#dc2626">Лидерборд недоступен: ${err.message}</div>`;
}
}
function renderLeaderboardHTML(data) {
return `
<div class="jk-hero" style="background:linear-gradient(135deg,#7c3aed,#0ea5e9)">
<h2 style="display:flex;justify-content:space-between;align-items:center">
<span>Топ-30 ЖК Екб · профиль ${data.audience}</span>
<select id="lb-audience" style="font-size:11px;padding:4px 8px;border-radius:6px;background:white;color:#0f172a;border:0">
${Object.entries({balanced:'Сбалансированный',family:'Семейный',premium:'Премиум',economy:'Эконом',investor:'Инвестор'}).map(([k,v]) => `<option value="${k}" ${k===data.audience?'selected':''}>${v}</option>`).join('')}
</select>
</h2>
<div class="subtitle"><span>Веса: ${Object.entries(data.weights).map(([k,v])=>k+'·'+(v*100).toFixed(0)+'%').join(' · ')}</span></div>
</div>
<div style="padding:18px 24px 24px">
<table class="lot-table">
<thead><tr>
<th>#</th><th>ЖК</th><th>Девелопер</th><th>Класс</th><th>Район</th>
<th class="r">Цена</th><th class="r">Vel</th><th class="r">Балл</th>
</tr></thead>
<tbody>
${data.results.map((r,i) => `
<tr style="cursor:pointer" onclick="document.getElementById('topModal').classList.remove('show'); openJK('${r.site_id}')" onmouseover="this.style.background='#f1f5f9'" onmouseout="this.style.background=''">
<td><b>${i+1}</b></td>
<td>${r.name||'—'}</td>
<td style="color:#64748b">${r.developer||'—'}</td>
<td>${r.obj_class||'—'}</td>
<td>${r.obj_district||'—'}</td>
<td class="r">${r.price_m2_kr?r.price_m2_kr+' тыс':'—'}</td>
<td class="r">${r.velocity_6mo?r.velocity_6mo.toFixed(1):'—'}</td>
<td class="r"><b style="color:${r.weighted>=75?'#0a6':r.weighted>=50?'#0ea5e9':'#94a3b8'};font-size:14px">${r.weighted.toFixed(1)}</b></td>
</tr>
`).join('')}
</tbody>
</table>
<div class="hint" style="margin-top:8px">Всего совпадает по фильтру: ${data.total_matched}. Клик по строке карточка ЖК.</div>
</div>
`;
}
document.getElementById('btn-top').addEventListener('click', async () => {
document.getElementById('topModal').classList.add('show');
await loadLeaderboard(document.getElementById('audience').value);
});
// Live re-rank inside leaderboard modal
document.addEventListener('change', (e) => {
if (e.target.id === 'lb-audience') {
loadLeaderboard(e.target.value);
}
});
// Dark mode (persisted)
function applyDark(on) {
document.documentElement.classList.toggle('dark', on);
localStorage.setItem('gendesign_dark', on ? '1' : '0');
}
applyDark(localStorage.getItem('gendesign_dark') === '1');
document.getElementById('btn-dark').addEventListener('click', () => {
applyDark(!document.documentElement.classList.contains('dark'));
});
// =================== Global error overlay (Sentry-style) ===================
window.addEventListener('error', e => {
if (typeof toast === 'function') toast('JS error: ' + (e.message?.slice(0, 80) || 'unknown'), 'error', 5000);
console.error('[err]', e.message, e.error);
});
window.addEventListener('unhandledrejection', e => {
if (typeof toast === 'function') toast('Promise rejected: ' + String(e.reason).slice(0, 80), 'warn', 5000);
console.warn('[reject]', e.reason);
});
// =================== Quick-win banner ===================
function showQuickTipIfFresh() {
const seen = JSON.parse(localStorage.getItem('gendesign_tips_seen')||'[]');
const TIPS = [
{id:'poly', text:'Включи слой <b>Полигоны ЖК</b> на карте — увидишь footprint каждого ЖК', when: () => !document.getElementById('layer-jk-poly')?.checked},
{id:'dark', text:'Тёмная тема: жми кнопку 🌓 или клавишу <b>d</b>', when: () => !document.documentElement.classList.contains('dark')},
{id:'top', text:'Кнопка <b>★ Топ</b> покажет 30 лучших ЖК Екб по выбранному профилю', when: () => true},
{id:'pin', text:'Закрепи 2-3 участка через <b>📍+</b> и сравни на карте side-by-side', when: () => true},
{id:'tep', text:'Открой вкладку <b>ТЭП·IRR</b> в результате — посчитает выручку, NPV, IRR от участка', when: () => !!LAST_RESULT},
{id:'whatif', text:'Раздел <b>Что-если</b>: добавь гипотетическое метро или школу — пересчитает балл', when: () => !!LAST_RESULT},
{id:'export', text:'Кнопка <b>⬇</b> в шапке — скачать всю выборку в CSV или GeoJSON', when: () => true},
{id:'shortcut', text:'Жми клавиши <b>/</b> поиск, <b>Esc</b> закрыть, <b>r</b> сброс, <b>a</b> A/B, <b>p</b> печать', when: () => true},
];
const eligible = TIPS.filter(t => !seen.includes(t.id) && t.when());
if (!eligible.length) return;
const tip = eligible[Math.floor(Math.random() * eligible.length)];
setTimeout(() => {
const banner = document.createElement('div');
banner.style.cssText = 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#0f172a;color:white;padding:10px 16px;border-radius:99px;box-shadow:0 6px 22px rgba(0,0,0,.3);font-size:12px;z-index:1500;display:flex;align-items:center;gap:10px;animation:fade .3s';
banner.innerHTML = `<span>💡 ${tip.text}</span><button style="background:transparent;border:0;color:#94a3b8;cursor:pointer;font-size:14px" onclick="this.parentElement.remove();const s=JSON.parse(localStorage.getItem('gendesign_tips_seen')||'[]');s.push('${tip.id}');localStorage.setItem('gendesign_tips_seen',JSON.stringify(s))">×</button>`;
document.body.appendChild(banner);
setTimeout(() => banner.remove(), 12000);
const arr = seen.concat([tip.id]);
localStorage.setItem('gendesign_tips_seen', JSON.stringify(arr));
}, 6000);
}
showQuickTipIfFresh();
// =================== Cross-tab sync (BroadcastChannel) ===================
const BC = ('BroadcastChannel' in window) ? new BroadcastChannel('gendesign') : null;
if (BC) {
BC.onmessage = e => {
if (e.data?.type === 'recent_updated') renderRecent();
if (e.data?.type === 'scenario_updated') renderScenarios();
if (e.data?.type === 'pin_updated') renderPinnedCompare();
};
// Trigger sync when localStorage changes from same-tab actions:
const _saveRecentOrig = saveRecent;
saveRecent = function(p) { _saveRecentOrig(p); BC.postMessage({type:'recent_updated'}); };
const _saveScnOrig = saveScenarios;
saveScenarios = function(l) { _saveScnOrig(l); BC.postMessage({type:'scenario_updated'}); };
}
// Also listen to native storage events (works for different domains/tabs)
window.addEventListener('storage', (e) => {
if (e.key === 'gendesign_recent_parcels') renderRecent();
if (e.key === 'gendesign_scenarios') renderScenarios();
});
// =================== Onboarding tour ===================
function startTour() {
const STEPS = [
{sel: '#searchBox', text: 'Поиск ЖК по имени, девелоперу, кадастру. Жми «/» для быстрого фокуса.'},
{sel: '#audience', text: 'Профиль аудитории — переключение моментально пере-взвешивает баллы (live).'},
{sel: '#go', text: 'Главная кнопка — анализ участка. POI, экономика, walkability, ТЭП.'},
{sel: '#btn-fetch-poly', text: 'Тянет точную геометрию из Росреестра по кадастру (через rosreestr2coord).'},
{sel: '.layer-controls', text: 'Слои карты: ЖК, полигоны, POI, heatmap, плотность POI, фильтры.'},
{sel: '#btn-top', text: 'Топ-30 ЖК Екб по выбранному профилю.'},
{sel: '#btn-pin', text: 'Закрепи участок — для multi-parcel сравнения на карте.'},
{sel: '#btn-export', text: 'Скачай весь срез данных в CSV или GeoJSON.'},
];
let i = 0;
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(15,23,42,0.7);z-index:5000;backdrop-filter:blur(2px)';
document.body.appendChild(overlay);
const tip = document.createElement('div');
tip.style.cssText = 'position:fixed;background:white;color:#0f172a;padding:14px 18px;border-radius:10px;box-shadow:0 6px 22px rgba(0,0,0,.3);max-width:340px;z-index:5001;font-size:13px;line-height:1.5';
document.body.appendChild(tip);
function show() {
if (i >= STEPS.length) { overlay.remove(); tip.remove(); localStorage.setItem('gendesign_tour_done','1'); return; }
const step = STEPS[i];
const el = document.querySelector(step.sel);
if (!el) { i++; show(); return; }
const r = el.getBoundingClientRect();
el.style.outline = '3px solid #f59e0b';
el.style.outlineOffset = '3px';
el.style.position = 'relative';
el.style.zIndex = '5002';
tip.innerHTML = `<div style="font-size:10px;color:#94a3b8;text-transform:uppercase;font-weight:700;margin-bottom:4px">шаг ${i+1}/${STEPS.length}</div>
<div>${step.text}</div>
<div style="margin-top:10px;display:flex;gap:6px;justify-content:flex-end">
<button id="tour-skip" style="background:transparent;border:0;color:#94a3b8;cursor:pointer;font-size:11px">пропустить</button>
<button id="tour-next" style="background:#0ea5e9;color:white;border:0;border-radius:6px;padding:5px 12px;cursor:pointer;font-weight:600">далее →</button>
</div>`;
const tipR = {w: 340, h: 130};
let top = r.bottom + 8, left = r.left;
if (top + tipR.h > window.innerHeight - 10) top = Math.max(10, r.top - tipR.h - 8);
if (left + tipR.w > window.innerWidth - 10) left = Math.max(10, window.innerWidth - tipR.w - 10);
tip.style.top = top + 'px'; tip.style.left = left + 'px';
document.getElementById('tour-next').onclick = () => {
el.style.outline = ''; el.style.outlineOffset = ''; el.style.zIndex='';
i++; show();
};
document.getElementById('tour-skip').onclick = () => {
el.style.outline = ''; el.style.outlineOffset = ''; el.style.zIndex='';
overlay.remove(); tip.remove(); localStorage.setItem('gendesign_tour_done','1');
};
}
show();
}
// Auto-show on first visit
if (!localStorage.getItem('gendesign_tour_done')) {
setTimeout(startTour, 1500);
}
// Mobile burger
document.getElementById('mobileFab').addEventListener('click', () => {
const open = document.body.classList.toggle('mobile-menu');
document.getElementById('mobileFab').textContent = open ? '×' : '☰';
if (!open) setTimeout(() => map.invalidateSize(), 50);
});
// Sidebar toggle
document.getElementById('sideToggle').addEventListener('click', () => {
document.body.classList.toggle('side-collapsed');
document.getElementById('sideToggle').textContent = document.body.classList.contains('side-collapsed') ? '' : '';
setTimeout(() => map.invalidateSize(), 230);
});
// =================== ЖК full detail modal ===================
const PROD_PHOTO_BASE = 'http://46.173.16.127/api/v1/photos/'; // backend serves photos at this prefix; CORS may block — handled below
let jkDetailChart = null;
async function openJK(siteId) {
const m = document.getElementById('jkModal');
const c = document.getElementById('jkContent');
c.innerHTML = '<div style="padding:40px;text-align:center;color:#94a3b8">загружаем…</div>';
m.classList.add('show');
let d;
try { d = await fetch('/api/jk-full/' + encodeURIComponent(siteId)).then(r => r.json()); }
catch (e) { c.innerHTML = '<div style="padding:24px;color:#ef4444">ошибка загрузки</div>'; return; }
const e = d.economics || {};
const ls = d.lots_summary || {};
const photos = d.prod_extras?.photos || [];
const sg = d.prod_extras?.sale_graph || [];
const cls = d.weighted >= 75 ? 'b-good' : d.weighted >= 50 ? 'b-mid' : 'b-low';
const numColor = cls === 'b-good' ? '#0a6' : cls === 'b-mid' ? '#a16207' : '#dc2626';
const compRows = ['economic','education','retail','transit','health','leisure','market'].map(k => {
const v = d.scores?.[k] || 0;
return `<div class="comp">
<span class="lbl">${COMP_RU[k]}</span>
<span class="v">${v.toFixed(0)}</span>
<div class="bar"><span style="width:${v}%"></span></div>
<span class="pct"></span>
</div>`;
}).join('');
const poiRows = POI_GROUPS.map(g => {
const items = g.cats.map(cat => {
const p = d.pois?.[cat] || {};
return `<div class="poi-row" style="grid-template-columns:22px 1fr 50px 30px 30px">
<span class="icon">${svgIcon(cat,14)}</span>
<span class="cat">${RU[cat]||cat}</span>
<span class="d">${p.nearest_m?Math.round(p.nearest_m)+'м':'—'}</span>
<span class="c">${p.count_500m||0}</span>
<span class="c">${p.count_1km||0}</span>
</div>`;
}).join('');
return `<div style="font-size:9px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em;font-weight:700;padding:5px 2px 2px;margin-top:3px">${g.label}</div>${items}`;
}).join('');
const lotsRows = (d.lots_sample||[]).slice(0, 10).map(l => `
<tr>
<td>${l.corpus||'—'}/${l.section||'—'}</td>
<td class="r">${l.floor||'—'}</td>
<td>${l.rooms_obj||'—'}</td>
<td class="r">${(l.area_pd||0).toFixed(1)}</td>
<td class="r"><b>${l.price_per_m2 ? Math.round(l.price_per_m2/1000) : '—'}</b> тыс</td>
<td>${l.status||'—'}</td>
</tr>
`).join('');
const photoGrid = photos.length ? `
<div class="hint" style="margin-bottom:6px">${photos.length} фото на проде · последняя ${photos[0]?.period_dt||photos[0]?.photo_dttm||'—'}</div>
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:4px;font-size:10px;max-height:140px;overflow:auto;background:white;border:1px solid #e2e8f0;border-radius:4px;padding:6px">
${photos.map(p => `<div style="padding:3px 5px;border-bottom:1px solid #f1f5f9">
<div style="font-weight:500">${p.period_dt||p.photo_dttm||'—'}</div>
<div style="color:#64748b;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${p.url||''}">${p.ready_desc||p.name||'фото'}</div>
</div>`).join('')}
</div>
` : `<div class="hint">фото на проде: ${d.prod_extras?.error ? 'ошибка ('+d.prod_extras.error.slice(0,60)+')' : 'нет данных'}</div>`;
const salesAgg = d.prod_extras?.sales_agg || [];
const salesAggHTML = salesAgg.length ? `
<table class="lot-table" style="margin-top:4px">
<thead><tr><th>Категория</th><th class="r">Всего</th><th class="r">Продано</th><th class="r">%</th></tr></thead>
<tbody>${salesAgg.map(r => `<tr><td>${r.name||r.type||'—'}</td><td class="r">${r.total||0}</td><td class="r">${r.realised||0}</td><td class="r"><b>${r.perc?.toFixed?.(1)||'—'}%</b></td></tr>`).join('')}</tbody>
</table>
` : '<div class="hint">DOM.РФ sales_agg: нет данных</div>';
c.innerHTML = `
<div class="jk-hero">
<h2>${d.name || d.site_id}</h2>
<div class="subtitle">
<span>${d.developer||''}${d.obj_class?' · '+d.obj_class:''}</span>
<span>Район: ${e.district||d.district||'—'}</span>
<span>${d.address||''}</span>
<span>obj_id: ${d.obj_id||'—'} · ${d.flat_count?d.flat_count+' квартир':''}</span>
</div>
<div class="score-circle">
<div class="v" style="color:${numColor}">${d.weighted?.toFixed(1)||'—'}</div>
<div class="r">#${d.rank_overall||'?'} / 380</div>
</div>
</div>
<div class="jk-body">
<section class="full-w">
<h3>Компоненты балла</h3>
${compRows}
</section>
<section>
<h3>Экономика района (Объектив)</h3>
<div class="meta-grid">
<span class="l">Цена медиана</span><span class="v">${(e.real_median_price_m2||0).toFixed(1)} тыс/м²</span>
<span class="l">P25 / P75</span><span class="v">${(e.real_p25_price_m2||0).toFixed(0)} / ${(e.real_p75_price_m2||0).toFixed(0)}</span>
<span class="l">Скорость продаж (6м)</span><span class="v">${(e.real_velocity_6mo||0).toFixed(2)} ДДУ/мес</span>
<span class="l">Тренд</span><span class="v">${e.real_trend_ratio?e.real_trend_ratio.toFixed(2)+'×':'—'}</span>
<span class="l">Sold-out по району</span><span class="v">${(e.real_sold_pct||0).toFixed(1)}%</span>
<span class="l">Готовность ср.</span><span class="v">${(e.real_avg_readiness_pct||0).toFixed(0)}%</span>
<span class="l">Лотов в районе</span><span class="v">${(e.real_n_lots||0).toLocaleString('ru')}</span>
<span class="l">Проектов в районе</span><span class="v">${e.n_projects||'—'}</span>
</div>
</section>
<section>
<h3>Этот ЖК в Объективе</h3>
${d.objective_match ? `
<div class="meta-grid">
<span class="l">Проект (match)</span><span class="v">${d.objective_match.project}</span>
<span class="l">Match score</span><span class="v">${(d.objective_match.score*100).toFixed(0)}%</span>
<span class="l">Лотов в проекте</span><span class="v">${ls.total||0}</span>
<span class="l">Из них продано</span><span class="v">${ls.sold_n||0} (${ls.total?(100*ls.sold_n/ls.total).toFixed(0):0}%)</span>
<span class="l">Корпусов</span><span class="v">${ls.n_corpus||'—'}</span>
<span class="l">Типов комнат</span><span class="v">${ls.n_room_types||'—'}</span>
<span class="l">Цена м² ср.</span><span class="v">${ls.avg_price_kp||0} тыс</span>
<span class="l">Диапазон м²</span><span class="v">${ls.min_price_kp||0}${ls.max_price_kp||0}</span>
<span class="l">Площадь сделки</span><span class="v">${ls.avg_area||0} м²</span>
</div>
` : '<div class="hint">не сматчен с Объективом по имени</div>'}
</section>
<section class="full-w">
<h3>Регистрации ДДУ за 12 мес (по этому ЖК)</h3>
<div id="jkChart" class="chart-mini"></div>
</section>
<section>
<h3>POI вокруг ЖК (15 категорий)</h3>
${poiRows}
</section>
<section>
<h3>Лоты в продаже (10 шт.)</h3>
${lotsRows ? `
<table class="lot-table">
<thead><tr><th>Корп/Секц</th><th class="r">Эт</th><th>Тип</th><th class="r">м²</th><th class="r">Цена</th><th>Статус</th></tr></thead>
<tbody>${lotsRows}</tbody>
</table>
` : '<div class="hint">нет данных о лотах</div>'}
</section>
<section>
<h3>DOM.РФ продажи (текущие)</h3>
${salesAggHTML}
</section>
<section id="sellout-block">
<h3>Распродажа этого ЖК</h3>
<div class="hint" id="selloutLoading">загружаем…</div>
</section>
<section id="insol-block">
<h3>Инсоляция</h3>
<div class="hint">загружаем…</div>
</section>
<section id="poi-vs-block" class="full-w">
<h3>POI этого ЖК vs средний по району</h3>
<div class="hint">загружаем…</div>
</section>
<section id="dev-block" class="full-w">
<h3>Девелопер: трек-рекорд</h3>
<div class="hint" id="devLoading">загружаем…</div>
</section>
<section>
<h3>DOM.РФ фото-фиксации</h3>
${photoGrid}
</section>
${sg.length ? `
<section class="full-w">
<h3>DOM.РФ sale-graph (${sg.length} точек, ${sg[0]?.report_month||''}${sg.at(-1)?.report_month||''})</h3>
<div id="sgChart" class="chart-mini" style="height:160px"></div>
</section>
` : ''}
<section class="full-w">
<details>
<summary>Сырой JSON dossier</summary>
<pre>${JSON.stringify(d,null,2)}</pre>
</details>
</section>
</div>
`;
// POI vs district average
fetch('/api/poi-vs-district/' + encodeURIComponent(siteId)).then(r => r.json()).then(pd => {
const el = document.getElementById('poi-vs-block');
if (!el) return;
if (!pd.comparison?.length) {
el.innerHTML = '<h3>POI vs район</h3><div class="hint">недостаточно данных</div>';
return;
}
const rows = pd.comparison.filter(c => c.delta_pct !== null).sort((a,b) => Math.abs(b.delta_pct) - Math.abs(a.delta_pct));
el.innerHTML = `<h3>POI этого ЖК vs средний по району «${pd.district}»</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:6px">
${rows.slice(0, 12).map(r => {
const col = r.delta_pct > 20 ? '#10b981' : r.delta_pct < -20 ? '#ef4444' : '#94a3b8';
const arrow = r.delta_pct > 5 ? '↑' : r.delta_pct < -5 ? '↓' : '→';
return `<div style="display:flex;align-items:center;gap:6px;padding:4px 8px;background:white;border:1px solid #e2e8f0;border-radius:6px;font-size:11px">
${svgIcon(r.category, 14)}
<div style="flex:1">
<div style="font-weight:500">${RU[r.category]||r.category}</div>
<div class="hint" style="font-size:10px">${r.mine} vs ${r.district_avg}</div>
</div>
<div style="font-weight:700;color:${col};font-size:13px">${arrow}${Math.abs(r.delta_pct)}%</div>
</div>`;
}).join('')}
</div>`;
}).catch(()=>{});
// Insolation block (sun exposure based on neighbouring buildings)
fetch(`/api/insolation?lat=${d.lat}&lon=${d.lon}`).then(r => r.json()).then(ins => {
const el = document.getElementById('insol-block');
if (!el) return;
const cls = ins.insolation_score >= 70 ? 'b-good' : ins.insolation_score >= 40 ? 'b-mid' : 'b-low';
const oct = ins.octants;
el.innerHTML = `<h3>Инсоляция / открытость на юг</h3>
<div class="meta-grid">
<span class="l">Insolation score</span><span class="v"><span class="badge ${cls}">${ins.insolation_score}</span></span>
<span class="l">Зданий в 500м</span><span class="v">${ins.n_buildings_500m}</span>
<span class="l">Давление с юга</span><span class="v">${ins.shadow_pressure_S}</span>
</div>
<div style="display:grid;grid-template-columns:repeat(8,1fr);gap:2px;margin-top:6px;font-size:9px;text-align:center">
${['N','NE','E','SE','S','SW','W','NW'].map(o => {
const n = oct[o] || 0; const max = Math.max(...Object.values(oct), 1);
const h = Math.round(20 + n*30/max);
return `<div><div style="background:#0ea5e9;height:${h}px;border-radius:2px;opacity:${0.4+0.6*n/max}"></div><div style="margin-top:3px">${o}</div><div style="color:#94a3b8">${n}</div></div>`;
}).join('')}
</div>
<div class="hint" style="margin-top:6px">Гистограмма зданий по сторонам света. Высокая колонка S = дома южнее → меньше солнца.</div>`;
}).catch(()=>{});
// sellout block (per-ЖК months-to-sellout)
fetch('/api/jk-sellout/' + encodeURIComponent(siteId)).then(r=>r.json()).then(s => {
const el = document.getElementById('sellout-block');
if (!el) return;
if (!s.matched) {
el.innerHTML = '<h3>Распродажа этого ЖК</h3><div class="hint">не сматчен с Объективом</div>';
return;
}
const mts = s.months_to_sellout;
const cls = mts === null ? '' : mts < 12 ? 'b-good' : mts < 24 ? 'b-mid' : 'b-low';
el.innerHTML = `<h3>Распродажа этого ЖК</h3>
<div class="meta-grid">
<span class="l">В продаже сейчас</span><span class="v">${s.stock_lots||0} лот(ов)${s.stock_m2 ? ' · '+Math.round(s.stock_m2)+' м²' : ''}</span>
<span class="l">Скорость (6мес)</span><span class="v">${(s.vel_6mo||0).toFixed(2)} лот/мес</span>
<span class="l">Распродано всего</span><span class="v">${s.sold_n||0} / ${s.total||0} (${s.total?(100*s.sold_n/s.total).toFixed(0):0}%)</span>
<span class="l">Месяцев до sellout</span><span class="v"><span class="badge ${cls}">${mts!==null ? mts.toFixed(1) + ' мес' : '—'}</span></span>
</div>`;
}).catch(()=>{});
// developer track record
if (d.developer) {
fetch('/api/developer/' + encodeURIComponent(d.developer)).then(r => r.ok ? r.json() : null).then(dev => {
const el = document.getElementById('dev-block');
if (!el) return;
if (!dev) { el.innerHTML = `<h3>Девелопер: ${d.developer}</h3><div class="hint">нет данных</div>`; return; }
const s = dev.summary;
const port = (dev.portfolio || []).slice(0, 8).map(p => {
const w = p.weighted ?? 0;
const c = w >= p75 ? '#0a6' : w >= 50 ? '#0ea5e9' : '#94a3b8';
return `<div style="display:grid;grid-template-columns:1fr auto;gap:8px;padding:5px 0;font-size:11px;border-bottom:1px solid #f1f5f9;align-items:center;cursor:pointer" onmouseover="this.style.background='#f8fafc'" onmouseout="this.style.background=''" onclick="openJK('${p.site_id}')">
<div>
<div style="font-weight:500">${p.name||'—'}</div>
<div style="color:#64748b;font-size:10px">${p.obj_class||''} · ${p.obj_district||'—'} · ${p.flat_count||'?'} кв${p.project_stats ? ' · '+(100*p.project_stats.sold_n/p.project_stats.total).toFixed(0)+'% sold' : ''}</div>
</div>
<div style="font-weight:700;font-size:14px;color:${c}">${w.toFixed(0)}<small style="color:#94a3b8;font-size:9px;display:block;text-align:right">#${p.rank_overall||'?'}</small></div>
</div>`;
}).join('');
el.innerHTML = `<h3>${d.developer}</h3>
<div class="kpis" style="margin-bottom:8px">
<div class="kpi"><div class="l">Проектов</div><div class="v">${s.n_projects}</div></div>
<div class="kpi"><div class="l">Ср. балл</div><div class="v">${s.avg_score}</div></div>
<div class="kpi"><div class="l">Total квартир</div><div class="v">${s.total_flats||'—'}</div></div>
<div class="kpi"><div class="l">Средняя цена</div><div class="v">${s.avg_price_m2_kr||'—'}<small>тыс</small></div></div>
<div class="kpi"><div class="l">Sold-out</div><div class="v">${s.sold_pct||'—'}<small>%</small></div></div>
<div class="kpi"><div class="l">Velocity 6м</div><div class="v">${s.total_velocity_6mo||0}<small>лот/мес</small></div></div>
</div>
<div class="hint" style="margin-bottom:5px">Портфель (топ-${Math.min((dev.portfolio||[]).length,8)} по баллу):</div>
${port}
<div class="hint" style="margin-top:5px">Районы: ${s.districts.join(' · ')||'—'}</div>`;
}).catch(()=>{});
}
// monthly velocity sparkline
if (d.monthly_velocity?.length) {
if (jkDetailChart) jkDetailChart.dispose();
setTimeout(() => {
const el = document.getElementById('jkChart');
if (!el) return;
jkDetailChart = echarts.init(el, null, {renderer:'canvas'});
jkDetailChart.setOption({
grid: {left: 30, right: 10, top: 10, bottom: 22},
xAxis: {type:'category', data: d.monthly_velocity.map(x=>x.month.slice(2)), axisLabel:{fontSize:9, color:'#64748b'}},
yAxis: {type:'value', axisLabel:{fontSize:9, color:'#64748b'}, splitLine:{lineStyle:{color:'#e2e8f0'}}},
series: [{type:'bar', data: d.monthly_velocity.map(x=>x.n),
itemStyle:{color:'#0ea5e9', borderRadius:[3,3,0,0]}}],
tooltip: {trigger:'axis', formatter: p => `${p[0].name}<br><b>${p[0].value}</b> ДДУ`},
});
}, 50);
}
// DOM.РФ sale-graph (cumulative realised vs contracted by report_month)
if (sg.length) {
setTimeout(() => {
const el = document.getElementById('sgChart');
if (!el) return;
const chart = echarts.init(el, null, {renderer:'canvas'});
// group by type
const byType = {};
for (const r of sg) {
if (!r.report_month) continue;
byType[r.type||'?'] ??= {};
byType[r.type||'?'][r.report_month] = r;
}
const months = [...new Set(sg.map(r=>r.report_month).filter(Boolean))].sort();
const series = [];
for (const t of Object.keys(byType)) {
series.push({name: t+' realised', type:'line', smooth:true, symbol:'none',
data: months.map(m => byType[t][m]?.realised ?? null)});
series.push({name: t+' contracted', type:'line', smooth:true, symbol:'none',
data: months.map(m => byType[t][m]?.contracted ?? null), lineStyle:{type:'dashed'}});
}
chart.setOption({
grid: {left: 40, right: 12, top: 24, bottom: 26},
xAxis: {type:'category', data: months.map(m=>m.slice(2,7)), axisLabel:{fontSize:9, color:'#64748b'}},
yAxis: {type:'value', axisLabel:{fontSize:9, color:'#64748b'}, splitLine:{lineStyle:{color:'#e2e8f0'}}},
series,
legend: {top: 0, textStyle:{fontSize:9}, type:'scroll'},
tooltip: {trigger:'axis'},
});
}, 50);
}
}
// =================== Search ===================
const sb = document.getElementById('searchBox');
const sr = document.getElementById('searchResults');
function fuzzyMatch(q, text) {
q = (q||'').toLowerCase(); text = (text||'').toLowerCase();
if (!q) return 0;
if (text.includes(q)) return 100 - text.indexOf(q);
// light fuzzy: all chars in order
let i=0; for (const c of text) if (c === q[i]) { i++; if (i === q.length) return 50; }
return 0;
}
sb.addEventListener('input', () => {
const q = sb.value.trim();
if (q.length < 2) { sr.style.display = 'none'; return; }
const matches = SITES.filter(s => s.kind==='jk').map(s => {
const score = Math.max(
fuzzyMatch(q, s.name||''),
fuzzyMatch(q, s.developer||''),
fuzzyMatch(q, s.address||'')
);
return {site: s, score};
}).filter(m => m.score > 0).sort((a,b)=>b.score-a.score).slice(0, 12);
if (!matches.length) { sr.innerHTML = '<div style="padding:10px;color:#94a3b8;font-size:11px">не найдено</div>'; sr.style.display='block'; return; }
sr.innerHTML = matches.map(({site:s}) => {
const w = s.weighted ?? 0;
const c = w >= p75 ? '#0a6' : w >= 50 ? '#0ea5e9' : '#94a3b8';
return `<div data-id="${s.site_id}" style="padding:7px 10px;cursor:pointer;border-bottom:1px solid #f1f5f9;display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center" onmouseover="this.style.background='#f8fafc'" onmouseout="this.style.background='white'">
<div style="overflow:hidden">
<div style="font-size:12px;font-weight:500;text-overflow:ellipsis;white-space:nowrap;overflow:hidden">${s.name||'—'}</div>
<div style="font-size:10px;color:#64748b;text-overflow:ellipsis;white-space:nowrap;overflow:hidden">${s.developer||''} · ${s.obj_district||s.district||''}</div>
</div>
<div style="font-size:13px;font-weight:700;color:${c}">${w.toFixed(0)}</div>
</div>`;
}).join('');
sr.style.display='block';
sr.querySelectorAll('[data-id]').forEach(el => el.addEventListener('click', () => {
sb.value = ''; sr.style.display = 'none';
openJK(el.dataset.id);
}));
});
document.addEventListener('click', e => {
if (!sb.contains(e.target) && !sr.contains(e.target)) sr.style.display = 'none';
});
// =================== Recent parcels (localStorage) ===================
const RECENT_KEY = 'gendesign_recent_parcels';
function loadRecent() { try { return JSON.parse(localStorage.getItem(RECENT_KEY)||'[]'); } catch { return []; } }
function saveRecent(parcel) {
let list = loadRecent();
list = list.filter(p => !(p.lat === parcel.lat && p.lon === parcel.lon));
list.unshift({...parcel, ts: Date.now()});
list = list.slice(0, 10);
localStorage.setItem(RECENT_KEY, JSON.stringify(list));
renderRecent();
renderScenarios();
// Live audience swap — reweights cached components, no server roundtrip
document.getElementById('audience').addEventListener('change', () => {
if (!LAST_RESULT) return;
const newAud = document.getElementById('audience').value;
const w = (window.AUDIENCES || {})[newAud];
if (!w) return;
const weights = {education:w.education, health:w.health, retail:w.retail,
transit:w.transit, leisure:w.leisure, economic:w.economic, market:w.market};
LAST_RESULT.weights = weights;
LAST_RESULT.weighted = +Object.entries(LAST_RESULT.scores).reduce(
(s, [k,v]) => s + (weights[k]||0) * v, 0).toFixed(2);
LAST_RESULT.input.audience = newAud;
renderResult(LAST_RESULT);
toast(`Профиль «${w.label}» · балл ${LAST_RESULT.weighted.toFixed(1)}`, 'info', 1800);
});
}
// =================== Scenarios (named saved states) ===================
const SCN_KEY = 'gendesign_scenarios';
function loadScenarios() { try { return JSON.parse(localStorage.getItem(SCN_KEY)||'[]'); } catch { return []; } }
function saveScenarios(list) { localStorage.setItem(SCN_KEY, JSON.stringify(list.slice(0, 30))); renderScenarios(); }
function captureScenario(name) {
return {
name,
ts: Date.now(),
cad: document.getElementById('cad').value,
lat: parseFloat(document.getElementById('lat').value) || null,
lon: parseFloat(document.getElementById('lon').value) || null,
audience: document.getElementById('audience').value,
what_if: WHAT_IF_POIS.slice(),
remove_cats: [...WHAT_IF_REMOVE],
weighted: LAST_RESULT?.weighted,
rank: LAST_RESULT?.rank_overall,
district: LAST_RESULT?.district,
};
}
function applyScenario(scn) {
document.getElementById('cad').value = scn.cad || '';
document.getElementById('lat').value = scn.lat || '';
document.getElementById('lon').value = scn.lon || '';
document.getElementById('audience').value = scn.audience || 'balanced';
WHAT_IF_POIS = scn.what_if || [];
WHAT_IF_REMOVE = new Set(scn.remove_cats || []);
document.getElementById('frm').requestSubmit();
}
function renderScenarios() {
const list = loadScenarios();
const block = document.getElementById('scenariosBlock');
const hdr = document.getElementById('scenariosHeader');
if (!list.length) { hdr.style.display='none'; block.innerHTML=''; return; }
hdr.style.display='flex';
block.innerHTML = list.map((s, i) => `
<div data-scn="${i}" style="padding:7px 10px;cursor:pointer;background:white;border:1px solid #e2e8f0;border-radius:6px;margin-bottom:4px;font-size:11px;display:grid;grid-template-columns:1fr auto auto;gap:6px;align-items:center" onmouseover="this.style.background='#f8fafc'" onmouseout="this.style.background='white'">
<div>
<div style="font-weight:600">${s.name}</div>
<div style="color:#64748b;font-size:10px">${s.audience} · ${s.district||'?'} · балл ${s.weighted?.toFixed?.(0)||'—'} · #${s.rank||'?'}${s.what_if?.length?' · what-if '+s.what_if.length:''}</div>
</div>
<span style="font-size:9px;color:#94a3b8">${new Date(s.ts).toLocaleString('ru', {day:'2-digit',month:'short'})}</span>
<button data-del="${i}" style="background:none;border:0;color:#94a3b8;cursor:pointer;font-size:14px;padding:0 4px" title="удалить">×</button>
</div>
`).join('');
block.querySelectorAll('[data-scn]').forEach(el => el.addEventListener('click', (ev) => {
if (ev.target.dataset.del !== undefined) return;
applyScenario(list[+el.dataset.scn]);
}));
block.querySelectorAll('[data-del]').forEach(el => el.addEventListener('click', (ev) => {
ev.stopPropagation();
const arr = loadScenarios(); arr.splice(+el.dataset.del, 1); saveScenarios(arr);
}));
}
document.getElementById('scn-save').addEventListener('click', () => {
const name = prompt('Название сценария:', 'Сценарий ' + (loadScenarios().length+1));
if (!name) return;
const arr = loadScenarios();
arr.unshift(captureScenario(name));
saveScenarios(arr);
toast('Сценарий «' + name + '» сохранён', 'success');
});
function renderRecent() {
const recent = loadRecent();
const c = document.getElementById('recentBlock');
if (!c) return;
if (!recent.length) { c.innerHTML = ''; return; }
c.innerHTML = `<h2 style="margin-top:14px">Недавние участки</h2>` +
recent.map(r => `<div data-recent='${JSON.stringify(r).replace(/'/g, "&apos;")}' style="padding:6px 10px;cursor:pointer;border:1px solid #e2e8f0;border-radius:6px;margin-bottom:4px;font-size:11px;display:flex;justify-content:space-between;align-items:center;background:white" onmouseover="this.style.background='#f8fafc'" onmouseout="this.style.background='white'">
<div>
<div style="font-weight:500">${r.cad || (r.lat.toFixed(4)+','+r.lon.toFixed(4))}</div>
<div style="color:#64748b;font-size:10px">${r.audience} · балл ${r.weighted?.toFixed?.(0)||'—'}</div>
</div>
<div style="font-size:9px;color:#94a3b8">${new Date(r.ts).toLocaleString('ru', {day:'2-digit',month:'short'})}</div>
</div>`).join('');
c.querySelectorAll('[data-recent]').forEach(el => el.addEventListener('click', () => {
const r = JSON.parse(el.dataset.recent.replace(/&apos;/g, "'"));
document.getElementById('cad').value = r.cad || '';
document.getElementById('lat').value = r.lat;
document.getElementById('lon').value = r.lon;
if (r.audience) document.getElementById('audience').value = r.audience;
document.getElementById('frm').requestSubmit();
}));
}
// =================== Toast ===================
function toast(msg, type='info', ms=3500) {
const colors = {info:'#0ea5e9', success:'#10b981', error:'#ef4444', warn:'#f59e0b'};
const t = document.createElement('div');
t.className = 'gd-toast';
t.style.cssText = `position:fixed;top:20px;right:20px;background:${colors[type]||'#0ea5e9'};color:white;padding:10px 16px;border-radius:8px;box-shadow:0 4px 18px rgba(0,0,0,.2);z-index:9999;font-size:12px;font-weight:500;max-width:340px;animation:fade .25s`;
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => { t.style.opacity='0'; t.style.transition='opacity .25s'; }, ms-250);
setTimeout(() => t.remove(), ms);
}
document.getElementById('btn-fetch-poly').addEventListener('click', async () => {
const cad = document.getElementById('cad').value.trim();
if (!cad) { toast('Введи кадастр', 'warn', 2500); return; }
const btn = document.getElementById('btn-fetch-poly');
const orig = btn.textContent;
btn.disabled = true; btn.textContent = 'Тянем из Росреестра…';
try {
const r = await fetch('/api/fetch-polygon/' + encodeURIComponent(cad), {method:'POST'});
if (!r.ok) {
const err = await r.json();
toast('Не удалось: ' + (err.detail || r.status), 'err', 3500);
return;
}
const fc = await r.json();
const c = fc._centroid;
document.getElementById('lat').value = c.lat.toFixed(6);
document.getElementById('lon').value = c.lon.toFixed(6);
// Show polygon immediately
polygonLayer.clearLayers();
polygonLayer.addLayer(L.geoJSON(fc, {style:{color:'#ef4444', weight:2.5, fillColor:'#ef4444', fillOpacity:0.18}}));
map.flyTo([c.lat, c.lon], 18, {duration: 0.6});
// Show toast with area + cost
const props = fc._props || {};
const ts = document.createElement('div');
ts.style.cssText = 'position:fixed;top:60px;right:20px;background:#10b981;color:white;padding:10px 16px;border-radius:8px;box-shadow:0 4px 18px rgba(0,0,0,.25);z-index:9999;font-size:12px;font-weight:600;animation:fade .3s';
ts.innerHTML = `Полигон загружен<br><small style="opacity:.85;font-weight:400">${fc._area_m2.toLocaleString('ru')} м² · ${props.cost_value ? (props.cost_value/1e6).toFixed(1)+' млн ₽ кад.ст.' : 'кад.ст. н/д'}</small>`;
document.body.appendChild(ts);
setTimeout(() => ts.remove(), 4500);
// Auto-rerun analysis with new precise centroid
document.getElementById('frm').requestSubmit();
} catch (e) {
toast('Ошибка: ' + e.message, 'err', 3500);
} finally {
btn.disabled = false; btn.textContent = orig;
}
});
</script>
</body>
</html>