380 lines
13 KiB
JavaScript
380 lines
13 KiB
JavaScript
let map, drawnItems, poleLayer, connectionLayer;
|
||
let poles = [];
|
||
let poleCounter = 1;
|
||
let currentPoleType = "1";
|
||
|
||
const POLE_TYPES = {
|
||
"1": { name: "Промежуточная", color: "#2563eb", icon: "●" },
|
||
"2": { name: "Угловая", color: "#dc2626", icon: "◆" },
|
||
"3": { name: "С муфтой", color: "#ca8a04", icon: "◎" },
|
||
"4": { name: "С ОРКсп", color: "#16a34a", icon: "◇" },
|
||
"5": { name: "С муфтой и ОРКсп", color: "#7c3aed", icon: "★" },
|
||
};
|
||
|
||
function initApp() {
|
||
initMap();
|
||
initSelectors();
|
||
initPoleControls();
|
||
updateTable();
|
||
}
|
||
|
||
function initMap() {
|
||
map = L.map("map", { zoomControl: true }).setView([48.0, 68.0], 6);
|
||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||
attribution: "© OpenStreetMap",
|
||
maxZoom: 19,
|
||
}).addTo(map);
|
||
|
||
drawnItems = L.featureGroup().addTo(map);
|
||
poleLayer = L.featureGroup().addTo(map);
|
||
connectionLayer = L.featureGroup().addTo(map);
|
||
|
||
map.on("click", function (e) {
|
||
addPole(e.latlng.lat, e.latlng.lng);
|
||
});
|
||
}
|
||
|
||
function initSelectors() {
|
||
const oblastSel = document.getElementById("oblast");
|
||
const rayonSel = document.getElementById("rayon");
|
||
const villageSel = document.getElementById("village");
|
||
const searchInput = document.getElementById("searchInput");
|
||
|
||
KZ_DATA.forEach((o) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = o.oblast;
|
||
opt.textContent = o.oblast;
|
||
oblastSel.appendChild(opt);
|
||
});
|
||
|
||
function updateRayons() {
|
||
rayonSel.innerHTML = '<option value="">— Выберите район —</option>';
|
||
villageSel.innerHTML = '<option value="">— Выберите село —</option>';
|
||
const oblast = KZ_DATA.find((o) => o.oblast === oblastSel.value);
|
||
if (!oblast) return;
|
||
oblast.rayons.forEach((r) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = r.rayon;
|
||
opt.textContent = r.rayon;
|
||
rayonSel.appendChild(opt);
|
||
});
|
||
updateVillages();
|
||
}
|
||
|
||
function updateVillages() {
|
||
villageSel.innerHTML = '<option value="">— Выберите село —</option>';
|
||
const oblast = KZ_DATA.find((o) => o.oblast === oblastSel.value);
|
||
if (!oblast) return;
|
||
const rayon = oblast.rayons.find((r) => r.rayon === rayonSel.value);
|
||
if (!rayon) return;
|
||
rayon.villages.forEach((v) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = v;
|
||
opt.textContent = v;
|
||
villageSel.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
oblastSel.addEventListener("change", updateRayons);
|
||
rayonSel.addEventListener("change", updateVillages);
|
||
|
||
villageSel.addEventListener("change", function () {
|
||
const name = this.value;
|
||
if (!name) return;
|
||
const oblast = KZ_DATA.find((o) => o.oblast === oblastSel.value);
|
||
if (!oblast) return;
|
||
map.setView(oblast.center, 10);
|
||
L.popup()
|
||
.setLatLng(oblast.center)
|
||
.setContent(`<b>${name}</b><br>${oblast.oblast}, ${rayonSel.value}`)
|
||
.openOn(map);
|
||
});
|
||
|
||
searchInput.addEventListener("input", function () {
|
||
const q = this.value.toLowerCase().trim();
|
||
if (q.length < 2) return;
|
||
const results = [];
|
||
KZ_DATA.forEach((o) => {
|
||
o.rayons.forEach((r) => {
|
||
r.villages.forEach((v) => {
|
||
if (v.toLowerCase().includes(q)) {
|
||
results.push({ village: v, rayon: r.rayon, oblast: o.oblast, center: o.center });
|
||
}
|
||
});
|
||
});
|
||
});
|
||
const list = document.getElementById("searchResults");
|
||
list.innerHTML = "";
|
||
if (results.length === 0 || results.length > 50) {
|
||
if (results.length > 50) list.innerHTML = "<li class='px-3 py-2 text-gray-500'>Слишком много результатов, уточните</li>";
|
||
return;
|
||
}
|
||
results.slice(0, 20).forEach((r) => {
|
||
const li = document.createElement("li");
|
||
li.className = "px-3 py-2 cursor-pointer hover:bg-cyan-50";
|
||
li.textContent = `${r.village} — ${r.rayon}, ${r.oblast}`;
|
||
li.addEventListener("click", () => {
|
||
oblastSel.value = r.oblast;
|
||
updateRayons();
|
||
rayonSel.value = r.rayon;
|
||
updateVillages();
|
||
villageSel.value = r.village;
|
||
map.setView(r.center, 12);
|
||
L.popup()
|
||
.setLatLng(r.center)
|
||
.setContent(`<b>${r.village}</b><br>${r.oblast}, ${r.rayon}`)
|
||
.openOn(map);
|
||
list.innerHTML = "";
|
||
searchInput.value = r.village;
|
||
});
|
||
list.appendChild(li);
|
||
});
|
||
});
|
||
|
||
document.addEventListener("click", function (e) {
|
||
if (!e.target.closest("#searchResults") && !e.target.closest("#searchInput")) {
|
||
document.getElementById("searchResults").innerHTML = "";
|
||
}
|
||
});
|
||
}
|
||
|
||
function initPoleControls() {
|
||
document.querySelectorAll(".pole-type-btn").forEach((btn) => {
|
||
btn.addEventListener("click", function () {
|
||
document.querySelectorAll(".pole-type-btn").forEach((b) => b.classList.remove("active"));
|
||
this.classList.add("active");
|
||
currentPoleType = this.dataset.type;
|
||
});
|
||
});
|
||
document.querySelector('[data-type="1"]').classList.add("active");
|
||
}
|
||
|
||
function addPole(lat, lng) {
|
||
const type = POLE_TYPES[currentPoleType];
|
||
const id = poleCounter++;
|
||
const pole = { id, lat, lng, type: currentPoleType, label: `Опора ${id}` };
|
||
poles.push(pole);
|
||
renderPole(pole);
|
||
updateTable();
|
||
updateConnections();
|
||
}
|
||
|
||
function renderPole(pole) {
|
||
const type = POLE_TYPES[pole.type];
|
||
const icon = L.divIcon({
|
||
className: "pole-marker",
|
||
html: `<div style="
|
||
width:28px;height:28px;border-radius:50%;
|
||
background:${type.color};color:#fff;
|
||
display:flex;align-items:center;justify-content:center;
|
||
font-size:14px;font-weight:700;border:2px solid #fff;
|
||
box-shadow:0 2px 6px rgba(0,0,0,.3);
|
||
cursor:pointer;
|
||
" title="${pole.label} (${type.name})">${pole.id}</div>`,
|
||
iconSize: [28, 28],
|
||
iconAnchor: [14, 14],
|
||
});
|
||
|
||
const marker = L.marker([pole.lat, pole.lng], { icon }).addTo(poleLayer);
|
||
marker.bindPopup(`
|
||
<b>${pole.label}</b><br>
|
||
Тип: ${type.name}<br>
|
||
Координаты: ${pole.lat.toFixed(6)}, ${pole.lng.toFixed(6)}
|
||
<br><button onclick="removePole(${pole.id})" style="color:red;cursor:pointer;border:none;background:none;margin-top:4px">✕ Удалить</button>
|
||
`);
|
||
pole._marker = marker;
|
||
}
|
||
|
||
function removePole(id) {
|
||
const idx = poles.findIndex((p) => p.id === id);
|
||
if (idx === -1) return;
|
||
const pole = poles[idx];
|
||
if (pole._marker) poleLayer.removeLayer(pole._marker);
|
||
poles.splice(idx, 1);
|
||
poles.forEach((p, i) => {
|
||
p.id = i + 1;
|
||
p.label = `Опора ${i + 1}`;
|
||
});
|
||
poleCounter = poles.length + 1;
|
||
poleLayer.clearLayers();
|
||
poles.forEach((p) => renderPole(p));
|
||
updateTable();
|
||
updateConnections();
|
||
}
|
||
|
||
function updateConnections() {
|
||
connectionLayer.clearLayers();
|
||
if (poles.length < 2) return;
|
||
|
||
const sorted = [...poles].sort((a, b) => a.id - b.id);
|
||
for (let i = 0; i < sorted.length - 1; i++) {
|
||
const a = sorted[i];
|
||
const b = sorted[i + 1];
|
||
const polyline = L.polyline(
|
||
[
|
||
[a.lat, a.lng],
|
||
[b.lat, b.lng],
|
||
],
|
||
{ color: "#64748b", weight: 2, dashArray: "6,4", opacity: 0.7 }
|
||
).addTo(connectionLayer);
|
||
|
||
const midLat = (a.lat + b.lat) / 2;
|
||
const midLng = (a.lng + b.lng) / 2;
|
||
const dist = calcDistance(a.lat, a.lng, b.lat, b.lng);
|
||
L.marker([midLat, midLng], {
|
||
icon: L.divIcon({
|
||
className: "dist-label",
|
||
html: `<div style="
|
||
background:rgba(255,255,255,.9);padding:2px 8px;
|
||
border-radius:4px;font-size:12px;font-weight:600;
|
||
border:1px solid #ccc;white-space:nowrap;
|
||
">${dist.toFixed(1)} м</div>`,
|
||
iconSize: [0, 0],
|
||
iconAnchor: [0, 0],
|
||
}),
|
||
}).addTo(connectionLayer);
|
||
}
|
||
}
|
||
|
||
function updateTable() {
|
||
document.getElementById("poleCount").textContent = poles.length;
|
||
const tbody = document.querySelector("#poleTable tbody");
|
||
tbody.innerHTML = "";
|
||
if (poles.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-gray-500 py-4">Нет опор. Кликните на карту, чтобы добавить.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
const sorted = [...poles].sort((a, b) => a.id - b.id);
|
||
sorted.forEach((p, i) => {
|
||
const type = POLE_TYPES[p.type];
|
||
const prevDist =
|
||
i > 0
|
||
? calcDistance(sorted[i - 1].lat, sorted[i - 1].lng, p.lat, p.lng).toFixed(1)
|
||
: "—";
|
||
const tr = document.createElement("tr");
|
||
tr.className = "border-b border-gray-200 hover:bg-gray-50";
|
||
tr.innerHTML = `
|
||
<td class="px-4 py-2 font-medium">${p.id}</td>
|
||
<td class="px-4 py-2"><span class="pole-badge" style="background:${type.color}">${type.name}</span></td>
|
||
<td class="px-4 py-2 font-mono text-sm">${p.lat.toFixed(6)}</td>
|
||
<td class="px-4 py-2 font-mono text-sm">${p.lng.toFixed(6)}</td>
|
||
<td class="px-4 py-2 text-right font-mono">${prevDist}</td>
|
||
<td class="px-4 py-2 text-center">
|
||
<button onclick="removePole(${p.id})" class="text-red-500 hover:text-red-700 text-sm" title="Удалить">✕</button>
|
||
</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
function calcDistance(lat1, lon1, lat2, lon2) {
|
||
const R = 6371000;
|
||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||
const a =
|
||
Math.sin(dLat / 2) ** 2 +
|
||
Math.cos((lat1 * Math.PI) / 180) *
|
||
Math.cos((lat2 * Math.PI) / 180) *
|
||
Math.sin(dLon / 2) ** 2;
|
||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||
}
|
||
|
||
function clearAll() {
|
||
if (poles.length === 0) return;
|
||
if (!confirm("Удалить все опоры?")) return;
|
||
poleLayer.clearLayers();
|
||
connectionLayer.clearLayers();
|
||
poles = [];
|
||
poleCounter = 1;
|
||
updateTable();
|
||
}
|
||
|
||
function exportSVG() {
|
||
if (poles.length === 0) return alert("Нет опор для экспорта");
|
||
|
||
const padding = 50;
|
||
const lats = poles.map((p) => p.lat);
|
||
const lngs = poles.map((p) => p.lng);
|
||
const minLat = Math.min(...lats);
|
||
const maxLat = Math.max(...lats);
|
||
const minLng = Math.min(...lngs);
|
||
const maxLng = Math.max(...lngs);
|
||
const latRange = maxLat - minLat || 0.001;
|
||
const lngRange = maxLng - minLng || 0.001;
|
||
|
||
const scale = 500 / Math.max(latRange, lngRange);
|
||
const toX = (lng) => padding + ((lng - minLng) / lngRange) * 500;
|
||
const toY = (lat) => padding + ((maxLat - lat) / latRange) * 500;
|
||
const w = 500 + padding * 2;
|
||
const h = 500 + padding * 2;
|
||
|
||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">
|
||
<style>
|
||
.pole-label{font:bold 10px sans-serif;text-anchor:middle;fill:#fff}
|
||
.dist-label{font:9px sans-serif;text-anchor:middle;fill:#333}
|
||
.title{font:14px sans-serif;font-weight:700;fill:#333}
|
||
</style>
|
||
<rect width="100%" height="100%" fill="#f8f9fa"/>
|
||
<text x="${w / 2}" y="24" text-anchor="middle" class="title">Схема расположения опор</text>`;
|
||
|
||
const sorted = [...poles].sort((a, b) => a.id - b.id);
|
||
|
||
// connections
|
||
for (let i = 0; i < sorted.length - 1; i++) {
|
||
const a = sorted[i],
|
||
b = sorted[i + 1];
|
||
const dist = calcDistance(a.lat, a.lng, b.lat, b.lng).toFixed(1);
|
||
const mx = (toX(a.lng) + toX(b.lng)) / 2;
|
||
const my = (toY(a.lat) + toY(b.lat)) / 2;
|
||
svg += `<line x1="${toX(a.lng)}" y1="${toY(a.lat)}" x2="${toX(b.lng)}" y2="${toY(b.lat)}" stroke="#94a3b8" stroke-width="1.5" stroke-dasharray="5,3"/>`;
|
||
svg += `<text x="${mx}" y="${my - 6}" class="dist-label">${dist} м</text>`;
|
||
}
|
||
|
||
// poles
|
||
sorted.forEach((p) => {
|
||
const type = POLE_TYPES[p.type];
|
||
const cx = toX(p.lng);
|
||
const cy = toY(p.lat);
|
||
svg += `<circle cx="${cx}" cy="${cy}" r="12" fill="${type.color}" stroke="#fff" stroke-width="2"/>`;
|
||
svg += `<text x="${cx}" y="${cy + 3}" class="pole-label">${p.id}</text>`;
|
||
svg += `<text x="${cx + 18}" y="${cy + 3}" font-size="8" fill="#666" font-family="sans-serif">${p.label} (${type.name})</text>`;
|
||
});
|
||
|
||
// Legend
|
||
const legendY = h - 40;
|
||
svg += `<rect x="${padding}" y="${legendY - 10}" width="${w - padding * 2}" height="2" fill="#e2e8f0"/>`;
|
||
let lx = padding;
|
||
Object.entries(POLE_TYPES).forEach(([k, t]) => {
|
||
svg += `<circle cx="${lx + 6}" cy="${legendY + 10}" r="6" fill="${t.color}" stroke="#ccc" stroke-width="1"/>`;
|
||
svg += `<text x="${lx + 16}" y="${legendY + 14}" font-size="9" fill="#555" font-family="sans-serif">${t.name}</text>`;
|
||
lx += 140;
|
||
});
|
||
|
||
svg += `</svg>`;
|
||
|
||
downloadFile(svg, "opory.svg", "image/svg+xml");
|
||
}
|
||
|
||
function exportVSD() {
|
||
alert(
|
||
"VSD (Visio) — бинарный формат. SVG можно открыть в Visio.\n\n" +
|
||
"Экспортируйте в SVG, затем откройте в Visio: Файл → Открыть → выберите .svg\n" +
|
||
"Или перетащите SVG-файл в окно Visio."
|
||
);
|
||
}
|
||
|
||
function downloadFile(content, filename, mime) {
|
||
const blob = new Blob([content], { type: mime });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", initApp);
|