174 lines
8.2 KiB
HTML
174 lines
8.2 KiB
HTML
<!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:15px/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:16px 24px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
|
||
.header h1{font-size:20px;font-weight:700}
|
||
.header .badge{background:var(--cyan);color:var(--ink);padding:4px 10px;border-radius:12px;font-size:12px;font-weight:700}
|
||
.controls{display:flex;gap:8px;flex-wrap:wrap}
|
||
.controls button{padding:8px 16px;border:none;border-radius:6px;font-weight:600;cursor:pointer;font-size:13px}
|
||
.btn-add{background:var(--cyan);color:var(--ink)}
|
||
.btn-undo{background:#ff5252;color:#fff}
|
||
.btn-export{background:#4caf50;color:#fff}
|
||
.btn-clear{background:#ff9800;color:#fff}
|
||
#map{height:calc(100vh - 200px);min-height:400px}
|
||
.sidebar{position:absolute;top:70px;right:16px;width:320px;max-height:calc(100vh - 100px);background:var(--white);border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.15);overflow:hidden;z-index:1000;display:flex;flex-direction:column}
|
||
.sidebar-header{padding:12px 16px;background:var(--gray-100);font-weight:700;font-size:14px;border-bottom:1px solid #ddd}
|
||
.sidebar table{width:100%;border-collapse:collapse;font-size:13px}
|
||
.sidebar th{background:var(--ink);color:var(--white);padding:8px 10px;text-align:left}
|
||
.sidebar td{padding:6px 10px;border-bottom:1px solid var(--gray-100)}
|
||
.sidebar td.num{text-align:center;font-weight:700;color:var(--cyan)}
|
||
.sidebar td.dist{text-align:right;font-family:monospace}
|
||
.sidebar-footer{padding:10px 16px;background:var(--gray-100);font-size:13px;font-weight:600;border-top:1px solid #ddd}
|
||
.pole-label{background:var(--ink);color:var(--cyan);border-radius:50%;width:28px;height:28px;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:13px;border:2px solid var(--cyan);box-shadow:0 2px 8px rgba(0,0,0,.3)}
|
||
.mode-badge{position:absolute;top:80px;left:16px;background:var(--cyan);color:var(--ink);padding:6px 14px;border-radius:8px;font-weight:700;font-size:13px;z-index:1000;pointer-events:none}
|
||
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--ink);color:var(--white);padding:10px 20px;border-radius:8px;font-size:13px;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:45vh;border-radius:12px 12px 0 0}#map{height:55vh}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header">
|
||
<h1>Планировщик опор</h1>
|
||
<span class="badge">Google Maps</span>
|
||
<div class="controls">
|
||
<button class="btn-add" id="btnAdd" onclick="toggleMode()">+ Добавить опору</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 id="map"></div>
|
||
<div class="mode-badge" id="modeBadge" style="display:none">🎯 Кликните на карту, чтобы поставить опору</div>
|
||
<div class="sidebar">
|
||
<div class="sidebar-header">Таблица опор</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 опор)</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=[], addMode=false, poleCount=0;
|
||
const LAYER_SATELLITE='https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
|
||
const LAYER_LABELS='https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}';
|
||
|
||
function init(){
|
||
map=L.map('map',{zoomControl:true}).setView([43.238,76.946],15);
|
||
L.tileLayer(LAYER_SATELLITE,{maxZoom:19,attribution:'Esri'}).addTo(map);
|
||
L.tileLayer(LAYER_LABELS,{maxZoom:19,opacity:0.7}).addTo(map);
|
||
map.on('click',onMapClick);
|
||
}
|
||
|
||
function toggleMode(){
|
||
addMode=!addMode;
|
||
document.getElementById('btnAdd').textContent=addMode?'✕ Отмена':'+ Добавить опору';
|
||
document.getElementById('modeBadge').style.display=addMode?'block':'none';
|
||
}
|
||
|
||
function onMapClick(e){
|
||
if(!addMode)return;
|
||
poleCount++;
|
||
const name='Опора '+poleCount;
|
||
const icon=L.divIcon({className:'',html:'<div class="pole-label">'+poleCount+'</div>',iconSize:[28,28],iconAnchor:[14,14]});
|
||
const marker=L.marker(e.latlng,{icon}).addTo(map).bindPopup('<b>'+name+'</b><br>'+e.latlng.lat.toFixed(6)+', '+e.latlng.lng.toFixed(6));
|
||
markers.push({id:poleCount,lat:e.latlng.lat,lng:e.latlng.lng,name,marker});
|
||
if(markers.length>1){
|
||
const prev=markers[markers.length-2];
|
||
const dist=haversine(prev.lat,prev.lng,e.latlng.lat,e.latlng.lng);
|
||
const line=L.polyline([[prev.lat,prev.lng],[e.latlng.lat,e.latlng.lng]],{color:'#00E5FF',weight:3,dashArray:'8,6'}).addTo(map);
|
||
lines.push(line);
|
||
}
|
||
updateTable();
|
||
showToast(name+' добавлена');
|
||
}
|
||
|
||
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);
|
||
});
|
||
document.getElementById('totalDist').textContent='Итого: '+total.toFixed(1)+' м ('+markers.length+' опор)';
|
||
}
|
||
|
||
function undo(){
|
||
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)return;
|
||
if(!confirm('Очистить все опоры?'))return;
|
||
markers.forEach(m=>map.removeLayer(m.marker));
|
||
lines.forEach(l=>map.removeLayer(l));
|
||
markers=[];lines=[];poleCount=0;
|
||
updateTable();
|
||
}
|
||
|
||
function showToast(msg){
|
||
const t=document.getElementById('toast');
|
||
t.textContent=msg;t.classList.add('show');
|
||
setTimeout(()=>t.classList.remove('show'),2000);
|
||
}
|
||
|
||
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,'poles.csv','text/csv');
|
||
}
|
||
|
||
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:p.lat,lng:p.lng,distance_m:parseFloat(dist.toFixed(1))};
|
||
});
|
||
download(JSON.stringify(data,null,2),'poles.json','application/json');
|
||
}
|
||
|
||
function download(content,filename,type){
|
||
const blob=new Blob([content],{type});
|
||
const a=document.createElement('a');a.href=URL.createObjectURL(blob);
|
||
a.download=filename;a.click();
|
||
showToast('Экспортировано: '+filename);
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|