Добавил геофильтры. Теперь нужен рефакторинг.

This commit is contained in:
2025-11-17 17:44:24 +03:00
parent b889fb29a3
commit c0f2f16303
7 changed files with 385 additions and 2 deletions

View File

@@ -3,6 +3,8 @@
{% block title %}Список объектов{% endblock %} {% block title %}Список объектов{% endblock %}
{% block extra_css %} {% block extra_css %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
<style> <style>
.table-responsive tr.selected { .table-responsive tr.selected {
background-color: #d4edff; background-color: #d4edff;
@@ -22,6 +24,9 @@
.btn-group .btn { .btn-group .btn {
position: relative; position: relative;
} }
#polygonFilterMap {
z-index: 1;
}
</style> </style>
{% endblock %} {% endblock %}
@@ -94,6 +99,23 @@
</button> </button>
</div> </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>
<!-- Pagination --> <!-- Pagination -->
<div class="ms-auto"> <div class="ms-auto">
{% include 'mainapp/components/_pagination.html' with page_obj=page_obj show_info=True %} {% include 'mainapp/components/_pagination.html' with page_obj=page_obj show_info=True %}
@@ -112,6 +134,11 @@
</div> </div>
<div class="offcanvas-body"> <div class="offcanvas-body">
<form method="get" id="filter-form"> <form method="get" id="filter-form">
<!-- Hidden field to preserve polygon filter -->
{% if polygon_coords %}
<input type="hidden" name="polygon" value="{{ polygon_coords }}">
{% endif %}
<!-- Satellite Selection - Multi-select --> <!-- Satellite Selection - Multi-select -->
<div class="mb-2"> <div class="mb-2">
<label class="form-label">Спутник:</label> <label class="form-label">Спутник:</label>
@@ -703,6 +730,167 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
<script>
// Polygon filter map variables
let polygonFilterMapInstance = null;
let drawnItems = null;
let drawControl = null;
let currentPolygon = null;
// Initialize polygon filter map
function initPolygonFilterMap() {
if (polygonFilterMapInstance) {
return; // Already initialized
}
// Create map centered on Russia
polygonFilterMapInstance = L.map('polygonFilterMap').setView([55.7558, 37.6173], 4);
// Add OpenStreetMap tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(polygonFilterMapInstance);
// Initialize FeatureGroup to store drawn items
drawnItems = new L.FeatureGroup();
polygonFilterMapInstance.addLayer(drawnItems);
// Initialize draw control
drawControl = new L.Control.Draw({
position: 'topright',
draw: {
polygon: {
allowIntersection: false,
showArea: true,
drawError: {
color: '#e1e100',
message: '<strong>Ошибка:</strong> полигон не должен пересекать сам себя!'
},
shapeOptions: {
color: '#3388ff',
fillOpacity: 0.2
}
},
polyline: false,
rectangle: {
shapeOptions: {
color: '#3388ff',
fillOpacity: 0.2
}
},
circle: false,
circlemarker: false,
marker: false
},
edit: {
featureGroup: drawnItems,
remove: true
}
});
polygonFilterMapInstance.addControl(drawControl);
// Handle polygon creation
polygonFilterMapInstance.on(L.Draw.Event.CREATED, function (event) {
const layer = event.layer;
// Remove existing polygon
drawnItems.clearLayers();
// Add new polygon
drawnItems.addLayer(layer);
currentPolygon = layer;
});
// Handle polygon edit
polygonFilterMapInstance.on(L.Draw.Event.EDITED, function (event) {
const layers = event.layers;
layers.eachLayer(function (layer) {
currentPolygon = layer;
});
});
// Handle polygon deletion
polygonFilterMapInstance.on(L.Draw.Event.DELETED, function () {
currentPolygon = null;
});
// Load existing polygon if present
{% if polygon_coords %}
try {
const coords = {{ polygon_coords|safe }};
if (coords && coords.length > 0) {
const latLngs = coords.map(coord => [coord[1], coord[0]]); // [lng, lat] -> [lat, lng]
const polygon = L.polygon(latLngs, {
color: '#3388ff',
fillOpacity: 0.2
});
drawnItems.addLayer(polygon);
currentPolygon = polygon;
// Fit map to polygon bounds
polygonFilterMapInstance.fitBounds(polygon.getBounds());
}
} catch (e) {
console.error('Error loading existing polygon:', e);
}
{% endif %}
}
// Open polygon filter map modal
function openPolygonFilterMap() {
const modal = new bootstrap.Modal(document.getElementById('polygonFilterModal'));
modal.show();
// Initialize map after modal is shown (to ensure proper rendering)
setTimeout(() => {
initPolygonFilterMap();
if (polygonFilterMapInstance) {
polygonFilterMapInstance.invalidateSize();
}
}, 300);
}
// Clear polygon on map
function clearPolygonOnMap() {
if (drawnItems) {
drawnItems.clearLayers();
currentPolygon = null;
}
}
// Apply polygon filter
function applyPolygonFilter() {
if (!currentPolygon) {
alert('Пожалуйста, нарисуйте полигон на карте');
return;
}
// Get polygon coordinates
const latLngs = currentPolygon.getLatLngs()[0]; // Get first ring for polygon
const coords = latLngs.map(latLng => [latLng.lng, latLng.lat]); // [lat, lng] -> [lng, lat]
// Close the polygon by adding first point at the end
coords.push(coords[0]);
// Add polygon coordinates to URL and reload
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('polygon', JSON.stringify(coords));
urlParams.delete('page'); // Reset to first page
window.location.search = urlParams.toString();
}
// Clear polygon filter
function clearPolygonFilter() {
const urlParams = new URLSearchParams(window.location.search);
urlParams.delete('polygon');
urlParams.delete('page');
window.location.search = urlParams.toString();
}
</script>
<script> <script>
let lastCheckedIndex = null; let lastCheckedIndex = null;
@@ -748,8 +936,15 @@ function showSelectedOnMap() {
selectedIds.push(checkbox.value); selectedIds.push(checkbox.value);
}); });
// Redirect to the map view with selected IDs as query parameter // Build URL with IDs and preserve polygon filter if present
const url = '{% url "mainapp:show_sources_map" %}' + '?ids=' + selectedIds.join(','); 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'); // Open in a new tab window.open(url, '_blank'); // Open in a new tab
} }
@@ -786,6 +981,8 @@ function performSearch() {
} }
urlParams.delete('page'); urlParams.delete('page');
// Preserve polygon filter
// (already in urlParams from window.location.search)
window.location.search = urlParams.toString(); window.location.search = urlParams.toString();
} }
@@ -794,6 +991,8 @@ function clearSearch() {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
urlParams.delete('search'); urlParams.delete('search');
urlParams.delete('page'); urlParams.delete('page');
// Preserve polygon filter
// (already in urlParams from window.location.search)
window.location.search = urlParams.toString(); window.location.search = urlParams.toString();
} }
@@ -810,6 +1009,8 @@ function updateItemsPerPage() {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
urlParams.set('items_per_page', itemsPerPage); urlParams.set('items_per_page', itemsPerPage);
urlParams.delete('page'); urlParams.delete('page');
// Preserve polygon filter
// (already in urlParams from window.location.search)
window.location.search = urlParams.toString(); window.location.search = urlParams.toString();
} }
@@ -829,6 +1030,8 @@ function updateSort(field) {
urlParams.set('sort', newSort); urlParams.set('sort', newSort);
urlParams.delete('page'); urlParams.delete('page');
// Preserve polygon filter
// (already in urlParams from window.location.search)
window.location.search = urlParams.toString(); window.location.search = urlParams.toString();
} }
@@ -884,6 +1087,12 @@ function updateFilterCounter() {
} }
} }
// Check if polygon filter is active
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('polygon')) {
filterCount++;
}
// Display the filter counter // Display the filter counter
const counterElement = document.getElementById('filterCounter'); const counterElement = document.getElementById('filterCounter');
if (counterElement) { if (counterElement) {
@@ -1386,4 +1595,42 @@ function showTransponderModal(transponderId) {
<!-- Include the satellite modal component --> <!-- Include the satellite modal component -->
{% include 'mainapp/components/_satellite_modal.html' %} {% include 'mainapp/components/_satellite_modal.html' %}
<!-- Polygon Filter Map Modal -->
<div class="modal fade" id="polygonFilterModal" tabindex="-1" aria-labelledby="polygonFilterModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="polygonFilterModalLabel">
<i class="bi bi-pentagon"></i> Нарисуйте полигон для фильтрации
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body p-0" style="position: relative;">
<div id="polygonHelpAlert" class="alert alert-info m-2" style="position: absolute; top: 10px; left: 10px; z-index: 1000; max-width: 400px; opacity: 0.95;">
<button type="button" class="btn-close btn-sm float-end" onclick="document.getElementById('polygonHelpAlert').style.display='none'"></button>
<small>
<strong>Инструкция:</strong>
<ul class="mb-0 ps-3">
<li>Используйте инструменты справа для рисования полигона или прямоугольника</li>
<li>Кликайте по карте для создания вершин полигона</li>
<li>Замкните полигон, кликнув на первую точку</li>
<li>Нажмите "Применить фильтр" для фильтрации источников</li>
</ul>
</small>
</div>
<div id="polygonFilterMap" style="height: calc(100vh - 120px); width: 100%;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-danger" onclick="clearPolygonOnMap()">
<i class="bi bi-trash"></i> Очистить полигон
</button>
<button type="button" class="btn btn-primary" onclick="applyPolygonFilter()">
<i class="bi bi-check-circle"></i> Применить фильтр
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -182,8 +182,50 @@
`; `;
{% endfor %} {% 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; return div;
}; };
legend.addTo(map); legend.addTo(map);
// Добавляем полигон фильтра на карту, если он есть
{% if polygon_coords %}
try {
const polygonCoords = {{ polygon_coords|safe }};
if (polygonCoords && polygonCoords.length > 0) {
// Преобразуем координаты из [lng, lat] в [lat, lng] для Leaflet
const latLngs = polygonCoords.map(coord => [coord[1], coord[0]]);
// Создаем полигон
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);
}
{% endif %}
</script> </script>
{% endblock extra_js %} {% endblock extra_js %}

View File

@@ -11,6 +11,7 @@ from .views import (
DeleteSelectedSourcesView, DeleteSelectedSourcesView,
DeleteSelectedTranspondersView, DeleteSelectedTranspondersView,
FillLyngsatDataView, FillLyngsatDataView,
GeoPointsAPIView,
GetLocationsView, GetLocationsView,
HomeView, HomeView,
LinkLyngsatSourcesView, LinkLyngsatSourcesView,
@@ -81,6 +82,7 @@ urlpatterns = [
path('api/source/<int:source_id>/objitems/', SourceObjItemsAPIView.as_view(), name='source_objitems_api'), path('api/source/<int:source_id>/objitems/', SourceObjItemsAPIView.as_view(), name='source_objitems_api'),
path('api/transponder/<int:transponder_id>/', TransponderDataAPIView.as_view(), name='transponder_data_api'), path('api/transponder/<int:transponder_id>/', TransponderDataAPIView.as_view(), name='transponder_data_api'),
path('api/satellite/<int:satellite_id>/', SatelliteDataAPIView.as_view(), name='satellite_data_api'), path('api/satellite/<int:satellite_id>/', SatelliteDataAPIView.as_view(), name='satellite_data_api'),
path('api/geo-points/', GeoPointsAPIView.as_view(), name='geo_points_api'),
path('kubsat-excel/', ProcessKubsatView.as_view(), name='kubsat_excel'), path('kubsat-excel/', ProcessKubsatView.as_view(), name='kubsat_excel'),
path('object/create/', ObjItemCreateView.as_view(), name='objitem_create'), path('object/create/', ObjItemCreateView.as_view(), name='objitem_create'),
path('object/<int:pk>/edit/', ObjItemUpdateView.as_view(), name='objitem_update'), path('object/<int:pk>/edit/', ObjItemUpdateView.as_view(), name='objitem_update'),

View File

@@ -18,6 +18,7 @@ from .data_import import (
ProcessKubsatView, ProcessKubsatView,
) )
from .api import ( from .api import (
GeoPointsAPIView,
GetLocationsView, GetLocationsView,
LyngsatDataAPIView, LyngsatDataAPIView,
SatelliteDataAPIView, SatelliteDataAPIView,
@@ -70,6 +71,7 @@ __all__ = [
'LinkVchSigmaView', 'LinkVchSigmaView',
'ProcessKubsatView', 'ProcessKubsatView',
# API # API
'GeoPointsAPIView',
'GetLocationsView', 'GetLocationsView',
'LyngsatDataAPIView', 'LyngsatDataAPIView',
'SatelliteDataAPIView', 'SatelliteDataAPIView',

View File

@@ -472,6 +472,52 @@ class TransponderDataAPIView(LoginRequiredMixin, View):
return JsonResponse({'error': str(e)}, status=500) return JsonResponse({'error': str(e)}, status=500)
class GeoPointsAPIView(LoginRequiredMixin, View):
"""API endpoint for getting all geo points for polygon filter visualization."""
def get(self, request):
from ..models import Geo
try:
# Limit to reasonable number of points to avoid performance issues
limit = int(request.GET.get('limit', 10000))
limit = min(limit, 50000) # Max 50k points
# Get all Geo objects with coordinates
geo_objs = Geo.objects.filter(
coords__isnull=False
).select_related(
'objitem',
'objitem__source'
)[:limit]
points = []
for geo_obj in geo_objs:
if not geo_obj.coords:
continue
# Get source_id if available
source_id = None
if hasattr(geo_obj, 'objitem') and geo_obj.objitem:
if hasattr(geo_obj.objitem, 'source') and geo_obj.objitem.source:
source_id = geo_obj.objitem.source.id
points.append({
'id': geo_obj.id,
'lat': geo_obj.coords.y,
'lng': geo_obj.coords.x,
'source_id': source_id or '-'
})
return JsonResponse({
'points': points,
'total': len(points),
'limited': len(points) >= limit
})
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)
class SatelliteDataAPIView(LoginRequiredMixin, View): class SatelliteDataAPIView(LoginRequiredMixin, View):
"""API endpoint for getting Satellite data.""" """API endpoint for getting Satellite data."""

View File

@@ -126,6 +126,7 @@ class ShowSourcesMapView(LoginRequiredMixin, View):
"""View for displaying selected sources on map.""" """View for displaying selected sources on map."""
def get(self, request): def get(self, request):
import json
from ..models import Source from ..models import Source
ids = request.GET.get("ids", "") ids = request.GET.get("ids", "")
@@ -168,8 +169,18 @@ class ShowSourcesMapView(LoginRequiredMixin, View):
else: else:
return redirect("mainapp:home") return redirect("mainapp:home")
# Get polygon filter from URL if present
polygon_coords_str = request.GET.get("polygon", "").strip()
polygon_coords = None
if polygon_coords_str:
try:
polygon_coords = json.loads(polygon_coords_str)
except (json.JSONDecodeError, ValueError, TypeError):
polygon_coords = None
context = { context = {
"groups": groups, "groups": groups,
"polygon_coords": json.dumps(polygon_coords) if polygon_coords else None,
} }
return render(request, "mainapp/source_map.html", context) return render(request, "mainapp/source_map.html", context)

View File

@@ -1,10 +1,12 @@
""" """
Source related views. Source related views.
""" """
import json
from datetime import datetime from datetime import datetime
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.gis.geos import Point, Polygon as GEOSPolygon
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Count, Q from django.db.models import Count, Q
from django.http import JsonResponse from django.http import JsonResponse
@@ -62,6 +64,23 @@ class SourceListView(LoginRequiredMixin, View):
snr_min = request.GET.get("snr_min", "").strip() snr_min = request.GET.get("snr_min", "").strip()
snr_max = request.GET.get("snr_max", "").strip() snr_max = request.GET.get("snr_max", "").strip()
# Get polygon filter
polygon_coords_str = request.GET.get("polygon", "").strip()
polygon_coords = None
polygon_geom = None
if polygon_coords_str:
try:
polygon_coords = json.loads(polygon_coords_str)
if polygon_coords and len(polygon_coords) >= 4:
# Create GEOS Polygon from coordinates
# Coordinates are in [lng, lat] format
polygon_geom = GEOSPolygon(polygon_coords, srid=4326)
except (json.JSONDecodeError, ValueError, TypeError) as e:
# Invalid polygon data, ignore
polygon_coords = None
polygon_geom = None
# Get all satellites for filter # Get all satellites for filter
satellites = ( satellites = (
Satellite.objects.filter(parameters__objitem__source__isnull=False) Satellite.objects.filter(parameters__objitem__source__isnull=False)
@@ -210,6 +229,11 @@ class SourceListView(LoginRequiredMixin, View):
objitem_filter_q &= Q(source_objitems__geo_obj__mirrors__id__in=selected_mirrors) objitem_filter_q &= Q(source_objitems__geo_obj__mirrors__id__in=selected_mirrors)
has_objitem_filter = True has_objitem_filter = True
# Add polygon filter
if polygon_geom:
objitem_filter_q &= Q(source_objitems__geo_obj__coords__within=polygon_geom)
has_objitem_filter = True
# Get all Source objects with query optimization # Get all Source objects with query optimization
# Using annotate to count ObjItems efficiently (single query with GROUP BY) # Using annotate to count ObjItems efficiently (single query with GROUP BY)
# Using prefetch_related for reverse ForeignKey relationships to avoid N+1 queries # Using prefetch_related for reverse ForeignKey relationships to avoid N+1 queries
@@ -443,6 +467,12 @@ class SourceListView(LoginRequiredMixin, View):
source_objitems__geo_obj__mirrors__id__in=selected_mirrors source_objitems__geo_obj__mirrors__id__in=selected_mirrors
).distinct() ).distinct()
# Filter by polygon
if polygon_geom:
sources = sources.filter(
source_objitems__geo_obj__coords__within=polygon_geom
).distinct()
# Apply sorting # Apply sorting
valid_sort_fields = { valid_sort_fields = {
"id": "id", "id": "id",
@@ -540,6 +570,8 @@ class SourceListView(LoginRequiredMixin, View):
objitems_to_display = objitems_to_display.filter(geo_obj__mirrors__id__in=selected_mirrors) objitems_to_display = objitems_to_display.filter(geo_obj__mirrors__id__in=selected_mirrors)
if search_by_name: if search_by_name:
objitems_to_display = objitems_to_display.filter(name__icontains=search_query) objitems_to_display = objitems_to_display.filter(name__icontains=search_query)
if polygon_geom:
objitems_to_display = objitems_to_display.filter(geo_obj__coords__within=polygon_geom)
# Use annotated count (consistent with filtering) # Use annotated count (consistent with filtering)
objitem_count = source.objitem_count objitem_count = source.objitem_count
@@ -652,6 +684,7 @@ class SourceListView(LoginRequiredMixin, View):
int(x) if isinstance(x, str) else x for x in selected_mirrors if (isinstance(x, int) or (isinstance(x, str) and x.isdigit())) int(x) if isinstance(x, str) else x for x in selected_mirrors if (isinstance(x, int) or (isinstance(x, str) and x.isdigit()))
], ],
'object_infos': object_infos, 'object_infos': object_infos,
'polygon_coords': json.dumps(polygon_coords) if polygon_coords else None,
'full_width_page': True, 'full_width_page': True,
} }