Страница с Кубсатами

This commit is contained in:
2025-11-19 17:36:39 +03:00
parent 4d7cc9f667
commit 66e1929978
12 changed files with 1429 additions and 159 deletions

View File

@@ -34,6 +34,9 @@
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:object_marks' %}">Наличие сигнала</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mapsapp:3dmap' %}">3D карта</a>
</li>

View File

@@ -0,0 +1,584 @@
{% extends 'mainapp/base.html' %}
{% load static %}
{% block title %}Кубсат{% endblock %}
{% block content %}
<div class="container-fluid px-3">
<div class="row mb-3">
<div class="col-12">
<h2>Кубсат</h2>
</div>
</div>
<!-- Форма фильтров -->
<form method="get" id="filterForm" class="mb-4">
{% csrf_token %}
<div class="card">
<div class="card-header">
<h5 class="mb-0">Фильтры</h5>
</div>
<div class="card-body">
<div class="row">
<!-- Спутники -->
<div class="col-md-3 mb-3">
<label for="{{ form.satellites.id_for_label }}" class="form-label">{{ form.satellites.label }}</label>
{{ form.satellites }}
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
</div>
<!-- Полоса спутника -->
<div class="col-md-3 mb-3">
<label for="{{ form.band.id_for_label }}" class="form-label">{{ form.band.label }}</label>
{{ form.band }}
</div>
<!-- Поляризация -->
<div class="col-md-3 mb-3">
<label for="{{ form.polarization.id_for_label }}" class="form-label">{{ form.polarization.label }}</label>
{{ form.polarization }}
</div>
<!-- Модуляция -->
<div class="col-md-3 mb-3">
<label for="{{ form.modulation.id_for_label }}" class="form-label">{{ form.modulation.label }}</label>
{{ form.modulation }}
</div>
</div>
<div class="row">
<!-- Центральная частота -->
<div class="col-md-3 mb-3">
<label class="form-label">Центральная частота (МГц)</label>
<div class="input-group">
{{ form.frequency_min }}
<span class="input-group-text"></span>
{{ form.frequency_max }}
</div>
</div>
<!-- Полоса -->
<div class="col-md-3 mb-3">
<label class="form-label">Полоса (МГц)</label>
<div class="input-group">
{{ form.freq_range_min }}
<span class="input-group-text"></span>
{{ form.freq_range_max }}
</div>
</div>
<!-- Тип объекта -->
<div class="col-md-3 mb-3">
<label for="{{ form.object_type.id_for_label }}" class="form-label">{{ form.object_type.label }}</label>
{{ form.object_type }}
</div>
<!-- Принадлежность объекта (заглушка) -->
<div class="col-md-3 mb-3">
<label for="{{ form.object_ownership.id_for_label }}" class="form-label">{{ form.object_ownership.label }} <span class="badge bg-secondary">Заглушка</span></label>
{{ form.object_ownership }}
</div>
</div>
<div class="row">
<!-- Количество ObjItem -->
<div class="col-md-3 mb-3">
<label class="form-label">{{ form.objitem_count.label }}</label>
<div>
{% for radio in form.objitem_count %}
<div class="form-check">
{{ radio.tag }}
<label class="form-check-label" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div>
</div>
<!-- Планы на (фиктивный) -->
<div class="col-md-3 mb-3">
<label class="form-label">{{ form.has_plans.label }} <span class="badge bg-secondary">Фиктивный</span></label>
<div>
{% for radio in form.has_plans %}
<div class="form-check">
{{ radio.tag }}
<label class="form-check-label" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div>
</div>
<!-- Успех 1 (фиктивный) -->
<div class="col-md-3 mb-3">
<label class="form-label">{{ form.success_1.label }} <span class="badge bg-secondary">Фиктивный</span></label>
<div>
{% for radio in form.success_1 %}
<div class="form-check">
{{ radio.tag }}
<label class="form-check-label" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div>
</div>
<!-- Успех 2 (фиктивный) -->
<div class="col-md-3 mb-3">
<label class="form-label">{{ form.success_2.label }} <span class="badge bg-secondary">Фиктивный</span></label>
<div>
{% for radio in form.success_2 %}
<div class="form-check">
{{ radio.tag }}
<label class="form-check-label" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="row">
<!-- Диапазон дат (фиктивный) -->
<div class="col-md-6 mb-3">
<label class="form-label">Диапазон дат <span class="badge bg-secondary">Фиктивный</span></label>
<div class="input-group">
{{ form.date_from }}
<span class="input-group-text"></span>
{{ form.date_to }}
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<button type="submit" class="btn btn-primary">Применить фильтры</button>
<a href="{% url 'mainapp:kubsat' %}" class="btn btn-secondary">Сбросить</a>
</div>
</div>
</div>
</div>
</form>
<!-- Кнопки экспорта и фильтрации -->
{% if sources_with_date_info %}
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center gap-3">
<button type="button" class="btn btn-success" onclick="exportToExcel()">
<i class="bi bi-file-earmark-excel"></i> Экспорт в Excel
</button>
<button type="button" class="btn btn-warning" onclick="filterByDateMatch()">
<i class="bi bi-funnel"></i> Оставить только подходящие по дате
</button>
<span class="ms-auto text-muted"></span>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Таблица результатов -->
{% if sources_with_date_info %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 75vh; overflow-y: auto;">
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;" id="resultsTable">
<thead class="table-dark sticky-top">
<tr>
<th style="min-width: 80px;">ID Source</th>
<th style="min-width: 120px;">Тип объекта</th>
<th class="text-center" style="min-width: 100px;">Кол-во точек</th>
<th style="min-width: 120px;">Имя точки</th>
<th style="min-width: 150px;">Спутник</th>
<th style="min-width: 100px;">Частота (МГц)</th>
<th style="min-width: 100px;">Полоса (МГц)</th>
<th style="min-width: 100px;">Поляризация</th>
<th style="min-width: 100px;">Модуляция</th>
<th style="min-width: 150px;">Координаты ГЛ</th>
<th style="min-width: 100px;">Дата ГЛ</th>
<th style="min-width: 150px;">Действия</th>
</tr>
</thead>
<tbody>
{% for source_data in sources_with_date_info %}
{% for objitem_data in source_data.objitems_data %}
<tr data-source-id="{{ source_data.source.id }}"
data-objitem-id="{{ objitem_data.objitem.id }}"
class="{% if objitem_data.matches_date %}table-success{% endif %}"
data-matches-date="{{ objitem_data.matches_date|yesno:'true,false' }}"
data-is-first-in-source="{% if forloop.first %}true{% else %}false{% endif %}">
<!-- ID Source (только для первой строки источника) -->
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="source-id-cell">{{ source_data.source.id }}</td>
{% endif %}
<!-- Тип объекта (только для первой строки источника) -->
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="source-type-cell">{{ source_data.source.info.name|default:"-" }}</td>
{% endif %}
<!-- Количество точек (только для первой строки источника) -->
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-count-cell" data-initial-count="{{ source_data.objitems_data|length }}">{{ source_data.objitems_data|length }}</td>
{% endif %}
<!-- Имя точки -->
<td>{{ objitem_data.objitem.name|default:"-" }}</td>
<!-- Спутник -->
<td>
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.id_satellite %}
{{ objitem_data.objitem.parameter_obj.id_satellite.name }}
{% if objitem_data.objitem.parameter_obj.id_satellite.norad %}
({{ objitem_data.objitem.parameter_obj.id_satellite.norad }})
{% endif %}
{% else %}
-
{% endif %}
</td>
<!-- Частота -->
<td>
{% if objitem_data.objitem.parameter_obj %}
{{ objitem_data.objitem.parameter_obj.frequency|default:"-" }}
{% else %}
-
{% endif %}
</td>
<!-- Полоса -->
<td>
{% if objitem_data.objitem.parameter_obj %}
{{ objitem_data.objitem.parameter_obj.freq_range|default:"-" }}
{% else %}
-
{% endif %}
</td>
<!-- Поляризация -->
<td>
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.polarization %}
{{ objitem_data.objitem.parameter_obj.polarization.name }}
{% else %}
-
{% endif %}
</td>
<!-- Модуляция -->
<td>
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.modulation %}
{{ objitem_data.objitem.parameter_obj.modulation.name }}
{% else %}
-
{% endif %}
</td>
<!-- Координаты ГЛ -->
<td>
{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}
{{ objitem_data.objitem.geo_obj.coords.y|floatformat:6 }}, {{ objitem_data.objitem.geo_obj.coords.x|floatformat:6 }}
{% else %}
-
{% endif %}
</td>
<!-- Дата ГЛ -->
<td>
{% if objitem_data.geo_date %}
{{ objitem_data.geo_date|date:"d.m.Y" }}
{% else %}
-
{% endif %}
</td>
<!-- Действия -->
<td>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-danger" onclick="removeObjItem(this)" title="Удалить точку">
<i class="bi bi-trash"></i>
</button>
{% if forloop.first %}
<button type="button" class="btn btn-sm btn-warning" onclick="removeSource(this)" title="Удалить весь объект">
<i class="bi bi-trash-fill"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% elif request.GET %}
<div class="alert alert-info">
По заданным критериям ничего не найдено.
</div>
{% endif %}
</div>
<script>
function removeObjItem(button) {
// Удаляем строку точки из таблицы (не из базы данных)
const row = button.closest('tr');
const sourceId = row.dataset.sourceId;
const isFirstInSource = row.dataset.isFirstInSource === 'true';
// Получаем все строки этого источника
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
if (sourceRows.length === 1) {
// Последняя строка источника - просто удаляем
row.remove();
} else if (isFirstInSource) {
// Удаляем первую строку - нужно перенести ячейки с rowspan на следующую строку
const nextRow = sourceRows[1];
// Находим ячейки с rowspan (ID Source, Тип объекта, Количество точек)
const sourceIdCell = row.querySelector('.source-id-cell');
const sourceTypeCell = row.querySelector('.source-type-cell');
const sourceCountCell = row.querySelector('.source-count-cell');
if (sourceIdCell && sourceTypeCell && sourceCountCell) {
const currentRowspan = parseInt(sourceIdCell.getAttribute('rowspan'));
const newRowspan = currentRowspan - 1;
// Создаем новые ячейки для следующей строки
const newSourceIdCell = sourceIdCell.cloneNode(true);
const newSourceTypeCell = sourceTypeCell.cloneNode(true);
const newSourceCountCell = sourceCountCell.cloneNode(true);
newSourceIdCell.setAttribute('rowspan', newRowspan);
newSourceTypeCell.setAttribute('rowspan', newRowspan);
newSourceCountCell.setAttribute('rowspan', newRowspan);
// Обновляем счетчик точек
newSourceCountCell.textContent = newRowspan;
// Вставляем ячейки в начало следующей строки
nextRow.insertBefore(newSourceCountCell, nextRow.firstChild);
nextRow.insertBefore(newSourceTypeCell, nextRow.firstChild);
nextRow.insertBefore(newSourceIdCell, nextRow.firstChild);
// Переносим кнопку "Удалить объект" на следующую строку
const actionsCell = nextRow.querySelector('td:last-child');
if (actionsCell) {
const btnGroup = actionsCell.querySelector('.btn-group');
if (btnGroup && btnGroup.children.length === 1) {
// Добавляем кнопку удаления объекта
const deleteSourceBtn = document.createElement('button');
deleteSourceBtn.type = 'button';
deleteSourceBtn.className = 'btn btn-sm btn-warning';
deleteSourceBtn.onclick = function() { removeSource(this); };
deleteSourceBtn.title = 'Удалить весь объект';
deleteSourceBtn.innerHTML = '<i class="bi bi-trash-fill"></i>';
btnGroup.appendChild(deleteSourceBtn);
}
}
}
// Обновляем data-is-first-in-source для следующей строки
nextRow.dataset.isFirstInSource = 'true';
// Удаляем текущую строку
row.remove();
} else {
// Удаляем не первую строку - уменьшаем rowspan в первой строке
const firstRow = sourceRows[0];
const sourceIdCell = firstRow.querySelector('.source-id-cell');
const sourceTypeCell = firstRow.querySelector('.source-type-cell');
const sourceCountCell = firstRow.querySelector('.source-count-cell');
if (sourceIdCell && sourceTypeCell && sourceCountCell) {
const currentRowspan = parseInt(sourceIdCell.getAttribute('rowspan'));
const newRowspan = currentRowspan - 1;
sourceIdCell.setAttribute('rowspan', newRowspan);
sourceTypeCell.setAttribute('rowspan', newRowspan);
sourceCountCell.setAttribute('rowspan', newRowspan);
// Обновляем счетчик точек
sourceCountCell.textContent = newRowspan;
}
// Удаляем текущую строку
row.remove();
}
// Обновляем общий счетчик
updateCounter();
}
function removeSource(button) {
// Удаляем все строки источника из таблицы (не из базы данных)
const row = button.closest('tr');
const sourceId = row.dataset.sourceId;
// Находим все строки с этим source_id и удаляем их
const rows = document.querySelectorAll(`tr[data-source-id="${sourceId}"]`);
rows.forEach(r => r.remove());
// Обновляем счетчик
updateCounter();
}
function updateCounter() {
const rows = document.querySelectorAll('#resultsTable tbody tr');
const counter = document.querySelector('.text-muted');
if (counter) {
// Подсчитываем уникальные источники
const uniqueSources = new Set();
let matchingCount = 0;
rows.forEach(row => {
uniqueSources.add(row.dataset.sourceId);
if (row.dataset.matchesDate === 'true') {
matchingCount++;
}
});
counter.textContent = `Найдено объектов: ${uniqueSources.size}, точек: ${rows.length} (подходящих по дате: ${matchingCount})`;
}
}
function filterByDateMatch() {
// Удаляем все строки, которые не подходят по дате
const rows = Array.from(document.querySelectorAll('#resultsTable tbody tr'));
const rowsToRemove = rows.filter(row => row.dataset.matchesDate !== 'true');
if (rowsToRemove.length === 0) {
alert('Все точки уже подходят по дате или фильтр по дате не задан');
return;
}
if (confirm(`Удалить ${rowsToRemove.length} точек, не подходящих по дате?`)) {
// Группируем строки по источникам
const sourceGroups = {};
rows.forEach(row => {
const sourceId = row.dataset.sourceId;
if (!sourceGroups[sourceId]) {
sourceGroups[sourceId] = [];
}
sourceGroups[sourceId].push(row);
});
// Обрабатываем каждый источник отдельно
Object.keys(sourceGroups).forEach(sourceId => {
const sourceRows = sourceGroups[sourceId];
const rowsToKeep = sourceRows.filter(row => row.dataset.matchesDate === 'true');
const rowsToDelete = sourceRows.filter(row => row.dataset.matchesDate !== 'true');
if (rowsToDelete.length === 0) {
return; // Все строки подходят
}
if (rowsToKeep.length === 0) {
// Удаляем все строки источника
rowsToDelete.forEach(row => row.remove());
} else {
// Есть строки для сохранения
// Удаляем строки по одной, используя функцию removeObjItem
rowsToDelete.forEach(row => {
const button = row.querySelector('button[onclick*="removeObjItem"]');
if (button) {
removeObjItem(button);
}
});
}
});
updateCounter();
}
}
function exportToExcel() {
// Собираем ID оставшихся в таблице точек (ObjItem)
const rows = document.querySelectorAll('#resultsTable tbody tr');
const objitemIds = Array.from(rows).map(row => row.dataset.objitemId);
if (objitemIds.length === 0) {
alert('Нет данных для экспорта');
return;
}
// Создаем форму для отправки
const form = document.createElement('form');
form.method = 'POST';
form.action = '{% url "mainapp:kubsat_export" %}';
// Добавляем CSRF токен
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
if (csrfToken) {
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = csrfToken.value;
form.appendChild(csrfInput);
}
// Добавляем ID точек
objitemIds.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'objitem_ids';
input.value = id;
form.appendChild(input);
});
// Отправляем форму
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}
// Обновляем счетчик при загрузке страницы
document.addEventListener('DOMContentLoaded', function() {
updateCounter();
});
</script>
<style>
.table th {
white-space: nowrap;
}
.badge {
font-size: 0.7rem;
}
.form-check {
margin-bottom: 0.25rem;
}
/* Выделение строк, подходящих по дате */
.table-success {
background-color: #d1e7dd !important;
}
/* Стили для кнопок действий */
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
/* Sticky header */
.sticky-top {
position: sticky;
top: 0;
z-index: 10;
}
</style>
{% endblock %}

View File

@@ -195,7 +195,7 @@
<!-- Valid Coordinates Filter -->
<div class="mb-2">
<label class="form-label">Координаты оперативников:</label>
<label class="form-label">Координаты визуального наблюдения:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_coords_valid" id="has_coords_valid_1"
@@ -435,15 +435,15 @@
<th scope="col" style="min-width: 150px;">Имя</th>
<th scope="col" style="min-width: 120px;">Спутник</th>
<th scope="col" style="min-width: 120px;">Тип объекта</th>
<th scope="col" style="min-width: 150px;">Усредненные координаты</th>
<th scope="col" style="min-width: 150px;">Координаты ГЛ</th>
<th scope="col" class="text-center" style="min-width: 100px;">
{% include 'mainapp/components/_sort_header.html' with field='objitem_count' label='Кол-во ГЛ(точек)' current_sort=sort %}
</th>
<th scope="col" style="min-width: 150px;">Координаты Кубсата</th>
<th scope="col" style="min-width: 150px;">Координаты оперативников</th>
<th scope="col" style="min-width: 150px;">Координаты визуального наблюдения</th>
<th scope="col" style="min-width: 150px;">Координаты справочные</th>
<th scope="col" style="min-width: 180px;">Наличие сигнала</th>
<th scope="col" class="text-center" style="min-width: 80px;">ТВ или нет</th>
<th scope="col" class="text-center" style="min-width: 100px;">
{% include 'mainapp/components/_sort_header.html' with field='objitem_count' label='Кол-во точек' current_sort=sort %}
</th>
<th scope="col" style="min-width: 120px;">
{% include 'mainapp/components/_sort_header.html' with field='created_at' label='Создано' current_sort=sort %}
</th>
@@ -474,6 +474,7 @@
</td>
<td>{{ source.info }}</td>
<td>{{ source.coords_average }}</td>
<td class="text-center">{{ source.objitem_count }}</td>
<td>{{ source.coords_kupsat }}</td>
<td>{{ source.coords_valid }}</td>
<td>{{ source.coords_reference }}</td>
@@ -510,7 +511,7 @@
-
{% endif %}
</td>
<td class="text-center">{{ source.objitem_count }}</td>
<td>{{ source.created_at|date:"d.m.Y H:i" }}</td>
<td>{{ source.updated_at|date:"d.m.Y H:i" }}</td>
<td class="text-center">
@@ -704,8 +705,8 @@
{% endblock %}
{% 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 src="{% static 'leaflet/leaflet.js' %}"></script>
<script src="{% static 'leaflet-draw/leaflet.draw.js' %}"></script>
<script>
// Polygon filter map variables
let polygonFilterMapInstance = null;

View File

@@ -278,10 +278,6 @@
map.addControl(new maplibregl.NavigationControl(), 'top-right');
map.addControl(new maplibregl.ScaleControl({ unit: 'metric' }), 'bottom-right');
map.addControl(new maplibregl.FullscreenControl(), 'top-right');
map.addControl(new maplibregl.GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: true
}), 'top-right');
// Кастомный контрол для переключения проекции
class ProjectionControl {
@@ -307,60 +303,6 @@
}
}
// Кастомный контрол для 3D зданий
class Buildings3DControl {
onAdd(map) {
this._map = map;
this._container = document.createElement('div');
this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group maplibregl-ctrl-3d';
this._container.innerHTML = '<button type="button" title="3D здания"><span class="maplibregl-ctrl-icon"></span></button>';
this._container.onclick = () => {
if (is3DEnabled) {
if (map.getLayer('3d-buildings')) {
map.removeLayer('3d-buildings');
}
is3DEnabled = false;
this._container.querySelector('button').style.backgroundColor = '';
} else {
this.add3DBuildings();
is3DEnabled = true;
this._container.querySelector('button').style.backgroundColor = '#e3f2fd';
}
};
return this._container;
}
add3DBuildings() {
if (this._map.getLayer('3d-buildings')) return;
const layers = this._map.getStyle().layers;
let labelLayerId;
for (let i = 0; i < layers.length; i++) {
if (layers[i].type === 'symbol' && layers[i].layout && layers[i].layout['text-field']) {
labelLayerId = layers[i].id;
break;
}
}
this._map.addLayer({
'id': '3d-buildings',
'source': 'composite',
'source-layer': 'building',
'filter': ['==', 'extrude', 'true'],
'type': 'fill-extrusion',
'minzoom': 15,
'paint': {
'fill-extrusion-color': '#aaa',
'fill-extrusion-height': ['get', 'height'],
'fill-extrusion-base': ['get', 'min_height'],
'fill-extrusion-opacity': 0.6
}
}, labelLayerId);
}
onRemove() {
this._container.parentNode.removeChild(this._container);
this._map = undefined;
}
}
// Кастомный контрол для переключения стилей
class StyleControl {
@@ -563,7 +505,6 @@
// Добавляем кастомные контролы
map.addControl(new ProjectionControl(), 'top-right');
map.addControl(new Buildings3DControl(), 'top-right');
map.addControl(new StyleControl(), 'top-right');
map.addControl(new LayersControl(), 'top-left');
map.addControl(new LegendControl(), 'bottom-left');