pole-planner/index.html

470 lines
25 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;--white:#fff;--gray-100:#F2F4F7;--gray-500:#5B6573}
*{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:var(--gray-100)}
.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-line{background:#00E5FF;color:#0F1218}
.btn-add{background:#4CAF50;color:#fff}
.btn-driveway{background:#FF9800;color:#fff}
.btn-undo{background:#ff5252;color:#fff}
.btn-export{background:#4caf50;color:#fff}
.btn-clear{background:#ff9800;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;white-space:nowrap}
.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 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:var(--white);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:var(--gray-100);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:var(--white);padding:3px 5px;text-align:left;position:sticky;top:0;font-size:10px}
.sidebar td{padding:3px 5px;border-bottom:1px solid var(--gray-100);font-size:10px}
.sidebar td.num{text-align:center;font-weight:700;color:var(--cyan);font-size:11px}
.sidebar td.dist{text-align:right;font-family:monospace;font-size:10px}
.sidebar-footer{padding:5px 10px;background:var(--gray-100);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:var(--cyan);color:var(--ink);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:var(--white);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}
@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-line" id="btnLine" onclick="setMode('line')">📏 Линия</button>
<button class="btn-active" id="btnManual" onclick="setMode('manual')">🎯 Вручную</button>
<button class="btn-driveway" id="btnEntrance" onclick="setMode('entrance')">🚗 Въезд</button>
<button class="btn-undo" onclick="undo()"></button>
<button class="btn-clear" 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>
<button class="btn-export" onclick="exportJSON()">JSON</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="Название села..." style="padding:2px 6px;border:1px solid #555;border-radius:3px;background:#333;color:#fff;font-size:11px;width:140px">
<button onclick="searchLocation()">🔍 Найти</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>
<span class="mode-info" id="modeInfo">Режим: ручная расстановка</span>
</div>
<div id="map"></div>
<div class="mode-badge" id="modeBadge" style="display:none">🎯 Кликните для установки</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="display:inline-block;background:#FF9800;padding:0 3px;border-radius:2px;color:#fff;font-size:8px">В</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:[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';
// ====================== ИНИЦИАЛИЗАЦИЯ ======================
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);
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(){
const name=document.getElementById('districtSelect').value;
if(name)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('Не найдено: '+q);return;}
const loc=data[0];
map.setView([loc.lat,loc.lon],16);
showToast('📍 '+loc.display_name.split(',')[0]);
});
}
// ====================== РЕЖИМЫ ======================
function setMode(newMode){
mode=newMode;
['btnLine','btnManual','btnEntrance'].forEach(id=>document.getElementById(id).className='');
document.getElementById('btnLine').className='btn-line';
document.getElementById('btnManual').className='btn-add';
document.getElementById('btnEntrance').className='btn-driveway';
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');
const msgs={
manual:'🎯 Кликните для опоры',
line:'📏 Клик точки линии улицы · Enter = расставить',
entrance:'🚗 Клик на въезде во двор'
};
b.style.display='block';b.innerHTML=msgs[mode];
}
// ====================== КЛИК ======================
function onMapClick(e){
if(mode==='manual'){addPole(e.latlng);return;}
if(mode==='entrance'){addEntrance(e.latlng);return;}
if(mode==='line'){addLinePoint(e.latlng);return;}
}
// ====================== ОПОРЫ ======================
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)));
showToast('✅ Расставлено '+poles.length+' опор, шаг ~'+step+' м');
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 isNearEntrance(pt,margin){
for(const e of entrances)if(haversine(pt.lat,pt.lng,e.lat,e.lng)<margin)return true;
return false;
}
// ====================== ВЪЕЗДЫ ======================
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 haversine(lat1,lon1,lat2,lon2){
const R=6371000;
const toRad=x=>x*Math.PI/180;
const dLat=toRad(lat2-lat1),dLon=toRad(lon2-lon1);
const a=Math.sin(dLat/2)**2+Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLon/2)**2;
return R*2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a));
}
// ====================== ТАБЛИЦА ======================
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();});
// ====================== ЭКСПОРТ В SVG (Visio/draw.io) ======================
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"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<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;stroke:#FF9800;stroke-width:1}
.grid{stroke:#e0e0e0;stroke-width:0.5}
.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>`;
// сетка
svg+=`<line x1="20" y1="85" x2="780" y2="85" class="grid"/><line x1="20" y1="85" x2="20" y2="560" class="grid"/>`;
// конвертация geo → svg (простая проекция)
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)return;
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"/>`;
svg+=`<text x="${x}" y="${y}" class="pole-label">${p.id}</text>`;
// подпись
svg+=`<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"/>`;
svg+=`<text x="${x}" y="${y+3}" font-size="8" fill="#fff" text-anchor="middle" font-weight="bold">В</text>`;
});
// таблица расстояний
svg+=`<text x="20" y="580" class="sub">`;
let total=0;
markers.forEach((p,i)=>{
if(i>0){const d=haversine(markers[i-1].lat,markers[i-1].lng,p.lat,p.lng);total+=d;
svg+=`${p.id}: ${d.toFixed(1)}м `;}
});
svg+=`| Всего: ${total.toFixed(1)} м</text>`;
svg+=`</svg>`;
download(svg,'opory_vl04.svg','image/svg+xml');
showToast('📥 SVG экспортирован (открывается в Visio)');
}
// ====================== ЭКСПОРТ В DRAW.IO ======================
function exportDrawIO(){
if(!markers.length){showToast('Нет опор');return;}
const first=markers[0];
const step=parseFloat(document.getElementById('poleStep').value)||50;
const date=new Date().toLocaleDateString('ru-RU');
let mx='<mxfile><diagram name="Опоры ВЛ-0.4 кВ" id="pole-diagram">';
mx+=`<mxGraphModel dx="800" dy="600" grid="1" guides="1">`;
mx+=`<root><mxCell id="0"/><mxCell id="1" parent="0"/>`;
// заголовок
mx+=`<mxCell id="title" value="Схема расстановки опор ВЛ-0.4 кВ&amp;nbsp;&lt;br&gt;${date}&amp;nbsp;|&amp;nbsp;${markers.length} опор&amp;nbsp;|&amp;nbsp;шаг ${step} м" style="text;html=1;fontSize=14;fontWeight=bold;align=left;" vertex="1" parent="1"><mxGeometry x="20" y="10" width="500" height="40" 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)*500;
let lineId=100;
// линии
markers.forEach((p,i)=>{
if(i===0)return;
const p1=markers[i-1];
const d=haversine(p1.lat,p1.lng,p.lat,p.lng).toFixed(1);
lineId++;
mx+=`<mxCell id="l${lineId}" 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>`;
mx+=`<mxCell id="pl${p.id}" value="${p.name}&amp;nbsp;(${p.lat.toFixed(5)}, ${p.lng.toFixed(5)})" style="text;html=1;fontSize=9;align=left;labelBackgroundColor=#ffffff;" 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=#ffffff;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;strokeColor=#ddd;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 экспортирован');
}
// ====================== CSV / JSON ======================
function exportCSV(){
if(!markers.length){showToast('Нет опор');return;}
let csv='№,Название,Широта,Долгота,Расстояниео_предыдущей_м\n';
markers.forEach((p,i)=>{
const dist=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)+','+dist+'\n';
});
download(csv,'opory_vl04.csv','text/csv;charset=utf-8');
}
function exportJSON(){
if(!markers.length){showToast('Нет опор');return;}
const data=markers.map((p,i)=>{
const dist=i>0?haversine(markers[i-1].lat,markers[i-1].lng,p.lat,p.lng):0;
return{id:p.id,name:p.name,lat:parseFloat(p.lat.toFixed(6)),lng:parseFloat(p.lng.toFixed(6)),distance_m:parseFloat(dist.toFixed(1))};
});
download(JSON.stringify({poles:data,total_m:data.reduce((s,d)=>s+d.distance_m,0),average_span_m:data.length>1?(data.reduce((s,d)=>s+d.distance_m,0)/(data.length-1)):0},null,2),'opory_vl04.json','application/json');
}
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>