pole-planner-v2/index.html
2026-07-01 06:20:41 +00:00

306 lines
16 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>
*{box-sizing:border-box;margin:0;padding:0}
body{font:14px/1.5 system-ui,sans-serif;background:#f5f5f5;height:100vh;overflow:hidden}
#map{height:100vh;width:100%;background:#1a1a1a}
.header{position:fixed;top:0;left:0;right:0;z-index:1000;background:#0F1218;color:#fff;padding:8px 12px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.header h1{font-size:14px;font-weight:700}
.controls{display:flex;gap:4px;flex-wrap:wrap}
.btn{padding:4px 10px;border:none;border-radius:4px;font-weight:600;cursor:pointer;font-size:11px}
.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}
.loc-panel{position:fixed;top:44px;left:10px;right:10px;z-index:990;background:#fff;padding:8px 12px;border-radius:6px;box-shadow:0 2px 8px rgba(0,0,0,.15);display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:11px}
.loc-panel label{color:#555;white-space:nowrap}
.loc-panel select{padding:2px 6px;border:1px solid #ddd;border-radius:3px;background:#fff;color:#333;font-size:11px;max-width:140px}
.loc-panel input{padding:2px 6px;border:1px solid #ddd;border-radius:3px;background:#fff;color:#333;font-size:11px;width:120px}
.loc-panel button{padding:2px 8px;border:none;border-radius:3px;background:#00E5FF;color:#0F1218;font-weight:600;cursor:pointer;font-size:11px}
.legend{position:fixed;bottom:10px;left:10px;z-index:990;background:#fff;padding:6px 10px;border-radius:6px;box-shadow:0 2px 8px rgba(0,0,0,.15);font-size:10px;line-height:1.6}
.legend span{display:flex;align-items:center;gap:6px}
.sidebar{position:fixed;top:110px;right:10px;bottom:10px;z-index:990;width:300px;background:#fff;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,.15);display:flex;flex-direction:column;overflow:hidden;font-size:12px}
.sidebar-header{padding:8px 12px;background:#f5f5f5;font-weight:700;border-bottom:1px solid #ddd;display:flex;justify-content:space-between}
.sidebar table{width:100%;border-collapse:collapse;font-size:11px}
.sidebar th{background:#0F1218;color:#fff;padding:4px 6px;text-align:left;position:sticky;top:0;font-size:10px}
.sidebar td{padding:3px 6px;border-bottom:1px solid #f0f0f0}
.sidebar td.num{text-align:center;font-weight:700;color:#00E5FF}
.toast{position:fixed;bottom:10px;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}
@media(max-width:700px){
.loc-panel{top:auto;bottom:10px;left:10px;right:10px}
.sidebar{width:100%;height:40vh;top:auto;bottom:10px;border-radius:8px 8px 0 0}
#map{height:calc(100vh - 200px)}
}
</style>
</head>
<body>
<div class="header">
<h1>⚡ Опоры ВЛ-0.4 кВ</h1>
<div class="controls">
<button class="btn btn-primary" id="btnManual">🎯 Ручная</button>
<button class="btn btn-warning" id="btnEntrance">🚗 Въезд</button>
<button class="btn btn-danger" onclick="undo()"></button>
<button class="btn btn-danger" onclick="clearAll()">🗑</button>
<button class="btn btn-export" onclick="exportCSV()">CSV</button>
<button class="btn btn-export" onclick="exportJSON()">JSON</button>
</div>
</div>
<div class="loc-panel">
<label>📍 Область:</label>
<select id="regionSelect" onchange="loadRegion()"><option value="">— выберите —</option></select>
<label>Район:</label>
<select id="districtSelect" onchange="loadDistrict()"><option value="">— выберите район —</option></select>
<label>Село:</label>
<select id="villageSelect" onchange="goToVillage()"><option value="">— выберите село —</option></select>
<button onclick="autoLoad()">🔄 Авто</button>
<span id="status" style="color:#666;font-size:10px"></span>
</div>
<div class="legend">
<span>⚫ Опора</span>
<span>──── ВЛ</span>
<span>🚗 Въезд</span>
<span>🛣 Улица</span>
<span>🏠 Здание</span>
</div>
<div class="sidebar">
<div class="sidebar-header">📋 Спецификация <span id="cnt">0</span></div>
<div style="overflow:auto;flex:1">
<table>
<thead><tr><th></th><th>Широта</th><th>Долгота</th><th>Пролёт</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
</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:'Абай',c:[48.95,79.23]},{name:'Акмолинская',c:[52.0,69.0]},{name:'Актюбинская',c:[48.0,57.0]},
{name:'Алматинская',c:[44.5,78.0]},{name:'Атырауская',c:[47.0,52.0]},{name:'Восточно-Казахстанская',c:[49.0,82.0]},
{name:'Жамбылская',c:[44.0,72.0]},{name:'Жетысу',c:[45.0,80.0]},{name:'Западно-Казахстанская',c:[50.0,51.0]},
{name:'Карагандинская',c:[48.0,71.0]},{name:'Костанайская',c:[53.0,65.0]},{name:'Кызылординская',c:[45.0,65.0]},
{name:'Мангистауская',c:[44.0,52.0]},{name:'Павлодарская',c:[52.0,76.0]},{name:'Северо-Казахстанская',c:[54.5,69.0]},
{name:'Туркестанская',c:[43.0,68.0]},{name:'Улытау',c:[48.0,67.0]},
{name:'г.Алматы',c:[43.238,76.946]},{name:'г.Астана',c:[51.16,71.44]},{name:'г.Шымкент',c:[42.32,69.6]}
];
let map,markers=[],lines=[],entrances=[],poleCount=0;
let roadsLayer,buildingsLayer;
let districtList=[],villageList=[];
// ==== INIT ====
function init(){
REGIONS.forEach(r=>{
const o=document.createElement('option');
o.value=r.name;o.textContent=r.name;
document.getElementById('regionSelect').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.4});
map=L.map('map',{zoomControl:true,layers:[sat,osm]}).setView([48,68],6);
map.on('click',onClick);
roadsLayer=L.layerGroup().addTo(map);
buildingsLayer=L.layerGroup().addTo(map);
window.addEventListener('load',()=>{setTimeout(()=>map.invalidateSize(),200);});
}
function onClick(e){
const mode=document.getElementById('btnManual').classList.contains('active')?'manual':'entrance';
if(mode==='manual')addPole(e.latlng);
else addEntrance(e.latlng);
}
function addPole(l){
poleCount++;
const icon=L.divIcon({html:'<div style="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">'+poleCount+'</div>',iconSize:[22,22],iconAnchor:[11,11]});
const m=L.marker(l,{icon}).addTo(map).bindPopup('Опора '+poleCount);
markers.push({id:poleCount,lat:l.lat,lng:l.lng,m});
if(markers.length>1){
const p=markers[markers.length-2];
lines.push(L.polyline([[p.lat,p.lng],[l.lat,l.lng]],{color:'#00E5FF',weight:2.5,dashArray:'6,5'}).addTo(map));
}
refreshTable();
toast('Опора '+poleCount);
}
function addEntrance(l){
const icon=L.divIcon({html:'<div style="background:#FF9800;color:#fff;border-radius:3px;padding:1px 5px;font-size:9px;font-weight:700">🚗 В</div>',iconSize:[26,18],iconAnchor:[13,9]});
const m=L.marker(l,{icon}).addTo(map).bindPopup('Въезд');
entrances.push({lat:l.lat,lng:l.lng,m});
L.circle(l,{radius:5,color:'#FF9800',fillColor:'#FF9800',fillOpacity:0.15,dashArray:'3,3'}).addTo(map);
toast('Въезд отмечен');
}
function undo(){
if(entrances.length){entrances.pop().m.remove();toast('↩');return;}
if(!markers.length)return;
markers.pop().m.remove();
if(lines.length)lines.pop().remove();
poleCount--;
refreshTable();
}
function clearAll(){
if(!confirm('Очистить всё?'))return;
markers.forEach(m=>m.m.remove());
lines.forEach(l=>l.remove());
entrances.forEach(e=>e.m.remove());
roadsLayer.clearLayers();
buildingsLayer.clearLayers();
markers=[];lines=[];entrances=[];poleCount=0;
refreshTable();
}
function refreshTable(){
const t=document.getElementById('tbody');
t.innerHTML='';
let total=0;
markers.forEach((p,i)=>{
let d=0;
if(i>0)d=haversine(markers[i-1].lat,markers[i-1].lng,p.lat,p.lng);
total+=d;
const tr=document.createElement('tr');
tr.innerHTML='<td class="num">'+p.id+'</td><td>'+p.lat.toFixed(6)+'</td><td>'+p.lng.toFixed(6)+'</td><td style="text-align:right">'+(i>0?d.toFixed(1)+' м':'—')+'</td>';
t.appendChild(tr);
});
document.getElementById('cnt').textContent=markers.length+' шт, '+total.toFixed(1)+' м';
}
function haversine(lat1,lon1,lat2,lon2){
const R=6371000,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 toast(m){
const t=document.getElementById('toast');
t.textContent=m;t.classList.add('show');
setTimeout(()=>t.classList.remove('show'),2000);
}
// ==== LOCATION LOADERS ====
function loadRegion(){
const name=document.getElementById('regionSelect').value;
const r=REGIONS.find(x=>x.name===name);
const ds=document.getElementById('districtSelect');
const vs=document.getElementById('villageSelect');
ds.innerHTML='<option value="">— загрузка —</option>';
vs.innerHTML='<option value="">— выберите район —</option>';
districtList=[];villageList=[];
if(!r){document.getElementById('status').textContent='';return;}
map.setView(r.c,9);
const st=document.getElementById('status');
st.textContent='⏳ районы...';
// 4 варианта поиска
['районы '+name+' области','районы '+name+' облысы',name+' район',name+' аудан'].forEach(q=>{
fetch('https://nominatim.openstreetmap.org/search?format=json&q='+encodeURIComponent(q)+'&countrycodes=kz&limit=30&type=administrative')
.then(r=>r.json())
.then(d=>{
if(!d||!d.length)return;
const seen=new Set();
ds.innerHTML='<option value="">— все —</option>';
d.forEach(item=>{
const n=item.display_name.split(',')[0].trim();
if(!n||seen.has(n)||n===name||/город/i.test(n))return;
seen.add(n);
const o=document.createElement('option');
o.value=n;o.textContent=n;
ds.appendChild(o);
districtList.push({name:n,lat:item.lat,lon:item.lon});
});
st.textContent='✅ '+seen.size+' районов';
});
});
setTimeout(()=>{if(!districtList.length)st.textContent='⚠ введите район вручную';},4000);
}
function loadDistrict(){
const name=document.getElementById('districtSelect').value;
const vs=document.getElementById('villageSelect');
vs.innerHTML='<option value="">⏳ сёла...</option>';
villageList=[];
if(!name||!districtList.length){vs.innerHTML='<option value="">— выберите район —</option>';return;}
document.getElementById('status').textContent='⏳ сёла...';
fetch('https://nominatim.openstreetmap.org/search?format=json&q='+encodeURIComponent(name+' район село')+'&countrycodes=kz&limit=50')
.then(r=>r.json())
.then(d=>{
vs.innerHTML='<option value="">— выберите село —</option>';
if(!d||!d.length){vs.innerHTML='<option value="">— сёла не найдены —</option>';return;}
d.forEach(item=>{
if(!['village','hamlet','town','settlement'].includes(item.type))return;
const n=item.display_name.split(',')[0].trim();
const o=document.createElement('option');
o.value=n;o.dataset.lat=item.lat;o.dataset.lon=item.lon;o.textContent=n;
vs.appendChild(o);
villageList.push({name:n,lat:item.lat,lon:item.lon});
});
document.getElementById('status').textContent='✅ '+villageList.length+' сёл';
})
.catch(()=>{vs.innerHTML='<option value="">— ошибка —</option>';});
}
function goToVillage(){
const vs=document.getElementById('villageSelect');
const opt=vs.options[vs.selectedIndex];
if(!opt||!opt.dataset.lat)return;
const lat=parseFloat(opt.dataset.lat),lon=parseFloat(opt.dataset.lon);
map.setView([lat,lon],16);
toast('📍 '+opt.value);
setTimeout(autoLoad,500);
}
function autoLoad(){
if(map.getZoom()<13){toast('Приблизьтесь (зум 13+)');return;}
const b=map.getBounds();
const bbox=b.getSouth()+','+b.getWest()+','+b.getNorth()+','+b.getEast();
document.getElementById('status').textContent='⏳ авто...';
// Улицы
fetch('https://overpass-api.de/api/interpreter?data='+encodeURIComponent('[out:json];(way["highway"]('+bbox+'););out geom;'))
.then(r=>r.json())
.then(d=>{
roadsLayer.clearLayers();
const colors={primary:'#e74c3c',secondary:'#f39c12',tertiary:'#f39c12',residential:'#f39c12',service:'#95a5a6',unclassified:'#f39c12',track:'#95a5a6',path:'#95a5a6',road:'#f39c12'};
(d.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||'';
const c=colors[type]||'#7c3aed';
const w=type==='track'?1.5:type==='service'?2:2.5;
L.polyline(coords,{color:c,weight:w,opacity:0.8}).addTo(roadsLayer);
if(name&&coords.length>1){
const mid=coords[Math.floor(coords.length/2)];
L.marker(mid,{icon:L.divIcon({html:'<span style="background:rgba(0,0,0,.7);color:#00E5FF;padding:1px 4px;border-radius:3px;font-size:9px;font-weight:700;border:1px solid #00E5FF">'+name+'</span>',iconSize:[0,0]})}).addTo(roadsLayer);
}
});
});
// Здания
fetch('https://overpass-api.de/api/interpreter?data='+encodeURIComponent('[out:json];(way["building"]('+bbox+'););out geom;'))
.then(r=>r.json())
.then(d=>{
buildingsLayer.clearLayers();
(d.elements||[]).forEach(el=>{
if(!el.geometry||el.geometry.length<2)return;
const coords=el.geometry.map(g=>[g.lat,g.lon]);
L.polygon(coords,{color:'#ff6b35',weight:1,fillColor:'#ff6b35',fillOpacity:0.15}).addTo(buildingsLayer);
});
document.getElementById('status').textContent='✅ готово';
});
}
// ==== EXPORT ====
function exportCSV(){
if(!markers.length){toast('Нет опор');return;}
let csv='№,Широта,Долгота,Пролёт_м\n';
markers.forEach((p,i)=>{let d=0;if(i>0)d=haversine(markers[i-1].lat,markers[i-1].lng,p.lat,p.lng);csv+=p.id+','+p.lat.toFixed(6)+','+p.lng.toFixed(6)+','+d.toFixed(1)+'\n';});
download(csv,'opory.csv','text/csv;charset=utf-8');
}
function exportJSON(){
if(!markers.length){toast('Нет опор');return;}
const d=markers.map((p,i)=>({id:p.id,lat:parseFloat(p.lat.toFixed(6)),lng:parseFloat(p.lng.toFixed(6)),span_m:i>0?parseFloat(haversine(markers[i-1].lat,markers[i-1].lng,p.lat,p.lng).toFixed(1)):0}));
download(JSON.stringify(d,null,2),'opory.json','application/json');
}
function download(c,f,t){
const a=document.createElement('a');
a.href=URL.createObjectURL(new Blob([c],{type:t}));
a.download=f;a.click();a.remove();
toast('📥 '+f);
}
init();
</script>
</body>
</html>