pole-planner/index.html

525 lines
31 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}
body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif;color:var(--ink);background:#F2F4F7}
.header{background:var(--ink);padding:8px 12px;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.header h1{font-size:15px;font-weight:700;color:#fff;white-space:nowrap}
.controls{display:flex;gap:4px;flex-wrap:wrap}
.controls button{padding:4px 8px;border:none;border-radius:4px;font-weight:600;cursor:pointer;font-size:11px;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:4px 12px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:11px;border-bottom:1px solid #333}
.loc-panel label{color:#9aa3b2}
.loc-panel select{padding:2px 6px;border:1px solid #555;border-radius:3px;background:#333;color:#fff;font-size:11px;max-width:160px}
.loc-panel input{padding:2px 6px;border:1px solid #555;border-radius:3px;background:#333;color:#fff;font-size:11px;width:140px}
.loc-panel button{padding:2px 10px;border:none;border-radius:3px;background:var(--cyan);color:var(--ink);font-weight:600;cursor:pointer;font-size:11px}
#map{height:calc(100vh - 208px);min-height:350px}
.settings{background:#1a1a1a;padding:4px 12px;display:flex;gap:8px;align-items:center;font-size:11px;color:#9aa3b2;flex-wrap:wrap;border-bottom:1px solid #333}
.settings label{display:flex;align-items:center;gap:4px}
.settings input{width:42px;padding:2px 4px;border:1px solid #555;border-radius:3px;background:#222;color:#fff;font-size:11px;text-align:center}
.settings .mode-info{margin-left:auto;font-size:10px;color:#888}
.sidebar{position:absolute;top:54px;right:8px;width:340px;max-height:calc(100vh - 64px);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:12px}
.sidebar-header{padding:6px 10px;background:#F2F4F7;font-weight:700;font-size:11px;border-bottom:1px solid #ddd;display:flex;justify-content:space-between;align-items:center}
.sidebar table{width:100%;border-collapse:collapse;font-size:11px}
.sidebar th{background:var(--ink);color:#fff;padding:3px 5px;text-align:left;position:sticky;top:0;font-size:10px}
.sidebar td{padding:3px 5px;border-bottom:1px solid #F2F4F7;font-size:10px}
.sidebar td.num{text-align:center;font-weight:700;color:#00E5FF;font-size:11px}
.sidebar td.dist{text-align:right;font-family:monospace;font-size:10px}
.sidebar-footer{padding:5px 10px;background:#F2F4F7;font-size:10px;font-weight:600;border-top:1px solid #ddd}
.pole-label{background:#0F1218;color:#00E5FF;border-radius:50%;width:22px;height:22px;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:10px;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 5px;font-size:9px;font-weight:700;border:2px solid #FF9800;box-shadow:0 2px 6px rgba(0,0,0,.3)}
.mode-badge{position:absolute;top:54px;left:8px;background:#00E5FF;color:#0F1218;padding:3px 8px;border-radius:4px;font-weight:700;font-size:10px;z-index:1000;pointer-events:none}
.legend{position:absolute;bottom:12px;left:8px;background:#fff;padding:5px 8px;border-radius:6px;box-shadow:0 2px 10px rgba(0,0,0,.1);z-index:1000;font-size:10px;line-height:1.5}
.legend span{display:flex;align-items:center;gap:5px}
.toast{position:fixed;bottom:10px;left:50%;transform:translateX(-50%);background:#0F1218;color:#fff;padding:5px 12px;border-radius:4px;font-size:10px;z-index:9999;opacity:0;transition:opacity .3s}
.toast.show{opacity:1}
.auto-panel{position:absolute;top:54px;left:8px;background:#fff;padding:6px 10px;border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,.15);z-index:1000;font-size:11px;max-width:260px;display:none}
.auto-panel button{padding:3px 8px;border:none;border-radius:3px;font-weight:600;cursor:pointer;font-size:10px;margin-top:4px}
.auto-panel .tag{display:inline-block;background:#00E5FF;color:#0F1218;padding:1px 6px;border-radius:3px;font-size:9px;margin:1px}
@media(max-width:700px){.sidebar{width:100%;right:0;top:auto;bottom:0;max-height:30vh;border-radius:8px 8px 0 0}#map{height:45vh}}
</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()"><option value="">— выберите —</option></select>
<label>Район:</label>
<select id="districtSelect" onchange="onDistrictChange()"><option value="">— выберите —</option></select>
<label>Нас. пункт:</label>
<input type="text" id="villageInput" placeholder="Название села...">
<button onclick="searchLocation()">🔍 Найти</button>
<button onclick="autoDetect()" style="background:#FF9800;color:#fff">🔄 Авто</button>
</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:2px;background:#7c3aed;border-radius:2px"></i> Улица (OSM)</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></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();
}
// ====================== ВЫБОР ЛОКАЦИИ ======================
function onRegionChange(){
const name=document.getElementById('regionSelect').value;
const r=REGIONS.find(x=>x.name===name);
if(r)map.setView(r.center,9);
}
function onDistrictChange(){
if(document.getElementById('districtSelect').value)searchLocation();
}
function searchLocation(){
const q=[document.getElementById('regionSelect').value,document.getElementById('districtSelect').value,document.getElementById('villageInput').value].filter(Boolean).join(', ');
if(!q){showToast('Выберите регион или введите название');return;}
fetch('https://nominatim.openstreetmap.org/search?format=json&q='+encodeURIComponent(q)+'&countrycodes=kz&limit=5')
.then(r=>r.json()).then(data=>{
if(!data.length){showToast('Не найдено');return;}
map.setView([data[0].lat,data[0].lon],16);showToast('📍 '+data[0].display_name.split(',')[0]);
});
}
// ====================== АВТООПРЕДЕЛЕНИЕ УЛИЦ И ЗДАНИЙ (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"]('+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;
const colors={primary:'#7c3aed',secondary:'#7c3aed',tertiary:'#7c3aed',residential:'#7c3aed',service:'#7c3aed',unclassified:'#7c3aed',track:'#aaa'};
(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 color=colors[type]||'#7c3aed';
const weight=type==='track'?1.5:type==='service'?2:2.5;
const road=L.polyline(coords,{color,weight,opacity:0.7}).addTo(roadsLayer);
if(name){road.bindPopup('🛣 '+name);namedCount++;}
loadedRoads.push({coords,type,name,road});
roadCount++;
});
document.getElementById('autoStatus').textContent='✅ Улиц: '+roadCount+', здания: '+loadedBuildings;
let html='<span class="tag">'+roadCount+' улиц</span><span class="tag">'+namedCount+' с названиями</span><span class="tag">'+loadedBuildings+' зданий</span>';
document.getElementById('autoResults').innerHTML=html;
if(roadCount+namedCount+loadedBuildings===0)document.getElementById('autoStatus').textContent='⚠ Данные не загружены — проверьте зум';
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=>{
if(!road.name)return; // только именованные улицы
const pts=road.coords;
if(pts.length<2)return;
const len=pathLength2(pts);
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));
totalPoles++;
}
}
});
document.getElementById('autoPanel').style.display='none';
showToast('✅ Расставлено '+totalPoles+' опор вдоль '+loadedRoads.length+' улиц');
}
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 addPole(latlng){
poleCount++;
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));
markers.push({id:poleCount,lat:latlng.lat,lng:latlng.lng,name,marker});
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>'+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.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>