Files
dbstorage/dbapp/mainapp/templates/mainapp/points_averaging.html

845 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "mainapp/base.html" %}
{% load static %}
{% block title %}Усреднение точек{% endblock %}
{% block extra_css %}
<link href="{% static 'tabulator/css/tabulator_bootstrap5.min.css' %}" rel="stylesheet">
<style>
.averaging-container {
padding: 20px;
}
.form-section {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.table-section {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
#result-table {
margin-top: 20px;
font-size: 12px;
}
#result-table .tabulator-header {
font-size: 12px;
white-space: normal;
word-wrap: break-word;
}
#result-table .tabulator-header .tabulator-col {
white-space: normal;
word-wrap: break-word;
height: auto;
min-height: 40px;
}
#result-table .tabulator-header .tabulator-col-content {
white-space: normal;
word-wrap: break-word;
padding: 6px 4px;
}
#result-table .tabulator-cell {
font-size: 12px;
padding: 6px 4px;
}
.btn-group-custom {
margin-top: 15px;
}
.outlier-row {
background-color: #ffcccc !important;
}
.outlier-warning {
color: #dc3545;
font-weight: bold;
}
.group-header {
background-color: #e9ecef;
padding: 10px;
margin-bottom: 10px;
border-radius: 4px;
}
.points-detail-table {
margin-top: 10px;
font-size: 11px;
}
.modal-xl {
max-width: 95%;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255,255,255,0.8);
display: none;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loading-overlay.active {
display: flex;
}
/* Цвета для групп */
.group-color-0 { background-color: #cce5ff !important; }
.group-color-1 { background-color: #d4edda !important; }
.group-color-2 { background-color: #fff3cd !important; }
.group-color-3 { background-color: #f8d7da !important; }
.group-color-4 { background-color: #d1ecf1 !important; }
.group-color-5 { background-color: #e2d5f1 !important; }
.group-color-6 { background-color: #ffeeba !important; }
.group-color-7 { background-color: #c3e6cb !important; }
.group-color-8 { background-color: #bee5eb !important; }
.group-color-9 { background-color: #f5c6cb !important; }
.all-points-section {
margin-top: 20px;
}
#all-points-table {
margin-top: 10px;
font-size: 11px;
}
#all-points-table .tabulator-header {
font-size: 11px;
}
#all-points-table .tabulator-cell {
font-size: 11px;
padding: 4px 3px;
}
.legend-item {
display: inline-flex;
align-items: center;
margin-right: 15px;
margin-bottom: 5px;
}
.legend-color {
width: 20px;
height: 20px;
margin-right: 5px;
border: 1px solid #ccc;
border-radius: 3px;
}
.legend-container {
padding: 10px;
background: #f8f9fa;
border-radius: 4px;
margin-bottom: 10px;
max-height: 100px;
overflow-y: auto;
}
</style>
{% endblock %}
{% block content %}
<div class="loading-overlay" id="loading-overlay">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
<div class="averaging-container">
<h2>Усреднение точек спутников</h2>
<div class="form-section">
<div class="row">
<div class="col-md-3 mb-3">
<label for="satellite-select" class="form-label">Спутник</label>
<select id="satellite-select" class="form-select">
<option value="">Выберите спутник</option>
{% for satellite in satellites %}
<option value="{{ satellite.id }}">{{ satellite.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3 mb-3">
<label for="date-from" class="form-label">Дата с</label>
<input type="date" id="date-from" class="form-control">
</div>
<div class="col-md-3 mb-3">
<label for="date-to" class="form-label">Дата по</label>
<input type="date" id="date-to" class="form-control">
</div>
<div class="col-md-3 mb-3 d-flex align-items-end">
<button id="btn-process" class="btn btn-primary w-100">
<i class="bi bi-play-fill"></i> Добавить
</button>
</div>
</div>
</div>
<div class="table-section">
<div class="d-flex justify-content-between align-items-center">
<div>
<h5>Результаты усреднения <span id="group-count" class="badge bg-primary">0</span></h5>
</div>
<div class="btn-group-custom">
<button id="export-xlsx" class="btn btn-success" disabled>
<i class="bi bi-file-earmark-excel"></i> Сохранить в Excel
</button>
<button id="clear-table" class="btn btn-danger ms-2">
<i class="bi bi-trash"></i> Очистить таблицу
</button>
</div>
</div>
<div id="result-table"></div>
<!-- All points section -->
<div class="all-points-section" id="all-points-section" style="display: none;">
<hr>
<div class="d-flex justify-content-between align-items-center">
<h5>Все точки, участвующие в усреднении <span id="all-points-count" class="badge bg-secondary">0</span></h5>
<button id="toggle-all-points" class="btn btn-outline-primary btn-sm">
<i class="bi bi-eye"></i> Показать/Скрыть
</button>
</div>
<div id="all-points-wrapper" style="display: none;">
<div class="legend-container" id="legend-container"></div>
<div id="all-points-table"></div>
</div>
</div>
</div>
</div>
<!-- Modal for group details -->
<div class="modal fade" id="groupDetailsModal" tabindex="-1" aria-labelledby="groupDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="groupDetailsModalLabel">Детали группы</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="group-alert-container"></div>
<div class="card mb-3">
<div class="card-header bg-primary text-white">
<i class="bi bi-geo-alt"></i> Усреднённый результат
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<strong>Усреднённые координаты:</strong>
<span id="modal-avg-coords" class="fs-5 text-primary"></span>
</div>
<div class="col-md-2">
<strong>Всего точек:</strong>
<span id="modal-total-points" class="badge bg-secondary"></span>
</div>
<div class="col-md-2">
<strong>Валидных:</strong>
<span id="modal-valid-points" class="badge bg-success"></span>
</div>
<div class="col-md-2">
<strong>Выбросов:</strong>
<span id="modal-outliers-count" class="badge bg-danger"></span>
</div>
</div>
</div>
</div>
<div id="group-points-container"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
<button type="button" class="btn btn-warning" id="btn-average-all" style="display: none;">
<i class="bi bi-calculator"></i> Усреднить все точки
</button>
<button type="button" class="btn btn-primary" id="btn-average-valid" style="display: none;">
<i class="bi bi-check-circle"></i> Усреднить без выбросов
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
<script src="{% static 'sheetjs/xlsx.full.min.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
let allGroupsData = [];
let currentGroupIndex = null;
let allPointsTable = null;
// Color palette for groups
const groupColors = [
'#cce5ff', '#d4edda', '#fff3cd', '#f8d7da', '#d1ecf1',
'#e2d5f1', '#ffeeba', '#c3e6cb', '#bee5eb', '#f5c6cb'
];
// Initialize Tabulator
const table = new Tabulator("#result-table", {
layout: "fitDataStretch",
height: "500px",
placeholder: "Нет данных. Выберите спутник и диапазон дат, затем нажмите 'Добавить'.",
headerWordWrap: true,
columns: [
{title: "Объект наблюдения", field: "source_name", minWidth: 180, widthGrow: 2},
{title: "Частота, МГц", field: "frequency", minWidth: 100, widthGrow: 1},
{title: "Полоса, МГц", field: "freq_range", minWidth: 100, widthGrow: 1},
{title: "Символьная скорость, БОД", field: "bod_velocity", minWidth: 120, widthGrow: 1.5},
{title: "Модуляция", field: "modulation", minWidth: 100, widthGrow: 1},
{title: "ОСШ", field: "snr", minWidth: 70, widthGrow: 0.8},
{title: "Зеркала", field: "mirrors", minWidth: 130, widthGrow: 1.5},
{title: "Усреднённые координаты", field: "avg_coordinates", minWidth: 150, widthGrow: 2},
{title: "Медианное время", field: "avg_time", minWidth: 120, widthGrow: 1},
{title: "Кол-во точек", field: "total_points", minWidth: 80, widthGrow: 0.8, hozAlign: "center"},
{
title: "Действия",
field: "actions",
minWidth: 100,
widthGrow: 1,
hozAlign: "center",
formatter: function(cell, formatterParams, onRendered) {
const data = cell.getRow().getData();
const btnClass = data.has_outliers ? 'btn-warning' : 'btn-info';
return `<button class="btn btn-sm ${btnClass} btn-view-details" title="Просмотр точек"><i class="bi bi-eye"></i></button>`;
},
cellClick: function(e, cell) {
const data = cell.getRow().getData();
if (e.target.closest('.btn-view-details')) {
showGroupDetails(data._groupIndex);
}
}
}
],
data: [],
rowFormatter: function(row) {
const data = row.getData();
if (data.has_outliers) {
row.getElement().style.backgroundColor = "#fff3cd";
}
}
});
// Update group count
function updateGroupCount() {
document.getElementById('group-count').textContent = allGroupsData.length;
document.getElementById('export-xlsx').disabled = allGroupsData.length === 0;
}
// Show loading overlay
function showLoading() {
document.getElementById('loading-overlay').classList.add('active');
}
// Hide loading overlay
function hideLoading() {
document.getElementById('loading-overlay').classList.remove('active');
}
// Process button click
document.getElementById('btn-process').addEventListener('click', async function() {
const satelliteId = document.getElementById('satellite-select').value;
const dateFrom = document.getElementById('date-from').value;
const dateTo = document.getElementById('date-to').value;
if (!satelliteId) {
alert('Выберите спутник');
return;
}
if (!dateFrom || !dateTo) {
alert('Укажите диапазон дат');
return;
}
showLoading();
try {
const params = new URLSearchParams({
satellite_id: satelliteId,
date_from: dateFrom,
date_to: dateTo
});
const response = await fetch(`/api/points-averaging/?${params.toString()}`);
const data = await response.json();
if (!response.ok) {
alert(data.error || 'Ошибка при обработке данных');
return;
}
// Add groups to table
const startIndex = allGroupsData.length;
data.groups.forEach((group, idx) => {
group._groupIndex = startIndex + idx;
allGroupsData.push(group);
});
// Update table
table.setData(allGroupsData.map(g => ({
...g,
status: g.has_outliers ? 'outliers' : 'ok'
})));
updateGroupCount();
updateAllPointsTable();
} catch (error) {
console.error('Error:', error);
alert('Произошла ошибка при обработке данных');
} finally {
hideLoading();
}
});
// Update all points table
function updateAllPointsTable() {
if (allGroupsData.length === 0) {
document.getElementById('all-points-section').style.display = 'none';
return;
}
document.getElementById('all-points-section').style.display = 'block';
// Collect all points with group info
const allPoints = [];
allGroupsData.forEach((group, groupIdx) => {
group.points.forEach(point => {
allPoints.push({
...point,
group_name: group.source_name,
group_interval: group.interval_label,
group_index: groupIdx,
group_color: groupColors[groupIdx % groupColors.length],
avg_coordinates: group.avg_coordinates
});
});
});
document.getElementById('all-points-count').textContent = allPoints.length;
// Build legend
let legendHtml = '';
allGroupsData.forEach((group, idx) => {
const color = groupColors[idx % groupColors.length];
legendHtml += `
<span class="legend-item">
<span class="legend-color" style="background-color: ${color};"></span>
<small>${group.source_name} - ${group.interval_label}</small>
</span>
`;
});
document.getElementById('legend-container').innerHTML = legendHtml;
// Initialize or update all points table
if (!allPointsTable) {
allPointsTable = new Tabulator("#all-points-table", {
layout: "fitDataStretch",
height: "400px",
placeholder: "Нет точек",
headerWordWrap: true,
columns: [
{title: "ID", field: "id", minWidth: 60, widthGrow: 0.5},
{title: "Группа", field: "group_name", minWidth: 150, widthGrow: 1.5},
{title: "Интервал", field: "group_interval", minWidth: 140, widthGrow: 1.2},
{title: "Имя точки", field: "name", minWidth: 150, widthGrow: 1.5},
{title: "Частота", field: "frequency", minWidth: 80, widthGrow: 0.8},
{title: "Полоса", field: "freq_range", minWidth: 80, widthGrow: 0.8},
{title: "Символьная скорость", field: "bod_velocity", minWidth: 100, widthGrow: 1},
{title: "Модуляция", field: "modulation", minWidth: 80, widthGrow: 0.8},
{title: "ОСШ", field: "snr", minWidth: 50, widthGrow: 0.5},
{title: "Дата/Время", field: "timestamp", minWidth: 120, widthGrow: 1},
{title: "Зеркала", field: "mirrors", minWidth: 100, widthGrow: 1},
{title: "Местоположение", field: "location", minWidth: 100, widthGrow: 1},
{title: "Координаты точки", field: "coordinates", minWidth: 130, widthGrow: 1.2},
{title: "Усреднённые коорд.", field: "avg_coordinates", minWidth: 130, widthGrow: 1.2},
{title: "Расст. от среднего, км", field: "distance_from_avg", minWidth: 100, widthGrow: 0.8, hozAlign: "right"},
{
title: "Статус",
field: "is_outlier",
minWidth: 80,
widthGrow: 0.7,
formatter: function(cell) {
return cell.getValue()
? '<span class="badge bg-danger">Выброс</span>'
: '<span class="badge bg-success">OK</span>';
}
}
],
data: allPoints,
rowFormatter: function(row) {
const data = row.getData();
row.getElement().style.backgroundColor = data.group_color;
if (data.is_outlier) {
row.getElement().style.border = '2px solid #dc3545';
}
}
});
} else {
allPointsTable.setData(allPoints);
}
}
// Toggle all points visibility
document.getElementById('toggle-all-points').addEventListener('click', function() {
const wrapper = document.getElementById('all-points-wrapper');
if (wrapper.style.display === 'none') {
wrapper.style.display = 'block';
this.innerHTML = '<i class="bi bi-eye-slash"></i> Скрыть';
} else {
wrapper.style.display = 'none';
this.innerHTML = '<i class="bi bi-eye"></i> Показать/Скрыть';
}
});
// Show group details modal
// skipShow=true means just update content without calling modal.show()
function showGroupDetails(groupIndex, skipShow = false) {
currentGroupIndex = groupIndex;
const group = allGroupsData[groupIndex];
if (!group) return;
// Update modal title
document.getElementById('groupDetailsModalLabel').textContent =
`Детали группы: ${group.source_name} - ${group.interval_label}`;
// Update summary info
document.getElementById('modal-avg-coords').textContent = group.avg_coordinates;
document.getElementById('modal-total-points').textContent = group.total_points;
document.getElementById('modal-valid-points').textContent = group.valid_points_count;
document.getElementById('modal-outliers-count').textContent = group.outliers_count;
// Show/hide alert and buttons based on outliers
const alertContainer = document.getElementById('group-alert-container');
const btnAverageAll = document.getElementById('btn-average-all');
const btnAverageValid = document.getElementById('btn-average-valid');
if (group.has_outliers) {
alertContainer.innerHTML = `
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
<strong>Внимание!</strong> Обнаружены точки на расстоянии более 56 км от среднего значения.
Они выделены красным цветом в таблице ниже.
</div>
`;
btnAverageAll.style.display = 'inline-block';
btnAverageValid.style.display = 'inline-block';
} else {
alertContainer.innerHTML = `
<div class="alert alert-success">
<i class="bi bi-check-circle"></i>
Все точки находятся в пределах 56 км от среднего значения.
</div>
`;
btnAverageAll.style.display = 'none';
btnAverageValid.style.display = 'none';
}
// Build points table - first valid points, then outliers
let html = `
<h6 class="text-success"><i class="bi bi-check-circle"></i> Валидные точки (${group.valid_points_count})</h6>
<table class="table table-sm table-bordered points-detail-table">
<thead class="table-success">
<tr>
<th>ID</th>
<th>Имя</th>
<th>Частота</th>
<th>Полоса</th>
<th>Дата/Время</th>
<th>Координаты</th>
<th>Расстояние от среднего, км</th>
</tr>
</thead>
<tbody>
`;
// Valid points
group.points.forEach((point, idx) => {
if (!point.is_outlier) {
html += `
<tr class="table-success" data-point-index="${idx}">
<td>${point.id}</td>
<td>${point.name}</td>
<td>${point.frequency}</td>
<td>${point.freq_range}</td>
<td>${point.timestamp}</td>
<td>${point.coordinates}</td>
<td>${point.distance_from_avg}</td>
</tr>
`;
}
});
html += '</tbody></table>';
// Outliers section (if any)
if (group.has_outliers) {
html += `
<h6 class="text-danger mt-4"><i class="bi bi-exclamation-triangle"></i> Выбросы - точки за пределами 56 км (${group.outliers_count})</h6>
<table class="table table-sm table-bordered points-detail-table">
<thead class="table-danger">
<tr>
<th>ID</th>
<th>Имя</th>
<th>Частота</th>
<th>Полоса</th>
<th>Дата/Время</th>
<th>Координаты</th>
<th>Расстояние от среднего, км</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
`;
group.points.forEach((point, idx) => {
if (point.is_outlier) {
html += `
<tr class="table-danger" data-point-index="${idx}">
<td>${point.id}</td>
<td>${point.name}</td>
<td>${point.frequency}</td>
<td>${point.freq_range}</td>
<td>${point.timestamp}</td>
<td>${point.coordinates}</td>
<td><strong>${point.distance_from_avg}</strong></td>
<td>
<button class="btn btn-sm btn-danger btn-remove-point" data-index="${idx}" title="Удалить точку">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`;
}
});
html += '</tbody></table>';
}
document.getElementById('group-points-container').innerHTML = html;
// Add event listeners for remove buttons
document.querySelectorAll('.btn-remove-point').forEach(btn => {
btn.addEventListener('click', function() {
const pointIndex = parseInt(this.dataset.index);
removePointFromGroup(groupIndex, pointIndex);
});
});
// Show modal - use getOrCreateInstance to avoid creating multiple instances
if (!skipShow) {
const modalElement = document.getElementById('groupDetailsModal');
let modal = bootstrap.Modal.getInstance(modalElement);
if (!modal) {
modal = new bootstrap.Modal(modalElement);
}
modal.show();
}
}
// Remove point from group and recalculate
async function removePointFromGroup(groupIndex, pointIndex) {
const group = allGroupsData[groupIndex];
if (!group) return;
// Check if this is the last point
if (group.points.length <= 1) {
alert('Нельзя удалить последнюю точку. Удалите всю группу из основной таблицы.');
return;
}
if (!confirm('Удалить эту точку из выборки и пересчитать усреднение?')) {
return;
}
// Remove point
group.points.splice(pointIndex, 1);
// Recalculate with all remaining points
await recalculateGroup(groupIndex, true);
}
// Recalculate group
async function recalculateGroup(groupIndex, includeAll) {
const group = allGroupsData[groupIndex];
if (!group) {
return;
}
// Check if there are points to process
if (!group.points || group.points.length === 0) {
alert('Нет точек для пересчёта');
return;
}
showLoading();
try {
const response = await fetch('/api/points-averaging/recalculate/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({
points: group.points,
include_all: includeAll
})
});
const data = await response.json();
if (!response.ok) {
alert(data.error || 'Ошибка при пересчёте');
return;
}
// Update group data
group.avg_coordinates = data.avg_coordinates;
group.avg_coord_tuple = data.avg_coord_tuple;
group.total_points = data.total_points;
group.valid_points_count = data.valid_points_count;
group.outliers_count = data.outliers_count;
group.has_outliers = data.has_outliers;
group.mirrors = data.mirrors || group.mirrors;
group.avg_time = data.avg_time || group.avg_time;
group.points = data.points;
// Update table
table.setData(allGroupsData.map(g => ({
...g,
status: g.has_outliers ? 'outliers' : 'ok'
})));
// Update all points table
updateAllPointsTable();
// Update modal content without calling show() again
if (currentGroupIndex === groupIndex) {
showGroupDetails(groupIndex, true);
}
} catch (error) {
console.error('Error:', error);
alert('Произошла ошибка при пересчёте');
} finally {
hideLoading();
}
}
// Average all points button
document.getElementById('btn-average-all').addEventListener('click', async function() {
if (currentGroupIndex !== null) {
await recalculateGroup(currentGroupIndex, true);
}
});
// Average valid points button
document.getElementById('btn-average-valid').addEventListener('click', async function() {
if (currentGroupIndex !== null) {
await recalculateGroup(currentGroupIndex, false);
}
});
// Export to Excel
document.getElementById('export-xlsx').addEventListener('click', function() {
if (allGroupsData.length === 0) {
alert('Нет данных для экспорта');
return;
}
// Prepare summary data for export
const summaryData = allGroupsData.map(group => ({
'Объект наблюдения': group.source_name,
'Частота, МГц': group.frequency,
'Полоса, МГц': group.freq_range,
'Символьная скорость, БОД': group.bod_velocity,
'Модуляция': group.modulation,
'ОСШ': group.snr,
'Зеркала': group.mirrors,
'Усреднённые координаты': group.avg_coordinates,
'Медианное время': group.avg_time || '-',
'Кол-во точек': group.total_points
}));
// Prepare all points data for export
const allPointsData = [];
allGroupsData.forEach((group, groupIdx) => {
group.points.forEach(point => {
allPointsData.push({
'Группа': group.source_name,
'Интервал': group.interval_label,
'ID точки': point.id,
'Имя точки': point.name,
'Частота, МГц': point.frequency,
'Полоса, МГц': point.freq_range,
'Символьная скорость, БОД': point.bod_velocity,
'Модуляция': point.modulation,
'ОСШ': point.snr,
'Дата/Время': point.timestamp,
'Зеркала': point.mirrors,
'Местоположение': point.location,
'Координаты точки': point.coordinates,
'Усреднённые координаты': group.avg_coordinates,
'Расстояние от среднего, км': point.distance_from_avg,
'Статус': point.is_outlier ? 'Выброс' : 'OK'
});
});
});
// Create workbook with two sheets
const wb = XLSX.utils.book_new();
// Summary sheet
const wsSummary = XLSX.utils.json_to_sheet(summaryData);
XLSX.utils.book_append_sheet(wb, wsSummary, "Усреднение");
// All points sheet
const wsPoints = XLSX.utils.json_to_sheet(allPointsData);
XLSX.utils.book_append_sheet(wb, wsPoints, "Все точки");
// Generate filename with date
const now = new Date();
const dateStr = now.toISOString().slice(0, 10);
const filename = `averaging_${dateStr}.xlsx`;
// Download
XLSX.writeFile(wb, filename);
});
// Clear table
document.getElementById('clear-table').addEventListener('click', function() {
if (confirm('Вы уверены, что хотите очистить таблицу?')) {
allGroupsData = [];
table.clearData();
updateGroupCount();
if (allPointsTable) {
allPointsTable.clearData();
}
document.getElementById('all-points-section').style.display = 'none';
document.getElementById('all-points-wrapper').style.display = 'none';
document.getElementById('toggle-all-points').innerHTML = '<i class="bi bi-eye"></i> Показать/Скрыть';
}
});
// 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;
}
// Initialize
updateGroupCount();
});
</script>
{% endblock %}