pole-planner/index.html

307 lines
15 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>Планировщик опор ВЛ-0.4 кВ</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);color:var(--white);padding:10px 16px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.header h1{font-size:16px;font-weight:700;white-space:nowrap}
.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}
.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)}
#map{height:calc(100vh - 176px);min-height:350px}
.status{margin:4px 12px 0;font-size:11px;color:#9aa3b2;display:flex;gap:16px;flex-wrap:wrap}
.status span{margin-right:12px}
.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}
.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}
.sidebar table{width:100%;border-collapse:collapse;font-size:11px}
.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 input{width:45px;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}}
</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="exportCSV()">📥 CSV</button>
<button class="btn-export" onclick="exportJSON()">📥 JSON</button>
</div>
</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 id="modeInfo" style="margin-left:auto">Режим: ручная расстановка</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:12px;height:12px;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><span style="display:inline-block;background:#FF9800;padding:0 4px;border-radius:2px;color:#fff;font-size:9px">В</span> Въезд во двор</span>
</div>
<div class="sidebar">
<div class="sidebar-header"><span>📋 Спецификация опор</span><span id="stats" style="font-weight:400;font-size:11px">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>
let map, markers=[], lines=[], entrances=[], entranceCircles=[];
let poleCount=0, linePoints=[], mode='manual', addingLine=false;
function init(){
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([43.238,76.946],16);
map.on('click',onMapClick);
updateModeBadge();
}
function setMode(newMode){
mode=newMode;
document.getElementById('btnLine').className='btn-line';
document.getElementById('btnManual').className='btn-add';
document.getElementById('btnEntrance').className='btn-driveway';
const el=document.getElementById('btn'+newMode.charAt(0).toUpperCase()+newMode.slice(1));
if(el)el.className+=' btn-active';
if(mode==='line')addingLine=false;
if(mode==='line')linePoints=[];
document.getElementById('modeInfo').textContent={
line:'Режим: рисуем линию улицы → опоры автоматом',
manual:'Режим: ручная расстановка',
entrance:'Режим: отметить въезд во двор'
}[mode];
updateModeBadge();
}
function updateModeBadge(){
const b=document.getElementById('modeBadge');
if(mode==='manual'){b.style.display='block';b.innerHTML='🎯 Кликните для опоры';}
else if(mode==='line'){b.style.display='block';b.innerHTML='📏 Кликните точки линии улицы<br><small>Enter — расставить опоры</small>';}
else if(mode==='entrance'){b.style.display='block';b.innerHTML='🚗 Кликните на въезде во двор';}
else b.style.display='none';
}
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:[24,24],iconAnchor:[12,12]});
const marker=L.marker(latlng,{icon}).addTo(map).bindPopup('<b>'+name+'</b>');
markers.push({id:poleCount,lat:latlng.lat,lng:latlng.lng,name,marker});
updateTable();
if(markers.length>1){
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);
lines.push(line);
}
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;
showToast('📍 Точка '+(linePoints.length)+' линии. Enter — расставить опоры');
}
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 t=i/n;
const pt=pointOnPath(linePoints,t);
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);});
linePoints=[];
poles.forEach(p=>addPole(L.latLng(p.lat,p.lng)));
setMode('manual');
showToast('✅ Расставлено '+poles.length+' опор с шагом ~'+step+' м');
}
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){
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,distAlong,margin){
for(const e of entrances){
const d=haversine(pt.lat,pt.lng,e.lat,e.lng);
if(d<margin)return true;
}
return false;
}
function addEntrance(latlng){
const icon=L.divIcon({className:'',html:'<div class="entrance-label">🚗 В</div>',iconSize:[30,22],iconAnchor:[15,11]});
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.2,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);
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();
showToast('↩ Опора удалена');
}
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();
showToast('🗑 Всё очищено');
}
document.addEventListener('keydown',e=>{if(e.key==='Enter'&&mode==='line')finishLine();});
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);
}
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);
showToast('📥 '+filename);
}
init();
</script>
</body>
</html>