487 lines
17 KiB
JavaScript
487 lines
17 KiB
JavaScript
let map, poleLayer, connectionLayer, tileLayer;
|
||
let poles = [];
|
||
let poleCounter = 1;
|
||
let currentPoleType = "1";
|
||
let currentPoleStatus = "projected";
|
||
let currentExistingType = "communication";
|
||
let history = [];
|
||
let historyIndex = -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: "★" },
|
||
};
|
||
|
||
const TILE_PROVIDERS = {
|
||
street: { name: "Схема", url: "https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", attr: "© <a href='https://carto.com'>CARTO</a>" },
|
||
satellite: { name: "Спутник", url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", attr: "© Esri" },
|
||
};
|
||
|
||
function initApp() {
|
||
initMap();
|
||
initSelectors();
|
||
initPoleControls();
|
||
updateTable();
|
||
updateUndoButtons();
|
||
}
|
||
|
||
function initMap() {
|
||
map = L.map("map", { zoomControl: true }).setView([48.0, 68.0], 6);
|
||
tileLayer = L.tileLayer(TILE_PROVIDERS.street.url, { attribution: TILE_PROVIDERS.street.attr, maxZoom: 19 }).addTo(map);
|
||
|
||
poleLayer = L.featureGroup().addTo(map);
|
||
connectionLayer = L.featureGroup().addTo(map);
|
||
|
||
map.on("click", function (e) {
|
||
saveHistory();
|
||
addPole(e.latlng.lat, e.latlng.lng);
|
||
});
|
||
|
||
document.querySelectorAll(".map-mode-btn").forEach((btn) => {
|
||
btn.addEventListener("click", function () {
|
||
document.querySelectorAll(".map-mode-btn").forEach((b) => b.classList.remove("active"));
|
||
this.classList.add("active");
|
||
const mode = this.dataset.mode;
|
||
const provider = TILE_PROVIDERS[mode];
|
||
tileLayer.setUrl(provider.url);
|
||
tileLayer.setAttribution(provider.attr);
|
||
});
|
||
});
|
||
}
|
||
|
||
function saveHistory() {
|
||
const snapshot = poles.map((p) => ({ ...p }));
|
||
if (historyIndex < history.length - 1) {
|
||
history = history.slice(0, historyIndex + 1);
|
||
}
|
||
history.push(snapshot);
|
||
historyIndex = history.length - 1;
|
||
}
|
||
|
||
function undo() {
|
||
if (historyIndex < 0) return;
|
||
historyIndex--;
|
||
restoreHistory();
|
||
}
|
||
|
||
function redo() {
|
||
if (historyIndex >= history.length - 1) return;
|
||
historyIndex++;
|
||
restoreHistory();
|
||
}
|
||
|
||
function restoreHistory() {
|
||
if (historyIndex < 0 || historyIndex >= history.length) {
|
||
poles = [];
|
||
} else {
|
||
poles = history[historyIndex].map((p) => ({ ...p }));
|
||
}
|
||
poleCounter = poles.length > 0 ? Math.max(...poles.map((p) => p.id)) + 1 : 1;
|
||
poleLayer.clearLayers();
|
||
connectionLayer.clearLayers();
|
||
poles.forEach((p) => renderPole(p));
|
||
updateTable();
|
||
updateConnections();
|
||
updateUndoButtons();
|
||
}
|
||
|
||
function updateUndoButtons() {
|
||
document.getElementById("undoBtn").style.opacity = historyIndex >= 0 ? "1" : "0.3";
|
||
document.getElementById("redoBtn").style.opacity = historyIndex < history.length - 1 ? "1" : "0.3";
|
||
}
|
||
|
||
function initSelectors() {
|
||
const oblastSel = document.getElementById("oblast");
|
||
const rayonSel = document.getElementById("rayon");
|
||
const villageSel = document.getElementById("village");
|
||
const searchInput = document.getElementById("searchInput");
|
||
const manualVillage = document.getElementById("manualVillage");
|
||
|
||
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} (${r.villages.length} сёл)`;
|
||
rayonSel.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
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", function() {
|
||
updateRayons();
|
||
const oblast = KZ_DATA.find((o) => o.oblast === this.value);
|
||
if (oblast) map.setView(oblast.center, 8);
|
||
});
|
||
rayonSel.addEventListener("change", function() {
|
||
updateVillages();
|
||
const oblast = KZ_DATA.find((o) => o.oblast === oblastSel.value);
|
||
if (!oblast) return;
|
||
const rayon = oblast.rayons.find((r) => r.rayon === this.value);
|
||
const center = (rayon && rayon.center) ? rayon.center : (oblast ? oblast.center : null);
|
||
if (center) map.setView(center, 10);
|
||
});
|
||
|
||
function goToVillage(name) {
|
||
if (!name) return;
|
||
const oblast = KZ_DATA.find((o) => o.oblast === oblastSel.value);
|
||
if (!oblast) return;
|
||
const rayon = oblast.rayons.find((r) => r.rayon === rayonSel.value);
|
||
const center = (rayon && rayon.center) ? rayon.center : oblast.center;
|
||
map.setView(center, 12);
|
||
L.popup()
|
||
.setLatLng(center)
|
||
.setContent(`<b>${name}</b><br>${oblast.oblast}, ${rayon ? rayon.rayon : ''}`)
|
||
.openOn(map);
|
||
}
|
||
|
||
villageSel.addEventListener("change", function () {
|
||
goToVillage(this.value);
|
||
});
|
||
|
||
manualVillage.addEventListener("keydown", function (e) {
|
||
if (e.key === "Enter" && this.value.trim()) {
|
||
goToVillage(this.value.trim());
|
||
}
|
||
});
|
||
manualVillage.addEventListener("blur", function () {
|
||
if (this.value.trim()) {
|
||
goToVillage(this.value.trim());
|
||
}
|
||
});
|
||
|
||
searchInput.addEventListener("input", function () {
|
||
const q = this.value.toLowerCase().trim();
|
||
const list = document.getElementById("searchResults");
|
||
list.innerHTML = "";
|
||
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: r.center || o.center });
|
||
}
|
||
});
|
||
});
|
||
});
|
||
if (results.length === 0) {
|
||
list.innerHTML = "<li class='px-3 py-2 text-gray-500'>Не найдено</li>";
|
||
return;
|
||
}
|
||
results.slice(0, 30).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");
|
||
|
||
document.querySelectorAll(".pole-status-btn").forEach((btn) => {
|
||
btn.addEventListener("click", function () {
|
||
document.querySelectorAll(".pole-status-btn").forEach((b) => b.classList.remove("active"));
|
||
this.classList.add("active");
|
||
currentPoleStatus = this.dataset.status;
|
||
const existingTypeSel = document.getElementById("existingTypeGroup");
|
||
existingTypeSel.style.display = currentPoleStatus === "existing" ? "block" : "none";
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll(".existing-type-btn").forEach((btn) => {
|
||
btn.addEventListener("click", function () {
|
||
document.querySelectorAll(".existing-type-btn").forEach((b) => b.classList.remove("active"));
|
||
this.classList.add("active");
|
||
currentExistingType = this.dataset.existing;
|
||
});
|
||
});
|
||
document.querySelector('[data-status="projected"]').classList.add("active");
|
||
}
|
||
|
||
const EXISTING_TYPES = {
|
||
communication: "опора связи",
|
||
power: "ЛЭП",
|
||
lighting: "освещения",
|
||
};
|
||
|
||
function addPole(lat, lng) {
|
||
const type = POLE_TYPES[currentPoleType];
|
||
const id = poleCounter++;
|
||
const pole = { id, lat, lng, type: currentPoleType, status: currentPoleStatus, existingType: currentExistingType, label: `Опора ${id}` };
|
||
poles.push(pole);
|
||
renderPole(pole);
|
||
updateTable();
|
||
updateConnections();
|
||
updateUndoButtons();
|
||
}
|
||
|
||
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);
|
||
const statusLabel = pole.status === "existing" ? `Существующая (${EXISTING_TYPES[pole.existingType] || pole.existingType})` : "Проектируемая";
|
||
marker.bindPopup(`
|
||
<b>${pole.label}</b><br>
|
||
Тип: ${type.name}<br>
|
||
Статус: ${statusLabel}<br>
|
||
Координаты: ${pole.lat.toFixed(6)}, ${pole.lng.toFixed(6)}
|
||
<br><button onclick="removePoleById(${pole.id})" style="color:red;cursor:pointer;border:none;background:none;margin-top:4px">✕ Удалить</button>
|
||
`);
|
||
pole._marker = marker;
|
||
}
|
||
|
||
function removePoleById(id) {
|
||
saveHistory();
|
||
const idx = poles.findIndex((p) => p.id === id);
|
||
if (idx === -1) return;
|
||
if (poles[idx]._marker) poleLayer.removeLayer(poles[idx]._marker);
|
||
poles.splice(idx, 1);
|
||
renumberPoles();
|
||
}
|
||
|
||
function removePoleFromTable(id) {
|
||
saveHistory();
|
||
removePoleById(id);
|
||
}
|
||
|
||
function renumberPoles() {
|
||
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();
|
||
updateUndoButtons();
|
||
}
|
||
|
||
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];
|
||
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="7" 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 statusLabel = p.status === "existing" ? `Сущ. (${EXISTING_TYPES[p.existingType] || p.existingType})` : "Проект.";
|
||
const statusColor = p.status === "existing" ? "#f59e0b" : "#10b981";
|
||
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"><span class="pole-badge" style="background:${statusColor}">${statusLabel}</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="removePoleFromTable(${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;
|
||
saveHistory();
|
||
poleLayer.clearLayers();
|
||
connectionLayer.clearLayers();
|
||
poles = [];
|
||
poleCounter = 1;
|
||
updateTable();
|
||
updateConnections();
|
||
updateUndoButtons();
|
||
}
|
||
|
||
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 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;
|
||
|
||
const sorted = [...poles].sort((a, b) => a.id - b.id);
|
||
|
||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">
|
||
<style>
|
||
.lbl{font:bold 10px sans-serif;text-anchor:middle;fill:#fff}
|
||
.dl{font:9px sans-serif;text-anchor:middle;fill:#333}
|
||
.tl{font:14px sans-serif;font-weight:700;fill:#333}
|
||
.ll{font:9px sans-serif;fill:#555}
|
||
</style>
|
||
<rect width="100%" height="100%" fill="#f8f9fa"/>
|
||
<text x="${w/2}" y="24" text-anchor="middle" class="tl">Схема расположения опор</text>`;
|
||
|
||
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="dl">${dist} м</text>`;
|
||
}
|
||
|
||
sorted.forEach((p) => {
|
||
const type = POLE_TYPES[p.type];
|
||
const cx = toX(p.lng), 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="lbl">${p.id}</text>`;
|
||
const stLabel = p.status === "existing" ? `сущ.${EXISTING_TYPES[p.existingType]}` : "проект.";
|
||
svg += `<text x="${cx+18}" y="${cy+3}" class="ll">${p.label} (${type.name}, ${stLabel})</text>`;
|
||
});
|
||
|
||
const legY = h - 40;
|
||
svg += `<rect x="${padding}" y="${legY-10}" width="${w-padding*2}" height="2" fill="#e2e8f0"/>`;
|
||
let lx = padding;
|
||
Object.entries(POLE_TYPES).forEach(([, t]) => {
|
||
svg += `<circle cx="${lx+6}" cy="${legY+10}" r="6" fill="${t.color}" stroke="#ccc" stroke-width="1"/>`;
|
||
svg += `<text x="${lx+16}" y="${legY+14}" class="ll">${t.name}</text>`;
|
||
lx += 140;
|
||
});
|
||
|
||
svg += `</svg>`;
|
||
downloadFile(svg, "opory.svg", "image/svg+xml");
|
||
}
|
||
|
||
function exportVSD() {
|
||
alert('VSD (Visio) — бинарный формат.\n\nЭкспортируйте в SVG, затем откройте 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);
|