diff --git a/index.html b/index.html index 75a8dc8..e5dc326 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,san .btn-primary:hover{background:#1e293b} .btn-secondary{background:transparent;color:#0f172a;padding:9px 22px;border-radius:8px;font-weight:500;border:2px solid #cbd5e1;cursor:pointer;transition:all .15s;font-size:14px} .btn-secondary:hover{border-color:#94a3b8} +.btn-icon{background:transparent;color:#0f172a;padding:9px 12px;border-radius:8px;font-weight:500;border:2px solid #cbd5e1;cursor:pointer;transition:all .15s;font-size:16px;line-height:1} +.btn-icon:hover{border-color:#94a3b8;background:#f8fafc} +.map-mode-btn{padding:6px 14px;border-radius:8px;border:2px solid #e2e8f0;cursor:pointer;font-size:12px;font-weight:500;transition:all .15s;background:#fff} +.map-mode-btn:hover{border-color:#94a3b8} +.map-mode-btn.active{border-color:#0f172a;background:#0f172a;color:#fff} input,select{width:100%;padding:10px 14px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;background:#fff;outline:none} input:focus,select:focus{border-color:#0f172a} table{width:100%;border-collapse:collapse;font-size:14px} @@ -38,12 +43,27 @@ table td{padding:10px 16px;border-bottom:1px solid #f1f5f9}
-
-

PoleMap

-

Расстановка опор связи на карте

+
+
+

PoleMap

+

Расстановка опор связи

+
+
+ + +
-
+
+ +
+ +
+ + +
+
+
@@ -52,36 +72,59 @@ table td{padding:10px 16px;border-bottom:1px solid #f1f5f9}
-
+
- +
- +
- +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
- +
- +
-

Кликните на карту, чтобы поставить опору выбранного типа

+

Кликните на карту, чтобы поставить опору

-
+
@@ -105,6 +148,7 @@ table td{padding:10px 16px;border-bottom:1px solid #f1f5f9} № Тип + Статус Широта Долгота Дист. до пред. @@ -112,7 +156,7 @@ table td{padding:10px 16px;border-bottom:1px solid #f1f5f9} - Кликните на карту, чтобы добавить опору + Кликните на карту, чтобы добавить опору
diff --git a/js/app.js b/js/app.js index 3a14a6a..66f4be2 100644 --- a/js/app.js +++ b/js/app.js @@ -1,7 +1,11 @@ -let map, drawnItems, poleLayer, connectionLayer; +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: "●" }, @@ -11,27 +15,82 @@ const POLE_TYPES = { "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); - L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { - attribution: "© OpenStreetMap", - maxZoom: 19, - }).addTo(map); + tileLayer = L.tileLayer(TILE_PROVIDERS.street.url, { attribution: TILE_PROVIDERS.street.attr, maxZoom: 19 }).addTo(map); - drawnItems = L.featureGroup().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() { @@ -39,6 +98,7 @@ function initSelectors() { 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"); @@ -55,10 +115,9 @@ function initSelectors() { oblast.rayons.forEach((r) => { const opt = document.createElement("option"); opt.value = r.rayon; - opt.textContent = r.rayon; + opt.textContent = `${r.rayon} (${r.villages.length} сёл)`; rayonSel.appendChild(opt); }); - updateVillages(); } function updateVillages() { @@ -78,20 +137,36 @@ function initSelectors() { oblastSel.addEventListener("change", updateRayons); rayonSel.addEventListener("change", updateVillages); - villageSel.addEventListener("change", function () { - const name = this.value; + function goToVillage(name) { if (!name) return; const oblast = KZ_DATA.find((o) => o.oblast === oblastSel.value); if (!oblast) return; - map.setView(oblast.center, 10); + map.setView(oblast.center, 12); L.popup() .setLatLng(oblast.center) .setContent(`${name}
${oblast.oblast}, ${rayonSel.value}`) .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) => { @@ -103,13 +178,11 @@ function initSelectors() { }); }); }); - const list = document.getElementById("searchResults"); - list.innerHTML = ""; - if (results.length === 0 || results.length > 50) { - if (results.length > 50) list.innerHTML = "
  • Слишком много результатов, уточните
  • "; + if (results.length === 0) { + list.innerHTML = "
  • Не найдено
  • "; return; } - results.slice(0, 20).forEach((r) => { + 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}`; @@ -147,16 +220,42 @@ function initPoleControls() { }); }); 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, label: `Опора ${id}` }; + const pole = { id, lat, lng, type: currentPoleType, status: currentPoleStatus, existingType: currentExistingType, label: `Опора ${id}` }; poles.push(pole); renderPole(pole); updateTable(); updateConnections(); + updateUndoButtons(); } function renderPole(pole) { @@ -176,21 +275,32 @@ function renderPole(pole) { }); 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 removePole(id) { +function removePoleById(id) { + saveHistory(); const idx = poles.findIndex((p) => p.id === id); if (idx === -1) return; - const pole = poles[idx]; - if (pole._marker) poleLayer.removeLayer(pole._marker); + 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}`; @@ -200,6 +310,7 @@ function removePole(id) { poles.forEach((p) => renderPole(p)); updateTable(); updateConnections(); + updateUndoButtons(); } function updateConnections() { @@ -210,11 +321,8 @@ function updateConnections() { 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], - ], + L.polyline( + [[a.lat, a.lng], [b.lat, b.lng]], { color: "#64748b", weight: 2, dashArray: "6,4", opacity: 0.7 } ).addTo(connectionLayer); @@ -224,11 +332,7 @@ function updateConnections() { L.marker([midLat, midLng], { icon: L.divIcon({ className: "dist-label", - html: `
    ${dist.toFixed(1)} м
    `, + html: `
    ${dist.toFixed(1)} м
    `, iconSize: [0, 0], iconAnchor: [0, 0], }), @@ -241,27 +345,27 @@ function updateTable() { const tbody = document.querySelector("#poleTable tbody"); tbody.innerHTML = ""; if (poles.length === 0) { - tbody.innerHTML = 'Нет опор. Кликните на карту, чтобы добавить.'; + 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 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); @@ -272,22 +376,21 @@ 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; + 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() { @@ -303,65 +406,56 @@ function exportSVG() { 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 = ` - - -Схема расположения опор`; - const sorted = [...poles].sort((a, b) => a.id - b.id); - // connections + let svg = ` + + +Схема расположения опор`; + for (let i = 0; i < sorted.length - 1; i++) { - const a = sorted[i], - b = sorted[i + 1]; + 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 += ``; - svg += `${dist} м`; + svg += `${dist} м`; } - // poles sorted.forEach((p) => { const type = POLE_TYPES[p.type]; - const cx = toX(p.lng); - const cy = toY(p.lat); + const cx = toX(p.lng), cy = toY(p.lat); svg += ``; - svg += `${p.id}`; - svg += `${p.label} (${type.name})`; + svg += `${p.id}`; + const stLabel = p.status === "existing" ? `сущ.${EXISTING_TYPES[p.existingType]}` : "проект."; + svg += `${p.label} (${type.name}, ${stLabel})`; }); - // Legend - const legendY = h - 40; - svg += ``; + const legY = h - 40; + svg += ``; let lx = padding; - Object.entries(POLE_TYPES).forEach(([k, t]) => { - svg += ``; - svg += `${t.name}`; + Object.entries(POLE_TYPES).forEach(([, t]) => { + svg += ``; + svg += `${t.name}`; lx += 140; }); svg += ``; - downloadFile(svg, "opory.svg", "image/svg+xml"); } function exportVSD() { - alert( - "VSD (Visio) — бинарный формат. SVG можно открыть в Visio.\n\n" + - "Экспортируйте в SVG, затем откройте в Visio: Файл → Открыть → выберите .svg\n" + - "Или перетащите SVG-файл в окно Visio." - ); + alert('VSD (Visio) — бинарный формат.\n\nЭкспортируйте в SVG, затем откройте SVG в Visio: Файл → Открыть → выберите .svg\nИли перетащите SVG-файл в окно Visio.'); } function downloadFile(content, filename, mime) {