pole-planner/index.html
2026-06-19 07:11:46 +00:00

767 lines
41 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Планировщик опор ВЛ — РК</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
:root{--ink:#0F1218;--cyan:#00E5FF}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;margin:0;padding:0}
body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif;color:var(--ink);background:#F2F4F7;height:100%}
.header{background:var(--ink);padding:4px 8px;display:flex;align-items:center;gap:4px;flex-wrap:wrap}
.header h1{font-size:13px;font-weight:700;color:#fff;white-space:nowrap}
.controls{display:flex;gap:2px;flex-wrap:wrap}
.controls button{padding:3px 6px;border:none;border-radius:3px;font-weight:600;cursor:pointer;font-size:10px;white-space:nowrap}
.btn-primary{background:#00E5FF;color:#0F1218}
.btn-secondary{background:#4CAF50;color:#fff}
.btn-warning{background:#FF9800;color:#fff}
.btn-danger{background:#ff5252;color:#fff}
.btn-export{background:#4caf50;color:#fff}
.btn-active{background:#fff!important;color:#0F1218!important;box-shadow:0 0 0 2px var(--cyan)}
.loc-panel{background:#222;padding:2px 8px;display:flex;align-items:center;gap:4px;flex-wrap:wrap;font-size:10px;border-bottom:1px solid #333}
.loc-panel label{color:#9aa3b2}
.loc-panel select{padding:1px 4px;border:1px solid #555;border-radius:3px;background:#333;color:#fff;font-size:10px;max-width:120px}
.loc-panel input{padding:1px 4px;border:1px solid #555;border-radius:3px;background:#333;color:#fff;font-size:10px;width:100px}
.loc-panel button{padding:1px 6px;border:none;border-radius:3px;background:var(--cyan);color:var(--ink);font-weight:600;cursor:pointer;font-size:10px}
#map{height:100vh;width:100%;min-height:350px;background:#1a1a1a;position:relative;z-index:1}
.settings{background:#1a1a1a;padding:2px 8px;display:flex;gap:4px;align-items:center;font-size:10px;color:#9aa3b2;flex-wrap:wrap;border-bottom:none}
.settings label{display:flex;align-items:center;gap:2px}
.settings input{width:32px;padding:1px 3px;border:1px solid #555;border-radius:2px;background:#222;color:#fff;font-size:10px;text-align:center}
.settings .mode-info{margin-left:auto;font-size:9px;color:#888}
.sidebar{position:absolute;top:34px;right:6px;width:300px;max-height:calc(100vh - 44px);background:#fff;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15);overflow:hidden;z-index:1000;display:flex;flex-direction:column;font-size:11px;transition:max-height .2s;border:1px solid rgba(0,0,0,.08)}
.sidebar-header{padding:4px 8px;background:#F2F4F7;font-weight:700;font-size:10px;border-bottom:1px solid #ddd;display:flex;justify-content:space-between;align-items:center;cursor:pointer;user-select:none}
.sidebar table{width:100%;border-collapse:collapse;font-size:10px}
.sidebar th{background:var(--ink);color:#fff;padding:2px 4px;text-align:left;position:sticky;top:0;font-size:9px}
.sidebar td{padding:2px 4px;border-bottom:1px solid #F2F4F7;font-size:9px}
.sidebar td.num{text-align:center;font-weight:700;color:#00E5FF;font-size:10px}
.sidebar td.dist{text-align:right;font-family:monospace;font-size:9px}
.sidebar-footer{padding:3px 8px;background:#F2F4F7;font-size:9px;font-weight:600;border-top:1px solid #ddd}
.pole-label{background:#0F1218;color:#00E5FF;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:9px;border:2px solid #00E5FF;box-shadow:0 2px 6px rgba(0,0,0,.4)}
.entrance-label{background:#FF9800;color:#fff;border-radius:3px;padding:1px 4px;font-size:8px;font-weight:700;border:2px solid #FF9800;box-shadow:0 2px 6px rgba(0,0,0,.3)}
.mode-badge{position:absolute;bottom:30px;right:310px;background:#00E5FF;color:#0F1218;padding:4px 10px;border-radius:4px;font-weight:700;font-size:11px;z-index:1000;pointer-events:none;box-shadow:0 2px 8px rgba(0,0,0,.25)}
.legend{position:absolute;bottom:6px;left:6px;background:rgba(255,255,255,.9);padding:3px 6px;border-radius:4px;box-shadow:0 1px 6px rgba(0,0,0,.1);z-index:1000;font-size:9px;line-height:1.4}
.legend span{display:flex;align-items:center;gap:3px}
.auto-panel{position:absolute;top:34px;left:6px;background:#fff;padding:4px 8px;border-radius:5px;box-shadow:0 2px 12px rgba(0,0,0,.15);z-index:1000;font-size:10px;max-width:240px;display:none}
.auto-panel button{padding:2px 6px;border:none;border-radius:3px;font-weight:600;cursor:pointer;font-size:9px;margin-top:3px}
.auto-panel .tag{display:inline-block;background:#00E5FF;color:#0F1218;padding:1px 5px;border-radius:2px;font-size:8px;margin:1px}
@media(max-width:700px){.sidebar{width:100%;right:0;top:auto;bottom:0;max-height:25vh;border-radius:6px 6px 0 0;font-size:10px}#map{height:calc(100vh - 72px)}.legend{bottom:auto;top:34px}.header h1{font-size:12px}}
</style>
</head>
<body>
<div class="header">
<h1>⚡ Опоры ВЛ-0.4 кВ</h1>
<div class="controls">
<button class="btn-primary" id="btnLine" onclick="setMode('line')">📏 Линия</button>
<button class="btn-active" id="btnManual" onclick="setMode('manual')">🎯 Вручную</button>
<button class="btn-warning" id="btnEntrance" onclick="setMode('entrance')">🚗 Въезд</button>
<button class="btn-danger" onclick="undo()"></button>
<button class="btn-warning" onclick="clearAll()">🗑</button>
<button class="btn-export" onclick="exportSVG()">📥 SVG</button>
<button class="btn-export" onclick="exportDrawIO()">draw.io</button>
<button class="btn-export" onclick="exportCSV()">CSV</button>
</div>
</div>
<div class="loc-panel">
<label>📍 Область:</label>
<select id="regionSelect" onchange="onRegionChange()" style="max-width:110px"><option value="">— выберите —</option></select>
<label>Район:</label>
<select id="districtSelect" onchange="onDistrictSelect()" style="max-width:110px"><option value="">— загрузка —</option></select>
<label>Село:</label>
<select id="villageSelect" onchange="onVillageSelect()" style="max-width:120px"><option value="">— выберите район —</option></select>
<button onclick="autoDetect()" style="background:#FF9800;color:#fff">🔄 Авто</button>
<span id="locStatus" style="color:#888;font-size:9px"></span>
</div>
<div class="settings">
<label>📐 Шаг: <input type="number" id="poleStep" value="50" min="10" max="200"> м</label>
<label>⛔ Отступ: <input type="number" id="entranceMargin" value="5" min="0" max="30"> м</label>
<label>🔒 Привязка: <input type="checkbox" id="snapRoad" checked></label>
<span class="mode-info" id="modeInfo">Режим: ручная расстановка</span>
</div>
<div id="map"></div>
<div class="mode-badge" id="modeBadge" style="display:none">🎯 Кликните для установки</div>
<div class="auto-panel" id="autoPanel">
<b>🔄 Автоопределение</b><br>
<span id="autoStatus">Загрузка данных...</span>
<div id="autoResults"></div>
<button onclick="applyRoadPoles()">📥 Расставить опоры вдоль улиц</button>
</div>
<div class="legend">
<span><span style="display:inline-block;width:11px;height:11px;border-radius:50%;background:#0F1218;border:2px solid #00E5FF"></span> Опора</span>
<span><i style="display:inline-block;width:12px;height:3px;background:#00E5FF;border-radius:2px"></i> ВЛ</span>
<span><span style="background:#FF9800;padding:0 3px;border-radius:2px;color:#fff;font-size:8px">В</span> Въезд</span>
<span><i style="display:inline-block;width:12px;height:3px;background:#e74c3c;border-radius:2px"></i> Асфальт</span>
<span><i style="display:inline-block;width:12px;height:2px;background:#f39c12;border-radius:2px"></i> Грунтовка</span>
<span><i style="display:inline-block;width:12px;height:1px;background:#95a5a6;border-radius:1px"></i> Просёлок/поле</span>
<span><span style="display:inline-block;width:8px;height:8px;background:rgba(255,107,53,.25);border:1px solid #ff6b35"></span> Здание</span>
</div>
<div class="sidebar">
<div class="sidebar-header"><span>📋 Спецификация</span><span id="stats" style="font-weight:400;font-size:10px">0 шт</span></div>
<div style="overflow-y:auto;flex:1">
<table>
<thead><tr><th></th><th>Название</th><th>Улица</th><th>Широта</th><th>Долгота</th><th>Пролёт</th></tr></thead>
<tbody id="poleTable"></tbody>
</table>
</div>
<div class="sidebar-footer" id="totalDist">Итого: 0.0 м | 0 опор</div>
</div>
<div class="toast" id="toast"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// ====================== РЕГИОНЫ КАЗАХСТАНА ======================
const REGIONS = [
{name:'Абай',center:[48.95,79.23]},
{name:'Акмолинская',center:[52.0,69.0]},
{name:'Актюбинская',center:[48.0,57.0]},
{name:'Алматинская',center:[44.5,78.0]},
{name:'Атырауская',center:[47.0,52.0]},
{name:'Восточно-Казахстанская',center:[49.0,82.0]},
{name:'Жамбылская',center:[44.0,72.0]},
{name:'Жетысу',center:[45.0,80.0]},
{name:'Западно-Казахстанская',center:[50.0,51.0]},
{name:'Карагандинская',center:[48.0,71.0]},
{name:'Костанайская',center:[53.0,65.0]},
{name:'Кызылординская',center:[45.0,65.0]},
{name:'Мангистауская',center:[44.0,52.0]},
{name:'Павлодарская',center:[52.0,76.0]},
{name:'Северо-Казахстанская',center:[54.5,69.0]},
{name:'Туркестанская',center:[43.0,68.0]},
{name:'Улытау',center:[48.0,67.0]},
{name:'г.Алматы',center:[43.238,76.946]},
{name:'г.Астана',center:[51.16,71.44]},
{name:'г.Шымкент',center:[42.32,69.6]}
];
// ====================== ГЛОБАЛЬНЫЕ ======================
let map, markers=[], lines=[], entrances=[], entranceCircles=[];
let poleCount=0, linePoints=[], mode='manual';
let roadsLayer=null, buildingsLayer=null;
let loadedRoads=[], loadedBuildings=0;
// ====================== ИНИЦИАЛИЗАЦИЯ ======================
function init(){
const sel=document.getElementById('regionSelect');
REGIONS.forEach(r=>{const o=document.createElement('option');o.value=r.name;o.textContent=r.name;sel.appendChild(o);});
const sat=L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',{maxZoom:20,attribution:'Esri'});
const osm=L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:20,opacity:0.45,attribution:'OSM'});
map=L.map('map',{zoomControl:true,layers:[sat,osm]}).setView([48.0,68.0],6);
map.on('click',onMapClick);
map.on('moveend',()=>{if(map.getZoom()>10)autoLoadOSM();});
roadsLayer=L.layerGroup().addTo(map);
buildingsLayer=L.layerGroup().addTo(map);
updateModeBadge();
console.log('Map init done, center:',map.getCenter(),'zoom:',map.getZoom(),'size:',map.getSize());
setTimeout(()=>{map.invalidateSize();console.log('invalidateSize, size:',map.getSize());},100);
}
window.addEventListener('load',()=>{setTimeout(()=>{if(map)map.invalidateSize();},200);});
// ====================== ВЫБОР ЛОКАЦИИ ======================
let regionRelId=null;
let districtList=[];
function onRegionChange(){
const name=document.getElementById('regionSelect').value;
const region=REGIONS.find(x=>x.name===name);
const dsel=document.getElementById('districtSelect');
const vsel=document.getElementById('villageSelect');
dsel.innerHTML='<option value="">— загрузка —</option>';
vsel.innerHTML='<option value="">— выберите район —</option>';
districtList=[];
if(!region){document.getElementById('locStatus').textContent='';return;}
map.setView(region.center,9);
const st=document.getElementById('locStatus');
st.textContent='⏳ районы '+name+'...';
// Ищем районы через Nominatim
const queries=[
'районы '+name+' области',
'районы '+name+' облысы',
name+' район',
name+' аудан'
];
let done=false;
for(const q of queries){
if(done)break;
fetch('https://nominatim.openstreetmap.org/search?format=json&q='+encodeURIComponent(q)+'&countrycodes=kz&limit=30&type=administrative')
.then(r=>r.json())
.then(data=>{
if(done||!data||!data.length)return;
done=true;
const seen=new Set();
dsel.innerHTML='<option value="">— все —</option>';
data.forEach(item=>{
let n=item.display_name.split(',')[0].trim();
if(!n||seen.has(n)||n===name||n.toLowerCase().includes('город'))return;
seen.add(n);
const o=document.createElement('option');
o.value=n;o.textContent=n;
dsel.appendChild(o);
districtList.push({name:n,lat:item.lat,lon:item.lon});
});
st.textContent='✅ '+seen.size+' районов';
});
}
// fallback если ничего не нашли
setTimeout(()=>{
if(!done){
dsel.innerHTML='<option value="">— введите название района —</option>';
st.textContent='⚠ введите район вручную';
}
},3000);
}
function onDistrictSelect(){
const dname=document.getElementById('districtSelect').value;
const vsel=document.getElementById('villageSelect');
vsel.innerHTML='<option value="">⏳ сёла...</option>';
if(!dname||!districtList.length){
vsel.innerHTML='<option value="">— выберите район —</option>';
return;
}
const dist=districtList.find(d=>d.name===dname);
if(!dist){
vsel.innerHTML='<option value="">— ошибка —</option>';
return;
}
st.textContent='⏳ сёла '+dname+'...';
// Ищем сёла через Nominatim
fetch('https://nominatim.openstreetmap.org/search?format=json&q='+encodeURIComponent(dname+' район село')+'&countrycodes=kz&limit=50')
.then(r=>r.json())
.then(data=>{
vsel.innerHTML='<option value="">— выберите село —</option>';
if(!data||!data.length){
vsel.innerHTML='<option value="">— сёла не найдены —</option>';
st.textContent='⚠ сёла не найдены';
return;
}
const villages=[];
data.forEach(item=>{
if(item.type==='village'||item.type==='hamlet'||item.type==='town'||item.type==='settlement'){
villages.push({name:item.display_name.split(',')[0].trim(),lat:item.lat,lon:item.lon});
}
});
villages.sort((a,b)=>a.name.localeCompare(b.name,'ru'));
villages.forEach(v=>{
const o=document.createElement('option');
o.value=v.name;o.dataset.lat=v.lat;o.dataset.lon=v.lon;o.textContent=v.name;
vsel.appendChild(o);
});
st.textContent='✅ '+villages.length+' сёл';
})
.catch(()=>{
vsel.innerHTML='<option value="">— ошибка —</option>';
});
}
function onVillageSelect(){
const vsel=document.getElementById('villageSelect');
const opt=vsel.options[vsel.selectedIndex];
if(!opt||!opt.dataset.lat)return;
const lat=parseFloat(opt.dataset.lat),lon=parseFloat(opt.dataset.lon);
if(!isNaN(lat)&&!isNaN(lon)){
map.setView([lat,lon],16);
showToast('📍 '+opt.value);
setTimeout(autoDetect,500);
}
}
const seen=new Map();
data.elements.forEach(el=>{
if(!el.tags||!el.tags.name)return;
let clean=el.tags.name.replace(/\s*ауданы$/i,'').replace(/\s*районы$/i,'').replace(/\s*район$/i,'').trim();
if(clean&&!seen.has(clean))seen.set(clean,el.id);
});
dsel.innerHTML='<option value="">— все —</option>';
const sorted=[...seen.keys()].sort();
sorted.forEach(n=>{
const o=document.createElement('option');
o.value=n;o.textContent=n;
dsel.appendChild(o);
districtList.push({name:n,osm_id:seen.get(n)});
});
st.textContent='✅ '+sorted.length+' районов из OSM';
})
.catch(e=>{
st.textContent='⚠ ошибка: '+e.message;
});
}
const rel=data.elements[0];
regionRelId=rel.id;
st.textContent='✅ регион: '+rel.id+', ищу районы...';
// ШАГ 2: районы (admin_level=6,7) внутри rel
const q2='[out:json][timeout:20];area('+rel.id+')->.a;(rel(area.a)["boundary"="administrative"]["admin_level"~"6|7"];);out tags id;';
return fetch('https://overpass-api.de/api/interpreter?data='+encodeURIComponent(q2));
})
.then(r=>r&&r.json())
.then(data=>{
if(!data||!data.elements||!data.elements.length){
st.textContent='⚠ районы не найдены через OSM, пробую Nominatim...';
return fetchNominatimDistricts(name);
}
const seen=new Map();
data.elements.forEach(el=>{
if(!el.tags||!el.tags.name)return;
let clean=el.tags.name.replace(/\s*ауданы$/i,'').replace(/\s*районы$/i,'').replace(/\s*район$/i,'').trim();
if(clean&&!seen.has(clean))seen.set(clean,el.id);
});
dsel.innerHTML='<option value="">— все —</option>';
const sorted=[...seen.keys()].sort();
sorted.forEach(n=>{
const o=document.createElement('option');
o.value=n;o.textContent=n;
dsel.appendChild(o);
districtList.push({name:n,osm_id:seen.get(n)});
});
st.textContent='✅ '+sorted.length+' районов';
})
.catch(e=>{
st.textContent='⚠ ошибка OSM: '+e.message+', fallback...';
fetchNominatimDistricts(name);
});
}
function onDistrictSelect(){
const dname=document.getElementById('districtSelect').value;
const vsel=document.getElementById('villageSelect');
vsel.innerHTML='<option value="">⏳ загрузка сёл...</option>';
if(!dname||!districtList.length){
vsel.innerHTML='<option value="">— выберите район —</option>';return;
}
const dist=districtList.find(d=>d.name===dname);
if(!dist){
vsel.innerHTML='<option value="">— ошибка —</option>';return;
}
const areaId=3600000000+dist.osm_id;
const q='[out:json];area('+areaId+')->.a;(node(area.a)["place"~"village|hamlet|town"];);out tags;';
fetch('https://overpass-api.de/api/interpreter?data='+encodeURIComponent(q))
.then(r=>r.json())
.then(data=>{
vsel.innerHTML='<option value="">— выберите село —</option>';
if(!data||!data.elements||!data.elements.length){
vsel.innerHTML='<option value="">— сёла не найдены —</option>';return;
}
const villages=[];
data.elements.forEach(el=>{
if(!el.tags||!el.tags.name)return;
villages.push({name:el.tags.name,lat:el.lat,lng:el.lon});
});
villages.sort((a,b)=>a.name.localeCompare(b.name,'ru'));
villages.forEach(v=>{
const o=document.createElement('option');
o.value=v.name;o.dataset.lat=v.lat;o.dataset.lng=v.lng;o.textContent=v.name;
vsel.appendChild(o);
});
document.getElementById('locStatus').textContent='✅ '+villages.length+' сёл';
})
.catch(()=>{
vsel.innerHTML='<option value="">— ошибка —</option>';
});
}
function onVillageSelect(){
const vsel=document.getElementById('villageSelect');
const opt=vsel.options[vsel.selectedIndex];
if(!opt||!opt.dataset.lat)return;
const lat=parseFloat(opt.dataset.lat),lng=parseFloat(opt.dataset.lng);
if(!isNaN(lat)&&!isNaN(lng)){
map.setView([lat,lng],16);
showToast('📍 '+opt.value);
setTimeout(autoDetect,500);
}
}
// ====================== АВТООПРЕДЕЛЕНИЕ УЛИЦ И ЗДАНИЙ (OSM) ======================
function autoDetect(){
document.getElementById('autoPanel').style.display='block';
document.getElementById('autoStatus').textContent='⏳ Загрузка улиц и зданий...';
autoLoadOSM();
}
function autoLoadOSM(){
if(map.getZoom()<12){showToast('Приблизьтесь к селу (зум 13+)');return;}
const b=map.getBounds();
const bbox=b.getSouth()+','+b.getWest()+','+b.getNorth()+','+b.getEast();
document.getElementById('autoResults').innerHTML='';
document.getElementById('autoPanel').style.display='block';
document.getElementById('autoStatus').textContent='⏳ Загрузка...';
// Дороги — ВСЕ типы (асфальт, грунт, просёлок, полевая)
const qRoads='[out:json];(way["highway"~"motorway|trunk|primary|secondary|tertiary|unclassified|residential|service|track|path|living_street|road"]('+bbox+'););out geom;';
fetch('https://overpass-api.de/api/interpreter?data='+encodeURIComponent(qRoads))
.then(r=>r.json()).then(data=>{
roadsLayer.clearLayers();
loadedRoads=[];
let roadCount=0, namedCount=0, labelCount=0;
const roadStyles={
motorway:{color:'#e74c3c',weight:4,label:'асфальт'},
trunk:{color:'#e74c3c',weight:3.5,label:'асфальт'},
primary:{color:'#e74c3c',weight:3,label:'асфальт'},
secondary:{color:'#f39c12',weight:2.5,label:'грунт/асфальт'},
tertiary:{color:'#f39c12',weight:2.5,label:'грунт'},
unclassified:{color:'#f39c12',weight:2,label:'грунт'},
residential:{color:'#f39c12',weight:2,label:'грунт'},
living_street:{color:'#f39c12',weight:2,label:'грунт'},
service:{color:'#95a5a6',weight:1.5,label:'просёлок'},
track:{color:'#95a5a6',weight:1.2,label:'полевая/просёлок'},
path:{color:'#95a5a6',weight:1,label:'тропа',dash:'4,4'},
road:{color:'#f39c12',weight:2,label:'грунт'}
};
(data.elements||[]).forEach(el=>{
if(!el.geometry||el.geometry.length<2)return;
const coords=el.geometry.map(g=>[g.lat,g.lon]);
const type=el.tags&&el.tags.highway||'unclassified';
const name=el.tags&&el.tags.name||el.tags&&el.tags['ref']||'';
const style=roadStyles[type]||{color:'#95a5a6',weight:1.5,label:'просёлок'};
const opts={color:style.color,weight:style.weight,opacity:0.75};
if(style.dash)opts.dashArray=style.dash;
const road=L.polyline(coords,opts).addTo(roadsLayer);
// popup с названием и типом
const info=name?name+' ('+style.label+')':style.label;
road.bindPopup('🛣 '+info);
loadedRoads.push({coords,type,name,road,stylename:style.label});
roadCount++;
if(name)namedCount++;
// подпись названия улицы прямо на карте (в середине сегмента)
if(name&&coords.length>1){
const mid=Math.floor(coords.length/2);
const mlat=coords[mid][0],mlng=coords[mid][1];
const labelIcon=L.divIcon({
className:'',
html:'<span style="background:rgba(0,0,0,.75);color:#00E5FF;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:700;white-space:nowrap;border:1px solid #00E5FF;text-shadow:0 1px 3px rgba(0,0,0,.8)">'+name+'</span>',
iconSize:[0,0],iconAnchor:[0,0]
});
L.marker([mlat,mlng],{icon:labelIcon}).addTo(roadsLayer);
labelCount++;
}
});
document.getElementById('autoStatus').textContent='✅ Всего дорог: '+roadCount+' (с названиями: '+namedCount+'), здания: '+loadedBuildings;
let html='<span class="tag" style="background:#e74c3c;color:#fff">дopoг: '+roadCount+'</span><span class="tag" style="background:#f39c12;color:#fff">назв.: '+namedCount+'</span><span class="tag" style="background:#ff6b35;color:#fff">зданий: '+loadedBuildings+'</span>';
document.getElementById('autoResults').innerHTML=html;
if(roadCount===0)document.getElementById('autoStatus').textContent='⚠ Нет дорог в этом районе (проверьте зум)';
});
// Здания
const qBuild='[out:json];(way["building"]('+bbox+');rel["building"]('+bbox+'););out geom;';
fetch('https://overpass-api.de/api/interpreter?data='+encodeURIComponent(qBuild))
.then(r=>r.json()).then(data=>{
buildingsLayer.clearLayers();
loadedBuildings=0;
(data.elements||[]).forEach(el=>{
if(!el.geometry||el.geometry.length<2)return;
const coords=el.geometry.map(g=>[g.lat,g.lon]);
const poly=L.polygon(coords,{color:'#ff6b35',weight:1,fillColor:'#ff6b35',fillOpacity:0.15});
let info=[];
if(el.tags){
if(el.tags.building)info.push('🏠 '+el.tags.building);
if(el.tags['addr:housenumber'])info.push('№'+el.tags['addr:housenumber']);
if(el.tags['addr:street'])info.push(el.tags['addr:street']);
if(el.tags.name)info.push('📌 '+el.tags.name);
}
if(info.length)poly.bindPopup(info.join('<br>'));
poly.addTo(buildingsLayer);
loadedBuildings++;
});
document.getElementById('autoStatus').textContent='✅ Улиц: '+loadedRoads.length+', здания: '+loadedBuildings;
let html='<span class="tag">'+loadedRoads.length+' улиц</span><span class="tag">'+loadedBuildings+' зданий</span>';
document.getElementById('autoResults').innerHTML=html;
});
}
// ====================== РАССТАВИТЬ ОПОРЫ ВДОЛЬ УЛИЦ ======================
function applyRoadPoles(){
if(!loadedRoads.length){showToast('Нет улиц. Нажмите "Авто" сначала');return;}
const step=parseFloat(document.getElementById('poleStep').value)||50;
const margin=parseFloat(document.getElementById('entranceMargin').value)||5;
let totalPoles=0;
loadedRoads.forEach(road=>{
const pts=road.coords;
if(pts.length<2)return;
const len=pathLength2(pts);
if(len<10)return; // короче 10м — пропускаем
const n=Math.max(1,Math.round(len/step));
for(let i=0;i<=n;i++){
const pt=pointOnPath2(pts,i/n);
if(!isNearEntrance(pt,margin)){
addPole(L.latLng(pt.lat,pt.lng),road.name||road.stylename||'');
totalPoles++;
}
}
});
document.getElementById('autoPanel').style.display='none';
showToast('✅ Расставлено '+totalPoles+' опор вдоль дорог');
}
function pathLength2(pts){let t=0;for(let i=1;i<pts.length;i++)t+=haversine(pts[i-1][0],pts[i-1][1],pts[i][0],pts[i][1]);return t;}
function pointOnPath2(pts,t){
const total=pathLength2(pts);
let target=t*total,acc=0;
for(let i=1;i<pts.length;i++){
const seg=haversine(pts[i-1][0],pts[i-1][1],pts[i][0],pts[i][1]);
if(acc+seg>=target||i===pts.length-1){
const frac=(target-acc)/seg;
return{lat:pts[i-1][0]+(pts[i][0]-pts[i-1][0])*frac,lng:pts[i-1][1]+(pts[i][1]-pts[i-1][1])*frac};
}
acc+=seg;
}
return{lat:pts[pts.length-1][0],lng:pts[pts.length-1][1]};
}
// ====================== РЕЖИМЫ ======================
function setMode(newMode){
mode=newMode;
['btnLine','btnManual','btnEntrance'].forEach(id=>document.getElementById(id).className='');
document.getElementById('btnLine').className='btn-primary';
document.getElementById('btnManual').className='btn-secondary';
document.getElementById('btnEntrance').className='btn-warning';
const el=document.getElementById({line:'btnLine',manual:'btnManual',entrance:'btnEntrance'}[mode]);
if(el)el.classList.add('btn-active');
if(mode==='line')linePoints=[];
document.getElementById('modeInfo').textContent={
line:'📏 Линия: клик по улице, Enter = расставить',
manual:'🎯 Клик для установки опоры',
entrance:'🚗 Клик для отметки въезда'}[mode];
updateModeBadge();
}
function updateModeBadge(){
const b=document.getElementById('modeBadge');
b.style.display='block';
b.innerHTML={
manual:'🎯 Кликните для опоры',
line:'📏 Клик точки линии · Enter = расставить',
entrance:'🚗 Клик на въезде'
}[mode];
}
// ====================== КЛИК ======================
function onMapClick(e){
if(mode==='manual'){
const snap=document.getElementById('snapRoad').checked;
if(snap&&loadedRoads.length){const pt=snapToRoad(e.latlng);if(pt)addPole(pt);else addPole(e.latlng);}
else addPole(e.latlng);
return;
}
if(mode==='entrance'){addEntrance(e.latlng);return;}
if(mode==='line'){addLinePoint(e.latlng);return;}
}
// ====================== ПРИВЯЗКА К ДОРОГЕ ======================
function snapToRoad(latlng){
let best=null,bestDist=Infinity;
loadedRoads.forEach(road=>{
for(let i=1;i<road.coords.length;i++){
const a=road.coords[i-1],b=road.coords[i];
const p=closestPointOnSegment(latlng.lat,latlng.lng,a[0],a[1],b[0],b[1]);
if(p.dist<bestDist&&p.dist<30){bestDist=p.dist;best=L.latLng(p.lat,p.lng);}
}
});
return best;
}
function closestPointOnSegment(px,py,ax,ay,bx,by){
const dx=bx-ax,dy=by-ay;
const len2=dx*dx+dy*dy;
let t=len2>0?((px-ax)*dx+(py-ay)*dy)/len2:0;
t=Math.max(0,Math.min(1,t));
const lat=ax+t*dx,lng=ay+t*dy;
return{lat,lng,dist:haversine(px,py,lat,lng)};
}
// ====================== ОПОРЫ ======================
function getRoadName(latlng){
let best=null,bestDist=Infinity;
loadedRoads.forEach(road=>{
for(let i=1;i<road.coords.length;i++){
const a=road.coords[i-1],b=road.coords[i];
const p=closestPointOnSegment(latlng.lat,latlng.lng,a[0],a[1],b[0],b[1]);
if(p.dist<bestDist&&p.dist<20){bestDist=p.dist;best=road.name||road.stylename||'';}
}
});
return best;
}
function addPole(latlng,roadName){
poleCount++;
const rn=roadName||getRoadName(latlng);
const suffix=rn?' ('+rn+')':'';
const name='Опора '+poleCount;
const icon=L.divIcon({className:'',html:'<div class="pole-label">'+poleCount+'</div>',iconSize:[22,22],iconAnchor:[11,11]});
const marker=L.marker(latlng,{icon}).addTo(map).bindPopup('<b>'+name+'</b><br>'+latlng.lat.toFixed(6)+', '+latlng.lng.toFixed(6)+suffix);
markers.push({id:poleCount,lat:latlng.lat,lng:latlng.lng,name,marker,road:rn});
if(markers.length>1){
const p=markers[markers.length-2];
const line=L.polyline([[p.lat,p.lng],[latlng.lat,latlng.lng]],{color:'#00E5FF',weight:2.5,dashArray:'6,5'}).addTo(map);
lines.push(line);
}
updateTable();
showToast('✅ '+name);
}
// ====================== ЛИНИИ ======================
function addLinePoint(latlng){
linePoints.push(latlng);
if(linePoints.length>1){
const p=linePoints[linePoints.length-2];
L.polyline([[p.lat,p.lng],[latlng.lat,latlng.lng]],{color:'#FF9800',weight:2,dashArray:'4,4',opacity:0.6}).addTo(map)._tmpLine=true;
}
L.circleMarker(latlng,{radius:4,color:'#FF9800',fillColor:'#FF9800',fillOpacity:0.8}).addTo(map)._tmpPoint=true;
}
function finishLine(){
if(linePoints.length<2){showToast('Нарисуйте минимум 2 точки');return;}
const step=parseFloat(document.getElementById('poleStep').value)||50;
const margin=parseFloat(document.getElementById('entranceMargin').value)||5;
const totalLen=pathLength(linePoints);
const n=Math.max(1,Math.round(totalLen/step));
const poles=[];
for(let i=0;i<=n;i++){
const pt=pointOnPath(linePoints,i/n);
if(!isNearEntrance(pt,margin))poles.push(pt);
}
map.eachLayer(l=>{if(l._tmpLine||l._tmpPoint)map.removeLayer(l);});
linePoints=[];
if(!poles.length){showToast('❌ Всё перекрыто въездами');setMode('manual');return;}
poles.forEach(p=>addPole(L.latLng(p.lat,p.lng)));
setMode('manual');
}
function pathLength(pts){let t=0;for(let i=1;i<pts.length;i++)t+=haversine(pts[i-1].lat,pts[i-1].lng,pts[i].lat,pts[i].lng);return t;}
function pointOnPath(pts,t){
const total=pathLength(pts);
let target=t*total,acc=0;
for(let i=1;i<pts.length;i++){
const seg=haversine(pts[i-1].lat,pts[i-1].lng,pts[i].lat,pts[i].lng);
if(acc+seg>=target||i===pts.length-1){const frac=(target-acc)/seg;return{lat:pts[i-1].lat+(pts[i].lat-pts[i-1].lat)*frac,lng:pts[i-1].lng+(pts[i].lng-pts[i-1].lng)*frac};}
acc+=seg;
}
return pts[pts.length-1];
}
// ====================== ВЪЕЗДЫ ======================
function addEntrance(latlng){
const icon=L.divIcon({className:'',html:'<div class="entrance-label">🚗 В</div>',iconSize:[26,18],iconAnchor:[13,9]});
const marker=L.marker(latlng,{icon}).addTo(map).bindPopup('🚗 Въезд');
entrances.push({lat:latlng.lat,lng:latlng.lng,marker});
const circle=L.circle(latlng,{radius:5,color:'#FF9800',fillColor:'#FF9800',fillOpacity:0.15,dashArray:'3,3'}).addTo(map);
entranceCircles.push(circle);
showToast('🚗 Въезд отмечен');
}
function isNearEntrance(pt,margin){
for(const e of entrances)if(haversine(pt.lat,pt.lng,e.lat,e.lng)<margin)return true;
return false;
}
// ====================== РАССТОЯНИЕ ======================
function haversine(lat1,lon1,lat2,lon2){
const R=6371000;
const toRad=x=>x*Math.PI/180;
const dLat=toRad(lat2-lat1),dLon=toRad(lon2-lon1);
return R*2*Math.atan2(Math.sqrt(Math.sin(dLat/2)**2+Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLon/2)**2),Math.sqrt(1-(Math.sin(dLat/2)**2+Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLon/2)**2)));
}
// ====================== ТАБЛИЦА ======================
function updateTable(){
const tbody=document.getElementById('poleTable');
tbody.innerHTML='';
let total=0;
markers.forEach((p,i)=>{
const dist=i>0?haversine(markers[i-1].lat,markers[i-1].lng,p.lat,p.lng):0;
total+=dist;
const tr=document.createElement('tr');
tr.innerHTML='<td class="num">'+p.id+'</td><td>'+p.name+'</td><td style="max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+(p.road||'')+'</td><td>'+p.lat.toFixed(6)+'</td><td>'+p.lng.toFixed(6)+'</td><td class="dist">'+(i>0?dist.toFixed(1):'—')+'</td>';
tbody.appendChild(tr);
});
const avg=markers.length>1?(total/(markers.length-1)):0;
document.getElementById('totalDist').textContent='Итого: '+total.toFixed(1)+' м | '+markers.length+' опор | ср.пролёт '+avg.toFixed(1)+' м';
document.getElementById('stats').textContent=markers.length+' шт';
}
// ====================== УПРАВЛЕНИЕ ======================
function undo(){
if(entrances.length){
const e=entrances.pop();map.removeLayer(e.marker);
const c=entranceCircles.pop();if(c)map.removeLayer(c);
showToast('↩ Въезд удалён');return;
}
if(!markers.length)return;
const last=markers.pop();map.removeLayer(last.marker);
if(lines.length)map.removeLayer(lines.pop());
poleCount--;updateTable();
}
function clearAll(){
if(!markers.length&&!entrances.length)return;
if(!confirm('Удалить всё?'))return;
markers.forEach(m=>map.removeLayer(m.marker));
lines.forEach(l=>map.removeLayer(l));
entrances.forEach(e=>map.removeLayer(e.marker));
entranceCircles.forEach(c=>map.removeLayer(c));
map.eachLayer(l=>{if(l._tmpLine||l._tmpPoint)map.removeLayer(l);});
markers=[];lines=[];entrances=[];entranceCircles=[];poleCount=0;linePoints=[];
updateTable();
}
document.addEventListener('keydown',e=>{if(e.key==='Enter'&&mode==='line')finishLine();});
// ====================== ЭКСПОРТ ======================
function exportSVG(){
if(!markers.length){showToast('Нет опор');return;}
const first=markers[0];
const step=parseFloat(document.getElementById('poleStep').value)||50;
let svg=`<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600" viewBox="0 0 800 600">
<style>text{font-family:Arial,sans-serif}.pole{fill:#0F1218;stroke:#00E5FF;stroke-width:2}.pole-label{fill:#00E5FF;font-size:11px;font-weight:bold;text-anchor:middle;dominant-baseline:central}.line{fill:none;stroke:#00E5FF;stroke-width:2;stroke-dasharray:6,5}.entrance{fill:#FF9800}.title{font-size:16px;font-weight:bold}.sub{font-size:11px;fill:#666}</style>
<rect width="800" height="600" fill="#fff"/>`;
const date=new Date().toLocaleDateString('ru-RU');
svg+=`<text x="20" y="28" class="title">Схема расстановки опор ВЛ-0.4 кВ</text>
<text x="20" y="46" class="sub">Дата: ${date} | Шаг: ${step} м | ${markers.length} опор</text>
<text x="20" y="62" class="sub">Центр: ${first.lat.toFixed(4)}, ${first.lng.toFixed(4)}</text>`;
const lats=markers.map(m=>m.lat),lngs=markers.map(m=>m.lng);
const minLat=Math.min(...lats),maxLat=Math.max(...lats);
const minLng=Math.min(...lngs),maxLng=Math.max(...lngs);
const pad=60;
const sx=x=>pad+(x-minLng)/(maxLng-minLng||1)*(760-2*pad);
const sy=y=>560-pad-(y-minLat)/(maxLat-minLat||1)*(560-2*pad-85);
markers.forEach((p,i)=>{if(i>0){const p1=markers[i-1];svg+=`<line x1="${sx(p1.lng)}" y1="${sy(p1.lat)}" x2="${sx(p.lng)}" y2="${sy(p.lat)}" class="line"/>`;}});
markers.forEach(p=>{const x=sx(p.lng),y=sy(p.lat);svg+=`<circle cx="${x}" cy="${y}" r="10" class="pole"/><text x="${x}" y="${y}" class="pole-label">${p.id}</text><text x="${x+12}" y="${y+3}" font-size="9" fill="#333">${p.name}</text>`;});
entrances.forEach(e=>{const x=sx(e.lng),y=sy(e.lat);svg+=`<rect x="${x-8}" y="${y-5}" width="16" height="10" rx="2" class="entrance"/><text x="${x}" y="${y+3}" font-size="8" fill="#fff" text-anchor="middle" font-weight="bold">В</text>`;});
let total=0;markers.forEach((p,i)=>{if(i>0)total+=haversine(markers[i-1].lat,markers[i-1].lng,p.lat,p.lng);});
svg+=`<text x="20" y="580" class="sub">Всего: ${total.toFixed(1)} м | ${markers.length} опор | ср.пролёт: ${markers.length>1?(total/(markers.length-1)).toFixed(1):0} м</text></svg>`;
download(svg,'opory_vl04.svg','image/svg+xml');
showToast('📥 SVG (Visio/draw.io)');
}
function exportDrawIO(){
if(!markers.length){showToast('Нет опор');return;}
const date=new Date().toLocaleDateString('ru-RU');
let mx='<mxfile><diagram name="Опоры ВЛ-0.4 кВ" id="pole-diagram"><mxGraphModel dx="800" dy="600"><root><mxCell id="0"/><mxCell id="1" parent="0"/>';
mx+=`<mxCell id="t" value="Схема расстановки опор ВЛ-0.4 кВ&nbsp;|&nbsp;${date}&nbsp;|&nbsp;${markers.length} опор" style="text;html=1;fontSize=14;fontWeight=bold;align=left;" vertex="1" parent="1"><mxGeometry x="20" y="10" width="500" height="30" as="geometry"/></mxCell>`;
const lats=markers.map(m=>m.lat),lngs=markers.map(m=>m.lng);
const minLat=Math.min(...lats),maxLat=Math.max(...lats);
const minLng=Math.min(...lngs),maxLng=Math.max(...lngs);
const sx=x=>40+(x-minLng)/(maxLng-minLng||1)*700;
const sy=y=>560-(y-minLat)/(maxLat-minLat||1)*520;
let id=100;
markers.forEach((p,i)=>{if(i>0){const d=haversine(markers[i-1].lat,markers[i-1].lng,p.lat,p.lng).toFixed(1);id++;mx+=`<mxCell id="l${id}" value="${d} м" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#00E5FF;strokeWidth=2;dashed=1;fontSize=10;align=center;verticalAlign=bottom;labelBackgroundColor=#ffffff;" edge="1" parent="1" source="p${p1.id}" target="p${p.id}"><mxGeometry relative="1" as="geometry"/></mxCell>`;}});
markers.forEach(p=>{const x=sx(p.lng).toFixed(0),y=sy(p.lat).toFixed(0);mx+=`<mxCell id="p${p.id}" value="&lt;b&gt;${p.id}&lt;/b&gt;" style="ellipse;whiteSpace=wrap;html=1;fillColor=#0F1218;strokeColor=#00E5FF;strokeWidth=2;fontColor=#00E5FF;fontSize=11;fontWeight=bold;" vertex="1" parent="1"><mxGeometry x="${x}" y="${y}" width="24" height="24" as="geometry"/></mxCell><mxCell id="pl${p.id}" value="${p.name}" style="text;html=1;fontSize=9;align=left;" vertex="1" parent="1"><mxGeometry x="${Number(x)+28}" y="${Number(y)+6}" width="200" height="14" as="geometry"/></mxCell>`;});
entrances.forEach((e,i)=>{const x=sx(e.lng).toFixed(0),y=sy(e.lat).toFixed(0);mx+=`<mxCell id="e${i}" value="В" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FF9800;strokeColor=#FF9800;fontColor=#fff;fontSize=9;fontWeight=bold;" vertex="1" parent="1"><mxGeometry x="${Number(x)-8}" y="${Number(y)-6}" width="16" height="12" as="geometry"/></mxCell>`;});
let total=0;markers.forEach((p,i)=>{if(i>0)total+=haversine(markers[i-1].lat,markers[i-1].lng,p.lat,p.lng);});
mx+=`<mxCell id="sum" value="Всего: ${total.toFixed(1)} м | ${markers.length} опор | ср.пролёт ${markers.length>1?(total/(markers.length-1)).toFixed(1):0} м" style="text;html=1;fontSize=11;fontWeight=bold;align=left;fillColor=#F2F4F7;rounded=1;" vertex="1" parent="1"><mxGeometry x="20" y="560" width="300" height="24" as="geometry"/></mxCell>`;
mx+=`</root></mxGraphModel></diagram></mxfile>`;
download(mx,'opory_vl04.drawio','application/xml');
showToast('📥 draw.io');
}
function exportCSV(){
if(!markers.length){showToast('Нет опор');return;}
let csv='№,Название,Улица,Широта,Долгота,Расстояниео_предыдущей_м\n';
markers.forEach((p,i)=>{const d=i>0?haversine(markers[i-1].lat,markers[i-1].lng,p.lat,p.lng).toFixed(1):'';csv+=p.id+','+p.name+','+(p.road||'')+','+p.lat.toFixed(6)+','+p.lng.toFixed(6)+','+d+'\n';});
download(csv,'opory_vl04.csv','text/csv;charset=utf-8');
}
function download(content,filename,type){
const blob=new Blob([content],{type:'application/octet-stream'});
const a=document.createElement('a');a.href=URL.createObjectURL(blob);
a.download=filename;document.body.appendChild(a);a.click();document.body.removeChild(a);
}
function showToast(msg){
const t=document.getElementById('toast');
t.textContent=msg;t.classList.add('show');
clearTimeout(t._timer);
t._timer=setTimeout(()=>t.classList.remove('show'),2500);
}
init();
</script>
</body>
</html>