v4: auto-detect streets & buildings from OSM, snap to road
This commit is contained in:
parent
eab75dd21d
commit
a1f439001a
381
index.html
381
index.html
@ -6,44 +6,47 @@
|
||||
<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}
|
||||
:root{--ink:#0F1218;--cyan:#00E5FF}
|
||||
*{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:#F2F4F7}
|
||||
.header{background:var(--ink);padding:8px 12px;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||||
.header h1{font-size:15px;font-weight:700;color:#fff;white-space:nowrap}
|
||||
.controls{display:flex;gap:4px;flex-wrap:wrap}
|
||||
.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-add{background:#4CAF50;color:#fff}
|
||||
.btn-driveway{background:#FF9800;color:#fff}
|
||||
.btn-undo{background:#ff5252;color:#fff}
|
||||
.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}
|
||||
.btn-clear{background:#ff9800;color:#fff}
|
||||
.btn-active{background:#fff!important;color:#0F1218!important;box-shadow:0 0 0 2px var(--cyan)}
|
||||
.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}
|
||||
.loc-panel label{color:#9aa3b2;white-space:nowrap}
|
||||
.loc-panel label{color:#9aa3b2}
|
||||
.loc-panel select{padding:2px 6px;border:1px solid #555;border-radius:3px;background:#333;color:#fff;font-size:11px;max-width:160px}
|
||||
.loc-panel input{padding:2px 6px;border:1px solid #555;border-radius:3px;background:#333;color:#fff;font-size:11px;width:140px}
|
||||
.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}
|
||||
#map{height:calc(100vh - 208px);min-height:350px}
|
||||
.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}
|
||||
.settings label{display:flex;align-items:center;gap:4px}
|
||||
.settings input{width:42px;padding:2px 4px;border:1px solid #555;border-radius:3px;background:#222;color:#fff;font-size:11px;text-align:center}
|
||||
.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{position:absolute;top:54px;right:8px;width:340px;max-height:calc(100vh - 64px);background:#fff;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:#F2F4F7;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 th{background:var(--ink);color:#fff;padding:3px 5px;text-align:left;position:sticky;top:0;font-size:10px}
|
||||
.sidebar td{padding:3px 5px;border-bottom:1px solid #F2F4F7;font-size:10px}
|
||||
.sidebar td.num{text-align:center;font-weight:700;color:#00E5FF;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}
|
||||
.sidebar-footer{padding:5px 10px;background:#F2F4F7;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}
|
||||
.mode-badge{position:absolute;top:54px;left:8px;background:#00E5FF;color:#0F1218;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:#fff;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}
|
||||
.auto-panel{position:absolute;top:54px;left:8px;background:#fff;padding:6px 10px;border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,.15);z-index:1000;font-size:11px;max-width:260px;display:none}
|
||||
.auto-panel button{padding:3px 8px;border:none;border-radius:3px;font-weight:600;cursor:pointer;font-size:10px;margin-top:4px}
|
||||
.auto-panel .tag{display:inline-block;background:#00E5FF;color:#0F1218;padding:1px 6px;border-radius:3px;font-size:9px;margin:1px}
|
||||
@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>
|
||||
</head>
|
||||
@ -51,15 +54,14 @@ body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif;
|
||||
<div class="header">
|
||||
<h1>⚡ Опоры ВЛ-0.4 кВ</h1>
|
||||
<div class="controls">
|
||||
<button class="btn-line" id="btnLine" onclick="setMode('line')">📏 Линия</button>
|
||||
<button class="btn-primary" 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-warning" id="btnEntrance" onclick="setMode('entrance')">🚗 Въезд</button>
|
||||
<button class="btn-danger" onclick="undo()">↩</button>
|
||||
<button class="btn-warning" onclick="clearAll()">🗑</button>
|
||||
<button class="btn-export" onclick="exportSVG()">📥 SVG</button>
|
||||
<button class="btn-export" onclick="exportDrawIO()">📥 draw.io</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 class="loc-panel">
|
||||
@ -68,20 +70,30 @@ body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif;
|
||||
<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">
|
||||
<input type="text" id="villageInput" placeholder="Название села...">
|
||||
<button onclick="searchLocation()">🔍 Найти</button>
|
||||
<button onclick="autoDetect()" style="background:#FF9800;color:#fff">🔄 Авто</button>
|
||||
</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>
|
||||
<label>🔒 Привязка: <input type="checkbox" id="snapRoad" checked></label>
|
||||
<span class="mode-info" id="modeInfo">Режим: ручная расстановка</span>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
<div class="mode-badge" id="modeBadge" style="display:none">🎯 Кликните для установки</div>
|
||||
<div class="auto-panel" id="autoPanel">
|
||||
<b>🔄 Автоопределение</b><br>
|
||||
<span id="autoStatus">Загрузка данных...</span>
|
||||
<div id="autoResults"></div>
|
||||
<button onclick="applyRoadPoles()">📥 Расставить опоры вдоль улиц</button>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<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:12px;height:3px;background:#00E5FF;border-radius:2px"></i> Линия ВЛ</span>
|
||||
<span><span style="display:inline-block;background:#FF9800;padding:0 3px;border-radius:2px;color:#fff;font-size:8px">В</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:12px;height:3px;background:#00E5FF;border-radius:2px"></i> ВЛ</span>
|
||||
<span><span style="background:#FF9800;padding:0 3px;border-radius:2px;color:#fff;font-size:8px">В</span> Въезд</span>
|
||||
<span><i style="display:inline-block;width:12px;height:2px;background:#7c3aed;border-radius:2px"></i> Улица (OSM)</span>
|
||||
<span><span style="display:inline-block;width:8px;height:8px;background:rgba(255,107,53,.25);border:1px solid #ff6b35"></span> Здание</span>
|
||||
</div>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header"><span>📋 Спецификация</span><span id="stats" style="font-weight:400;font-size:10px">0 шт</span></div>
|
||||
@ -115,7 +127,6 @@ const REGIONS = [
|
||||
{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]}
|
||||
@ -124,18 +135,20 @@ const REGIONS = [
|
||||
// ====================== ГЛОБАЛЬНЫЕ ======================
|
||||
let map, markers=[], lines=[], entrances=[], entranceCircles=[];
|
||||
let poleCount=0, linePoints=[], mode='manual';
|
||||
let roadsLayer=null, buildingsLayer=null;
|
||||
let loadedRoads=[], loadedBuildings=0;
|
||||
|
||||
// ====================== ИНИЦИАЛИЗАЦИЯ ======================
|
||||
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);
|
||||
});
|
||||
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 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([48.0,68.0],6);
|
||||
map.on('click',onMapClick);
|
||||
map.on('moveend',()=>{if(map.getZoom()>10)autoLoadOSM();});
|
||||
roadsLayer=L.layerGroup().addTo(map);
|
||||
buildingsLayer=L.layerGroup().addTo(map);
|
||||
updateModeBadge();
|
||||
}
|
||||
|
||||
@ -146,59 +159,183 @@ function onRegionChange(){
|
||||
if(r)map.setView(r.center,9);
|
||||
}
|
||||
function onDistrictChange(){
|
||||
const name=document.getElementById('districtSelect').value;
|
||||
if(name)searchLocation();
|
||||
if(document.getElementById('districtSelect').value)searchLocation();
|
||||
}
|
||||
function searchLocation(){
|
||||
const q=[
|
||||
document.getElementById('regionSelect').value,
|
||||
document.getElementById('districtSelect').value,
|
||||
document.getElementById('villageInput').value
|
||||
].filter(Boolean).join(', ');
|
||||
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]);
|
||||
.then(r=>r.json()).then(data=>{
|
||||
if(!data.length){showToast('Не найдено');return;}
|
||||
map.setView([data[0].lat,data[0].lon],16);showToast('📍 '+data[0].display_name.split(',')[0]);
|
||||
});
|
||||
}
|
||||
|
||||
// ====================== АВТООПРЕДЕЛЕНИЕ УЛИЦ И ЗДАНИЙ (OSM) ======================
|
||||
function autoDetect(){
|
||||
document.getElementById('autoPanel').style.display='block';
|
||||
document.getElementById('autoStatus').textContent='⏳ Загрузка улиц и зданий...';
|
||||
autoLoadOSM();
|
||||
}
|
||||
|
||||
function autoLoadOSM(){
|
||||
if(map.getZoom()<12){showToast('Приблизьтесь к селу (зум 13+)');return;}
|
||||
const b=map.getBounds();
|
||||
const bbox=b.getSouth()+','+b.getWest()+','+b.getNorth()+','+b.getEast();
|
||||
document.getElementById('autoResults').innerHTML='';
|
||||
document.getElementById('autoPanel').style.display='block';
|
||||
document.getElementById('autoStatus').textContent='⏳ Загрузка...';
|
||||
|
||||
// Дороги
|
||||
const qRoads='[out:json];(way["highway"]('+bbox+'););out geom;';
|
||||
fetch('https://overpass-api.de/api/interpreter?data='+encodeURIComponent(qRoads))
|
||||
.then(r=>r.json()).then(data=>{
|
||||
roadsLayer.clearLayers();
|
||||
loadedRoads=[];
|
||||
let roadCount=0, namedCount=0;
|
||||
const colors={primary:'#7c3aed',secondary:'#7c3aed',tertiary:'#7c3aed',residential:'#7c3aed',service:'#7c3aed',unclassified:'#7c3aed',track:'#aaa'};
|
||||
(data.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||el.tags&&el.tags['ref']||'';
|
||||
const color=colors[type]||'#7c3aed';
|
||||
const weight=type==='track'?1.5:type==='service'?2:2.5;
|
||||
const road=L.polyline(coords,{color,weight,opacity:0.7}).addTo(roadsLayer);
|
||||
if(name){road.bindPopup('🛣 '+name);namedCount++;}
|
||||
loadedRoads.push({coords,type,name,road});
|
||||
roadCount++;
|
||||
});
|
||||
document.getElementById('autoStatus').textContent='✅ Улиц: '+roadCount+', здания: '+loadedBuildings;
|
||||
let html='<span class="tag">'+roadCount+' улиц</span><span class="tag">'+namedCount+' с названиями</span><span class="tag">'+loadedBuildings+' зданий</span>';
|
||||
document.getElementById('autoResults').innerHTML=html;
|
||||
if(roadCount+namedCount+loadedBuildings===0)document.getElementById('autoStatus').textContent='⚠ Данные не загружены — проверьте зум';
|
||||
if(roadCount===0)document.getElementById('autoStatus').textContent='⚠ Нет улиц в этом районе';
|
||||
});
|
||||
// Здания
|
||||
const qBuild='[out:json];(way["building"]('+bbox+');rel["building"]('+bbox+'););out geom;';
|
||||
fetch('https://overpass-api.de/api/interpreter?data='+encodeURIComponent(qBuild))
|
||||
.then(r=>r.json()).then(data=>{
|
||||
buildingsLayer.clearLayers();
|
||||
loadedBuildings=0;
|
||||
(data.elements||[]).forEach(el=>{
|
||||
if(!el.geometry||el.geometry.length<2)return;
|
||||
const coords=el.geometry.map(g=>[g.lat,g.lon]);
|
||||
const poly=L.polygon(coords,{color:'#ff6b35',weight:1,fillColor:'#ff6b35',fillOpacity:0.15});
|
||||
let info=[];
|
||||
if(el.tags){
|
||||
if(el.tags.building)info.push('🏠 '+el.tags.building);
|
||||
if(el.tags['addr:housenumber'])info.push('№'+el.tags['addr:housenumber']);
|
||||
if(el.tags['addr:street'])info.push(el.tags['addr:street']);
|
||||
if(el.tags.name)info.push('📌 '+el.tags.name);
|
||||
}
|
||||
if(info.length)poly.bindPopup(info.join('<br>'));
|
||||
poly.addTo(buildingsLayer);
|
||||
loadedBuildings++;
|
||||
});
|
||||
document.getElementById('autoStatus').textContent='✅ Улиц: '+loadedRoads.length+', здания: '+loadedBuildings;
|
||||
let html='<span class="tag">'+loadedRoads.length+' улиц</span><span class="tag">'+loadedBuildings+' зданий</span>';
|
||||
document.getElementById('autoResults').innerHTML=html;
|
||||
});
|
||||
}
|
||||
|
||||
// ====================== РАССТАВИТЬ ОПОРЫ ВДОЛЬ УЛИЦ ======================
|
||||
function applyRoadPoles(){
|
||||
if(!loadedRoads.length){showToast('Нет улиц. Нажмите "Авто" сначала');return;}
|
||||
const step=parseFloat(document.getElementById('poleStep').value)||50;
|
||||
const margin=parseFloat(document.getElementById('entranceMargin').value)||5;
|
||||
let totalPoles=0;
|
||||
loadedRoads.forEach(road=>{
|
||||
if(!road.name)return; // только именованные улицы
|
||||
const pts=road.coords;
|
||||
if(pts.length<2)return;
|
||||
const len=pathLength2(pts);
|
||||
const n=Math.max(1,Math.round(len/step));
|
||||
for(let i=0;i<=n;i++){
|
||||
const pt=pointOnPath2(pts,i/n);
|
||||
if(!isNearEntrance(pt,margin)){
|
||||
addPole(L.latLng(pt.lat,pt.lng));
|
||||
totalPoles++;
|
||||
}
|
||||
}
|
||||
});
|
||||
document.getElementById('autoPanel').style.display='none';
|
||||
showToast('✅ Расставлено '+totalPoles+' опор вдоль '+loadedRoads.length+' улиц');
|
||||
}
|
||||
function pathLength2(pts){let t=0;for(let i=1;i<pts.length;i++)t+=haversine(pts[i-1][0],pts[i-1][1],pts[i][0],pts[i][1]);return t;}
|
||||
function pointOnPath2(pts,t){
|
||||
const total=pathLength2(pts);
|
||||
let target=t*total,acc=0;
|
||||
for(let i=1;i<pts.length;i++){
|
||||
const seg=haversine(pts[i-1][0],pts[i-1][1],pts[i][0],pts[i][1]);
|
||||
if(acc+seg>=target||i===pts.length-1){
|
||||
const frac=(target-acc)/seg;
|
||||
return{lat:pts[i-1][0]+(pts[i][0]-pts[i-1][0])*frac,lng:pts[i-1][1]+(pts[i][1]-pts[i-1][1])*frac};
|
||||
}
|
||||
acc+=seg;
|
||||
}
|
||||
return{lat:pts[pts.length-1][0],lng:pts[pts.length-1][1]};
|
||||
}
|
||||
|
||||
// ====================== РЕЖИМЫ ======================
|
||||
function setMode(newMode){
|
||||
mode=newMode;
|
||||
['btnLine','btnManual','btnEntrance'].forEach(id=>document.getElementById(id).className='');
|
||||
document.getElementById('btnLine').className='btn-line';
|
||||
document.getElementById('btnManual').className='btn-add';
|
||||
document.getElementById('btnEntrance').className='btn-driveway';
|
||||
document.getElementById('btnLine').className='btn-primary';
|
||||
document.getElementById('btnManual').className='btn-secondary';
|
||||
document.getElementById('btnEntrance').className='btn-warning';
|
||||
const el=document.getElementById({line:'btnLine',manual:'btnManual',entrance:'btnEntrance'}[mode]);
|
||||
if(el)el.classList.add('btn-active');
|
||||
if(mode==='line')linePoints=[];
|
||||
document.getElementById('modeInfo').textContent={
|
||||
line:'📏 Линия: клик по улице → Enter для расстановки',
|
||||
line:'📏 Линия: клик по улице, Enter = расставить',
|
||||
manual:'🎯 Клик для установки опоры',
|
||||
entrance:'🚗 Клик для отметки въезда'}[mode];
|
||||
updateModeBadge();
|
||||
}
|
||||
function updateModeBadge(){
|
||||
const b=document.getElementById('modeBadge');
|
||||
const msgs={
|
||||
b.style.display='block';
|
||||
b.innerHTML={
|
||||
manual:'🎯 Кликните для опоры',
|
||||
line:'📏 Клик точки линии улицы · Enter = расставить',
|
||||
entrance:'🚗 Клик на въезде во двор'
|
||||
};
|
||||
b.style.display='block';b.innerHTML=msgs[mode];
|
||||
line:'📏 Клик точки линии · Enter = расставить',
|
||||
entrance:'🚗 Клик на въезде'
|
||||
}[mode];
|
||||
}
|
||||
|
||||
// ====================== КЛИК ======================
|
||||
function onMapClick(e){
|
||||
if(mode==='manual'){addPole(e.latlng);return;}
|
||||
if(mode==='manual'){
|
||||
const snap=document.getElementById('snapRoad').checked;
|
||||
if(snap&&loadedRoads.length){const pt=snapToRoad(e.latlng);if(pt)addPole(pt);else addPole(e.latlng);}
|
||||
else addPole(e.latlng);
|
||||
return;
|
||||
}
|
||||
if(mode==='entrance'){addEntrance(e.latlng);return;}
|
||||
if(mode==='line'){addLinePoint(e.latlng);return;}
|
||||
}
|
||||
|
||||
// ====================== ПРИВЯЗКА К ДОРОГЕ ======================
|
||||
function snapToRoad(latlng){
|
||||
let best=null,bestDist=Infinity;
|
||||
loadedRoads.forEach(road=>{
|
||||
for(let i=1;i<road.coords.length;i++){
|
||||
const a=road.coords[i-1],b=road.coords[i];
|
||||
const p=closestPointOnSegment(latlng.lat,latlng.lng,a[0],a[1],b[0],b[1]);
|
||||
if(p.dist<bestDist&&p.dist<30){bestDist=p.dist;best=L.latLng(p.lat,p.lng);}
|
||||
}
|
||||
});
|
||||
return best;
|
||||
}
|
||||
function closestPointOnSegment(px,py,ax,ay,bx,by){
|
||||
const dx=bx-ax,dy=by-ay;
|
||||
const len2=dx*dx+dy*dy;
|
||||
let t=len2>0?((px-ax)*dx+(py-ay)*dy)/len2:0;
|
||||
t=Math.max(0,Math.min(1,t));
|
||||
const lat=ax+t*dx,lng=ay+t*dy;
|
||||
return{lat,lng,dist:haversine(px,py,lat,lng)};
|
||||
}
|
||||
|
||||
// ====================== ОПОРЫ ======================
|
||||
function addPole(latlng){
|
||||
poleCount++;
|
||||
@ -239,7 +376,6 @@ function finishLine(){
|
||||
linePoints=[];
|
||||
if(!poles.length){showToast('❌ Всё перекрыто въездами');setMode('manual');return;}
|
||||
poles.forEach(p=>addPole(L.latLng(p.lat,p.lng)));
|
||||
showToast('✅ Расставлено '+poles.length+' опор, шаг ~'+step+' м');
|
||||
setMode('manual');
|
||||
}
|
||||
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;}
|
||||
@ -248,36 +384,32 @@ function pointOnPath(pts,t){
|
||||
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};
|
||||
}
|
||||
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,margin){
|
||||
for(const e of entrances)if(haversine(pt.lat,pt.lng,e.lat,e.lng)<margin)return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ====================== ВЪЕЗДЫ ======================
|
||||
function addEntrance(latlng){
|
||||
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});
|
||||
const circle=L.circle(latlng,{radius:5,color:'#FF9800',fillColor:'#FF9800',fillOpacity:0.15,dashArray:'3,3'}).addTo(map);
|
||||
entranceCircles.push(circle);
|
||||
showToast('🚗 Въезд отмечен');
|
||||
}
|
||||
function isNearEntrance(pt,margin){
|
||||
for(const e of entrances)if(haversine(pt.lat,pt.lng,e.lat,e.lng)<margin)return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ====================== РАССТОЯНИЕ ======================
|
||||
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));
|
||||
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)));
|
||||
}
|
||||
|
||||
// ====================== ТАБЛИЦА ======================
|
||||
@ -322,136 +454,59 @@ function clearAll(){
|
||||
}
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Enter'&&mode==='line')finishLine();});
|
||||
|
||||
// ====================== ЭКСПОРТ В SVG (Visio/draw.io) ======================
|
||||
// ====================== ЭКСПОРТ ======================
|
||||
function exportSVG(){
|
||||
if(!markers.length){showToast('Нет опор');return;}
|
||||
const first=markers[0];
|
||||
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>
|
||||
<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}.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="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>`;
|
||||
markers.forEach((p,i)=>{if(i>0){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"/><text x="${x}" y="${y}" class="pole-label">${p.id}</text><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"/><text x="${x}" y="${y+3}" font-size="8" fill="#fff" text-anchor="middle" font-weight="bold">В</text>`;});
|
||||
let total=0;markers.forEach((p,i)=>{if(i>0)total+=haversine(markers[i-1].lat,markers[i-1].lng,p.lat,p.lng);});
|
||||
svg+=`<text x="20" y="580" class="sub">Всего: ${total.toFixed(1)} м | ${markers.length} опор | ср.пролёт: ${markers.length>1?(total/(markers.length-1)).toFixed(1):0} м</text></svg>`;
|
||||
download(svg,'opory_vl04.svg','image/svg+xml');
|
||||
showToast('📥 SVG экспортирован (открывается в Visio)');
|
||||
showToast('📥 SVG (Visio/draw.io)');
|
||||
}
|
||||
|
||||
// ====================== ЭКСПОРТ В 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 кВ&nbsp;<br>${date}&nbsp;|&nbsp;${markers.length} опор&nbsp;|&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>`;
|
||||
// масштаб
|
||||
let mx='<mxfile><diagram name="Опоры ВЛ-0.4 кВ" id="pole-diagram"><mxGraphModel dx="800" dy="600"><root><mxCell id="0"/><mxCell id="1" parent="0"/>';
|
||||
mx+=`<mxCell id="t" value="Схема расстановки опор ВЛ-0.4 кВ | ${date} | ${markers.length} опор" style="text;html=1;fontSize=14;fontWeight=bold;align=left;" vertex="1" parent="1"><mxGeometry x="20" y="10" width="500" height="30" 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="<b>${p.id}</b>" 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}&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>`;
|
||||
const sy=y=>560-(y-minLat)/(maxLat-minLat||1)*520;
|
||||
let id=100;
|
||||
markers.forEach((p,i)=>{if(i>0){const d=haversine(markers[i-1].lat,markers[i-1].lng,p.lat,p.lng).toFixed(1);id++;mx+=`<mxCell id="l${id}" 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="<b>${p.id}</b>" 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><mxCell id="pl${p.id}" value="${p.name}" style="text;html=1;fontSize=9;align=left;" 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=#fff;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;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 экспортирован');
|
||||
showToast('📥 draw.io');
|
||||
}
|
||||
|
||||
// ====================== CSV / JSON ======================
|
||||
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';
|
||||
});
|
||||
markers.forEach((p,i)=>{const d=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)+','+d+'\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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user