306 lines
16 KiB
HTML
306 lines
16 KiB
HTML
<!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> |