/
G4TZZ
/
UrbanScore
Обзор
Документация
Войти
/
G4TZZ
/
UrbanScore
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/web/map_template.html
1 509 строк
64 KB
G4TZZ
Create: map_template.html
14 июн 2026, 18:47
Верифицирован
14 июн 2026, 18:47
e878514
Код
Авторство
О чём код?
<!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>__CITY_NAME__ — анализ</title> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"> <style> :root { --sb-bg: #1e2430; --accent: #42a5f5; --text: #e2e8f0; --muted: #90a4ae; --border: rgba(255,255,255,.07); --hover: rgba(255,255,255,.06); --active: rgba(66,165,245,.18); --header-bg: linear-gradient(135deg, #0d47a1, #1565C0); --btn-primary: #1565C0; --btn-primary-h: #1976D2; --btn-secondary: #2d3748; --btn-secondary-h:#3d4d5f; --modal-bg: #1e2430; --warning-bg: #263040; --scrollbar-thumb:#3d4d60; --even-row: rgba(255,255,255,.025); } [data-theme="light"] { --sb-bg: #f8fafc; --accent: #1565C0; --text: #1a202c; --muted: #718096; --border: rgba(0,0,0,.1); --hover: rgba(0,0,0,.04); --active: rgba(21,101,192,.1); --header-bg: linear-gradient(135deg, #e8f4fd, #f0f7ff); --btn-primary: #1565C0; --btn-primary-h: #1976D2; --btn-secondary: #e2e8f0; --btn-secondary-h:#cbd5e0; --modal-bg: #ffffff; --warning-bg: #ffffff; --scrollbar-thumb:#a0aec0; --even-row: rgba(0,0,0,.025); } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html, body { height: 100%; overflow: hidden; } body { display: flex; font-family: 'Segoe UI', Arial, sans-serif; } /* ── SIDEBAR ── */ #sidebar { width: 320px; flex-shrink: 0; background: var(--sb-bg); color: var(--text); display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden; box-shadow: 2px 0 16px rgba(0,0,0,.5); z-index: 500; } #sidebar::-webkit-scrollbar { width: 5px; } #sidebar::-webkit-scrollbar-track { background: var(--sb-bg); } #sidebar::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; } .sb-header { background: var(--header-bg); padding: 15px 18px 13px; flex-shrink: 0; border-bottom: 1px solid var(--border); } .sb-city { font-size: 16px; font-weight: 700; line-height: 1.3; color: var(--text); } .sb-ndvi { font-size: 11px; opacity: .55; margin-top: 5px; } .section { padding: 12px 15px; border-bottom: 1px solid var(--border); } .sec-title { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--accent); margin-bottom: 9px; } /* ── ОПЦИИ ── */ .opt-list { list-style: none; display: flex; flex-direction: column; gap: 3px; } .opt-list li label { display: flex; align-items: center; gap: 9px; font-size: 13px; cursor: pointer; padding: 5px 8px; border-radius: 6px; transition: background .12s; user-select: none; } .opt-list li label:hover { background: var(--hover); } .opt-list li label.active { background: var(--active); } .opt-list li label.no-data { opacity: .32; pointer-events: none; } input[type=radio], input[type=checkbox] { accent-color: var(--accent); width: 14px; height: 14px; cursor: pointer; flex-shrink: 0; } .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } /* ── ПОЛЗУНОК ЯРКОСТИ ── */ .slider-row { display: flex; align-items: center; gap: 10px; } input[type=range].bright-sl { flex: 1; height: 5px; accent-color: var(--accent); cursor: pointer; } .slider-val { font-size: 13px; font-weight: 700; color: var(--accent); min-width: 38px; text-align: right; } /* ── ВЕСА ── */ .w-group { margin-bottom: 10px; } .w-cat-row { display: flex; align-items: center; gap: 6px; cursor: pointer; padding: 3px 0; } .w-cat-label { font-size: 12px; font-weight: 600; width: 115px; flex-shrink: 0; display: flex; align-items: center; gap: 5px; } .w-arrow { font-size: 9px; transition: transform .2s; display: inline-block; } .w-arrow.open { transform: rotate(90deg); } input[type=range].w-sl { flex: 1; height: 4px; accent-color: var(--accent); cursor: pointer; } .w-val { font-size: 11px; font-weight: 700; color: var(--accent); min-width: 32px; text-align: right; } .w-sub { display: none; padding: 5px 0 3px 14px; border-left: 2px solid var(--border); margin-left: 6px; } .w-sub-row { display: flex; align-items: center; gap: 6px; margin: 4px 0; } .w-sub-label { font-size: 11px; color: var(--muted); width: 85px; flex-shrink: 0; } .btn-row { display: flex; gap: 7px; margin-top: 10px; } .apply-btn, .reset-btn { flex: 1; border: none; border-radius: 6px; padding: 8px 0; font-size: 13px; font-weight: 700; cursor: pointer; transition: background .15s; } .apply-btn { background: var(--btn-primary); color: #fff; } .apply-btn:hover { background: var(--btn-primary-h); } .reset-btn { background: var(--btn-secondary); color: var(--text); } .reset-btn:hover { background: var(--btn-secondary-h); } /* ── ЛЕГЕНДА ── */ .legend-bar { height: 11px; border-radius: 4px; background: linear-gradient(to right, #ff0000 0%, #ffff00 50%, #00ff00 100%); margin-bottom: 5px; } .legend-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); } /* ── СТАТИСТИКА ── */ #stats-table { width: 100%; border-collapse: collapse; font-size: 12px; } #stats-table thead th { text-align: left; color: var(--muted); font-weight: 600; padding: 2px 4px 7px; border-bottom: 1px solid var(--border); } #stats-table td { padding: 4px; } #stats-table tbody tr:nth-child(even) { background: var(--even-row); } .badge { display: inline-block; padding: 1px 7px; border-radius: 10px; background: var(--btn-primary); color: #fff; font-weight: 700; } /* ── РЕСАЙЗЕР ПАНЕЛИ ── */ #resizer { width: 4px; flex-shrink: 0; cursor: col-resize; background: var(--border); transition: background .15s; } #resizer:hover, #resizer.dragging { background: var(--accent); } /* ── КНОПКА СВОРАЧИВАНИЯ ПАНЕЛИ ── */ #panel-toggle { position: absolute; left: 0; top: 50%; transform: translateY(-50%); z-index: 600; width: 22px; height: 54px; border: 1px solid var(--border); border-left: none; border-radius: 0 8px 8px 0; background: var(--sb-bg); color: var(--text); cursor: pointer; font-size: 14px; font-weight: 600; display: flex; align-items: center; justify-content: center; transition: color .15s, background .15s, box-shadow .15s; box-shadow: 3px 0 10px rgba(0,0,0,.25); padding: 0; } #panel-toggle:hover { color: var(--accent); box-shadow: 3px 0 14px rgba(0,0,0,.35); } /* Состояние: панель свёрнута */ body.sidebar-collapsed #sidebar { width: 0; overflow: hidden; min-width: 0; } body.sidebar-collapsed #resizer { display: none; } body.sidebar-collapsed #panel-toggle { border-left: 1px solid var(--border); border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,.25); } /* ── ФОТОРЕЖИМ ── */ body.photo-mode #sidebar { width: 0; overflow: hidden; min-width: 0; } body.photo-mode #resizer { display: none; } body.photo-mode #legend-panel { display: none; } body.photo-mode #info-panel { display: none; } body.photo-mode #save-btn { display: none; } body.photo-mode #panel-toggle { display: none; } /* ── ОБЁРТКА КАРТЫ ── */ #map-wrap { flex: 1; position: relative; min-height: 0; } #map { width: 100%; height: 100%; } /* ── КНОПКА СОХРАНЕНИЯ ── */ #save-btn { position: absolute; top: 46px; right: 16px; background: var(--btn-primary); color: #fff; border: none; border-radius: 8px; padding: 8px 16px; font-size: 13px; font-weight: 600; cursor: pointer; z-index: 710; box-shadow: 0 2px 12px rgba(0,0,0,.5); transition: background .15s; } #save-btn:hover { background: var(--btn-primary-h); } /* ── МОДАЛЬНОЕ ОКНО СОХРАНЕНИЯ ── */ #save-modal { display: none; position: absolute; inset: 0; background: rgba(0,0,0,.55); z-index: 800; align-items: center; justify-content: center; } #save-modal.open { display: flex; } #save-modal-box { background: var(--modal-bg); border-radius: 12px; padding: 22px; width: 300px; box-shadow: 0 8px 40px rgba(0,0,0,.75); color: var(--text); } .save-modal-title { font-size: 14px; font-weight: 700; margin-bottom: 10px; } .save-option-btn { width: 100%; padding: 10px; margin-bottom: 4px; background: var(--btn-primary); color: #fff; border: none; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600; font-family: inherit; transition: background .15s; } .save-option-btn:hover { background: var(--btn-primary-h); } .save-option-desc { font-size: 11px; color: var(--muted); line-height: 1.5; margin: 0 0 14px; padding: 0 2px; } .save-divider { border: none; border-top: 1px solid var(--border); margin: 12px 0; } #save-confirm { } #save-cancel { width: 100%; padding: 9px; margin-top: 4px; background: none; border: 1px solid var(--border); border-radius: 8px; color: var(--muted); cursor: pointer; font-size: 13px; font-family: inherit; transition: border-color .15s, color .15s; } #save-cancel:hover { border-color: var(--muted); color: var(--text); } /* ── ЛЕГЕНДА + СТАТИСТИКА (плавающий блок снизу справа) ── */ #legend-panel { position: absolute; bottom: 24px; right: 16px; width: 264px; background: var(--sb-bg); color: var(--text); border-radius: 10px; overflow: hidden; z-index: 600; box-shadow: 0 4px 24px rgba(0,0,0,.6); pointer-events: auto; } /* ── ИНФОРМАЦИЯ О ТЕРРИТОРИИ (плавающий блок, поверх легенды) ── */ #info-panel { position: absolute; bottom: 24px; right: 16px; width: 264px; max-height: calc(100% - 48px); background: var(--sb-bg); color: var(--text); border-radius: 10px; display: none; flex-direction: column; overflow-y: auto; overflow-x: hidden; z-index: 700; box-shadow: 0 4px 28px rgba(0,0,0,.75); } #info-panel.open { display: flex; } #info-panel::-webkit-scrollbar { width: 4px; } #info-panel::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; } .ip-header { background: var(--header-bg); padding: 11px 14px; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; border-bottom: 1px solid var(--border); } .ip-header span { font-size: 13px; font-weight: 700; } #info-close { background: none; border: none; color: var(--muted); font-size: 16px; cursor: pointer; line-height: 1; padding: 2px 4px; border-radius: 4px; } #info-close:hover { color: var(--text); background: rgba(255,255,255,.1); } .ip-overall { padding: 14px 14px 10px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; } .ip-overall-label { font-size: 12px; color: var(--muted); } .ip-overall-score { font-size: 26px; font-weight: 700; } [data-theme="light"] .ip-overall-score { text-shadow: 0 0 6px rgba(0,0,0,.25), 0 1px 3px rgba(0,0,0,.2); } .ip-cat { padding: 10px 14px; border-bottom: 1px solid var(--border); } .ip-cat-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 5px; } .ip-cat-label { font-size: 12px; font-weight: 700; } .ip-cat-score { font-size: 13px; font-weight: 700; color: var(--accent); } .ip-bar-wrap { height: 6px; background: rgba(128,128,128,.2); border-radius: 3px; margin-bottom: 8px; overflow: hidden; } .ip-bar-fill { height: 100%; border-radius: 3px; transition: width .3s; } .ip-sub { display: flex; align-items: center; gap: 6px; margin: 5px 0; } .ip-sub-label { font-size: 11px; color: var(--muted); width: 130px; flex-shrink: 0; } .ip-sub-bar-wrap { flex: 1; height: 4px; background: rgba(128,128,128,.18); border-radius: 2px; overflow: hidden; } .ip-sub-bar-fill { height: 100%; background: var(--accent); opacity: .7; border-radius: 2px; } .ip-sub-val { font-size: 11px; font-weight: 700; min-width: 34px; text-align: right; } /* ── ПОДПИСИ РАЙОНОВ (Leaflet pane с высоким z-index) ── */ .district-label { font-size: 12px; font-weight: 700; color: #111; pointer-events: none; white-space: nowrap; text-shadow: -1px -1px 3px #fff, 1px -1px 3px #fff, -1px 1px 3px #fff, 1px 1px 3px #fff; } [data-theme="light"] .district-label { color: #111; } </style> </head> <body> <!-- Модальное окно предупреждения (показывается если WARNING_MESSAGE не пустой) --> <div id="warning-overlay" style=" display:none; position:fixed; inset:0; z-index:9999; background:rgba(0,0,0,.65); align-items:center; justify-content:center;"> <div style=" background:var(--warning-bg); color:var(--text); border-radius:10px; padding:28px 32px; max-width:480px; width:90%; box-shadow:0 8px 40px rgba(0,0,0,.7); font-family:'Segoe UI',Arial,sans-serif;"> <div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;"> <span style="font-size:28px;">⚠️</span> <strong style="font-size:16px;">Внимание</strong> </div> <p id="warning-text" style="line-height:1.6;white-space:pre-line;font-size:14px;margin-bottom:20px;"></p> <button onclick="document.getElementById('warning-overlay').style.display='none'; _showNextWarning();" style=" background:var(--btn-primary);color:#fff;border:none;border-radius:6px; padding:9px 24px;font-size:14px;cursor:pointer;width:100%;"> Понятно </button> </div> </div> <!-- SVG-паттерн для промзон (штриховка 45°). Определён на уровне документа — браузер находит его из любого SVG. --> <svg width="0" height="0" style="position:absolute;pointer-events:none" aria-hidden="true"> <defs> <pattern id="ind-hatch" x="0" y="0" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45 0 0)"> <line x1="0" y1="0" x2="0" y2="7" stroke="#546E7A" stroke-width="2.5"/> </pattern> </defs> </svg> <div id="sidebar"> <div class="sb-header"> <p class="sb-city">__CITY_NAME__</p> <p class="sb-ndvi">данные NDVI: __NDVI_LABEL__</p> </div> <!-- Базовый слой --> <div class="section"> <div class="sec-title">Базовый слой</div> <ul class="opt-list" id="base-list"> <li><label class="active"><input type="radio" name="base" value="voyager" checked> Карта улиц</label></li> <li><label> <input type="radio" name="base" value="light"> Светлая карта</label></li> <li><label> <input type="radio" name="base" value="satellite"> Спутниковый снимок</label></li> </ul> </div> <!-- Индекс --> <div class="section"> <div class="sec-title">Индекс привлекательности</div> <ul class="opt-list" id="index-list"> <li><label class="active"><input type="radio" name="idx" value="attractiveness" checked> <span class="dot" style="background:#4caf50"></span> Общий индекс</label></li> <li><label><input type="radio" name="idx" value="ecology"> <span class="dot" style="background:#81c784"></span> Экология</label></li> <li><label><input type="radio" name="idx" value="infrastructure"> <span class="dot" style="background:#64b5f6"></span> Инфраструктура</label></li> <li><label><input type="radio" name="idx" value="transport"> <span class="dot" style="background:#ffb74d"></span> Транспорт</label></li> </ul> </div> <!-- Яркость --> <div class="section"> <div class="sec-title">Прозрачность слоя</div> <div class="slider-row"> <input type="range" class="bright-sl" id="opSlider" min="0" max="100" value="70"> <span class="slider-val" id="opVal">70%</span> </div> </div> <!-- Внутренние границы — отдельная секция с разделителем --> <div class="section" id="section-districts"> <div class="sec-title">Внутренние границы</div> <ul class="opt-list"> <li id="row-districts"><label> <input type="checkbox" id="cb-districts"> Районы / поселения </label></li> </ul> </div> <!-- Объекты --> <div class="section"> <div class="sec-title">Объекты на карте</div> <ul class="opt-list"> <li id="row-shops"> <label><input type="checkbox" data-poi="shops"> <span class="dot" style="background:#2196F3"></span> Магазины</label></li> <li id="row-schools"> <label><input type="checkbox" data-poi="schools"> <span class="dot" style="background:#4CAF50"></span> Школы</label></li> <li id="row-hospitals"> <label><input type="checkbox" data-poi="hospitals"> <span class="dot" style="background:#F44336"></span> Медицинские учреждения</label></li> <li id="row-stops"> <label><input type="checkbox" data-poi="stops"> <span class="dot" style="background:#FF9800"></span> Остановки общ. транспорта</label></li> <li id="row-metro"> <label><input type="checkbox" data-poi="metro"> <span class="dot" style="background:#9C27B0"></span> Станции метро</label></li> <li id="row-parking"> <label><input type="checkbox" data-poi="parking"> <span class="dot" style="background:#795548"></span> Автомобильные парковки</label></li> <li id="row-sports"> <label><input type="checkbox" data-poi="sports"> <span class="dot" style="background:#00BCD4"></span> Спортивные объекты</label></li> <li id="row-universities"><label><input type="checkbox" data-poi="universities"> <span class="dot" style="background:#FF5722"></span> Высшие уч. заведения</label></li> <li id="row-fuel"> <label><input type="checkbox" data-poi="fuel"> <span class="dot" style="background:#FFC107"></span> Автомобильные заправки</label></li> <li id="row-industrial"> <label><input type="checkbox" data-poi="industrial"> <span class="dot" style="background:#546E7A;border-radius:2px"></span> Промышленные зоны</label></li> <li id="row-landfill"> <label><input type="checkbox" data-poi="landfill"> <span class="dot" style="background:#8D6E63;border-radius:2px"></span> Свалки</label></li> </ul> </div> <!-- Веса критериев --> <div class="section"> <div class="sec-title">Настройка весов</div> <!-- Экология --> <div class="w-group"> <div class="w-cat-row" onclick="toggleSub('eco-sub','eco-arrow')"> <span class="w-cat-label"> <span class="w-arrow" id="eco-arrow">▶</span> Экология </span> <input type="range" class="w-sl" id="wc-eco" min="0" max="100" value="40" onclick="event.stopPropagation()"> <span class="w-val" id="wc-eco-v">40%</span> </div> <div class="w-sub" id="eco-sub"> <div class="w-sub-row"> <span class="w-sub-label">Промзоны</span> <input type="range" class="w-sl" id="ws-ind" min="0" max="100" value="40"> <span class="w-val" id="ws-ind-v">40%</span> </div> <div class="w-sub-row" data-criterion="ndvi"> <span class="w-sub-label">NDVI (спутник)</span> <input type="range" class="w-sl" id="ws-ndvi" min="0" max="100" value="30"> <span class="w-val" id="ws-ndvi-v">30%</span> </div> <div class="w-sub-row" data-criterion="green_proximity"> <span class="w-sub-label">Зелёные зоны (OSM)</span> <input type="range" class="w-sl" id="ws-green" min="0" max="100" value="30"> <span class="w-val" id="ws-green-v">30%</span> </div> <div class="w-sub-row" data-criterion="landfill_distance"> <span class="w-sub-label">Свалки</span> <input type="range" class="w-sl" id="ws-landfill" min="0" max="100" value="0"> <span class="w-val" id="ws-landfill-v">0%</span> </div> <div class="w-sub-row"> <span class="w-sub-label">Водоёмы</span> <input type="range" class="w-sl" id="ws-water" min="0" max="100" value="10"> <span class="w-val" id="ws-water-v">10%</span> </div> </div> </div> <!-- Инфраструктура --> <div class="w-group"> <div class="w-cat-row" onclick="toggleSub('infra-sub','infra-arrow')"> <span class="w-cat-label"> <span class="w-arrow" id="infra-arrow">▶</span> Инфраструктура </span> <input type="range" class="w-sl" id="wc-infra" min="0" max="100" value="35" onclick="event.stopPropagation()"> <span class="w-val" id="wc-infra-v">35%</span> </div> <div class="w-sub" id="infra-sub"> <div class="w-sub-row"> <span class="w-sub-label">Больницы</span> <input type="range" class="w-sl" id="ws-hosp" min="0" max="100" value="45"> <span class="w-val" id="ws-hosp-v">45%</span> </div> <div class="w-sub-row"> <span class="w-sub-label">Магазины</span> <input type="range" class="w-sl" id="ws-shops" min="0" max="100" value="30"> <span class="w-val" id="ws-shops-v">30%</span> </div> <div class="w-sub-row"> <span class="w-sub-label">Школы</span> <input type="range" class="w-sl" id="ws-schools" min="0" max="100" value="25"> <span class="w-val" id="ws-schools-v">25%</span> </div> <div class="w-sub-row" data-criterion="sports"> <span class="w-sub-label">Спорт</span> <input type="range" class="w-sl" id="ws-sports" min="0" max="100" value="0"> <span class="w-val" id="ws-sports-v">0%</span> </div> <div class="w-sub-row" data-criterion="universities"> <span class="w-sub-label">Вузы</span> <input type="range" class="w-sl" id="ws-uni" min="0" max="100" value="0"> <span class="w-val" id="ws-uni-v">0%</span> </div> </div> </div> <!-- Транспорт --> <div class="w-group"> <div class="w-cat-row" onclick="toggleSub('trans-sub','trans-arrow')"> <span class="w-cat-label"> <span class="w-arrow" id="trans-arrow">▶</span> Транспорт </span> <input type="range" class="w-sl" id="wc-trans" min="0" max="100" value="25" onclick="event.stopPropagation()"> <span class="w-val" id="wc-trans-v">25%</span> </div> <div class="w-sub" id="trans-sub"> <div class="w-sub-row"> <span class="w-sub-label">Плотн. дорог</span> <input type="range" class="w-sl" id="ws-roads" min="0" max="100" value="45"> <span class="w-val" id="ws-roads-v">45%</span> </div> <div class="w-sub-row"> <span class="w-sub-label">До центра</span> <input type="range" class="w-sl" id="ws-center" min="0" max="100" value="35"> <span class="w-val" id="ws-center-v">35%</span> </div> <div class="w-sub-row"> <span class="w-sub-label">Остановки</span> <input type="range" class="w-sl" id="ws-stops" min="0" max="100" value="20"> <span class="w-val" id="ws-stops-v">20%</span> </div> <div class="w-sub-row" data-criterion="metro"> <span class="w-sub-label">Метро</span> <input type="range" class="w-sl" id="ws-metro" min="0" max="100" value="0"> <span class="w-val" id="ws-metro-v">0%</span> </div> <div class="w-sub-row" data-criterion="parking"> <span class="w-sub-label">Парковки</span> <input type="range" class="w-sl" id="ws-parking" min="0" max="100" value="0"> <span class="w-val" id="ws-parking-v">0%</span> </div> <div class="w-sub-row" data-criterion="fuel_proximity"> <span class="w-sub-label">Заправки</span> <input type="range" class="w-sl" id="ws-fuel" min="0" max="100" value="0"> <span class="w-val" id="ws-fuel-v">0%</span> </div> </div> </div> <div class="btn-row"> <button class="reset-btn" id="reset-weights">↻ Сбросить</button> <button class="apply-btn" id="apply-weights">✓ Применить</button> </div> </div> </div><!-- /sidebar --> <div id="resizer" title="Потяните для изменения ширины"></div> <div id="map-wrap"> <div id="map"></div> <!-- Кнопка сворачивания левой панели --> <button id="panel-toggle" title="Свернуть панель">‹</button> <!-- Кнопка сохранения --> <button id="save-btn"><img src="/icons/save.png" width="15" height="15" style="filter:brightness(0) invert(1);vertical-align:middle;margin-right:5px;" alt=""> Сохранить</button> <!-- Модальное окно подтверждения сохранения --> <div id="save-modal"> <div id="save-modal-box"> <div class="save-modal-title">Сохранить карту</div> <button id="save-html" class="save-option-btn">⇓ Скачать HTML карту</button> <p class="save-option-desc">Интерактивная карта для браузера. Все данные встроены — работает без интернета. Слайдеры весов и слои полностью сохранены.</p> <hr class="save-divider"> <button id="save-confirm" class="save-option-btn">⇓ Скачать ZIP (GeoJSON)</button> <p class="save-option-desc">Слои в формате GeoJSON с QML-стилями для импорта в QGIS и другие ГИС-приложения.</p> <button id="save-cancel">Отмена</button> </div> </div> <!-- Легенда + статистика, снизу справа --> <div id="legend-panel"> <div class="section" style="border-bottom:1px solid var(--border)"> <div class="sec-title">Легенда</div> <div class="legend-bar"></div> <div class="legend-labels"> <span>Низкая привлекательность</span><span>Высокая</span> </div> </div> <div class="section" style="border-bottom:none"> <div class="sec-title">Статистика</div> <table id="stats-table"> <thead><tr><th>Индекс</th><th>Мин</th><th>Среднее</th><th>Макс</th></tr></thead> <tbody id="stats-body"></tbody> </table> </div> </div> <!-- Информация о территории (поверх легенды при клике) --> <div id="info-panel"> <div class="ip-header"> <span>Территория</span> <button id="info-close">✕</button> </div> <div id="info-content"></div> </div> </div> <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script> //__DATA_PLACEHOLDER__ // Обновляем заголовок вкладки браузера (карта в iframe — меняем title родителя) try { window.parent.document.title = MAP_CITY + ' \u2014 \u0430\u043d\u0430\u043b\u0438\u0437'; } catch(e) {} // ── КАРТА ───────────────────────────────────────────────────────────────── var map = L.map('map', {zoomControl: true, zoomSnap: 0.5, zoomDelta: 0.5}).setView(MAP_CENTER, 12); L.control.scale({imperial: false, position: 'bottomright'}).addTo(map); // ОтдельныйPane для индексных слоёв — всегда ниже объектов (overlayPane z=400) map.createPane('indexPane'); map.getPane('indexPane').style.zIndex = 350; var baseLayers = { voyager: L.tileLayer( 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19}), light: L.tileLayer( 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19}), satellite: L.tileLayer( 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {attribution: 'Esri', maxZoom: 19}) }; baseLayers.voyager.addTo(map); // ── ЦВЕТ (0=красный · 0.5=жёлтый · 1=зелёный) ─────────────────────────── function scoreToColor(score) { score = Math.max(0, Math.min(1, parseFloat(score) || 0)); var r, g; if (score <= 0.5) { r = 255; g = Math.round(510 * score); } else { r = Math.round(510*(1-score)); g = 255; } return 'rgb(' + r + ',' + g + ',20)'; } // ── ИНФОРМАЦИОННАЯ ПАНЕЛЬ ──────────────────────────────────────────────── var _selFeature = null; var _selIdx = null; var CAT_META = [ { key: 'ecology', label: 'Экология', color: '#81c784', crits: [ {key: 'industrial_distance', label: 'Удаленность от промзон'}, {key: 'ndvi', label: 'NDVI (озеленение)'}, {key: 'green_proximity', label: 'Близость к зеленым зонам (OSM)'}, {key: 'landfill_distance', label: 'Удаленность от свалок'}, {key: 'water_proximity', label: 'Близость к водоемам'}, ]}, { key: 'infrastructure', label: 'Инфраструктура', color: '#64b5f6', crits: [ {key: 'hospitals', label: 'Медицина'}, {key: 'shops', label: 'Магазины'}, {key: 'schools', label: 'Школы'}, {key: 'sports', label: 'Спорт'}, {key: 'universities', label: 'Вузы'}, ]}, { key: 'transport', label: 'Транспорт', color: '#ffb74d', crits: [ {key: 'roads_density', label: 'Плотность дорог'}, {key: 'center_distance', label: 'Близость к центру'}, {key: 'public_transport',label: 'Остановки'}, {key: 'metro', label: 'Станции метро'}, {key: 'parking', label: 'Парковки'}, {key: 'fuel_proximity', label: 'Заправки'}, ]}, ]; function showInfoPanel(feature, idx) { _selFeature = feature; _selIdx = idx; var p = feature.properties; var html = ''; var score = +(p.attractiveness || 0); html += '<div class="ip-overall">' + '<span class="ip-overall-label">Общий индекс</span>' + '<span class="ip-overall-score" style="color:' + scoreToColor(score) + '">' + score.toFixed(3) + '</span></div>'; CAT_META.forEach(function(cat) { var cval = +(p[cat.key] || 0); html += '<div class="ip-cat">' + '<div class="ip-cat-header">' + '<span class="ip-cat-label" style="color:' + cat.color + '">' + cat.label + '</span>' + '<span class="ip-cat-score">' + cval.toFixed(3) + '</span>' + '</div>' + '<div class="ip-bar-wrap"><div class="ip-bar-fill" style="width:' + (cval * 100).toFixed(1) + '%;background:' + cat.color + '"></div></div>'; cat.crits.forEach(function(cr) { if (!ACTIVE_CRITERIA[cr.key]) return; var arr = CRITERIA[cr.key]; if (!arr || arr[idx] === undefined) return; var v = +arr[idx]; html += '<div class="ip-sub">' + '<span class="ip-sub-label">' + cr.label + '</span>' + '<div class="ip-sub-bar-wrap"><div class="ip-sub-bar-fill" style="width:' + (v * 100).toFixed(1) + '%"></div></div>' + '<span class="ip-sub-val">' + v.toFixed(3) + '</span>' + '</div>'; }); html += '</div>'; }); document.getElementById('info-content').innerHTML = html; document.getElementById('info-panel').classList.add('open'); } document.getElementById('info-close').addEventListener('click', function() { if (_selLayer) { var prev = _selLayer; _selLayer = null; _selIdx = null; currentLayer.resetStyle(prev); } _selFeature = null; document.getElementById('info-panel').classList.remove('open'); }); // ── СЛОЙ ИНДЕКСА ───────────────────────────────────────────────────────── var currentLayer = null; var currentOpacity = 0.70; var currentCol = 'attractiveness'; var hoveredLayer = null; var _selLayer = null; var _hexJustClicked = false; function _selStyle() { return {weight: 3, color: '#ffffff', fillOpacity: Math.min(1, currentOpacity + 0.2)}; } function makeLayer(col) { return L.geoJSON(HEX_DATA, { pane: 'indexPane', style: function(f) { var idx = HEX_DATA.features.indexOf(f); var sel = (idx === _selIdx); return { fillColor: scoreToColor(f.properties[col]), fillOpacity: sel ? Math.min(1, currentOpacity + 0.2) : currentOpacity, color: sel ? '#FFD600' : '#444', weight: sel ? 3 : 0.6, opacity: 0.9 }; }, onEachFeature: function(feature, layer) { var idx = HEX_DATA.features.indexOf(feature); if (idx === _selIdx) _selLayer = layer; layer.on('click', function(e) { _hexJustClicked = true; if (_selLayer) { var prev = _selLayer; _selLayer = null; _selIdx = null; currentLayer.resetStyle(prev); } _selLayer = e.target; _selIdx = idx; e.target.setStyle(_selStyle()); showInfoPanel(feature, idx); }); layer.on('mouseover', function(e) { if (hoveredLayer && hoveredLayer !== e.target) { currentLayer.resetStyle(hoveredLayer); if (hoveredLayer === _selLayer) hoveredLayer.setStyle(_selStyle()); } hoveredLayer = e.target; e.target.setStyle({weight: 2.5, color: '#fff', fillOpacity: Math.min(1, currentOpacity + 0.12)}); e.target.bringToFront(); }); layer.on('mouseout', function(e) { currentLayer.resetStyle(e.target); if (e.target === _selLayer) e.target.setStyle(_selStyle()); if (hoveredLayer === e.target) hoveredLayer = null; }); } }); } function showIndex(col) { if (currentLayer) map.removeLayer(currentLayer); currentCol = col; currentLayer = makeLayer(col); currentLayer.addTo(map); } showIndex('attractiveness'); // Закрытие инфо-панели кликом по пустому месту карты map.on('click', function() { if (_hexJustClicked) { _hexJustClicked = false; return; } if (!document.getElementById('info-panel').classList.contains('open')) return; if (_selLayer) { var prev = _selLayer; _selLayer = null; _selIdx = null; currentLayer.resetStyle(prev); } _selFeature = null; document.getElementById('info-panel').classList.remove('open'); }); // ── POI СЛОИ ───────────────────────────────────────────────────────────── // Точечные объекты — кружки (единый стиль, разный цвет) function circleLayer(data, color, r) { if (!data) return null; return L.geoJSON(data, { pointToLayer: function(f, ll) { return L.circleMarker(ll, { radius: r, color: '#fff', weight: 1.2, // белая обводка для читаемости fillColor: color, fillOpacity: 0.9 }); } }); } // Промзоны — полигоны со штриховкой (url(#ind-hatch) из SVG <defs> выше) function industrialLayer(data) { if (!data) return null; return L.geoJSON(data, { style: { fillColor: 'url(#ind-hatch)', fillOpacity: 1, color: '#37474F', weight: 2, dashArray: '4 3' } }); } // (greenZoneLayer удалён: зелёные зоны OSM заменены NDVI из спутниковых снимков) function _greenZoneLayerLegacy(data) { if (!data) return null; return L.geoJSON(data, { style: { fillColor: 'url(#green-hatch)', fillOpacity: 1, color: '#2e7d32', weight: 2, dashArray: '4 3' } }); } // Свалки — полигоны с коричневой штриховкой (inline-стиль, без SVG defs) function landfillLayer(data) { if (!data) return null; return L.geoJSON(data, { style: { fillColor: '#8D6E63', fillOpacity: 0.45, color: '#6D4C41', weight: 2, dashArray: '4 3' } }); } var poiLayers = { shops: circleLayer(POI_SHOPS, '#2196F3', 4), schools: circleLayer(POI_SCHOOLS, '#4CAF50', 5), hospitals: circleLayer(POI_HOSPITALS, '#F44336', 6), stops: circleLayer(POI_STOPS, '#FF9800', 3), metro: circleLayer(POI_METRO, '#9C27B0', 6), parking: circleLayer(POI_PARKING, '#795548', 3), sports: circleLayer(POI_SPORTS, '#00BCD4', 5), universities: circleLayer(POI_UNIVERSITIES, '#FF5722', 6), fuel: circleLayer(POI_FUEL, '#FFC107', 4), industrial: industrialLayer(POI_INDUSTRIAL), landfill: landfillLayer(POI_LANDFILL) }; // Скрываем строки без данных Object.keys(poiLayers).forEach(function(key) { var row = document.getElementById('row-' + key); if (row && !poiLayers[key]) row.querySelector('label').classList.add('no-data'); }); document.querySelectorAll('[data-poi]').forEach(function(cb) { cb.addEventListener('change', function() { var layer = poiLayers[cb.dataset.poi]; if (!layer) return; if (cb.checked) layer.addTo(map); else map.removeLayer(layer); cb.closest('label').classList.toggle('active', cb.checked); }); }); // ── ГРАНИЦА ГОРОДА ─────────────────────────────────────────────────────── if (BOUNDARY) { L.geoJSON(BOUNDARY, { style: {fillColor: 'none', color: '#ef5350', weight: 2.5, dashArray: '7 5', fillOpacity: 0} }).addTo(map); } // ── ВНУТРЕННИЕ ГРАНИЦЫ (районы / поселения) ────────────────────────────── (function() { var section = document.getElementById('section-districts'); var cb = document.getElementById('cb-districts'); if (!DISTRICT_BOUNDARIES || !DISTRICT_BOUNDARIES.features || !DISTRICT_BOUNDARIES.features.length) { if (section) section.style.display = 'none'; return; } // Pane для подписей — выше всего (маркеры 600, тайлы 200) map.createPane('districtLabels'); map.getPane('districtLabels').style.zIndex = 630; map.getPane('districtLabels').style.pointerEvents = 'none'; var features = DISTRICT_BOUNDARIES.features; var _isPoints = features.length > 0 && (features[0].geometry.type === 'Point' || features[0].geometry.type === 'MultiPoint'); // Линии-границы — только для полигональных данных var _districtLines = _isPoints ? null : L.geoJSON(DISTRICT_BOUNDARIES, { style: { color: '#333', weight: 2.2, opacity: 0.85, fill: false }, interactive: false, }); // Вычисление центроида для полигона function _featureCentroid(f) { try { var coords; if (f.geometry.type === 'Polygon') { coords = f.geometry.coordinates[0]; } else if (f.geometry.type === 'MultiPolygon') { coords = f.geometry.coordinates.reduce(function(a, b) { return b[0].length > a[0].length ? b : a; })[0]; } else { return null; } var sumLon = 0, sumLat = 0; coords.forEach(function(c) { sumLon += c[0]; sumLat += c[1]; }); return [sumLat / coords.length, sumLon / coords.length]; } catch(e) { return null; } } // Подписи — для точек берём координату напрямую, для полигонов — центроид var _districtLabels = L.layerGroup(); features.forEach(function(f) { var name = f.properties && f.properties.name; if (!name) return; var centroid; if (_isPoints) { var c = f.geometry.coordinates; centroid = [c[1], c[0]]; } else { centroid = _featureCentroid(f); } if (!centroid) return; L.marker(centroid, { icon: L.divIcon({ className: 'district-label', html: name, iconSize: null, iconAnchor: [0, 0], }), pane: 'districtLabels', interactive: false, }).addTo(_districtLabels); }); cb.addEventListener('change', function() { if (this.checked) { if (_districtLines) _districtLines.addTo(map); _districtLabels.addTo(map); } else { if (_districtLines && map.hasLayer(_districtLines)) map.removeLayer(_districtLines); if (map.hasLayer(_districtLabels)) map.removeLayer(_districtLabels); } this.closest('label').classList.toggle('active', this.checked); }); })(); // ── БАЗОВЫЙ СЛОЙ ───────────────────────────────────────────────────────── document.querySelectorAll('input[name="base"]').forEach(function(rb) { rb.addEventListener('change', function() { Object.values(baseLayers).forEach(function(l) { map.removeLayer(l); }); baseLayers[rb.value].addTo(map); document.querySelectorAll('#base-list label').forEach(function(l) { l.classList.remove('active'); }); rb.closest('label').classList.add('active'); }); }); // ── ПЕРЕКЛЮЧЕНИЕ ИНДЕКСА ───────────────────────────────────────────────── document.querySelectorAll('input[name="idx"]').forEach(function(rb) { rb.addEventListener('change', function() { showIndex(rb.value); document.querySelectorAll('#index-list label').forEach(function(l) { l.classList.remove('active'); }); rb.closest('label').classList.add('active'); }); }); // ── ПОЛЗУНОК ЯРКОСТИ ───────────────────────────────────────────────────── document.getElementById('opSlider').addEventListener('input', function() { currentOpacity = this.value / 100; document.getElementById('opVal').innerText = this.value + '%'; if (currentLayer) currentLayer.setStyle({fillOpacity: currentOpacity}); if (_selLayer) _selLayer.setStyle(_selStyle()); }); // ── НАСТРОЙКА ВЕСОВ ────────────────────────────────────────────────────── // Раскрытие / скрытие подкритериев function toggleSub(subId, arrowId) { var sub = document.getElementById(subId); var arrow = document.getElementById(arrowId); var open = sub.style.display !== 'block'; sub.style.display = open ? 'block' : 'none'; if (arrow) arrow.classList.toggle('open', open); } // Соответствие критерий → ID слайдера var CRIT_TO_SLIDER = { industrial_distance: 'ws-ind', ndvi: 'ws-ndvi', green_proximity: 'ws-green', water_proximity: 'ws-water', landfill_distance: 'ws-landfill', hospitals: 'ws-hosp', shops: 'ws-shops', schools: 'ws-schools', sports: 'ws-sports', universities: 'ws-uni', roads_density: 'ws-roads', center_distance: 'ws-center', public_transport: 'ws-stops', metro: 'ws-metro', parking: 'ws-parking', fuel_proximity: 'ws-fuel' }; // Скрываем строки неактивных критериев и применяем эффективные веса (function() { Object.keys(CRIT_TO_SLIDER).forEach(function(crit) { var row = document.querySelector('[data-criterion="' + crit + '"]'); if (!ACTIVE_CRITERIA[crit]) { if (row) row.style.display = 'none'; } else if (INIT_SUB_WEIGHTS[crit] !== undefined) { setSlider(CRIT_TO_SLIDER[crit], Math.round(INIT_SUB_WEIGHTS[crit])); } }); // Веса категорий setSlider('wc-eco', Math.round(INIT_WEIGHTS.ecology.weight * 100)); setSlider('wc-infra', Math.round(INIT_WEIGHTS.infrastructure.weight * 100)); setSlider('wc-trans', Math.round(INIT_WEIGHTS.transport.weight * 100)); })(); // Значения по умолчанию для кнопки «Сбросить» (эффективные веса из Python) var DEFAULTS = { 'wc-eco': Math.round(INIT_WEIGHTS.ecology.weight * 100), 'wc-infra': Math.round(INIT_WEIGHTS.infrastructure.weight * 100), 'wc-trans': Math.round(INIT_WEIGHTS.transport.weight * 100) }; Object.keys(CRIT_TO_SLIDER).forEach(function(crit) { if (ACTIVE_CRITERIA[crit] && INIT_SUB_WEIGHTS[crit] !== undefined) DEFAULTS[CRIT_TO_SLIDER[crit]] = Math.round(INIT_SUB_WEIGHTS[crit]); }); // Группы слайдеров — только активные субкритерии function buildGroups() { function activeSliders(crits) { return crits.filter(function(c) { return !!ACTIVE_CRITERIA[c]; }) .map(function(c) { return CRIT_TO_SLIDER[c]; }); } return [ ['wc-eco', 'wc-infra', 'wc-trans'], activeSliders(['industrial_distance','ndvi','green_proximity','water_proximity','landfill_distance']), activeSliders(['hospitals','shops','schools','sports','universities']), activeSliders(['roads_density','center_distance','public_transport','metro','parking','fuel_proximity']) ].filter(function(g) { return g.length > 0; }); } var GROUPS = buildGroups(); function setSlider(id, val) { val = Math.max(0, Math.round(val)); var el = document.getElementById(id); if (el) { el.value = val; document.getElementById(id + '-v').innerText = val + '%'; } } // При движении одного ползунка остальные в группе пересчитываются пропорционально function onSliderChange(group, changedId) { var newVal = Math.max(0, Math.min(100, parseInt(document.getElementById(changedId).value) || 0)); document.getElementById(changedId).value = newVal; var others = group.filter(function(id) { return id !== changedId; }); var remaining = 100 - newVal; var sumOthers = others.reduce(function(s, id) { return s + (parseInt(document.getElementById(id).value) || 0); }, 0); if (sumOthers === 0) { // Раздаём поровну var share = Math.floor(remaining / others.length); var leftover = remaining - share * others.length; others.forEach(function(id, i) { setSlider(id, share + (i === 0 ? leftover : 0)); }); } else { // Пропорционально текущим значениям others.forEach(function(id) { var cur = parseInt(document.getElementById(id).value) || 0; setSlider(id, cur / sumOthers * remaining); }); // Корректируем ошибку округления на последнем ползунке var actualSum = others.reduce(function(s, id) { return s + (parseInt(document.getElementById(id).value) || 0); }, 0); var diff = remaining - actualSum; var lastId = others[others.length - 1]; var lastEl = document.getElementById(lastId); var fixed = Math.max(0, (parseInt(lastEl.value) || 0) + diff); lastEl.value = fixed; document.getElementById(lastId + '-v').innerText = fixed + '%'; } document.getElementById(changedId + '-v').innerText = newVal + '%'; } // Навешиваем на все ползунки их группу GROUPS.forEach(function(group) { group.forEach(function(id) { var el = document.getElementById(id); if (el) el.addEventListener('input', function() { onSliderChange(group, id); }); }); }); // Кнопка «Сбросить» document.getElementById('reset-weights').addEventListener('click', function() { Object.keys(DEFAULTS).forEach(function(id) { setSlider(id, DEFAULTS[id]); }); }); // Чтение весов из слайдеров (слайдеры суммируются в 100 → делим на 100) function getWeights() { function val(crit) { var id = CRIT_TO_SLIDER[crit]; if (!id || !ACTIVE_CRITERIA[crit]) return 0; return (parseInt(document.getElementById(id).value) || 0) / 100; } function catVal(id) { return (parseInt(document.getElementById(id).value) || 0) / 100; } return { ecology: { weight: catVal('wc-eco'), industrial_distance: val('industrial_distance'), ndvi: val('ndvi'), green_proximity: val('green_proximity'), water_proximity: val('water_proximity'), landfill_distance: val('landfill_distance') }, infrastructure: { weight: catVal('wc-infra'), hospitals: val('hospitals'), shops: val('shops'), schools: val('schools'), sports: val('sports'), universities: val('universities') }, transport: { weight: catVal('wc-trans'), roads_density: val('roads_density'), center_distance: val('center_distance'), public_transport: val('public_transport'), metro: val('metro'), parking: val('parking'), fuel_proximity: val('fuel_proximity') } }; } // Пересчёт баллов по новым весам (воспроизводит формулу index_calculator.py) function recalculate(w) { var n = HEX_DATA.features.length; var eco = [], infra = [], trans = [], final_ = []; function c(key, i) { return (CRITERIA[key] && CRITERIA[key][i]) || 0; } for (var i = 0; i < n; i++) { var ecoVal = c('industrial_distance',i) * w.ecology.industrial_distance + c('ndvi',i) * w.ecology.ndvi + c('green_proximity',i) * w.ecology.green_proximity + c('water_proximity',i) * w.ecology.water_proximity + c('landfill_distance',i) * w.ecology.landfill_distance; var infraVal = c('hospitals',i) * w.infrastructure.hospitals + c('shops',i) * w.infrastructure.shops + c('schools',i) * w.infrastructure.schools + c('sports',i) * w.infrastructure.sports + c('universities',i) * w.infrastructure.universities; var transVal = c('roads_density',i) * w.transport.roads_density + c('center_distance',i) * w.transport.center_distance + c('public_transport',i) * w.transport.public_transport + c('metro',i) * w.transport.metro + c('parking',i) * w.transport.parking + c('fuel_proximity',i) * w.transport.fuel_proximity; var finalVal = ecoVal * w.ecology.weight + infraVal * w.infrastructure.weight + transVal * w.transport.weight; eco.push(ecoVal); infra.push(infraVal); trans.push(transVal); final_.push(finalVal); } return {final: final_, ecology: eco, infrastructure: infra, transport: trans}; } // Пересчёт статистики из текущих данных HEX_DATA function updateStatsFromData() { var tbody = document.getElementById('stats-body'); tbody.innerHTML = ''; var cols = [ ['Общий', 'attractiveness'], ['Экология', 'ecology'], ['Инфраструктура','infrastructure'], ['Транспорт', 'transport'] ]; cols.forEach(function(pair) { var lbl = pair[0], col = pair[1]; var vals = HEX_DATA.features.map(function(f){return +(f.properties[col]||0);}); var mn = Math.min.apply(null, vals).toFixed(3); var mx = Math.max.apply(null, vals).toFixed(3); var avg = (vals.reduce(function(a,b){return a+b;},0)/vals.length).toFixed(3); var tr = document.createElement('tr'); tr.innerHTML = '<td>' + lbl + '</td>' + '<td style="color:var(--muted)">' + mn + '</td>' + '<td><span class="badge">' + avg + '</span></td>' + '<td style="color:var(--muted)">' + mx + '</td>'; tbody.appendChild(tr); }); } // Кнопка «Применить» document.getElementById('apply-weights').addEventListener('click', function() { var w = getWeights(); var result = recalculate(w); // Обновляем свойства features в HEX_DATA (мутация в месте — redraw читает отсюда) HEX_DATA.features.forEach(function(f, i) { f.properties.attractiveness = +result.final[i].toFixed(4); f.properties.ecology = +result.ecology[i].toFixed(4); f.properties.infrastructure = +result.infrastructure[i].toFixed(4); f.properties.transport = +result.transport[i].toFixed(4); }); showIndex(currentCol); updateStatsFromData(); }); // ── СТАТИСТИКА (начальная) + автопересчёт если файл сохранён с изменёнными весами ── // WEIGHTS_PATCHED выставляется в true функцией _patchWeights при скачивании HTML var WEIGHTS_PATCHED = false; (function() { if (WEIGHTS_PATCHED) { var result = recalculate(getWeights()); HEX_DATA.features.forEach(function(f, i) { f.properties.attractiveness = +result.final[i].toFixed(4); f.properties.ecology = +result.ecology[i].toFixed(4); f.properties.infrastructure = +result.infrastructure[i].toFixed(4); f.properties.transport = +result.transport[i].toFixed(4); }); showIndex(currentCol); } updateStatsFromData(); })(); // ── РЕСАЙЗЕР ПАНЕЛИ ────────────────────────────────────────────────────── (function() { var rz = document.getElementById('resizer'); var sb = document.getElementById('sidebar'); var dragging = false; rz.addEventListener('mousedown', function(e) { dragging = true; rz.classList.add('dragging'); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }); document.addEventListener('mousemove', function(e) { if (!dragging) return; var w = Math.max(220, Math.min(540, e.clientX)); sb.style.width = w + 'px'; }); document.addEventListener('mouseup', function() { if (!dragging) return; dragging = false; rz.classList.remove('dragging'); document.body.style.cursor = ''; document.body.style.userSelect = ''; map.invalidateSize(); }); })(); // ── ПРЕДУПРЕЖДЕНИЯ (очередь: NDVI → границы) ───────────────────────────── var _warningsQueue = []; if (WARNING_MESSAGE) _warningsQueue.push(WARNING_MESSAGE); if (WARNING_BOUNDARIES) _warningsQueue.push(WARNING_BOUNDARIES); var _warningIdx = 0; function _showNextWarning() { if (_warningIdx >= _warningsQueue.length) return; document.getElementById('warning-text').textContent = _warningsQueue[_warningIdx++]; document.getElementById('warning-overlay').style.display = 'flex'; } _showNextWarning(); // ── СОХРАНЕНИЕ КАРТЫ ───────────────────────────────────────────────────── document.getElementById('save-btn').addEventListener('click', function() { document.getElementById('save-modal').classList.add('open'); }); document.getElementById('save-cancel').addEventListener('click', function() { document.getElementById('save-modal').classList.remove('open'); }); document.getElementById('save-modal').addEventListener('click', function(e) { if (e.target === this) this.classList.remove('open'); }); document.getElementById('save-html').addEventListener('click', function() { document.getElementById('save-modal').classList.remove('open'); var pageUrl = window.location.href.split('?')[0]; var filename = _fileName() + '_map.html'; function _patchWeights(html) { var w = getWeights(); var subW = {}; Object.keys(CRIT_TO_SLIDER).forEach(function(crit) { if (ACTIVE_CRITERIA[crit]) { var el = document.getElementById(CRIT_TO_SLIDER[crit]); if (el) subW[crit] = parseInt(el.value) || 0; } }); html = html.replace(/var INIT_WEIGHTS\s*=[^\n]*;/, 'var INIT_WEIGHTS = ' + JSON.stringify(w) + ';'); html = html.replace(/var INIT_SUB_WEIGHTS\s*=[^\n]*;/, 'var INIT_SUB_WEIGHTS = ' + JSON.stringify(subW) + ';'); html = html.replace(/var WEIGHTS_PATCHED\s*=[^\n]*;/, 'var WEIGHTS_PATCHED = true;'); return html; } if (pageUrl.startsWith('http://') || pageUrl.startsWith('https://')) { fetch(pageUrl) .then(function(r) { return r.text(); }) .then(function(html) { var blob = new Blob([_patchWeights(html)], {type: 'text/html;charset=utf-8'}); _triggerDownload(blob, filename); }) .catch(function() { var blob = new Blob([_patchWeights(document.documentElement.outerHTML)], {type: 'text/html;charset=utf-8'}); _triggerDownload(blob, filename); }); } else { var blob = new Blob([_patchWeights(document.documentElement.outerHTML)], {type: 'text/html;charset=utf-8'}); _triggerDownload(blob, filename); } }); function _triggerDownload(blob, filename) { var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); setTimeout(function() { URL.revokeObjectURL(url); a.remove(); }, 1500); } function _fileName() { return (MAP_CITY || 'map').replace(/[\\/:*?"<>|]/g, '_'); } // ── ТЕМА + ФОТОРЕЖИМ (получение от родительской страницы) ──────────────── window.addEventListener('message', function(e) { if (!e.data) return; if (e.data.theme) { document.documentElement.dataset.theme = e.data.theme; } if (typeof e.data.photoMode !== 'undefined') { document.body.classList.toggle('photo-mode', e.data.photoMode); if (e.data.photoMode) document.body.classList.remove('sidebar-collapsed'); setTimeout(function() { map.invalidateSize(); }, 300); } }); // ── СВОРАЧИВАНИЕ ПАНЕЛИ ─────────────────────────────────────────────────── document.getElementById('panel-toggle').addEventListener('click', function() { var collapsed = document.body.classList.toggle('sidebar-collapsed'); this.innerHTML = collapsed ? '›' : '‹'; this.title = collapsed ? 'Развернуть панель' : 'Свернуть панель'; setTimeout(function() { map.invalidateSize(); }, 300); }); // Генератор QML-стиля: 10 фиксированных равных интервалов 0.0–1.0, шкала RdYlGn function _generateQml(field) { var colors = [ '#a50026','#d73027','#f46d43','#fdae61','#fee08b', '#d9ef8b','#a6d96a','#66bd63','#1a9850','#006837' ]; var alpha = 0.8; var alphaInt = Math.round(alpha * 255); function hexToRgba(h) { h = h.replace('#',''); var r = parseInt(h.slice(0,2),16), g = parseInt(h.slice(2,4),16), b = parseInt(h.slice(4,6),16); return r+','+g+','+b+','+alphaInt; } var ranges = '', symbols = ''; for (var i = 0; i < colors.length; i++) { var lo = (i/10).toFixed(4), hi = ((i+1)/10).toFixed(4); var loF = parseFloat(lo), hiF = parseFloat(hi); var label = loF.toFixed(3)+' – '+hiF.toFixed(3); ranges += ' <range lower="'+lo+'" upper="'+hi+'" symbol="'+i+'" label="'+label+'" render="true"/>\n'; symbols += ' <symbol alpha="'+alpha+'" clip_to_extent="1" type="fill" name="'+i+'">\n' + ' <layer class="SimpleFill" enabled="1" pass="0" locked="0">\n' + ' <prop k="color" v="'+hexToRgba(colors[i])+'"/>\n' + ' <prop k="style" v="solid"/>\n' + ' <prop k="outline_style" v="no"/>\n' + ' <prop k="outline_color" v="0,0,0,0"/>\n' + ' </layer>\n' + ' </symbol>\n'; } return '<!DOCTYPE qgis PUBLIC \'http://mrcc.com/qgis.dtd\' \'SYSTEM\'>\n' + '<qgis version="3.0">\n' + ' <renderer-v2 type="graduatedSymbol" attr="'+field+'" graduatedMethod="GraduatedColor" enableorderby="0" forceraster="0">\n' + ' <ranges>\n'+ranges+' </ranges>\n' + ' <symbols>\n'+symbols+' </symbols>\n' + ' </renderer-v2>\n' + '</qgis>\n'; } document.getElementById('save-confirm').addEventListener('click', function() { document.getElementById('save-modal').classList.remove('open'); var zip = new JSZip(); // ── 4 слоя гексагонов из текущего состояния HEX_DATA + CRITERIA ── var hexFeatures = HEX_DATA.features.map(function(f, i) { var src = f.properties || {}; var p = { hex_id: i, index: +((src.attractiveness || src.index || 0).toFixed(4)), ecology: +((src.ecology || 0).toFixed(4)), infrastructure: +((src.infrastructure || 0).toFixed(4)), transport: +((src.transport || 0).toFixed(4)), }; Object.keys(CRITERIA).forEach(function(key) { var arr = CRITERIA[key]; if (arr && arr[i] !== undefined) p[key] = +arr[i].toFixed(4); }); return {type: 'Feature', geometry: f.geometry, properties: p}; }); function _hexLayer(cols) { var feats = hexFeatures.map(function(f) { var p = {hex_id: f.properties.hex_id}; cols.forEach(function(c) { if (f.properties[c] !== undefined) p[c] = f.properties[c]; }); return {type: 'Feature', geometry: f.geometry, properties: p}; }); return JSON.stringify({type: 'FeatureCollection', features: feats}); } zip.file('hexagons_index.geojson', _hexLayer(['index','ecology','infrastructure','transport'])); zip.file('hexagons_ecology.geojson', _hexLayer(['ecology','industrial_distance','ndvi','green_proximity','water_proximity','landfill_distance'])); zip.file('hexagons_infrastructure.geojson', _hexLayer(['infrastructure','hospitals','shops','schools','sports','universities'])); zip.file('hexagons_transport.geojson', _hexLayer(['transport','roads_density','center_distance','public_transport','metro','parking','fuel_proximity'])); // QML-стили для каждого слоя гексагонов zip.file('hexagons_index.qml', _generateQml('index')); zip.file('hexagons_ecology.qml', _generateQml('ecology')); zip.file('hexagons_infrastructure.qml', _generateQml('infrastructure')); zip.file('hexagons_transport.qml', _generateQml('transport')); // ── POI-слои ── var layers = { 'shops.geojson': POI_SHOPS, 'schools.geojson': POI_SCHOOLS, 'hospitals.geojson': POI_HOSPITALS, 'public_transport.geojson': POI_STOPS, 'metro.geojson': POI_METRO, 'parking.geojson': POI_PARKING, 'sports.geojson': POI_SPORTS, 'universities.geojson': POI_UNIVERSITIES, 'fuel.geojson': POI_FUEL, 'industrial.geojson': POI_INDUSTRIAL, 'landfill.geojson': POI_LANDFILL, }; Object.keys(layers).forEach(function(fname) { if (layers[fname]) zip.file(fname, JSON.stringify(layers[fname])); }); // ── Граница и внутренние границы ── if (BOUNDARY) zip.file('boundary.geojson', JSON.stringify(BOUNDARY)); if (DISTRICT_BOUNDARIES) zip.file('district_boundaries.geojson', JSON.stringify(DISTRICT_BOUNDARIES)); zip.generateAsync({type: 'blob'}).then(function(blob) { _triggerDownload(blob, _fileName() + '_layers.zip'); }); }); </script> </body> </html>