Adds site-finder/ subfolder with:
- server.py — FastAPI scoring service v2 (35 endpoints, ~85KB)
- 01_load_sites.py … 12_more_pois.py — data ingest pipeline
- db_init.py — SQLite schema bootstrap
- static/ — Leaflet UI (index.html ~3500 lines + sw.js)
- cache/ — small persistent caches (admin districts, jk polygons,
geocode warm cache, parcel polygons drop-zone with README)
- reports/ — sample generated parcel report (HTML+JSON)
Excluded via .gitignore (regeneratable, too big for git):
- analysis.db (336MB SQLite — rebuild via 01_*..12_*.py)
- cache/objective_raw/ (1.2GB Объектив raw dumps)
- cache/overpass_raw.json, cache/osm_buildings_all.geojson
(regen from Overpass API)
Production deploy: /opt/gendesign/site-finder/ on gendsgn.ru
(container gendesign-site-finder-1, served at /sf/).
3513 lines
231 KiB
HTML
3513 lines
231 KiB
HTML
<!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>< 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"># >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×(1500−792)/(1500−400) = 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>
|
||
<tr><td><b>sat_factor</b></td><td>prod-style: <code>1 + (sold% − 50)/100 × 0.30</code>. Используется отдельно как мультипликатор. Не входит напрямую в market.<br><span class="hint">Зрелые рынки получают +30% к velocity</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>Если <15% — слабо, <25% — средне, >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 < 13 близкие ЖК группируются в badge с числом. Цвет — средний балл группы.</td></tr>
|
||
<tr><td><b>POI cluster</b></td><td>При z < 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+ — горячий рынок, <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+клик по 1–3 ЖК во вкладке «ЖК рядом», потом нажми 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, "'")}' 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(/'/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>
|