Добавил объединение источников. Вернул норм карту. Удалил ненужные либы

This commit is contained in:
2025-11-26 10:33:07 +03:00
parent 388753ba31
commit 609fd5a1da
10 changed files with 785 additions and 1100 deletions

View File

@@ -97,6 +97,22 @@
</button>
</div>
<!-- Add to List Button -->
<div>
<button class="btn btn-outline-success btn-sm" type="button" onclick="addSelectedToList()">
<i class="bi bi-plus-circle"></i> Добавить к
</button>
</div>
<!-- Selected Sources Counter Button -->
<div>
<button class="btn btn-outline-info btn-sm" type="button" data-bs-toggle="offcanvas"
data-bs-target="#selectedSourcesOffcanvas" aria-controls="selectedSourcesOffcanvas">
<i class="bi bi-list-check"></i> Список
<span id="selectedSourcesCounter" class="badge bg-info" style="display: none;">0</span>
</button>
</div>
<!-- Filter Toggle Button -->
<div>
<button class="btn btn-outline-primary btn-sm" type="button" data-bs-toggle="offcanvas"
@@ -106,23 +122,6 @@
</button>
</div>
<!-- Polygon Filter Button -->
<div>
<button class="btn btn-outline-success btn-sm" type="button"
onclick="openPolygonFilterMap()">
<i class="bi bi-pentagon"></i> Фильтр по полигону
{% if polygon_coords %}
<span class="badge bg-success"></span>
{% endif %}
</button>
{% if polygon_coords %}
<button class="btn btn-outline-danger btn-sm" type="button"
onclick="clearPolygonFilter()" title="Очистить фильтр по полигону">
<i class="bi bi-x-circle"></i>
</button>
{% endif %}
</div>
<!-- Column visibility toggle button -->
<div>
<div class="dropdown">
@@ -182,6 +181,30 @@
<input type="hidden" name="polygon" value="{{ polygon_coords }}">
{% endif %}
<!-- Polygon Filter Section -->
<div class="mb-3">
<label class="form-label fw-bold">
<i class="bi bi-pentagon"></i> Фильтр по полигону
</label>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-success btn-sm"
onclick="openPolygonFilterMap()">
<i class="bi bi-pentagon"></i> Нарисовать полигон
{% if polygon_coords %}
<span class="badge bg-success ms-1">✓ Активен</span>
{% endif %}
</button>
{% if polygon_coords %}
<button type="button" class="btn btn-outline-danger btn-sm"
onclick="clearPolygonFilter()" title="Очистить фильтр по полигону">
<i class="bi bi-x-circle"></i> Очистить полигон
</button>
{% endif %}
</div>
</div>
<hr class="my-3">
<!-- Satellite Selection - Multi-select -->
<div class="mb-2">
<label class="form-label">Спутник:</label>
@@ -1165,8 +1188,95 @@ function toggleColumnWithoutSave(checkbox) {
}
}
// Initialize selected sources array from localStorage
function loadSelectedSourcesFromStorage() {
try {
const storedSources = localStorage.getItem('selectedSources');
if (storedSources) {
window.selectedSources = JSON.parse(storedSources);
} else {
window.selectedSources = [];
}
} catch (e) {
console.error('Error loading selected sources from storage:', e);
window.selectedSources = [];
}
}
// Function to save selected sources to localStorage
window.saveSelectedSourcesToStorage = function () {
try {
localStorage.setItem('selectedSources', JSON.stringify(window.selectedSources));
} catch (e) {
console.error('Error saving selected sources to storage:', e);
}
}
// Function to update the selected sources counter
window.updateSelectedSourcesCounter = function () {
const counterElement = document.getElementById('selectedSourcesCounter');
if (window.selectedSources && window.selectedSources.length > 0) {
counterElement.textContent = window.selectedSources.length;
counterElement.style.display = 'inline';
} else {
counterElement.style.display = 'none';
}
}
// Function to add selected sources to the list
window.addSelectedToList = function () {
const checkedCheckboxes = document.querySelectorAll('.item-checkbox:checked');
if (checkedCheckboxes.length === 0) {
alert('Пожалуйста, выберите хотя бы один источник для добавления в список');
return;
}
// Get the data for each selected row and add to the selectedSources array
checkedCheckboxes.forEach(checkbox => {
const row = checkbox.closest('tr');
const sourceId = checkbox.value;
const sourceExists = window.selectedSources.some(source => source.id === sourceId);
if (!sourceExists) {
const rowData = {
id: sourceId,
name: row.cells[2].textContent.trim(),
satellite: row.cells[3].textContent.trim(),
info: row.cells[4].textContent.trim(),
ownership: row.cells[5].textContent.trim(),
coords_average: row.cells[6].textContent.trim(),
objitem_count: row.cells[7].textContent.trim()
};
window.selectedSources.push(rowData);
}
});
// Update the counter
updateSelectedSourcesCounter();
// Save selected sources to localStorage
saveSelectedSourcesToStorage();
// Clear selections in the main table
const itemCheckboxes = document.querySelectorAll('.item-checkbox');
itemCheckboxes.forEach(checkbox => {
checkbox.checked = false;
updateRowHighlight(checkbox);
});
const selectAllCheckbox = document.getElementById('select-all');
if (selectAllCheckbox) {
selectAllCheckbox.checked = false;
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
// Load selected sources from localStorage
loadSelectedSourcesFromStorage();
updateSelectedSourcesCounter();
// Setup select-all checkbox
const selectAllCheckbox = document.getElementById('select-all');
const itemCheckboxes = document.querySelectorAll('.item-checkbox');
@@ -1227,8 +1337,221 @@ document.addEventListener('DOMContentLoaded', function() {
// Initialize column visibility
setTimeout(initColumnVisibility, 100);
// Update the selected sources table when the offcanvas is shown
const selectedSourcesOffcanvas = document.getElementById('selectedSourcesOffcanvas');
if (selectedSourcesOffcanvas) {
selectedSourcesOffcanvas.addEventListener('show.bs.offcanvas', function () {
populateSelectedSourcesTable();
});
}
});
// Function to populate the selected sources table in the offcanvas
function populateSelectedSourcesTable() {
const tableBody = document.getElementById('selected-sources-table-body');
const noDataDiv = document.getElementById('selectedSourcesNoData');
const table = tableBody.closest('.table-responsive');
const offcanvasCounter = document.getElementById('selectedSourcesOffcanvasCounter');
if (!tableBody) return;
// Clear existing rows
tableBody.innerHTML = '';
if (!window.selectedSources || window.selectedSources.length === 0) {
// Show no data message
if (table) table.style.display = 'none';
if (noDataDiv) noDataDiv.style.display = 'block';
if (offcanvasCounter) offcanvasCounter.textContent = '0';
return;
}
// Hide no data message and show table
if (table) table.style.display = 'block';
if (noDataDiv) noDataDiv.style.display = 'none';
if (offcanvasCounter) offcanvasCounter.textContent = window.selectedSources.length;
// Add rows for each selected source
window.selectedSources.forEach((source, index) => {
const row = document.createElement('tr');
row.innerHTML = `
<td class="text-center">
<input type="checkbox" class="form-check-input selected-source-checkbox" value="${source.id}">
</td>
<td>${source.id}</td>
<td>${source.name}</td>
<td>${source.satellite}</td>
<td>${source.info}</td>
<td>${source.ownership}</td>
<td>${source.coords_average}</td>
<td class="text-center">${source.objitem_count}</td>
`;
tableBody.appendChild(row);
});
}
// Function to remove selected sources from the list
function removeSelectedSources() {
const checkboxes = document.querySelectorAll('#selected-sources-table-body .selected-source-checkbox:checked');
if (checkboxes.length === 0) {
alert('Пожалуйста, выберите хотя бы один источник для удаления из списка');
return;
}
// Get IDs of sources to remove
const idsToRemove = Array.from(checkboxes).map(checkbox => checkbox.value);
// Remove sources from the selectedSources array
window.selectedSources = window.selectedSources.filter(source => !idsToRemove.includes(source.id));
// Save selected sources to localStorage
saveSelectedSourcesToStorage();
// Update the counter and table
updateSelectedSourcesCounter();
populateSelectedSourcesTable();
}
// Function to show selected sources on map
function showSelectedSourcesOnMap() {
if (!window.selectedSources || window.selectedSources.length === 0) {
alert('Список источников пуст');
return;
}
const selectedIds = window.selectedSources.map(source => source.id);
const urlParams = new URLSearchParams(window.location.search);
const polygonParam = urlParams.get('polygon');
let url = '{% url "mainapp:show_sources_map" %}' + '?ids=' + selectedIds.join(',');
if (polygonParam) {
url += '&polygon=' + encodeURIComponent(polygonParam);
}
window.open(url, '_blank');
}
// Function to merge selected sources
function mergeSelectedSources() {
if (!window.selectedSources || window.selectedSources.length < 2) {
alert('Для объединения необходимо выбрать минимум 2 источника');
return;
}
// Show merge modal
const modal = new bootstrap.Modal(document.getElementById('mergeSourcesModal'));
// Populate target source info
const targetSource = window.selectedSources[0];
document.getElementById('targetSourceInfo').innerHTML = `
<strong>ID:</strong> ${targetSource.id}<br>
<strong>Имя:</strong> ${targetSource.name}<br>
<strong>Спутник:</strong> ${targetSource.satellite}<br>
<strong>Количество точек:</strong> ${targetSource.objitem_count}
`;
// Populate sources to merge list
const sourcesToMergeList = document.getElementById('sourcesToMergeList');
sourcesToMergeList.innerHTML = '';
for (let i = 1; i < window.selectedSources.length; i++) {
const source = window.selectedSources[i];
const li = document.createElement('li');
li.className = 'list-group-item';
li.innerHTML = `
<strong>ID ${source.id}:</strong> ${source.name}
<span class="badge bg-secondary">${source.objitem_count} точек</span>
`;
sourcesToMergeList.appendChild(li);
}
modal.show();
}
// Function to confirm merge
function confirmMerge() {
const infoId = document.getElementById('mergeInfoSelect').value;
const ownershipId = document.getElementById('mergeOwnershipSelect').value;
const note = document.getElementById('mergeNoteTextarea').value;
if (!infoId) {
alert('Пожалуйста, выберите тип объекта');
return;
}
if (!ownershipId) {
alert('Пожалуйста, выберите принадлежность объекта');
return;
}
// Prepare data
const sourceIds = window.selectedSources.map(source => source.id);
// Get CSRF token
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken');
// Send AJAX request
fetch('{% url "mainapp:merge_sources" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken
},
body: JSON.stringify({
source_ids: sourceIds,
info_id: infoId,
ownership_id: ownershipId,
note: note
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
// Clear selected sources
window.selectedSources = [];
saveSelectedSourcesToStorage();
updateSelectedSourcesCounter();
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('mergeSourcesModal'));
modal.hide();
// Reload page
location.reload();
} else {
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Произошла ошибка при объединении источников');
});
}
// Function to toggle all checkboxes in the selected sources table
function toggleAllSelectedSources(checkbox) {
const checkboxes = document.querySelectorAll('#selected-sources-table-body .selected-source-checkbox');
checkboxes.forEach(cb => {
cb.checked = checkbox.checked;
});
}
// Show source details in modal
function showSourceDetails(sourceId) {
// Update modal title
@@ -1724,6 +2047,132 @@ function showTransponderModal(transponderId) {
<!-- Include the satellite modal component -->
{% include 'mainapp/components/_satellite_modal.html' %}
<!-- Selected Sources Offcanvas -->
<div class="offcanvas offcanvas-end" tabindex="-1" id="selectedSourcesOffcanvas" aria-labelledby="selectedSourcesOffcanvasLabel" style="width: 80%;">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="selectedSourcesOffcanvasLabel">
Выбранные источники
<span class="badge bg-info" id="selectedSourcesOffcanvasCounter">0</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
</div>
<div class="offcanvas-body">
<div class="mb-3">
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-primary btn-sm" onclick="showSelectedSourcesOnMap()">
<i class="bi bi-map"></i> Карта
</button>
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<button type="button" class="btn btn-outline-success btn-sm" onclick="mergeSelectedSources()">
<i class="bi bi-union"></i> Объединить
</button>
{% endif %}
<button type="button" class="btn btn-outline-danger btn-sm" onclick="removeSelectedSources()">
<i class="bi bi-trash"></i> Удалить из списка
</button>
</div>
</div>
<div class="table-responsive" style="max-height: 70vh; overflow-y: auto;">
<table class="table table-striped table-hover table-sm table-bordered">
<thead class="table-light sticky-top">
<tr>
<th class="text-center" style="width: 3%;">
<input type="checkbox" id="select-all-sources" class="form-check-input" onchange="toggleAllSelectedSources(this)">
</th>
<th class="text-center" style="min-width: 60px;">ID</th>
<th style="min-width: 150px;">Имя</th>
<th style="min-width: 120px;">Спутник</th>
<th style="min-width: 120px;">Тип объекта</th>
<th style="min-width: 150px;">Принадлежность</th>
<th style="min-width: 150px;">Координаты ГЛ</th>
<th class="text-center" style="min-width: 100px;">Кол-во точек</th>
</tr>
</thead>
<tbody id="selected-sources-table-body">
<!-- Data will be loaded here via JavaScript -->
</tbody>
</table>
</div>
<div id="selectedSourcesNoData" class="text-center text-muted py-4" style="display: none;">
Список пуст. Добавьте источники из основной таблицы.
</div>
</div>
</div>
<!-- Merge Sources Modal -->
<div class="modal fade" id="mergeSourcesModal" tabindex="-1" aria-labelledby="mergeSourcesModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="mergeSourcesModalLabel">
<i class="bi bi-union"></i> Объединение источников
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
<strong>Внимание:</strong> Все точки из выбранных источников будут присвоены первому источнику в списке.
Остальные источники будут удалены.
</div>
<div class="card mb-3">
<div class="card-header bg-light">
<strong>Целевой источник (все точки будут присвоены ему):</strong>
</div>
<div class="card-body" id="targetSourceInfo">
<!-- Target source info will be populated here -->
</div>
</div>
<div class="card mb-3">
<div class="card-header bg-light">
<strong>Источники для объединения (будут удалены):</strong>
</div>
<div class="card-body">
<ul class="list-group" id="sourcesToMergeList">
<!-- Sources to merge will be populated here -->
</ul>
</div>
</div>
<div class="mb-3">
<label for="mergeInfoSelect" class="form-label">Тип объекта <span class="text-danger">*</span></label>
<select class="form-select" id="mergeInfoSelect" required>
<option value="">Выберите тип объекта</option>
{% for info in object_infos %}
<option value="{{ info.id }}">{{ info.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="mergeOwnershipSelect" class="form-label">Принадлежность объекта <span class="text-danger">*</span></label>
<select class="form-select" id="mergeOwnershipSelect" required>
<option value="">Выберите принадлежность</option>
{% for ownership in object_ownerships %}
<option value="{{ ownership.id }}">{{ ownership.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="mergeNoteTextarea" class="form-label">Примечание</label>
<textarea class="form-control" id="mergeNoteTextarea" rows="3" placeholder="Введите примечание (необязательно)"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-success" onclick="confirmMerge()">
<i class="bi bi-check-circle"></i> Объединить
</button>
</div>
</div>
</div>
</div>
<!-- Polygon Filter Map Modal -->
<div class="modal fade" id="polygonFilterModal" tabindex="-1" aria-labelledby="polygonFilterModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">

View File

@@ -3,169 +3,46 @@
{% block title %}Карта объектов{% endblock title %}
{% block extra_css %}
<!-- MapLibre GL CSS -->
<link href="{% static 'maplibre/maplibre-gl.css' %}" rel="stylesheet">
<!-- Leaflet CSS -->
<link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-measure/leaflet-measure.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-tree/L.Control.Layers.Tree.css' %}" rel="stylesheet">
<style>
body {
overflow: hidden;
}
#map {
position: fixed;
top: 56px;
top: 56px; /* Высота navbar */
bottom: 0;
left: 0;
right: 0;
z-index: 0;
z-index: 1;
}
/* Легенда */
.maplibregl-ctrl-legend {
.legend {
background: white;
padding: 10px;
padding: 8px;
border-radius: 4px;
box-shadow: 0 0 0 2px rgba(0,0,0,.1);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
box-shadow: 0 0 10px rgba(0,0,0,0.2);
font-size: 11px;
max-height: 400px;
overflow-y: auto;
}
.maplibregl-ctrl-legend h6 {
.legend h6 {
font-size: 12px;
margin: 0 0 8px 0;
font-weight: bold;
margin: 0 0 6px 0;
}
.legend-item {
margin: 4px 0;
margin: 3px 0;
display: flex;
align-items: center;
padding: 2px;
}
.legend-marker {
width: 12px;
height: 12px;
margin-right: 8px;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 0 3px rgba(0,0,0,0.3);
}
.legend-section {
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid #ddd;
}
.legend-section:last-child {
border-bottom: none;
margin-bottom: 0;
}
/* Слои контрол */
.maplibregl-ctrl-layers {
background: white;
padding: 10px;
border-radius: 4px;
box-shadow: 0 0 0 2px rgba(0,0,0,.1);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 250px;
max-height: 400px;
overflow-y: auto;
}
.maplibregl-ctrl-layers h6 {
margin: 0 0 10px 0;
font-size: 13px;
font-weight: bold;
}
.layer-item {
margin: 5px 0;
font-size: 12px;
}
.layer-item label {
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
user-select: none;
}
.layer-item input[type="checkbox"] {
cursor: pointer;
}
/* Кастомные кнопки контролов */
.maplibregl-ctrl-projection .maplibregl-ctrl-icon,
.maplibregl-ctrl-3d .maplibregl-ctrl-icon,
.maplibregl-ctrl-style .maplibregl-ctrl-icon {
background-size: 20px 20px;
background-position: center;
width: 18px;
height: 30px;
margin-right: 6px;
background-size: contain;
background-repeat: no-repeat;
}
.maplibregl-ctrl-projection .maplibregl-ctrl-icon {
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="6" fill="none" stroke="%23333" stroke-width="1.5"/><ellipse cx="10" cy="10" rx="3" ry="6" fill="none" stroke="%23333" stroke-width="1.5"/><line x1="4" y1="10" x2="16" y2="10" stroke="%23333" stroke-width="1.5"/></svg>');
}
.maplibregl-ctrl-3d .maplibregl-ctrl-icon {
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path d="M 5 13 L 5 8 L 10 5 L 15 8 L 15 13 L 10 16 Z M 5 8 L 10 10.5 M 10 10.5 L 15 8 M 10 10.5 L 10 16" fill="none" stroke="%23333" stroke-width="1.5" stroke-linejoin="round"/></svg>');
}
.maplibregl-ctrl-style .maplibregl-ctrl-icon {
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><rect x="3" y="3" width="6" height="6" fill="none" stroke="%23333" stroke-width="1.5"/><rect x="11" y="3" width="6" height="6" fill="none" stroke="%23333" stroke-width="1.5"/><rect x="3" y="11" width="6" height="6" fill="none" stroke="%23333" stroke-width="1.5"/><rect x="11" y="11" width="6" height="6" fill="none" stroke="%23333" stroke-width="1.5"/></svg>');
}
/* Popup стили */
.maplibregl-popup-content {
padding: 10px 15px;
min-width: 150px;
}
.popup-title {
font-weight: bold;
margin-bottom: 5px;
font-size: 13px;
}
.popup-info {
font-size: 12px;
color: #666;
}
/* Стили меню */
.style-menu {
background: white;
border-radius: 4px;
box-shadow: 0 0 0 2px rgba(0,0,0,.1);
padding: 5px;
min-width: 120px;
}
.style-menu button {
display: block;
width: 100%;
padding: 6px 10px;
border: none;
background: none;
text-align: left;
cursor: pointer;
font-size: 12px;
border-radius: 2px;
}
.style-menu button:hover {
background-color: #f0f0f0;
}
.style-menu button.active {
background-color: #e3f2fd;
color: #1976d2;
font-weight: 500;
}
</style>
{% endblock %}
@@ -174,508 +51,181 @@
{% endblock content %}
{% block extra_js %}
<!-- MapLibre GL JavaScript -->
<script src="{% static 'maplibre/maplibre-gl.js' %}"></script>
<!-- Leaflet JavaScript -->
<script src="{% static 'leaflet/leaflet.js' %}"></script>
<script src="{% static 'leaflet-measure/leaflet-measure.ru.js' %}"></script>
<script src="{% static 'leaflet-tree/L.Control.Layers.Tree.js' %}"></script>
<script>
// Цвета для маркеров
const markerColors = {
'blue': '#3388ff',
'orange': '#ff8c00',
'green': '#28a745',
'violet': '#9c27b0'
};
// Данные групп из Django
const groups = [
{% for group in groups %}
{
name: '{{ group.name|escapejs }}',
color: '{{ group.color }}',
points: [
{% for point_data in group.points %}
{
id: '{{ point_data.source_id|escapejs }}',
coordinates: [{{ point_data.point.0|safe }}, {{ point_data.point.1|safe }}]
}{% if not forloop.last %},{% endif %}
{% endfor %}
]
}{% if not forloop.last %},{% endif %}
{% endfor %}
];
// Полигон фильтра
const polygonCoords = {% if polygon_coords %}{{ polygon_coords|safe }}{% else %}null{% endif %};
// Стили карт
const mapStyles = {
streets: {
version: 8,
sources: {
'osm': {
type: 'raster',
tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '&copy; OpenStreetMap'
}
},
layers: [{
id: 'osm',
type: 'raster',
source: 'osm'
}]
},
satellite: {
version: 8,
sources: {
'satellite': {
type: 'raster',
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
tileSize: 256,
attribution: '&copy; Esri'
}
},
layers: [{
id: 'satellite',
type: 'raster',
source: 'satellite'
}]
},
local: {
version: 8,
sources: {
'local': {
type: 'raster',
tiles: ['http://127.0.0.1:8080/styles/basic-preview/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: 'Local'
}
},
layers: [{
id: 'local',
type: 'raster',
source: 'local'
}]
}
};
// Инициализация карты
const map = new maplibregl.Map({
container: 'map',
style: mapStyles.streets,
center: [37.62, 55.75],
zoom: 10,
maxZoom: 18,
minZoom: 0
let map = L.map('map').setView([55.75, 37.62], 10);
L.control.scale({
imperial: false,
metric: true
}).addTo(map);
map.attributionControl.setPrefix(false);
const street = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
});
street.addTo(map);
const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri'
});
const street_local = L.tileLayer('http://127.0.0.1:8080/styles/basic-preview/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: 'Local Tiles'
});
const baseLayers = {
"Улицы": street,
"Спутник": satellite,
"Локально": street_local
};
L.control.layers(baseLayers).addTo(map);
map.setMaxZoom(18);
map.setMinZoom(0);
L.control.measure({ primaryLengthUnit: 'kilometers' }).addTo(map);
let currentStyle = 'streets';
let is3DEnabled = false;
let isGlobeProjection = false;
const allMarkers = [];
// Добавляем стандартные контролы
map.addControl(new maplibregl.NavigationControl(), 'top-right');
map.addControl(new maplibregl.ScaleControl({ unit: 'metric' }), 'bottom-right');
map.addControl(new maplibregl.FullscreenControl(), 'top-right');
// Кастомный контрол для переключения проекции
class ProjectionControl {
onAdd(map) {
this._map = map;
this._container = document.createElement('div');
this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group maplibregl-ctrl-projection';
this._container.innerHTML = '<button type="button" title="Переключить проекцию"><span class="maplibregl-ctrl-icon"></span></button>';
this._container.onclick = () => {
if (isGlobeProjection) {
map.setProjection({ type: 'mercator' });
isGlobeProjection = false;
} else {
map.setProjection({ type: 'globe' });
isGlobeProjection = true;
}
};
return this._container;
}
onRemove() {
this._container.parentNode.removeChild(this._container);
this._map = undefined;
}
}
// Кастомный контрол для переключения стилей
class StyleControl {
onAdd(map) {
this._map = map;
this._container = document.createElement('div');
this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group maplibregl-ctrl-style';
const button = document.createElement('button');
button.type = 'button';
button.title = 'Стиль карты';
button.innerHTML = '<span class="maplibregl-ctrl-icon"></span>';
const menu = document.createElement('div');
menu.className = 'style-menu';
menu.style.display = 'none';
menu.style.position = 'absolute';
menu.style.top = '100%';
menu.style.right = '0';
menu.style.marginTop = '5px';
const styles = [
{ id: 'streets', name: 'Улицы' },
{ id: 'satellite', name: 'Спутник' },
{ id: 'local', name: 'Локально' }
];
styles.forEach(style => {
const btn = document.createElement('button');
btn.textContent = style.name;
btn.className = style.id === currentStyle ? 'active' : '';
btn.onclick = () => {
this.switchStyle(style.id);
menu.querySelectorAll('button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
menu.style.display = 'none';
};
menu.appendChild(btn);
});
button.onclick = (e) => {
e.stopPropagation();
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
};
document.addEventListener('click', () => {
menu.style.display = 'none';
});
this._container.appendChild(button);
this._container.appendChild(menu);
this._container.style.position = 'relative';
return this._container;
}
switchStyle(styleName) {
const center = this._map.getCenter();
const zoom = this._map.getZoom();
const bearing = this._map.getBearing();
const pitch = this._map.getPitch();
this._map.setStyle(mapStyles[styleName]);
currentStyle = styleName;
this._map.once('styledata', () => {
this._map.setCenter(center);
this._map.setZoom(zoom);
this._map.setBearing(bearing);
this._map.setPitch(pitch);
addMarkersToMap();
addFilterPolygon();
});
}
onRemove() {
this._container.parentNode.removeChild(this._container);
this._map = undefined;
}
}
// Кастомный контрол для слоев
class LayersControl {
onAdd(map) {
this._map = map;
this._container = document.createElement('div');
this._container.className = 'maplibregl-ctrl maplibregl-ctrl-layers';
const title = document.createElement('h6');
title.textContent = 'Слои точек';
this._container.appendChild(title);
groups.forEach((group, groupIndex) => {
const layerId = `points-layer-${groupIndex}`;
const layerItem = document.createElement('div');
layerItem.className = 'layer-item';
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = true;
checkbox.addEventListener('change', (e) => {
const visibility = e.target.checked ? 'visible' : 'none';
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', visibility);
}
// Также скрываем/показываем внутренний круг
const innerLayerId = `${layerId}-inner`;
if (map.getLayer(innerLayerId)) {
map.setLayoutProperty(innerLayerId, 'visibility', visibility);
}
});
const colorSpan = document.createElement('span');
colorSpan.className = 'legend-marker';
colorSpan.style.backgroundColor = markerColors[group.color];
const nameSpan = document.createElement('span');
nameSpan.textContent = `${group.name} (${group.points.length})`;
label.appendChild(checkbox);
label.appendChild(colorSpan);
label.appendChild(nameSpan);
layerItem.appendChild(label);
this._container.appendChild(layerItem);
});
return this._container;
}
onRemove() {
this._container.parentNode.removeChild(this._container);
this._map = undefined;
}
}
// Кастомный контрол для легенды
class LegendControl {
onAdd(map) {
this._map = map;
this._container = document.createElement('div');
this._container.className = 'maplibregl-ctrl maplibregl-ctrl-legend';
const title = document.createElement('h6');
title.textContent = 'Легенда';
this._container.appendChild(title);
groups.forEach(group => {
const section = document.createElement('div');
section.className = 'legend-section';
const item = document.createElement('div');
item.className = 'legend-item';
const marker = document.createElement('div');
marker.className = 'legend-marker';
marker.style.backgroundColor = markerColors[group.color];
const text = document.createElement('span');
text.textContent = `${group.name} (${group.points.length})`;
item.appendChild(marker);
item.appendChild(text);
section.appendChild(item);
this._container.appendChild(section);
});
if (polygonCoords && polygonCoords.length > 0) {
const section = document.createElement('div');
section.className = 'legend-section';
const item = document.createElement('div');
item.className = 'legend-item';
const marker = document.createElement('div');
marker.style.width = '18px';
marker.style.height = '18px';
marker.style.marginRight = '8px';
marker.style.backgroundColor = 'rgba(51, 136, 255, 0.2)';
marker.style.border = '2px solid #3388ff';
marker.style.borderRadius = '2px';
const text = document.createElement('span');
text.textContent = 'Область фильтра';
item.appendChild(marker);
item.appendChild(text);
section.appendChild(item);
this._container.appendChild(section);
}
return this._container;
}
onRemove() {
this._container.parentNode.removeChild(this._container);
this._map = undefined;
}
}
// Добавляем кастомные контролы
map.addControl(new ProjectionControl(), 'top-right');
map.addControl(new StyleControl(), 'top-right');
map.addControl(new LayersControl(), 'top-left');
map.addControl(new LegendControl(), 'bottom-left');
// Добавление маркеров на карту
function addMarkersToMap() {
groups.forEach((group, groupIndex) => {
const sourceId = `points-${groupIndex}`;
const layerId = `points-layer-${groupIndex}`;
// Создаем GeoJSON для группы
const geojson = {
type: 'FeatureCollection',
features: group.points.map(point => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: point.coordinates
},
properties: {
id: point.id,
groupName: group.name,
color: markerColors[group.color]
}
}))
};
// Добавляем источник данных
if (!map.getSource(sourceId)) {
map.addSource(sourceId, {
type: 'geojson',
data: geojson
});
}
// Добавляем слой с кругами (основной маркер)
if (!map.getLayer(layerId)) {
map.addLayer({
id: layerId,
type: 'circle',
source: sourceId,
paint: {
'circle-radius': 10,
'circle-color': ['get', 'color'],
'circle-stroke-width': 3,
'circle-stroke-color': '#ffffff',
'circle-opacity': 1
}
});
// Добавляем внутренний круг
map.addLayer({
id: `${layerId}-inner`,
type: 'circle',
source: sourceId,
paint: {
'circle-radius': 4,
'circle-color': '#ffffff',
'circle-opacity': 1
}
});
// Добавляем popup при клике
map.on('click', layerId, (e) => {
const coordinates = e.features[0].geometry.coordinates.slice();
const { id, groupName } = e.features[0].properties;
new maplibregl.Popup()
.setLngLat(coordinates)
.setHTML(`<div class="popup-title">${id}</div><div class="popup-info">Группа: ${groupName}</div>`)
.addTo(map);
});
// Меняем курсор при наведении
map.on('mouseenter', layerId, () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', layerId, () => {
map.getCanvas().style.cursor = '';
});
}
// Цвета для маркеров
var markerColors = {
'blue': 'blue',
'orange': 'orange',
'green': 'green',
'violet': 'violet'
};
var getColorIcon = function(color) {
return L.icon({
iconUrl: '{% static "leaflet-markers/img/marker-icon-" %}' + color + '.png',
shadowUrl: '{% static "leaflet-markers/img/marker-shadow.png" %}',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
}
};
// Добавление полигона фильтра
function addFilterPolygon() {
if (!polygonCoords || polygonCoords.length === 0) return;
try {
const polygonGeoJSON = {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [polygonCoords]
}
};
if (!map.getSource('filter-polygon')) {
map.addSource('filter-polygon', {
type: 'geojson',
data: polygonGeoJSON
});
}
if (!map.getLayer('filter-polygon-fill')) {
map.addLayer({
id: 'filter-polygon-fill',
type: 'fill',
source: 'filter-polygon',
paint: {
'fill-color': '#3388ff',
'fill-opacity': 0.2
}
});
}
if (!map.getLayer('filter-polygon-outline')) {
map.addLayer({
id: 'filter-polygon-outline',
type: 'line',
source: 'filter-polygon',
paint: {
'line-color': '#3388ff',
'line-width': 2,
'line-dasharray': [2, 2]
}
});
}
map.on('click', 'filter-polygon-fill', (e) => {
new maplibregl.Popup()
.setLngLat(e.lngLat)
.setHTML('<div class="popup-title">Область фильтра</div><div class="popup-info">Отображаются только источники с точками в этой области</div>')
.addTo(map);
});
} catch (e) {
console.error('Ошибка при отображении полигона фильтра:', e);
}
}
var overlays = [];
// Подгонка карты под все маркеры
function fitMapToBounds() {
const allCoordinates = [];
groups.forEach(group => {
group.points.forEach(point => {
allCoordinates.push(point.coordinates);
// Создаём слои для каждого типа координат
{% for group in groups %}
var groupName = '{{ group.name|escapejs }}';
var colorName = '{{ group.color }}';
var groupIcon = getColorIcon(colorName);
var groupLayer = L.layerGroup();
var subgroup = [];
{% for point_data in group.points %}
var pointName = "{{ point_data.source_id|escapejs }}";
var marker = L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}], {
icon: groupIcon
}).bindPopup(pointName);
groupLayer.addLayer(marker);
subgroup.push({
label: "{{ forloop.counter }} - {{ point_data.source_id|escapejs }}",
layer: marker
});
{% endfor %}
overlays.push({
label: groupName,
selectAllCheckbox: true,
children: subgroup,
layer: groupLayer
});
{% endfor %}
// Корневая группа
const rootGroup = {
label: "Все точки",
selectAllCheckbox: true,
children: overlays,
layer: L.layerGroup()
};
// Создаём tree control
const layerControl = L.control.layers.tree(baseLayers, [rootGroup], {
collapsed: false,
autoZIndex: true
});
layerControl.addTo(map);
// Подгоняем карту под все маркеры
{% if groups %}
var groupBounds = L.featureGroup([]);
{% for group in groups %}
{% for point_data in group.points %}
groupBounds.addLayer(L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}]));
{% endfor %}
{% endfor %}
map.fitBounds(groupBounds.getBounds().pad(0.1));
{% endif %}
// Добавляем легенду в левый нижний угол
var legend = L.control({ position: 'bottomleft' });
legend.onAdd = function(map) {
var div = L.DomUtil.create('div', 'legend');
div.innerHTML = '<h6><strong>Легенда</strong></h6>';
{% for group in groups %}
div.innerHTML += `
<div class="legend-item">
<div class="legend-marker" style="background-image: url('{% static "leaflet-markers/img/marker-icon-" %}{{ group.color }}.png');"></div>
<span>{{ group.name|escapejs }}</span>
</div>
`;
{% endfor %}
{% if polygon_coords %}
div.innerHTML += `
<div class="legend-item" style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #ddd;">
<div style="width: 18px; height: 18px; margin-right: 6px; background-color: rgba(51, 136, 255, 0.2); border: 2px solid #3388ff;"></div>
<span>Область фильтра</span>
</div>
`;
{% endif %}
return div;
};
legend.addTo(map);
// Добавляем полигон фильтра на карту, если он есть
{% if polygon_coords %}
try {
const polygonCoords = {{ polygon_coords|safe }};
if (polygonCoords && polygonCoords.length > 0) {
polygonCoords.forEach(coord => {
allCoordinates.push(coord);
});
}
if (allCoordinates.length > 0) {
const bounds = allCoordinates.reduce((bounds, coord) => {
return bounds.extend(coord);
}, new maplibregl.LngLatBounds(allCoordinates[0], allCoordinates[0]));
// Преобразуем координаты из [lng, lat] в [lat, lng] для Leaflet
const latLngs = polygonCoords.map(coord => [coord[1], coord[0]]);
map.fitBounds(bounds, { padding: 50 });
// Создаем полигон
const filterPolygon = L.polygon(latLngs, {
color: '#3388ff',
fillColor: '#3388ff',
fillOpacity: 0.2,
weight: 2,
dashArray: '5, 5'
});
// Добавляем полигон на карту
filterPolygon.addTo(map);
// Добавляем popup с информацией
filterPolygon.bindPopup('<strong>Область фильтра</strong><br>Отображаются только источники с точками в этой области');
// Если нет других точек, центрируем карту на полигоне
{% if not groups %}
map.fitBounds(filterPolygon.getBounds());
{% endif %}
}
} catch (e) {
console.error('Ошибка при отображении полигона фильтра:', e);
}
// Инициализация после загрузки карты
map.on('load', () => {
addMarkersToMap();
addFilterPolygon();
fitMapToBounds();
});
{% endif %}
</script>
{% endblock extra_js %}
{% endblock extra_js %}