v3: regions selection, Visio/SVG export, draw.io

This commit is contained in:
Dauren777 2026-06-19 05:57:54 +00:00
parent 1b51bb3b8e
commit eab75dd21d

View File

@ -3,16 +3,16 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Планировщик опор ВЛ-0.4 кВ</title> <title>Планировщик опор ВЛРК</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style> <style>
:root{--ink:#0F1218;--cyan:#00E5FF;--white:#fff;--gray-100:#F2F4F7;--gray-500:#5B6573} :root{--ink:#0F1218;--cyan:#00E5FF;--white:#fff;--gray-100:#F2F4F7;--gray-500:#5B6573}
*{box-sizing:border-box;margin:0;padding:0} *{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)} body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif;color:var(--ink);background:var(--gray-100)}
.header{background:var(--ink);color:var(--white);padding:10px 16px;display:flex;align-items:center;gap:8px;flex-wrap:wrap} .header{background:var(--ink);padding:8px 12px;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.header h1{font-size:16px;font-weight:700;white-space:nowrap} .header h1{font-size:15px;font-weight:700;color:#fff;white-space:nowrap}
.controls{display:flex;gap:4px;flex-wrap:wrap} .controls{display:flex;gap:4px;flex-wrap:wrap}
.controls button{padding:5px 10px;border:none;border-radius:4px;font-weight:600;cursor:pointer;font-size:11px;white-space:nowrap} .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-line{background:#00E5FF;color:#0F1218}
.btn-add{background:#4CAF50;color:#fff} .btn-add{background:#4CAF50;color:#fff}
.btn-driveway{background:#FF9800;color:#fff} .btn-driveway{background:#FF9800;color:#fff}
@ -20,60 +20,74 @@ body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif;
.btn-export{background:#4caf50;color:#fff} .btn-export{background:#4caf50;color:#fff}
.btn-clear{background:#ff9800;color:#fff} .btn-clear{background:#ff9800;color:#fff}
.btn-active{background:#fff!important;color:#0F1218!important;box-shadow:0 0 0 2px var(--cyan)} .btn-active{background:#fff!important;color:#0F1218!important;box-shadow:0 0 0 2px var(--cyan)}
#map{height:calc(100vh - 176px);min-height:350px} .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}
.status{margin:4px 12px 0;font-size:11px;color:#9aa3b2;display:flex;gap:16px;flex-wrap:wrap} .loc-panel label{color:#9aa3b2;white-space:nowrap}
.status span{margin-right:12px} .loc-panel select{padding:2px 6px;border:1px solid #555;border-radius:3px;background:#333;color:#fff;font-size:11px;max-width:160px}
.sidebar{position:absolute;top:56px;right:8px;width:320px;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} .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}
.sidebar-header{padding:8px 12px;background:var(--gray-100);font-weight:700;font-size:12px;border-bottom:1px solid #ddd;display:flex;justify-content:space-between;align-items:center} #map{height:calc(100vh - 208px);min-height:350px}
.sidebar table{width:100%;border-collapse:collapse;font-size:11px} .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}
.sidebar th{background:var(--ink);color:var(--white);padding:4px 6px;text-align:left;position:sticky;top:0;font-size:11px}
.sidebar td{padding:3px 6px;border-bottom:1px solid var(--gray-100)}
.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:11px}
.sidebar-footer{padding:6px 12px;background:var(--gray-100);font-size:11px;font-weight:600;border-top:1px solid #ddd}
.pole-label{background:#0F1218;color:#00E5FF;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:11px;border:2px solid #00E5FF;box-shadow:0 2px 6px rgba(0,0,0,.4)}
.entrance-label{background:#FF9800;color:#fff;border-radius:4px;padding:2px 6px;font-size:10px;font-weight:700;white-space:nowrap;border:2px solid #FF9800;box-shadow:0 2px 6px rgba(0,0,0,.3)}
.mode-badge{position:absolute;top:56px;left:8px;background:var(--cyan);color:var(--ink);padding:4px 10px;border-radius:4px;font-weight:700;font-size:11px;z-index:1000;pointer-events:none}
.legend{position:absolute;bottom:16px;left:8px;background:var(--white);padding:6px 10px;border-radius:6px;box-shadow:0 2px 10px rgba(0,0,0,.1);z-index:1000;font-size:11px;line-height:1.6}
.legend span{display:flex;align-items:center;gap:6px}
.toast{position:fixed;bottom:12px;left:50%;transform:translateX(-50%);background:#0F1218;color:#fff;padding:6px 14px;border-radius:4px;font-size:11px;z-index:9999;opacity:0;transition:opacity .3s}
.toast.show{opacity:1}
.settings{display:flex;gap:4px;align-items:center;margin:0 12px 4px;font-size:11px;color:#9aa3b2;flex-wrap:wrap}
.settings label{display:flex;align-items:center;gap:4px} .settings label{display:flex;align-items:center;gap:4px}
.settings input{width:45px;padding:2px 4px;border:1px solid #555;border-radius:3px;background:#222;color:#fff;font-size:11px;text-align:center} .settings input{width:42px;padding:2px 4px;border:1px solid #555;border-radius:3px;background:#222;color:#fff;font-size:11px;text-align:center}
@media(max-width:700px){.sidebar{width:100%;right:0;top:auto;bottom:0;max-height:35vh;border-radius:8px 8px 0 0}#map{height:50vh}} .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> </style>
</head> </head>
<body> <body>
<div class="header"> <div class="header">
<h1>Опоры ВЛ-0.4 кВ</h1> <h1>Опоры ВЛ-0.4 кВ</h1>
<div class="controls"> <div class="controls">
<button class="btn-line" id="btnLine" onclick="setMode('line')">📏 Линия</button> <button class="btn-line" id="btnLine" onclick="setMode('line')">📏 Линия</button>
<button class="btn-active" id="btnManual" onclick="setMode('manual')">🎯 Вручную</button> <button class="btn-active" id="btnManual" onclick="setMode('manual')">🎯 Вручную</button>
<button class="btn-driveway" id="btnEntrance" onclick="setMode('entrance')">🚗 Въезд</button> <button class="btn-driveway" id="btnEntrance" onclick="setMode('entrance')">🚗 Въезд</button>
<button class="btn-undo" onclick="undo()"></button> <button class="btn-undo" onclick="undo()"></button>
<button class="btn-clear" onclick="clearAll()">🗑</button> <button class="btn-clear" onclick="clearAll()">🗑</button>
<button class="btn-export" onclick="exportCSV()">📥 CSV</button> <button class="btn-export" onclick="exportSVG()">📥 SVG</button>
<button class="btn-export" onclick="exportJSON()">📥 JSON</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> </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"> <div class="settings">
<label>📐 Шаг опор: <input type="number" id="poleStep" value="50" min="10" max="200"> м</label> <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="number" id="entranceMargin" value="5" min="0" max="30"> м</label>
<span id="modeInfo" style="margin-left:auto">Режим: ручная расстановка</span> <span class="mode-info" id="modeInfo">Режим: ручная расстановка</span>
</div> </div>
<div id="map"></div> <div id="map"></div>
<div class="mode-badge" id="modeBadge" style="display:none">🎯 Кликните для установки</div> <div class="mode-badge" id="modeBadge" style="display:none">🎯 Кликните для установки</div>
<div class="legend"> <div class="legend">
<span><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#0F1218;border:2px solid #00E5FF"></span> Опора ЖБ</span> <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:14px;height:4px;background:#00E5FF;border-radius:2px"></i> Линия ВЛ</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 4px;border-radius:2px;color:#fff;font-size:9px">В</span> Въезд во двор</span> <span><span style="display:inline-block;background:#FF9800;padding:0 3px;border-radius:2px;color:#fff;font-size:8px">В</span> Въезд</span>
</div> </div>
<div class="sidebar"> <div class="sidebar">
<div class="sidebar-header"><span>📋 Спецификация опор</span><span id="stats" style="font-weight:400;font-size:11px">0 шт</span></div> <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"> <div style="overflow-y:auto;flex:1">
<table> <table>
<thead><tr><th></th><th>Тип</th><th>Широта</th><th>Долгота</th><th>К линии</th></tr></thead> <thead><tr><th></th><th>Название</th><th>Широта</th><th>Долгота</th><th>Пролёт</th></tr></thead>
<tbody id="poleTable"></tbody> <tbody id="poleTable"></tbody>
</table> </table>
</div> </div>
@ -82,64 +96,126 @@ body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif;
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script> <script>
let map, markers=[], lines=[], entrances=[], entranceCircles=[]; // ====================== РЕГИОНЫ КАЗАХСТАНА ======================
let poleCount=0, linePoints=[], mode='manual', addingLine=false; 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(){ 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 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'}); 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([43.238,76.946],16); map=L.map('map',{zoomControl:true,layers:[sat,osm]}).setView([48.0,68.0],6);
map.on('click',onMapClick); map.on('click',onMapClick);
updateModeBadge(); 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){ function setMode(newMode){
mode=newMode; mode=newMode;
['btnLine','btnManual','btnEntrance'].forEach(id=>document.getElementById(id).className='');
document.getElementById('btnLine').className='btn-line'; document.getElementById('btnLine').className='btn-line';
document.getElementById('btnManual').className='btn-add'; document.getElementById('btnManual').className='btn-add';
document.getElementById('btnEntrance').className='btn-driveway'; document.getElementById('btnEntrance').className='btn-driveway';
const el=document.getElementById('btn'+newMode.charAt(0).toUpperCase()+newMode.slice(1)); const el=document.getElementById({line:'btnLine',manual:'btnManual',entrance:'btnEntrance'}[mode]);
if(el)el.className+=' btn-active'; if(el)el.classList.add('btn-active');
if(mode==='line')addingLine=false;
if(mode==='line')linePoints=[]; if(mode==='line')linePoints=[];
document.getElementById('modeInfo').textContent={ document.getElementById('modeInfo').textContent={
line:'Режим: рисуем линию улицы → опоры автоматом', line:'📏 Линия: клик по улице → Enter для расстановки',
manual:'Режим: ручная расстановка', manual:'🎯 Клик для установки опоры',
entrance:'Режим: отметить въезд во двор' entrance:'🚗 Клик для отметки въезда'}[mode];
}[mode];
updateModeBadge(); updateModeBadge();
} }
function updateModeBadge(){ function updateModeBadge(){
const b=document.getElementById('modeBadge'); const b=document.getElementById('modeBadge');
if(mode==='manual'){b.style.display='block';b.innerHTML='🎯 Кликните для опоры';} const msgs={
else if(mode==='line'){b.style.display='block';b.innerHTML='📏 Кликните точки линии улицы<br><small>Enter — расставить опоры</small>';} manual:'🎯 Кликните для опоры',
else if(mode==='entrance'){b.style.display='block';b.innerHTML='🚗 Кликните на въезде во двор';} line:'📏 Клик точки линии улицы · Enter = расставить',
else b.style.display='none'; entrance:'🚗 Клик на въезде во двор'
};
b.style.display='block';b.innerHTML=msgs[mode];
} }
// ====================== КЛИК ======================
function onMapClick(e){ function onMapClick(e){
if(mode==='manual'){addPole(e.latlng);return;} if(mode==='manual'){addPole(e.latlng);return;}
if(mode==='entrance'){addEntrance(e.latlng);return;} if(mode==='entrance'){addEntrance(e.latlng);return;}
if(mode==='line'){addLinePoint(e.latlng);return;} if(mode==='line'){addLinePoint(e.latlng);return;}
} }
// ====================== ОПОРЫ ======================
function addPole(latlng){ function addPole(latlng){
poleCount++; poleCount++;
const name='Опора '+poleCount; const name='Опора '+poleCount;
const icon=L.divIcon({className:'',html:'<div class="pole-label">'+poleCount+'</div>',iconSize:[24,24],iconAnchor:[12,12]}); 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>'); 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}); markers.push({id:poleCount,lat:latlng.lat,lng:latlng.lng,name,marker});
updateTable();
if(markers.length>1){ if(markers.length>1){
const p=markers[markers.length-2]; const p=markers[markers.length-2];
const dist=haversine(p.lat,p.lng,latlng.lat,latlng.lng);
const line=L.polyline([[p.lat,p.lng],[latlng.lat,latlng.lng]],{color:'#00E5FF',weight:2.5,dashArray:'6,5'}).addTo(map); 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); lines.push(line);
} }
updateTable();
showToast('✅ '+name); showToast('✅ '+name);
} }
// ====================== ЛИНИИ ======================
function addLinePoint(latlng){ function addLinePoint(latlng){
linePoints.push(latlng); linePoints.push(latlng);
if(linePoints.length>1){ if(linePoints.length>1){
@ -147,39 +223,26 @@ function addLinePoint(latlng){
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.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; L.circleMarker(latlng,{radius:4,color:'#FF9800',fillColor:'#FF9800',fillOpacity:0.8}).addTo(map)._tmpPoint=true;
showToast('📍 Точка '+(linePoints.length)+' линии. Enter — расставить опоры');
} }
function finishLine(){ function finishLine(){
if(linePoints.length<2){showToast('Нарисуйте минимум 2 точки линии');return;} if(linePoints.length<2){showToast('Нарисуйте минимум 2 точки');return;}
const step=parseFloat(document.getElementById('poleStep').value)||50; const step=parseFloat(document.getElementById('poleStep').value)||50;
const margin=parseFloat(document.getElementById('entranceMargin').value)||5; const margin=parseFloat(document.getElementById('entranceMargin').value)||5;
const totalLen=pathLength(linePoints); const totalLen=pathLength(linePoints);
const n=Math.max(1,Math.round(totalLen/step)); const n=Math.max(1,Math.round(totalLen/step));
const poles=[]; const poles=[];
for(let i=0;i<=n;i++){ for(let i=0;i<=n;i++){
const t=i/n; const pt=pointOnPath(linePoints,i/n);
const pt=pointOnPath(linePoints,t); if(!isNearEntrance(pt,margin))poles.push(pt);
const distAlong=t*totalLen;
if(!isNearEntrance(pt,distAlong,margin)){
poles.push(pt);
}
} }
if(!poles.length){showToast('❌ Все позиции перекрыты въездами');return;}
// clear temp drawings
map.eachLayer(l=>{if(l._tmpLine||l._tmpPoint)map.removeLayer(l);}); map.eachLayer(l=>{if(l._tmpLine||l._tmpPoint)map.removeLayer(l);});
linePoints=[]; linePoints=[];
if(!poles.length){showToast('❌ Всё перекрыто въездами');setMode('manual');return;}
poles.forEach(p=>addPole(L.latLng(p.lat,p.lng))); poles.forEach(p=>addPole(L.latLng(p.lat,p.lng)));
showToast('✅ Расставлено '+poles.length+' опор, шаг ~'+step+' м');
setMode('manual'); setMode('manual');
showToast('✅ Расставлено '+poles.length+' опор с шагом ~'+step+' м');
} }
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 pathLength(pts){
let total=0;
for(let i=1;i<pts.length;i++)total+=haversine(pts[i-1].lat,pts[i-1].lng,pts[i].lat,pts[i].lng);
return total;
}
function pointOnPath(pts,t){ function pointOnPath(pts,t){
const total=pathLength(pts); const total=pathLength(pts);
let target=t*total,acc=0; let target=t*total,acc=0;
@ -193,31 +256,31 @@ function pointOnPath(pts,t){
} }
return pts[pts.length-1]; return pts[pts.length-1];
} }
function isNearEntrance(pt,margin){
function isNearEntrance(pt,distAlong,margin){ for(const e of entrances)if(haversine(pt.lat,pt.lng,e.lat,e.lng)<margin)return true;
for(const e of entrances){
const d=haversine(pt.lat,pt.lng,e.lat,e.lng);
if(d<margin)return true;
}
return false; return false;
} }
// ====================== ВЪЕЗДЫ ======================
function addEntrance(latlng){ function addEntrance(latlng){
const icon=L.divIcon({className:'',html:'<div class="entrance-label">🚗 В</div>',iconSize:[30,22],iconAnchor:[15,11]}); 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('🚗 Въезд во двор'); const marker=L.marker(latlng,{icon}).addTo(map).bindPopup('🚗 Въезд во двор');
entrances.push({lat:latlng.lat,lng:latlng.lng,marker}); entrances.push({lat:latlng.lat,lng:latlng.lng,marker});
const circle=L.circle(latlng,{radius:5,color:'#FF9800',fillColor:'#FF9800',fillOpacity:0.2,dashArray:'3,3'}).addTo(map); const circle=L.circle(latlng,{radius:5,color:'#FF9800',fillColor:'#FF9800',fillOpacity:0.15,dashArray:'3,3'}).addTo(map);
entranceCircles.push(circle); entranceCircles.push(circle);
showToast('🚗 Въезд отмечен'); showToast('🚗 Въезд отмечен');
} }
// ====================== РАССТОЯНИЕ ======================
function haversine(lat1,lon1,lat2,lon2){ function haversine(lat1,lon1,lat2,lon2){
const R=6371000; const R=6371000;
const toRad=x=>x*Math.PI/180; const toRad=x=>x*Math.PI/180;
const dLat=toRad(lat2-lat1),dLon=toRad(lon2-lon1); 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))); 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(){ function updateTable(){
const tbody=document.getElementById('poleTable'); const tbody=document.getElementById('poleTable');
tbody.innerHTML=''; tbody.innerHTML='';
@ -234,24 +297,18 @@ function updateTable(){
document.getElementById('stats').textContent=markers.length+' шт'; document.getElementById('stats').textContent=markers.length+' шт';
} }
// ====================== УПРАВЛЕНИЕ ======================
function undo(){ function undo(){
if(entrances.length){ if(entrances.length){
const e=entrances.pop(); const e=entrances.pop();map.removeLayer(e.marker);
map.removeLayer(e.marker); const c=entranceCircles.pop();if(c)map.removeLayer(c);
const c=entranceCircles.pop(); showToast('↩ Въезд удалён');return;
if(c)map.removeLayer(c);
showToast('↩ Въезд удалён');
return;
} }
if(!markers.length)return; if(!markers.length)return;
const last=markers.pop(); const last=markers.pop();map.removeLayer(last.marker);
map.removeLayer(last.marker); if(lines.length)map.removeLayer(lines.pop());
if(lines.length){map.removeLayer(lines.pop());} poleCount--;updateTable();
poleCount--;
updateTable();
showToast('↩ Опора удалена');
} }
function clearAll(){ function clearAll(){
if(!markers.length&&!entrances.length)return; if(!markers.length&&!entrances.length)return;
if(!confirm('Удалить всё?'))return; if(!confirm('Удалить всё?'))return;
@ -262,18 +319,122 @@ function clearAll(){
map.eachLayer(l=>{if(l._tmpLine||l._tmpPoint)map.removeLayer(l);}); map.eachLayer(l=>{if(l._tmpLine||l._tmpPoint)map.removeLayer(l);});
markers=[];lines=[];entrances=[];entranceCircles=[];poleCount=0;linePoints=[]; markers=[];lines=[];entrances=[];entranceCircles=[];poleCount=0;linePoints=[];
updateTable(); updateTable();
showToast('🗑 Всё очищено');
} }
document.addEventListener('keydown',e=>{if(e.key==='Enter'&&mode==='line')finishLine();}); document.addEventListener('keydown',e=>{if(e.key==='Enter'&&mode==='line')finishLine();});
function showToast(msg){ // ====================== ЭКСПОРТ В SVG (Visio/draw.io) ======================
const t=document.getElementById('toast'); function exportSVG(){
t.textContent=msg;t.classList.add('show'); if(!markers.length){showToast('Нет опор');return;}
clearTimeout(t._timer); const first=markers[0];
t._timer=setTimeout(()=>t.classList.remove('show'),2500); 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(){ function exportCSV(){
if(!markers.length){showToast('Нет опор');return;} if(!markers.length){showToast('Нет опор');return;}
let csv='№,Название,Широта,Долгота,Расстояниео_предыдущей_м\n'; let csv='№,Название,Широта,Долгота,Расстояниео_предыдущей_м\n';
@ -283,7 +444,6 @@ function exportCSV(){
}); });
download(csv,'opory_vl04.csv','text/csv;charset=utf-8'); download(csv,'opory_vl04.csv','text/csv;charset=utf-8');
} }
function exportJSON(){ function exportJSON(){
if(!markers.length){showToast('Нет опор');return;} if(!markers.length){showToast('Нет опор');return;}
const data=markers.map((p,i)=>{ const data=markers.map((p,i)=>{
@ -292,14 +452,17 @@ function exportJSON(){
}); });
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'); 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){ function download(content,filename,type){
const blob=new Blob([content],{type:'application/octet-stream'}); const blob=new Blob([content],{type:'application/octet-stream'});
const a=document.createElement('a');a.href=URL.createObjectURL(blob); const a=document.createElement('a');a.href=URL.createObjectURL(blob);
a.download=filename;document.body.appendChild(a);a.click();document.body.removeChild(a); a.download=filename;document.body.appendChild(a);a.click();document.body.removeChild(a);
showToast('📥 '+filename);
} }
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(); init();
</script> </script>
</body> </body>