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: "© CARTO" },
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 = '';
villageSel.innerHTML = '';
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 = '';
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 findVillageData(name) {
const q = name.toLowerCase().trim();
for (const o of KZ_DATA) {
for (const r of o.rayons) {
for (const v of r.villages) {
if (v.toLowerCase() === q) {
return { village: v, rayon: r.rayon, oblast: o.oblast, center: r.center || o.center };
}
}
}
}
return null;
}
function goToVillage(name) {
if (!name) return;
const found = findVillageData(name);
if (found) {
oblastSel.value = found.oblast;
updateRayons();
rayonSel.value = found.rayon;
updateVillages();
villageSel.value = found.village;
map.setView(found.center, 13);
L.popup()
.setLatLng(found.center)
.setContent(`${found.village}
${found.oblast}, ${found.rayon}`)
.openOn(map);
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(`${name}
${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 = "
Не найдено";
return;
}
results.slice(0, 50).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(`${r.village}
${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: `${pole.id}
`,
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(`
${pole.label}
Тип: ${type.name}
Статус: ${statusLabel}
Координаты: ${pole.lat.toFixed(6)}, ${pole.lng.toFixed(6)}
`);
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: `${dist.toFixed(1)} м
`,
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 = '| Нет опор. Кликните на карту, чтобы добавить. |
';
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 = `
${p.id} |
${type.name} |
${statusLabel} |
${p.lat.toFixed(6)} |
${p.lng.toFixed(6)} |
${prevDist} |
|
`;
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 = ``;
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);