Compare commits

...

7 Commits
main ... pages

4 changed files with 16249 additions and 328 deletions

View File

@ -22,8 +22,16 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,san
.btn-primary:hover{background:#1e293b} .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{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-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,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} input:focus,select:focus{border-color:#0f172a}
select{max-height:200px;overflow-y:auto}
#village option,#rayon option{padding:4px 6px;font-size:13px}
#searchResults{max-height:300px;overflow-y:auto}
table{width:100%;border-collapse:collapse;font-size:14px} table{width:100%;border-collapse:collapse;font-size:14px}
table th{text-align:left;padding:10px 16px;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#64748b;border-bottom:2px solid #e2e8f0} table th{text-align:left;padding:10px 16px;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#64748b;border-bottom:2px solid #e2e8f0}
table td{padding:10px 16px;border-bottom:1px solid #f1f5f9} table td{padding:10px 16px;border-bottom:1px solid #f1f5f9}
@ -38,12 +46,27 @@ table td{padding:10px 16px;border-bottom:1px solid #f1f5f9}
<div class="flex h-screen overflow-hidden"> <div class="flex h-screen overflow-hidden">
<!-- Sidebar --> <!-- Sidebar -->
<div class="w-[420px] min-w-[420px] bg-white border-r border-gray-200 flex flex-col overflow-hidden"> <div class="w-[420px] min-w-[420px] bg-white border-r border-gray-200 flex flex-col overflow-hidden">
<div class="px-5 py-4 border-b border-gray-200"> <div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
<div>
<h1 class="text-xl font-bold">PoleMap</h1> <h1 class="text-xl font-bold">PoleMap</h1>
<p class="text-sm text-gray-500 mt-1">Расстановка опор связи на карте</p> <p class="text-sm text-gray-500 mt-1">Расстановка опор связи</p>
</div>
<div class="flex gap-1">
<button id="undoBtn" class="btn-icon" onclick="undo()" title="Откатить"></button>
<button id="redoBtn" class="btn-icon" onclick="redo()" title="Вернуть"></button>
</div>
</div>
<div class="flex-1 overflow-y-auto scrollbar-thin px-5 py-4 space-y-4">
<!-- Map mode -->
<div>
<label class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-1.5 block">Вид карты</label>
<div class="flex gap-2">
<button class="map-mode-btn active" data-mode="street">🗺 Схема</button>
<button class="map-mode-btn" data-mode="satellite">🛰 Спутник</button>
</div>
</div> </div>
<div class="flex-1 overflow-y-auto scrollbar-thin px-5 py-4 space-y-5">
<!-- Search --> <!-- Search -->
<div class="relative"> <div class="relative">
<label class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-1.5 block">Поиск села</label> <label class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-1.5 block">Поиск села</label>
@ -52,36 +75,59 @@ table td{padding:10px 16px;border-bottom:1px solid #f1f5f9}
</div> </div>
<!-- Selectors --> <!-- Selectors -->
<div class="space-y-3"> <div class="space-y-2">
<div> <div>
<label class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-1.5 block">Область</label> <label class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-1 block">Область</label>
<select id="oblast"><option value="">— Выберите область —</option></select> <select id="oblast"><option value="">— Выберите область —</option></select>
</div> </div>
<div> <div>
<label class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-1.5 block">Район</label> <label class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-1 block">Район</label>
<select id="rayon"><option value="">— Выберите район —</option></select> <select id="rayon"><option value="">— Выберите район —</option></select>
</div> </div>
<div> <div>
<label class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-1.5 block">Село</label> <label class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-1 block">Село</label>
<select id="village"><option value="">— Выберите село —</option></select> <select id="village"><option value="">— Выберите село —</option></select>
</div> </div>
<div>
<label class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-1 block">Или введите название</label>
<input id="manualVillage" type="text" placeholder="Например: Косшы, Жетыген..." autocomplete="off">
</div>
</div>
<!-- Pole status -->
<div>
<label class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2 block">Статус опоры</label>
<div class="flex gap-2">
<button class="pole-status-btn active" data-status="projected" style="padding:7px 16px;border-radius:8px;border:2px solid #e2e8f0;cursor:pointer;font-size:13px;font-weight:500;background:#fff;flex:1">🆕 Проектируемая</button>
<button class="pole-status-btn" data-status="existing" style="padding:7px 16px;border-radius:8px;border:2px solid #e2e8f0;cursor:pointer;font-size:13px;font-weight:500;background:#fff;flex:1">🏗 Существующая</button>
</div>
</div>
<!-- Existing type (shown when existing selected) -->
<div id="existingTypeGroup" style="display:none">
<label class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2 block">Тип существующей опоры</label>
<div class="flex gap-1">
<button class="existing-type-btn active" data-existing="communication" style="padding:6px 10px;border-radius:8px;border:2px solid #e2e8f0;cursor:pointer;font-size:12px;font-weight:500;background:#fff;flex:1">📡 Связи</button>
<button class="existing-type-btn" data-existing="power" style="padding:6px 10px;border-radius:8px;border:2px solid #e2e8f0;cursor:pointer;font-size:12px;font-weight:500;background:#fff;flex:1">⚡ ЛЭП</button>
<button class="existing-type-btn" data-existing="lighting" style="padding:6px 10px;border-radius:8px;border:2px solid #e2e8f0;cursor:pointer;font-size:12px;font-weight:500;background:#fff;flex:1">💡 Освещения</button>
</div>
</div> </div>
<!-- Pole types --> <!-- Pole types -->
<div> <div>
<label class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2 block">Тип опоры</label> <label class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2 block">Тип опоры (конструкция)</label>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<button class="pole-type-btn" data-type="1"><span class="dot" style="background:#2563eb"></span>Промежуточная</button> <button class="pole-type-btn" data-type="1"><span class="dot" style="background:#2563eb"></span>Промежуточная</button>
<button class="pole-type-btn" data-type="2"><span class="dot" style="background:#dc2626"></span>Угловая</button> <button class="pole-type-btn" data-type="2"><span class="dot" style="background:#dc2626"></span>Угловая</button>
<button class="pole-type-btn" data-type="3"><span class="dot" style="background:#ca8a04"></span>С муфтой</button> <button class="pole-type-btn" data-type="3"><span class="dot" style="background:#ca8a04"></span>С муфтой</button>
<button class="pole-type-btn" data-type="4"><span class="dot" style="background:#16a34a"></span>С ОРКсп</button> <button class="pole-type-btn" data-type="4"><span class="dot" style="background:#16a34a"></span>С ОРКсп</button>
<button class="pole-type-btn" data-type="5"><span class="dot" style="background:#7c3aed"></span>С муфтой и ОРКсп</button> <button class="pole-type-btn col-span-2" data-type="5"><span class="dot" style="background:#7c3aed"></span>С муфтой и ОРКсп</button>
</div> </div>
<p class="text-xs text-gray-400 mt-2">Кликните на карту, чтобы поставить опору выбранного типа</p> <p class="text-xs text-gray-400 mt-2">Кликните на карту, чтобы поставить опору</p>
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="flex gap-2"> <div class="flex gap-2 pt-2">
<button class="btn-primary flex-1" onclick="exportSVG()">📥 SVG</button> <button class="btn-primary flex-1" onclick="exportSVG()">📥 SVG</button>
<button class="btn-secondary flex-1" onclick="exportVSD()">📥 VSD</button> <button class="btn-secondary flex-1" onclick="exportVSD()">📥 VSD</button>
<button class="btn-secondary px-3" onclick="clearAll()" title="Очистить всё">🗑</button> <button class="btn-secondary px-3" onclick="clearAll()" title="Очистить всё">🗑</button>
@ -105,6 +151,7 @@ table td{padding:10px 16px;border-bottom:1px solid #f1f5f9}
<tr> <tr>
<th class="w-14"></th> <th class="w-14"></th>
<th>Тип</th> <th>Тип</th>
<th>Статус</th>
<th>Широта</th> <th>Широта</th>
<th>Долгота</th> <th>Долгота</th>
<th class="text-right">Дист. до пред.</th> <th class="text-right">Дист. до пред.</th>
@ -112,7 +159,7 @@ table td{padding:10px 16px;border-bottom:1px solid #f1f5f9}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr><td colspan="6" class="text-center text-gray-500 py-4">Кликните на карту, чтобы добавить опору</td></tr> <tr><td colspan="7" class="text-center text-gray-500 py-4">Кликните на карту, чтобы добавить опору</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>

317
js/app.js
View File

@ -1,7 +1,11 @@
let map, drawnItems, poleLayer, connectionLayer; let map, poleLayer, connectionLayer, tileLayer;
let poles = []; let poles = [];
let poleCounter = 1; let poleCounter = 1;
let currentPoleType = "1"; let currentPoleType = "1";
let currentPoleStatus = "projected";
let currentExistingType = "communication";
let history = [];
let historyIndex = -1;
const POLE_TYPES = { const POLE_TYPES = {
"1": { name: "Промежуточная", color: "#2563eb", icon: "●" }, "1": { name: "Промежуточная", color: "#2563eb", icon: "●" },
@ -11,27 +15,82 @@ const POLE_TYPES = {
"5": { name: "С муфтой и ОРКсп", color: "#7c3aed", 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() { function initApp() {
initMap(); initMap();
initSelectors(); initSelectors();
initPoleControls(); initPoleControls();
updateTable(); updateTable();
updateUndoButtons();
} }
function initMap() { function initMap() {
map = L.map("map", { zoomControl: true }).setView([48.0, 68.0], 6); map = L.map("map", { zoomControl: true }).setView([48.0, 68.0], 6);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { tileLayer = L.tileLayer(TILE_PROVIDERS.street.url, { attribution: TILE_PROVIDERS.street.attr, maxZoom: 19 }).addTo(map);
attribution: "&copy; OpenStreetMap",
maxZoom: 19,
}).addTo(map);
drawnItems = L.featureGroup().addTo(map);
poleLayer = L.featureGroup().addTo(map); poleLayer = L.featureGroup().addTo(map);
connectionLayer = L.featureGroup().addTo(map); connectionLayer = L.featureGroup().addTo(map);
map.on("click", function (e) { map.on("click", function (e) {
saveHistory();
addPole(e.latlng.lat, e.latlng.lng); 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() { function initSelectors() {
@ -39,6 +98,7 @@ function initSelectors() {
const rayonSel = document.getElementById("rayon"); const rayonSel = document.getElementById("rayon");
const villageSel = document.getElementById("village"); const villageSel = document.getElementById("village");
const searchInput = document.getElementById("searchInput"); const searchInput = document.getElementById("searchInput");
const manualVillage = document.getElementById("manualVillage");
KZ_DATA.forEach((o) => { KZ_DATA.forEach((o) => {
const opt = document.createElement("option"); const opt = document.createElement("option");
@ -55,10 +115,9 @@ function initSelectors() {
oblast.rayons.forEach((r) => { oblast.rayons.forEach((r) => {
const opt = document.createElement("option"); const opt = document.createElement("option");
opt.value = r.rayon; opt.value = r.rayon;
opt.textContent = r.rayon; opt.textContent = `${r.rayon} (${r.villages.length} сёл)`;
rayonSel.appendChild(opt); rayonSel.appendChild(opt);
}); });
updateVillages();
} }
function updateVillages() { function updateVillages() {
@ -69,47 +128,118 @@ function initSelectors() {
if (!rayon) return; if (!rayon) return;
rayon.villages.forEach((v) => { rayon.villages.forEach((v) => {
const opt = document.createElement("option"); const opt = document.createElement("option");
opt.value = v; const vname = typeof v === "string" ? v : v.name;
opt.textContent = v; opt.value = vname;
opt.textContent = vname;
villageSel.appendChild(opt); villageSel.appendChild(opt);
}); });
} }
oblastSel.addEventListener("change", updateRayons); oblastSel.addEventListener("change", function() {
rayonSel.addEventListener("change", updateVillages); 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 getVillageCenter(v) {
if (typeof v === "object" && v.lat && v.lng) return [v.lat, v.lng];
return null;
}
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) {
const vname = typeof v === "string" ? v : v.name;
if (vname.toLowerCase() === q) {
const center = getVillageCenter(v) || r.center || o.center;
return { village: vname, rayon: r.rayon, oblast: o.oblast, 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, 15);
L.popup()
.setLatLng(found.center)
.setContent(`<b>${found.village}</b><br>${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(`<b>${name}</b><br>${oblast.oblast}, ${rayon ? rayon.rayon : ''}`)
.openOn(map);
}
villageSel.addEventListener("change", function () { villageSel.addEventListener("change", function () {
const name = this.value; const name = this.value;
if (!name) return; if (!name) return;
const oblast = KZ_DATA.find((o) => o.oblast === oblastSel.value); const found = findVillageData(name);
if (!oblast) return; if (found && found.center) {
map.setView(oblast.center, 10); map.setView(found.center, 15);
L.popup() L.popup().setLatLng(found.center).setContent(`<b>${found.village}</b><br>${found.oblast}, ${found.rayon}`).openOn(map);
.setLatLng(oblast.center) }
.setContent(`<b>${name}</b><br>${oblast.oblast}, ${rayonSel.value}`) });
.openOn(map);
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 () { searchInput.addEventListener("input", function () {
const q = this.value.toLowerCase().trim(); const q = this.value.toLowerCase().trim();
const list = document.getElementById("searchResults");
list.innerHTML = "";
if (q.length < 2) return; if (q.length < 2) return;
const results = []; const results = [];
KZ_DATA.forEach((o) => { KZ_DATA.forEach((o) => {
o.rayons.forEach((r) => { o.rayons.forEach((r) => {
r.villages.forEach((v) => { r.villages.forEach((v) => {
if (v.toLowerCase().includes(q)) { const vname = typeof v === "string" ? v : v.name;
results.push({ village: v, rayon: r.rayon, oblast: o.oblast, center: o.center }); if (vname.toLowerCase().includes(q)) {
const center = getVillageCenter(v) || r.center || o.center;
results.push({ village: vname, rayon: r.rayon, oblast: o.oblast, center });
} }
}); });
}); });
}); });
const list = document.getElementById("searchResults"); if (results.length === 0) {
list.innerHTML = ""; list.innerHTML = "<li class='px-3 py-2 text-gray-500'>Не найдено</li>";
if (results.length === 0 || results.length > 50) {
if (results.length > 50) list.innerHTML = "<li class='px-3 py-2 text-gray-500'>Слишком много результатов, уточните</li>";
return; return;
} }
results.slice(0, 20).forEach((r) => { results.slice(0, 50).forEach((r) => {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "px-3 py-2 cursor-pointer hover:bg-cyan-50"; li.className = "px-3 py-2 cursor-pointer hover:bg-cyan-50";
li.textContent = `${r.village}${r.rayon}, ${r.oblast}`; li.textContent = `${r.village}${r.rayon}, ${r.oblast}`;
@ -147,16 +277,42 @@ function initPoleControls() {
}); });
}); });
document.querySelector('[data-type="1"]').classList.add("active"); 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) { function addPole(lat, lng) {
const type = POLE_TYPES[currentPoleType]; const type = POLE_TYPES[currentPoleType];
const id = poleCounter++; 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); poles.push(pole);
renderPole(pole); renderPole(pole);
updateTable(); updateTable();
updateConnections(); updateConnections();
updateUndoButtons();
} }
function renderPole(pole) { function renderPole(pole) {
@ -176,21 +332,32 @@ function renderPole(pole) {
}); });
const marker = L.marker([pole.lat, pole.lng], { icon }).addTo(poleLayer); const marker = L.marker([pole.lat, pole.lng], { icon }).addTo(poleLayer);
const statusLabel = pole.status === "existing" ? `Существующая (${EXISTING_TYPES[pole.existingType] || pole.existingType})` : "Проектируемая";
marker.bindPopup(` marker.bindPopup(`
<b>${pole.label}</b><br> <b>${pole.label}</b><br>
Тип: ${type.name}<br> Тип: ${type.name}<br>
Статус: ${statusLabel}<br>
Координаты: ${pole.lat.toFixed(6)}, ${pole.lng.toFixed(6)} Координаты: ${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> <br><button onclick="removePoleById(${pole.id})" style="color:red;cursor:pointer;border:none;background:none;margin-top:4px"> Удалить</button>
`); `);
pole._marker = marker; pole._marker = marker;
} }
function removePole(id) { function removePoleById(id) {
saveHistory();
const idx = poles.findIndex((p) => p.id === id); const idx = poles.findIndex((p) => p.id === id);
if (idx === -1) return; if (idx === -1) return;
const pole = poles[idx]; if (poles[idx]._marker) poleLayer.removeLayer(poles[idx]._marker);
if (pole._marker) poleLayer.removeLayer(pole._marker);
poles.splice(idx, 1); poles.splice(idx, 1);
renumberPoles();
}
function removePoleFromTable(id) {
saveHistory();
removePoleById(id);
}
function renumberPoles() {
poles.forEach((p, i) => { poles.forEach((p, i) => {
p.id = i + 1; p.id = i + 1;
p.label = `Опора ${i + 1}`; p.label = `Опора ${i + 1}`;
@ -200,6 +367,7 @@ function removePole(id) {
poles.forEach((p) => renderPole(p)); poles.forEach((p) => renderPole(p));
updateTable(); updateTable();
updateConnections(); updateConnections();
updateUndoButtons();
} }
function updateConnections() { function updateConnections() {
@ -210,11 +378,8 @@ function updateConnections() {
for (let i = 0; i < sorted.length - 1; i++) { for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i]; const a = sorted[i];
const b = sorted[i + 1]; const b = sorted[i + 1];
const polyline = L.polyline( L.polyline(
[ [[a.lat, a.lng], [b.lat, b.lng]],
[a.lat, a.lng],
[b.lat, b.lng],
],
{ color: "#64748b", weight: 2, dashArray: "6,4", opacity: 0.7 } { color: "#64748b", weight: 2, dashArray: "6,4", opacity: 0.7 }
).addTo(connectionLayer); ).addTo(connectionLayer);
@ -224,11 +389,7 @@ function updateConnections() {
L.marker([midLat, midLng], { L.marker([midLat, midLng], {
icon: L.divIcon({ icon: L.divIcon({
className: "dist-label", className: "dist-label",
html: `<div style=" 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>`,
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], iconSize: [0, 0],
iconAnchor: [0, 0], iconAnchor: [0, 0],
}), }),
@ -241,27 +402,27 @@ function updateTable() {
const tbody = document.querySelector("#poleTable tbody"); const tbody = document.querySelector("#poleTable tbody");
tbody.innerHTML = ""; tbody.innerHTML = "";
if (poles.length === 0) { if (poles.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-gray-500 py-4">Нет опор. Кликните на карту, чтобы добавить.</td></tr>'; tbody.innerHTML = '<tr><td colspan="7" class="text-center text-gray-500 py-4">Нет опор. Кликните на карту, чтобы добавить.</td></tr>';
return; return;
} }
const sorted = [...poles].sort((a, b) => a.id - b.id); const sorted = [...poles].sort((a, b) => a.id - b.id);
sorted.forEach((p, i) => { sorted.forEach((p, i) => {
const type = POLE_TYPES[p.type]; const type = POLE_TYPES[p.type];
const prevDist = const prevDist = i > 0 ? calcDistance(sorted[i - 1].lat, sorted[i - 1].lng, p.lat, p.lng).toFixed(1) : "—";
i > 0 const statusLabel = p.status === "existing" ? `Сущ. (${EXISTING_TYPES[p.existingType] || p.existingType})` : "Проект.";
? calcDistance(sorted[i - 1].lat, sorted[i - 1].lng, p.lat, p.lng).toFixed(1) const statusColor = p.status === "existing" ? "#f59e0b" : "#10b981";
: "—";
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.className = "border-b border-gray-200 hover:bg-gray-50"; tr.className = "border-b border-gray-200 hover:bg-gray-50";
tr.innerHTML = ` tr.innerHTML = `
<td class="px-4 py-2 font-medium">${p.id}</td> <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:${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.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 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-right font-mono">${prevDist}</td>
<td class="px-4 py-2 text-center"> <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> <button onclick="removePoleFromTable(${p.id})" class="text-red-500 hover:text-red-700 text-sm" title="Удалить"></button>
</td> </td>
`; `;
tbody.appendChild(tr); tbody.appendChild(tr);
@ -272,22 +433,21 @@ function calcDistance(lat1, lon1, lat2, lon2) {
const R = 6371000; const R = 6371000;
const dLat = ((lat2 - lat1) * Math.PI) / 180; const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180; const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a = const a = Math.sin(dLat / 2) ** 2 + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2;
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)); return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
} }
function clearAll() { function clearAll() {
if (poles.length === 0) return; if (poles.length === 0) return;
if (!confirm("Удалить все опоры?")) return; if (!confirm("Удалить все опоры?")) return;
saveHistory();
poleLayer.clearLayers(); poleLayer.clearLayers();
connectionLayer.clearLayers(); connectionLayer.clearLayers();
poles = []; poles = [];
poleCounter = 1; poleCounter = 1;
updateTable(); updateTable();
updateConnections();
updateUndoButtons();
} }
function exportSVG() { function exportSVG() {
@ -303,65 +463,56 @@ function exportSVG() {
const latRange = maxLat - minLat || 0.001; const latRange = maxLat - minLat || 0.001;
const lngRange = maxLng - minLng || 0.001; const lngRange = maxLng - minLng || 0.001;
const scale = 500 / Math.max(latRange, lngRange);
const toX = (lng) => padding + ((lng - minLng) / lngRange) * 500; const toX = (lng) => padding + ((lng - minLng) / lngRange) * 500;
const toY = (lat) => padding + ((maxLat - lat) / latRange) * 500; const toY = (lat) => padding + ((maxLat - lat) / latRange) * 500;
const w = 500 + padding * 2; const w = 500 + padding * 2;
const h = 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); const sorted = [...poles].sort((a, b) => a.id - b.id);
// connections 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++) { for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i], const a = sorted[i], b = sorted[i + 1];
b = sorted[i + 1];
const dist = calcDistance(a.lat, a.lng, b.lat, b.lng).toFixed(1); const dist = calcDistance(a.lat, a.lng, b.lat, b.lng).toFixed(1);
const mx = (toX(a.lng) + toX(b.lng)) / 2; const mx = (toX(a.lng) + toX(b.lng)) / 2;
const my = (toY(a.lat) + toY(b.lat)) / 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 += `<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>`; svg += `<text x="${mx}" y="${my-6}" class="dl">${dist} м</text>`;
} }
// poles
sorted.forEach((p) => { sorted.forEach((p) => {
const type = POLE_TYPES[p.type]; const type = POLE_TYPES[p.type];
const cx = toX(p.lng); const cx = toX(p.lng), cy = toY(p.lat);
const cy = toY(p.lat);
svg += `<circle cx="${cx}" cy="${cy}" r="12" fill="${type.color}" stroke="#fff" stroke-width="2"/>`; 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}" y="${cy+3}" class="lbl">${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>`; 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>`;
}); });
// Legend const legY = h - 40;
const legendY = h - 40; svg += `<rect x="${padding}" y="${legY-10}" width="${w-padding*2}" height="2" fill="#e2e8f0"/>`;
svg += `<rect x="${padding}" y="${legendY - 10}" width="${w - padding * 2}" height="2" fill="#e2e8f0"/>`;
let lx = padding; let lx = padding;
Object.entries(POLE_TYPES).forEach(([k, t]) => { Object.entries(POLE_TYPES).forEach(([, t]) => {
svg += `<circle cx="${lx + 6}" cy="${legendY + 10}" r="6" fill="${t.color}" stroke="#ccc" stroke-width="1"/>`; svg += `<circle cx="${lx+6}" cy="${legY+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>`; svg += `<text x="${lx+16}" y="${legY+14}" class="ll">${t.name}</text>`;
lx += 140; lx += 140;
}); });
svg += `</svg>`; svg += `</svg>`;
downloadFile(svg, "opory.svg", "image/svg+xml"); downloadFile(svg, "opory.svg", "image/svg+xml");
} }
function exportVSD() { function exportVSD() {
alert( alert('VSD (Visio) — бинарный формат.\n\nЭкспортируйте в SVG, затем откройте SVG в Visio: Файл → Открыть → выберите .svg\nИли перетащите SVG-файл в окно Visio.');
"VSD (Visio) — бинарный формат. SVG можно открыть в Visio.\n\n" +
"Экспортируйте в SVG, затем откройте в Visio: Файл → Открыть → выберите .svg\n" +
"Или перетащите SVG-файл в окно Visio."
);
} }
function downloadFile(content, filename, mime) { function downloadFile(content, filename, mime) {

File diff suppressed because it is too large Load Diff

BIN
spec.docx Normal file

Binary file not shown.