Добавил отметки с источниками
This commit is contained in:
@@ -1620,6 +1620,19 @@ function showPlaybackAnimation() {
|
|||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to show source marks for selected sources
|
||||||
|
function showSourceMarks() {
|
||||||
|
if (!window.selectedSources || window.selectedSources.length === 0) {
|
||||||
|
alert('Список источников пуст');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIds = window.selectedSources.map(source => source.id);
|
||||||
|
const url = '{% url "mainapp:source_marks" %}' + '?ids=' + selectedIds.join(',');
|
||||||
|
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
// Function to merge selected sources
|
// Function to merge selected sources
|
||||||
function mergeSelectedSources() {
|
function mergeSelectedSources() {
|
||||||
if (!window.selectedSources || window.selectedSources.length < 2) {
|
if (!window.selectedSources || window.selectedSources.length < 2) {
|
||||||
@@ -2226,6 +2239,9 @@ function showTransponderModal(transponderId) {
|
|||||||
<button type="button" class="btn btn-outline-info btn-sm" onclick="showPlaybackAnimation()" title="Анимация движения объектов">
|
<button type="button" class="btn btn-outline-info btn-sm" onclick="showPlaybackAnimation()" title="Анимация движения объектов">
|
||||||
<i class="bi bi-play-circle"></i> Анимация
|
<i class="bi bi-play-circle"></i> Анимация
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-warning btn-sm" onclick="showSourceMarks()" title="Отметки сигналов по источникам">
|
||||||
|
<i class="bi bi-check2-square"></i> Отметки
|
||||||
|
</button>
|
||||||
{% if user|has_perm:'source_merge' %}
|
{% if user|has_perm:'source_merge' %}
|
||||||
<button type="button" class="btn btn-outline-success btn-sm" onclick="mergeSelectedSources()">
|
<button type="button" class="btn btn-outline-success btn-sm" onclick="mergeSelectedSources()">
|
||||||
<i class="bi bi-union"></i> Объединить
|
<i class="bi bi-union"></i> Объединить
|
||||||
|
|||||||
483
dbapp/mainapp/templates/mainapp/source_marks.html
Normal file
483
dbapp/mainapp/templates/mainapp/source_marks.html
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
{% extends "mainapp/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Отметки сигналов по источникам{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.mark-cell { text-align: center; padding: 4px 6px; font-size: 0.8rem; white-space: nowrap; }
|
||||||
|
.mark-present { background-color: #d4edda !important; color: #155724; }
|
||||||
|
.mark-absent { background-color: #f8d7da !important; color: #721c24; }
|
||||||
|
.mark-empty { background-color: #f8f9fa; color: #adb5bd; }
|
||||||
|
|
||||||
|
.filter-panel { background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 0.375rem; padding: 1rem; margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
.history-table { font-size: 0.85rem; border-collapse: collapse; border-spacing: 0; }
|
||||||
|
.history-table th { position: sticky; top: 0; background: #343a40; color: white; font-weight: 500; white-space: nowrap; padding: 6px 8px; font-size: 0.75rem; z-index: 5; border: 1px solid #495057; }
|
||||||
|
.history-table td { padding: 4px 6px; vertical-align: middle; border: 1px solid #dee2e6; }
|
||||||
|
|
||||||
|
/* Фиксированные колонки */
|
||||||
|
.history-table .sticky-col { position: sticky; background: #f8f9fa; z-index: 2; box-sizing: border-box; }
|
||||||
|
.history-table thead .sticky-col { background: #343a40; z-index: 10; }
|
||||||
|
.history-table tbody tr:hover .sticky-col { background: #e9ecef; }
|
||||||
|
|
||||||
|
/* Жёсткие размеры для фиксированных колонок */
|
||||||
|
.history-table .col-name { left: 0; width: 200px !important; min-width: 200px !important; max-width: 200px !important; white-space: normal; word-break: break-word; }
|
||||||
|
.history-table .col-coords { left: 200px; width: 150px !important; min-width: 150px !important; max-width: 150px !important; text-align: center; font-size: 0.75rem; }
|
||||||
|
.history-table .col-satellite { left: 350px; width: 100px !important; min-width: 100px !important; max-width: 100px !important; text-align: center; }
|
||||||
|
.history-table .col-frequency { left: 450px; width: 80px !important; min-width: 80px !important; max-width: 80px !important; text-align: center; }
|
||||||
|
.history-table .col-freq_range { left: 530px; width: 70px !important; min-width: 70px !important; max-width: 70px !important; text-align: center; }
|
||||||
|
.history-table .col-polarization { left: 600px; width: 70px !important; min-width: 70px !important; max-width: 70px !important; text-align: center; }
|
||||||
|
.history-table .col-modulation { left: 670px; width: 70px !important; min-width: 70px !important; max-width: 70px !important; text-align: center; }
|
||||||
|
.history-table .col-bod_velocity { left: 740px; width: 80px !important; min-width: 80px !important; max-width: 80px !important; text-align: center; }
|
||||||
|
|
||||||
|
.history-wrapper { max-height: 75vh; overflow: auto; }
|
||||||
|
.col-hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* Отметки в ячейке */
|
||||||
|
.marks-container { display: flex; gap: 3px; justify-content: center; align-items: center; flex-wrap: nowrap; }
|
||||||
|
.mark-icon { font-size: 0.85rem; font-weight: bold; cursor: default; padding: 1px 3px; border-radius: 2px; }
|
||||||
|
.mark-icon-yes { color: #155724; background: #d4edda; }
|
||||||
|
.mark-icon-no { color: #721c24; background: #f8d7da; }
|
||||||
|
|
||||||
|
.history-table td.mark-cell-container { white-space: nowrap; min-width: 40px; }
|
||||||
|
|
||||||
|
.tooltip-inner { text-align: left !important; max-width: 300px; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-3">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<h2>
|
||||||
|
<i class="bi bi-check2-square"></i> Отметки сигналов по источникам
|
||||||
|
<span class="badge bg-info">{{ source_ids|length }} источников</span>
|
||||||
|
</h2>
|
||||||
|
<a href="{% url 'mainapp:source_list' %}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left"></i> Назад к списку
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-panel">
|
||||||
|
<div class="row align-items-end g-2">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Поиск (имя или частота):</label>
|
||||||
|
<input type="text" id="history-search" class="form-control form-control-sm" placeholder="Введите имя или частоту..." oninput="filterHistoryTable()">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="loadData()">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i> Обновить
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#filtersOffcanvas">
|
||||||
|
<i class="bi bi-funnel"></i> Фильтры <span id="filterCounter" class="badge bg-danger" style="display: none;">0</span>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||||
|
<i class="bi bi-gear"></i> Колонки
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu" style="min-width: 200px;">
|
||||||
|
<li><label class="dropdown-item"><input type="checkbox" id="col-toggle-all" onchange="toggleAllColumns(this)"> Выбрать всё</label></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><label class="dropdown-item"><input type="checkbox" class="col-toggle" data-col="name" checked onchange="toggleColumn(this)"> Имя</label></li>
|
||||||
|
<li><label class="dropdown-item"><input type="checkbox" class="col-toggle" data-col="coords" checked onchange="toggleColumn(this)"> Координаты</label></li>
|
||||||
|
<li><label class="dropdown-item"><input type="checkbox" class="col-toggle" data-col="satellite" checked onchange="toggleColumn(this)"> Спутник</label></li>
|
||||||
|
<li><label class="dropdown-item"><input type="checkbox" class="col-toggle" data-col="frequency" onchange="toggleColumn(this)"> Частота</label></li>
|
||||||
|
<li><label class="dropdown-item"><input type="checkbox" class="col-toggle" data-col="freq_range" onchange="toggleColumn(this)"> Полоса</label></li>
|
||||||
|
<li><label class="dropdown-item"><input type="checkbox" class="col-toggle" data-col="polarization" onchange="toggleColumn(this)"> Поляризация</label></li>
|
||||||
|
<li><label class="dropdown-item"><input type="checkbox" class="col-toggle" data-col="modulation" onchange="toggleColumn(this)"> Модуляция</label></li>
|
||||||
|
<li><label class="dropdown-item"><input type="checkbox" class="col-toggle" data-col="bod_velocity" onchange="toggleColumn(this)"> Бодовая скорость</label></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<span id="dateRangeInfo" class="text-muted small"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="history-wrapper" id="data-container">
|
||||||
|
<div class="text-center p-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2">Загрузка данных...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Offcanvas Filters -->
|
||||||
|
<div class="offcanvas offcanvas-start" tabindex="-1" id="filtersOffcanvas" aria-labelledby="filtersLabel">
|
||||||
|
<div class="offcanvas-header">
|
||||||
|
<h5 class="offcanvas-title" id="filtersLabel">Фильтры</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="offcanvas-body">
|
||||||
|
<!-- Polarization -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold">Поляризация:</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="selectAllFilterOptions('filter-polarization', true)">Все</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="selectAllFilterOptions('filter-polarization', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
<select id="filter-polarization" class="form-select form-select-sm" multiple size="5">
|
||||||
|
{% for pol in polarizations %}
|
||||||
|
<option value="{{ pol.id }}">{{ pol.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modulation -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold">Модуляция:</label>
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="selectAllFilterOptions('filter-modulation', true)">Все</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="selectAllFilterOptions('filter-modulation', false)">Снять</button>
|
||||||
|
</div>
|
||||||
|
<select id="filter-modulation" class="form-select form-select-sm" multiple size="5">
|
||||||
|
{% for mod in modulations %}
|
||||||
|
<option value="{{ mod.id }}">{{ mod.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Frequency Range -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold">Частота (МГц):</label>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6"><input type="number" id="filter-freq-min" class="form-control form-control-sm" placeholder="От" step="0.001"></div>
|
||||||
|
<div class="col-6"><input type="number" id="filter-freq-max" class="form-control form-control-sm" placeholder="До" step="0.001"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bandwidth Range -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold">Полоса (МГц):</label>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6"><input type="number" id="filter-freq-range-min" class="form-control form-control-sm" placeholder="От" step="0.001"></div>
|
||||||
|
<div class="col-6"><input type="number" id="filter-freq-range-max" class="form-control form-control-sm" placeholder="До" step="0.001"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Baud Velocity Range -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold">Бодовая скорость:</label>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6"><input type="number" id="filter-bod-min" class="form-control form-control-sm" placeholder="От" step="0.1"></div>
|
||||||
|
<div class="col-6"><input type="number" id="filter-bod-max" class="form-control form-control-sm" placeholder="До" step="0.1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button class="btn btn-primary" onclick="loadData(); bootstrap.Offcanvas.getInstance(document.getElementById('filtersOffcanvas')).hide();">
|
||||||
|
Применить фильтры
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary" onclick="clearAllFilters()">
|
||||||
|
Очистить фильтры
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
const sourceIds = {{ source_ids_json|safe }};
|
||||||
|
|
||||||
|
// Column visibility state
|
||||||
|
const COLUMN_STORAGE_KEY = 'sourceMarksColumns';
|
||||||
|
let columnVisibility = {
|
||||||
|
name: true,
|
||||||
|
coords: true,
|
||||||
|
satellite: true,
|
||||||
|
frequency: false,
|
||||||
|
freq_range: false,
|
||||||
|
polarization: false,
|
||||||
|
modulation: false,
|
||||||
|
bod_velocity: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load column visibility from localStorage
|
||||||
|
function loadColumnVisibility() {
|
||||||
|
const saved = localStorage.getItem(COLUMN_STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
columnVisibility = JSON.parse(saved);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.col-toggle').forEach(cb => {
|
||||||
|
const col = cb.dataset.col;
|
||||||
|
cb.checked = columnVisibility[col] !== false;
|
||||||
|
});
|
||||||
|
updateSelectAllCheckbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveColumnVisibility() {
|
||||||
|
localStorage.setItem(COLUMN_STORAGE_KEY, JSON.stringify(columnVisibility));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleColumn(checkbox) {
|
||||||
|
const col = checkbox.dataset.col;
|
||||||
|
columnVisibility[col] = checkbox.checked;
|
||||||
|
saveColumnVisibility();
|
||||||
|
applyColumnVisibility();
|
||||||
|
updateSelectAllCheckbox();
|
||||||
|
updateStickyPositions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllColumns(checkbox) {
|
||||||
|
const checked = checkbox.checked;
|
||||||
|
document.querySelectorAll('.col-toggle').forEach(cb => {
|
||||||
|
cb.checked = checked;
|
||||||
|
columnVisibility[cb.dataset.col] = checked;
|
||||||
|
});
|
||||||
|
saveColumnVisibility();
|
||||||
|
applyColumnVisibility();
|
||||||
|
updateStickyPositions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectAllCheckbox() {
|
||||||
|
const checkboxes = document.querySelectorAll('.col-toggle');
|
||||||
|
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
|
||||||
|
document.getElementById('col-toggle-all').checked = allChecked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyColumnVisibility() {
|
||||||
|
const cols = ['name', 'coords', 'satellite', 'frequency', 'freq_range', 'polarization', 'modulation', 'bod_velocity'];
|
||||||
|
cols.forEach(col => {
|
||||||
|
const cells = document.querySelectorAll(`.col-${col}`);
|
||||||
|
cells.forEach(cell => {
|
||||||
|
if (columnVisibility[col]) {
|
||||||
|
cell.classList.remove('col-hidden');
|
||||||
|
} else {
|
||||||
|
cell.classList.add('col-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStickyPositions() {
|
||||||
|
const colWidths = {
|
||||||
|
name: 200,
|
||||||
|
coords: 150,
|
||||||
|
satellite: 100,
|
||||||
|
frequency: 80,
|
||||||
|
freq_range: 70,
|
||||||
|
polarization: 70,
|
||||||
|
modulation: 70,
|
||||||
|
bod_velocity: 80
|
||||||
|
};
|
||||||
|
const cols = ['name', 'coords', 'satellite', 'frequency', 'freq_range', 'polarization', 'modulation', 'bod_velocity'];
|
||||||
|
let leftPos = 0;
|
||||||
|
|
||||||
|
cols.forEach(col => {
|
||||||
|
const cells = document.querySelectorAll(`.col-${col}`);
|
||||||
|
if (columnVisibility[col]) {
|
||||||
|
cells.forEach(cell => {
|
||||||
|
cell.style.left = leftPos + 'px';
|
||||||
|
});
|
||||||
|
leftPos += colWidths[col];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadData() {
|
||||||
|
const container = document.getElementById('data-container');
|
||||||
|
container.innerHTML = '<div class="text-center p-4"><div class="spinner-border"></div> Загрузка...</div>';
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
ids: sourceIds.join(',')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add filter params
|
||||||
|
const polSelect = document.getElementById('filter-polarization');
|
||||||
|
Array.from(polSelect.selectedOptions).forEach(opt => params.append('polarization_id', opt.value));
|
||||||
|
|
||||||
|
const modSelect = document.getElementById('filter-modulation');
|
||||||
|
Array.from(modSelect.selectedOptions).forEach(opt => params.append('modulation_id', opt.value));
|
||||||
|
|
||||||
|
const filterMapping = {
|
||||||
|
'freq-min': 'freq_min',
|
||||||
|
'freq-max': 'freq_max',
|
||||||
|
'freq-range-min': 'freq_range_min',
|
||||||
|
'freq-range-max': 'freq_range_max',
|
||||||
|
'bod-min': 'bod_velocity_min',
|
||||||
|
'bod-max': 'bod_velocity_max'
|
||||||
|
};
|
||||||
|
Object.entries(filterMapping).forEach(([id, paramName]) => {
|
||||||
|
const el = document.getElementById('filter-' + id);
|
||||||
|
if (el && el.value) {
|
||||||
|
params.append(paramName, el.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch(`{% url 'mainapp:source_marks_api' %}?${params}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
container.innerHTML = `<div class="alert alert-danger m-3">${data.error}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.message && (!data.data || data.data.length === 0)) {
|
||||||
|
container.innerHTML = `<div class="alert alert-info m-3">${data.message}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update date range info
|
||||||
|
if (data.date_range) {
|
||||||
|
document.getElementById('dateRangeInfo').textContent = data.date_range;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTable(data);
|
||||||
|
updateFilterCounter();
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
container.innerHTML = `<div class="alert alert-danger m-3">Ошибка загрузки: ${err}</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(data) {
|
||||||
|
const container = document.getElementById('data-container');
|
||||||
|
|
||||||
|
if (!data.data || data.data.length === 0) {
|
||||||
|
container.innerHTML = '<div class="alert alert-info m-3">Нет данных для отображения</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<table class="table table-bordered table-sm history-table"><thead><tr>';
|
||||||
|
html += '<th class="sticky-col col-name">Имя</th>';
|
||||||
|
html += '<th class="sticky-col col-coords">Координаты ГЛ</th>';
|
||||||
|
html += '<th class="sticky-col col-satellite">Спутник</th>';
|
||||||
|
html += '<th class="sticky-col col-frequency">Частота</th>';
|
||||||
|
html += '<th class="sticky-col col-freq_range">Полоса</th>';
|
||||||
|
html += '<th class="sticky-col col-polarization">Пол.</th>';
|
||||||
|
html += '<th class="sticky-col col-modulation">Мод.</th>';
|
||||||
|
html += '<th class="sticky-col col-bod_velocity">Бод. скор.</th>';
|
||||||
|
|
||||||
|
if (data.periods && data.periods.length > 0) {
|
||||||
|
data.periods.forEach(p => { html += `<th class="mark-cell">${p}</th>`; });
|
||||||
|
}
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
data.data.forEach(row => {
|
||||||
|
html += '<tr>';
|
||||||
|
html += `<td class="sticky-col col-name">${row.name}</td>`;
|
||||||
|
html += `<td class="sticky-col col-coords">${row.coords_average || '-'}</td>`;
|
||||||
|
html += `<td class="sticky-col col-satellite">${row.satellite || '-'}</td>`;
|
||||||
|
html += `<td class="sticky-col col-frequency">${row.frequency || '-'}</td>`;
|
||||||
|
html += `<td class="sticky-col col-freq_range">${row.freq_range || '-'}</td>`;
|
||||||
|
html += `<td class="sticky-col col-polarization">${row.polarization}</td>`;
|
||||||
|
html += `<td class="sticky-col col-modulation">${row.modulation}</td>`;
|
||||||
|
html += `<td class="sticky-col col-bod_velocity">${row.bod_velocity || '-'}</td>`;
|
||||||
|
|
||||||
|
if (row.marks) {
|
||||||
|
row.marks.forEach(m => {
|
||||||
|
if (m && m.items && m.items.length > 0) {
|
||||||
|
const sortedItems = [...m.items].sort((a, b) => {
|
||||||
|
const timeA = a.time.split(':').map(Number);
|
||||||
|
const timeB = b.time.split(':').map(Number);
|
||||||
|
return (timeA[0] * 60 + timeA[1]) - (timeB[0] * 60 + timeB[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const tooltipLines = sortedItems.map(item => {
|
||||||
|
const icon = item.mark ? '✓' : '✗';
|
||||||
|
return `${icon} ${item.time} - ${item.user}`;
|
||||||
|
});
|
||||||
|
const tooltipContent = tooltipLines.join('<br>');
|
||||||
|
|
||||||
|
let iconsHtml = '<div class="marks-container">';
|
||||||
|
sortedItems.forEach(item => {
|
||||||
|
const iconCls = item.mark ? 'mark-icon-yes' : 'mark-icon-no';
|
||||||
|
const icon = item.mark ? '✓' : '✗';
|
||||||
|
iconsHtml += `<span class="mark-icon ${iconCls}">${icon}</span>`;
|
||||||
|
});
|
||||||
|
iconsHtml += '</div>';
|
||||||
|
|
||||||
|
html += `<td class="mark-cell mark-cell-container" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="top" title="${tooltipContent.replace(/"/g, '"')}">${iconsHtml}</td>`;
|
||||||
|
} else {
|
||||||
|
html += '<td class="mark-cell mark-empty">-</td>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
loadColumnVisibility();
|
||||||
|
applyColumnVisibility();
|
||||||
|
updateStickyPositions();
|
||||||
|
|
||||||
|
// Initialize Bootstrap tooltips
|
||||||
|
const tooltipTriggerList = container.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||||
|
tooltipTriggerList.forEach(el => new bootstrap.Tooltip(el));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterHistoryTable() {
|
||||||
|
const search = document.getElementById('history-search').value.toLowerCase().trim();
|
||||||
|
const rows = document.querySelectorAll('#data-container tbody tr');
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const name = row.querySelector('.col-name')?.textContent.toLowerCase() || '';
|
||||||
|
const freq = row.querySelector('.col-frequency')?.textContent.toLowerCase() || '';
|
||||||
|
const match = !search || name.includes(search) || freq.includes(search);
|
||||||
|
row.style.display = match ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllFilters() {
|
||||||
|
document.getElementById('filter-polarization').selectedIndex = -1;
|
||||||
|
document.getElementById('filter-modulation').selectedIndex = -1;
|
||||||
|
document.getElementById('filter-freq-min').value = '';
|
||||||
|
document.getElementById('filter-freq-max').value = '';
|
||||||
|
document.getElementById('filter-freq-range-min').value = '';
|
||||||
|
document.getElementById('filter-freq-range-max').value = '';
|
||||||
|
document.getElementById('filter-bod-min').value = '';
|
||||||
|
document.getElementById('filter-bod-max').value = '';
|
||||||
|
updateFilterCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllFilterOptions(selectId, select) {
|
||||||
|
const el = document.getElementById(selectId);
|
||||||
|
Array.from(el.options).forEach(opt => opt.selected = select);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilterCounter() {
|
||||||
|
let count = 0;
|
||||||
|
if (document.getElementById('filter-polarization').selectedOptions.length > 0) count++;
|
||||||
|
if (document.getElementById('filter-modulation').selectedOptions.length > 0) count++;
|
||||||
|
if (document.getElementById('filter-freq-min').value) count++;
|
||||||
|
if (document.getElementById('filter-freq-max').value) count++;
|
||||||
|
if (document.getElementById('filter-freq-range-min').value) count++;
|
||||||
|
if (document.getElementById('filter-freq-range-max').value) count++;
|
||||||
|
if (document.getElementById('filter-bod-min').value) count++;
|
||||||
|
if (document.getElementById('filter-bod-max').value) count++;
|
||||||
|
|
||||||
|
const badge = document.getElementById('filterCounter');
|
||||||
|
if (count > 0) {
|
||||||
|
badge.textContent = count;
|
||||||
|
badge.style.display = '';
|
||||||
|
} else {
|
||||||
|
badge.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadColumnVisibility();
|
||||||
|
if (sourceIds && sourceIds.length > 0) {
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
document.getElementById('data-container').innerHTML = '<div class="alert alert-warning m-3">Не выбраны источники</div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -72,6 +72,10 @@ from .views.marks import (
|
|||||||
AddObjectMarkView,
|
AddObjectMarkView,
|
||||||
UpdateObjectMarkView,
|
UpdateObjectMarkView,
|
||||||
)
|
)
|
||||||
|
from .views.source_marks import (
|
||||||
|
SourceMarksView,
|
||||||
|
SourceMarksAPIView,
|
||||||
|
)
|
||||||
from .views.source_requests import (
|
from .views.source_requests import (
|
||||||
SourceRequestListView,
|
SourceRequestListView,
|
||||||
SourceRequestCreateView,
|
SourceRequestCreateView,
|
||||||
@@ -178,6 +182,9 @@ urlpatterns = [
|
|||||||
path('object-marks/', ObjectMarksListView.as_view(), name='object_marks'),
|
path('object-marks/', ObjectMarksListView.as_view(), name='object_marks'),
|
||||||
path('api/add-object-mark/', AddObjectMarkView.as_view(), name='add_object_mark'),
|
path('api/add-object-mark/', AddObjectMarkView.as_view(), name='add_object_mark'),
|
||||||
path('api/update-object-mark/', UpdateObjectMarkView.as_view(), name='update_object_mark'),
|
path('api/update-object-mark/', UpdateObjectMarkView.as_view(), name='update_object_mark'),
|
||||||
|
# Source Marks (отметки по выбранным источникам)
|
||||||
|
path('source-marks/', SourceMarksView.as_view(), name='source_marks'),
|
||||||
|
path('api/source-marks/', SourceMarksAPIView.as_view(), name='source_marks_api'),
|
||||||
path('kubsat/', KubsatView.as_view(), name='kubsat'),
|
path('kubsat/', KubsatView.as_view(), name='kubsat'),
|
||||||
path('kubsat/export/', KubsatExportView.as_view(), name='kubsat_export'),
|
path('kubsat/export/', KubsatExportView.as_view(), name='kubsat_export'),
|
||||||
path('kubsat/create-requests/', KubsatCreateRequestsView.as_view(), name='kubsat_create_requests'),
|
path('kubsat/create-requests/', KubsatCreateRequestsView.as_view(), name='kubsat_create_requests'),
|
||||||
|
|||||||
301
dbapp/mainapp/views/source_marks.py
Normal file
301
dbapp/mainapp/views/source_marks.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
"""
|
||||||
|
Views для отображения отметок сигналов по выбранным источникам.
|
||||||
|
Сопоставляет теханализы с первой точкой источника по имени.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.db.models import Max, Min, Q
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views import View
|
||||||
|
|
||||||
|
from mainapp.models import (
|
||||||
|
Source,
|
||||||
|
ObjItem,
|
||||||
|
TechAnalyze,
|
||||||
|
ObjectMark,
|
||||||
|
Polarization,
|
||||||
|
Modulation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceMarksView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
Страница отображения отметок сигналов для выбранных источников.
|
||||||
|
Сопоставляет теханализы с первой точкой источника по имени.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
# Получаем IDs источников из параметра
|
||||||
|
ids_param = request.GET.get('ids', '')
|
||||||
|
source_ids = [int(id_str) for id_str in ids_param.split(',') if id_str.strip().isdigit()]
|
||||||
|
|
||||||
|
# Справочники для фильтров
|
||||||
|
polarizations = Polarization.objects.all().order_by('name')
|
||||||
|
modulations = Modulation.objects.all().order_by('name')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'source_ids': source_ids,
|
||||||
|
'source_ids_json': json.dumps(source_ids),
|
||||||
|
'full_width_page': True,
|
||||||
|
'polarizations': polarizations,
|
||||||
|
'modulations': modulations,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'mainapp/source_marks.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceMarksAPIView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
API для получения данных отметок по выбранным источникам.
|
||||||
|
Сопоставляет теханализы с первой точкой источника по имени.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Получаем параметры
|
||||||
|
ids_param = request.GET.get('ids', '')
|
||||||
|
source_ids = [int(id_str) for id_str in ids_param.split(',') if id_str.strip().isdigit()]
|
||||||
|
|
||||||
|
size = int(request.GET.get('size', 0))
|
||||||
|
search = request.GET.get('search', '').strip()
|
||||||
|
|
||||||
|
# Фильтры
|
||||||
|
polarization_ids = request.GET.getlist('polarization_id')
|
||||||
|
modulation_ids = request.GET.getlist('modulation_id')
|
||||||
|
freq_min = request.GET.get('freq_min')
|
||||||
|
freq_max = request.GET.get('freq_max')
|
||||||
|
freq_range_min = request.GET.get('freq_range_min')
|
||||||
|
freq_range_max = request.GET.get('freq_range_max')
|
||||||
|
bod_velocity_min = request.GET.get('bod_velocity_min')
|
||||||
|
bod_velocity_max = request.GET.get('bod_velocity_max')
|
||||||
|
|
||||||
|
if not source_ids:
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'Не выбраны источники',
|
||||||
|
'periods': [],
|
||||||
|
'data': [],
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Получаем источники с их первыми точками
|
||||||
|
sources = Source.objects.filter(id__in=source_ids).prefetch_related('source_objitems')
|
||||||
|
|
||||||
|
if not sources.exists():
|
||||||
|
return JsonResponse({
|
||||||
|
'message': 'Источники не найдены',
|
||||||
|
'periods': [],
|
||||||
|
'data': [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Собираем имена первых точек для каждого источника
|
||||||
|
source_first_objitem_names = {}
|
||||||
|
source_coords = {}
|
||||||
|
|
||||||
|
for source in sources:
|
||||||
|
# Получаем первую точку источника (по ID - порядок добавления)
|
||||||
|
first_objitem = source.source_objitems.order_by('id').first()
|
||||||
|
if first_objitem and first_objitem.name:
|
||||||
|
source_first_objitem_names[source.id] = first_objitem.name
|
||||||
|
|
||||||
|
# Сохраняем усреднённые координаты
|
||||||
|
if source.coords_average:
|
||||||
|
source_coords[source.id] = f"{source.coords_average.y:.6f}, {source.coords_average.x:.6f}"
|
||||||
|
else:
|
||||||
|
source_coords[source.id] = "-"
|
||||||
|
|
||||||
|
if not source_first_objitem_names:
|
||||||
|
return JsonResponse({
|
||||||
|
'message': 'У выбранных источников нет точек с именами',
|
||||||
|
'periods': [],
|
||||||
|
'data': [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Получаем имена для поиска теханализов
|
||||||
|
objitem_names = list(source_first_objitem_names.values())
|
||||||
|
|
||||||
|
# Ищем теханализы по именам
|
||||||
|
tech_analyzes = TechAnalyze.objects.filter(
|
||||||
|
name__in=objitem_names
|
||||||
|
).select_related(
|
||||||
|
'satellite', 'polarization', 'modulation', 'standard'
|
||||||
|
).order_by('frequency', 'name')
|
||||||
|
|
||||||
|
if not tech_analyzes.exists():
|
||||||
|
return JsonResponse({
|
||||||
|
'message': 'Не найдены теханализы, соответствующие именам точек выбранных источников',
|
||||||
|
'periods': [],
|
||||||
|
'data': [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Применяем фильтры к теханализам
|
||||||
|
if polarization_ids:
|
||||||
|
tech_analyzes = tech_analyzes.filter(polarization_id__in=polarization_ids)
|
||||||
|
if modulation_ids:
|
||||||
|
tech_analyzes = tech_analyzes.filter(modulation_id__in=modulation_ids)
|
||||||
|
if freq_min:
|
||||||
|
try:
|
||||||
|
tech_analyzes = tech_analyzes.filter(frequency__gte=float(freq_min))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if freq_max:
|
||||||
|
try:
|
||||||
|
tech_analyzes = tech_analyzes.filter(frequency__lte=float(freq_max))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if freq_range_min:
|
||||||
|
try:
|
||||||
|
tech_analyzes = tech_analyzes.filter(freq_range__gte=float(freq_range_min))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if freq_range_max:
|
||||||
|
try:
|
||||||
|
tech_analyzes = tech_analyzes.filter(freq_range__lte=float(freq_range_max))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if bod_velocity_min:
|
||||||
|
try:
|
||||||
|
tech_analyzes = tech_analyzes.filter(bod_velocity__gte=float(bod_velocity_min))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if bod_velocity_max:
|
||||||
|
try:
|
||||||
|
tech_analyzes = tech_analyzes.filter(bod_velocity__lte=float(bod_velocity_max))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if search:
|
||||||
|
tech_analyzes = tech_analyzes.filter(
|
||||||
|
Q(name__icontains=search) | Q(id__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Создаём маппинг имя теханализа -> source_id для координат
|
||||||
|
name_to_source_id = {name: sid for sid, name in source_first_objitem_names.items()}
|
||||||
|
|
||||||
|
# Получаем ID теханализов
|
||||||
|
ta_ids = list(tech_analyzes.values_list('id', flat=True))
|
||||||
|
|
||||||
|
# Фильтруем отметки за последние 90 дней
|
||||||
|
date_90_days_ago = timezone.now() - timedelta(days=90)
|
||||||
|
marks_qs = ObjectMark.objects.filter(
|
||||||
|
tech_analyze_id__in=ta_ids,
|
||||||
|
timestamp__gte=date_90_days_ago
|
||||||
|
).select_related('created_by__user', 'tech_analyze')
|
||||||
|
|
||||||
|
# Получаем диапазон дат с отметками
|
||||||
|
date_range = marks_qs.aggregate(
|
||||||
|
min_date=Min('timestamp'),
|
||||||
|
max_date=Max('timestamp')
|
||||||
|
)
|
||||||
|
|
||||||
|
min_date = date_range['min_date']
|
||||||
|
max_date = date_range['max_date']
|
||||||
|
|
||||||
|
if not min_date or not max_date:
|
||||||
|
# Нет отметок, но есть теханализы - показываем пустую таблицу
|
||||||
|
data = []
|
||||||
|
for ta in tech_analyzes:
|
||||||
|
source_id = name_to_source_id.get(ta.name)
|
||||||
|
coords = source_coords.get(source_id, '-') if source_id else '-'
|
||||||
|
|
||||||
|
data.append({
|
||||||
|
'id': ta.id,
|
||||||
|
'name': ta.name,
|
||||||
|
'frequency': float(ta.frequency) if ta.frequency else 0,
|
||||||
|
'freq_range': float(ta.freq_range) if ta.freq_range else 0,
|
||||||
|
'polarization': ta.polarization.name if ta.polarization else '-',
|
||||||
|
'modulation': ta.modulation.name if ta.modulation else '-',
|
||||||
|
'bod_velocity': float(ta.bod_velocity) if ta.bod_velocity else 0,
|
||||||
|
'coords_average': coords,
|
||||||
|
'satellite': ta.satellite.name if ta.satellite else '-',
|
||||||
|
'marks': [],
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'periods': [],
|
||||||
|
'data': data,
|
||||||
|
'message': 'Нет отметок за последние 90 дней',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Генерируем список дней от max_date до min_date (от новых к старым)
|
||||||
|
days = []
|
||||||
|
current_date = max_date.date()
|
||||||
|
end_date = min_date.date()
|
||||||
|
while current_date >= end_date:
|
||||||
|
days.append(current_date)
|
||||||
|
current_date -= timedelta(days=1)
|
||||||
|
|
||||||
|
# Формируем заголовки колонок (дни)
|
||||||
|
periods = []
|
||||||
|
for day in days:
|
||||||
|
periods.append({
|
||||||
|
'date': day,
|
||||||
|
'label': day.strftime('%d.%m'),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Загружаем все отметки
|
||||||
|
all_marks = list(marks_qs.order_by('-timestamp'))
|
||||||
|
|
||||||
|
# Создаём словарь: {(tech_analyze_id, date): список всех отметок за день}
|
||||||
|
marks_dict = {}
|
||||||
|
for mark in all_marks:
|
||||||
|
mark_date = timezone.localtime(mark.timestamp).date()
|
||||||
|
key = (mark.tech_analyze_id, mark_date)
|
||||||
|
if key not in marks_dict:
|
||||||
|
marks_dict[key] = []
|
||||||
|
marks_dict[key].append(mark)
|
||||||
|
|
||||||
|
# Формируем данные
|
||||||
|
data = []
|
||||||
|
for ta in tech_analyzes:
|
||||||
|
source_id = name_to_source_id.get(ta.name)
|
||||||
|
coords = source_coords.get(source_id, '-') if source_id else '-'
|
||||||
|
|
||||||
|
row = {
|
||||||
|
'id': ta.id,
|
||||||
|
'name': ta.name,
|
||||||
|
'frequency': float(ta.frequency) if ta.frequency else 0,
|
||||||
|
'freq_range': float(ta.freq_range) if ta.freq_range else 0,
|
||||||
|
'polarization': ta.polarization.name if ta.polarization else '-',
|
||||||
|
'modulation': ta.modulation.name if ta.modulation else '-',
|
||||||
|
'bod_velocity': float(ta.bod_velocity) if ta.bod_velocity else 0,
|
||||||
|
'coords_average': coords,
|
||||||
|
'satellite': ta.satellite.name if ta.satellite else '-',
|
||||||
|
'marks': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Для каждого дня собираем все отметки
|
||||||
|
for period in periods:
|
||||||
|
key = (ta.id, period['date'])
|
||||||
|
day_marks = marks_dict.get(key, [])
|
||||||
|
|
||||||
|
if day_marks:
|
||||||
|
# Сортируем по времени (от раннего к позднему)
|
||||||
|
day_marks_sorted = sorted(day_marks, key=lambda m: m.timestamp)
|
||||||
|
|
||||||
|
marks_list = []
|
||||||
|
for mark in day_marks_sorted:
|
||||||
|
local_time = timezone.localtime(mark.timestamp)
|
||||||
|
marks_list.append({
|
||||||
|
'mark': mark.mark,
|
||||||
|
'user': str(mark.created_by) if mark.created_by else '-',
|
||||||
|
'time': local_time.strftime('%H:%M'),
|
||||||
|
})
|
||||||
|
row['marks'].append({
|
||||||
|
'count': len(marks_list),
|
||||||
|
'items': marks_list,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
row['marks'].append(None)
|
||||||
|
|
||||||
|
data.append(row)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'periods': [p['label'] for p in periods],
|
||||||
|
'data': data,
|
||||||
|
'total': len(data),
|
||||||
|
'date_range': f"Последние 90 дней (с {date_90_days_ago.strftime('%d.%m.%Y')})",
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user