pole-map/js/app.js

487 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: "&copy; <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: "&copy; 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);