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

811 lines
33 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);
}
#sources-table {
margin-top: 15px;
font-size: 12px;
}
#sources-table .tabulator-header {
font-size: 12px;
}
#sources-table .tabulator-cell {
font-size: 12px;
padding: 6px 4px;
}
.btn-group-custom {
margin-top: 15px;
}
.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;
}
.modal-xl {
max-width: 95%;
}
.group-card {
border: 1px solid #e9ecef;
border-radius: 6px;
margin-bottom: 10px;
background: #fff;
}
.group-header {
background: #f8f9fa;
padding: 10px 12px;
border-bottom: 1px solid #e9ecef;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.group-header.has-outliers {
background: #fff3cd;
}
.group-info {
display: flex;
flex-wrap: wrap;
gap: 15px;
align-items: center;
}
.group-info-item {
font-size: 13px;
}
.group-info-item strong {
color: #495057;
}
.group-actions {
display: flex;
gap: 5px;
}
.group-body {
padding: 10px;
}
.points-table {
font-size: 11px;
width: 100%;
}
.points-table th, .points-table td {
padding: 5px 6px;
border: 1px solid #dee2e6;
}
.points-table th {
background: #f8f9fa;
font-weight: 600;
}
.points-table tr.outlier {
background-color: #ffcccc !important;
}
.points-table tr.valid {
background-color: #d4edda !important;
}
.source-has-outliers {
background-color: #fff3cd !important;
}
</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="source-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="export-json" class="btn btn-info ms-2" disabled>
<i class="bi bi-filetype-json"></i> Сохранить в JSON
</button>
<button id="clear-all" class="btn btn-danger ms-2">
<i class="bi bi-trash"></i> Очистить всё
</button>
</div>
</div>
<div id="sources-table"></div>
</div>
</div>
<!-- Modal for source details -->
<div class="modal fade" id="sourceDetailsModal" tabindex="-1" aria-labelledby="sourceDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="sourceDetailsModalLabel">Детали объекта</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="modal-body-content">
<!-- Groups will be rendered here -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</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 allSourcesData = [];
let currentSourceIdx = null;
let sourcesTable = null;
function showLoading() {
document.getElementById('loading-overlay').classList.add('active');
}
function hideLoading() {
document.getElementById('loading-overlay').classList.remove('active');
}
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;
}
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
function updateCounts() {
document.getElementById('source-count').textContent = allSourcesData.length;
const hasData = allSourcesData.length > 0;
document.getElementById('export-xlsx').disabled = !hasData;
document.getElementById('export-json').disabled = !hasData;
}
// Prepare table data from sources
function getTableData() {
const data = [];
allSourcesData.forEach((source, sourceIdx) => {
const totalPoints = source.groups.reduce((sum, g) => sum + g.valid_points_count, 0);
const hasOutliers = source.groups.some(g => g.has_outliers);
// Get first group's params as representative
const firstGroup = source.groups[0] || {};
data.push({
_sourceIdx: sourceIdx,
source_name: source.source_name,
source_id: source.source_id,
groups_count: source.groups.length,
total_points: totalPoints,
has_outliers: hasOutliers,
frequency: firstGroup.frequency || '-',
modulation: firstGroup.modulation || '-',
mirrors: firstGroup.mirrors || '-',
});
});
return data;
}
// Initialize or update sources table
function updateSourcesTable() {
const data = getTableData();
if (!sourcesTable) {
sourcesTable = new Tabulator("#sources-table", {
layout: "fitDataStretch",
height: "500px",
placeholder: "Нет данных. Выберите спутник и диапазон дат, затем нажмите 'Загрузить данные'.",
initialSort: [
{column: "frequency", dir: "asc"}
],
columns: [
{title: "Объект", field: "source_name", minWidth: 180, widthGrow: 2},
{title: "Групп", field: "groups_count", minWidth: 70, hozAlign: "center"},
{title: "Точек", field: "total_points", minWidth: 70, hozAlign: "center"},
{title: "Частота", field: "frequency", minWidth: 100, sorter: "number"},
{title: "Модуляция", field: "modulation", minWidth: 90},
{title: "Зеркала", field: "mirrors", minWidth: 130},
{
title: "Действия",
field: "actions",
minWidth: 150,
hozAlign: "center",
formatter: function(cell) {
const data = cell.getRow().getData();
const outlierBadge = data.has_outliers ? '<span class="badge bg-warning me-1">!</span>' : '';
return `${outlierBadge}
<button class="btn btn-sm btn-primary btn-view-source" title="Открыть детали">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-sm btn-danger btn-delete-source ms-1" title="Удалить">
<i class="bi bi-trash"></i>
</button>`;
},
cellClick: function(e, cell) {
const data = cell.getRow().getData();
if (e.target.closest('.btn-view-source')) {
openSourceModal(data._sourceIdx);
} else if (e.target.closest('.btn-delete-source')) {
deleteSource(data._sourceIdx);
}
}
}
],
data: data,
rowFormatter: function(row) {
if (row.getData().has_outliers) {
row.getElement().classList.add('source-has-outliers');
}
}
});
} else {
sourcesTable.setData(data);
}
updateCounts();
}
// Delete source
function deleteSource(sourceIdx) {
//if (!confirm('Удалить этот объект со всеми группами?')) return;
allSourcesData.splice(sourceIdx, 1);
updateSourcesTable();
}
// Open source modal
function openSourceModal(sourceIdx) {
currentSourceIdx = sourceIdx;
const source = allSourcesData[sourceIdx];
if (!source) return;
document.getElementById('sourceDetailsModalLabel').textContent = `Объект: ${source.source_name}`;
renderModalContent();
const modal = new bootstrap.Modal(document.getElementById('sourceDetailsModal'));
modal.show();
}
// Render modal content
function renderModalContent() {
const source = allSourcesData[currentSourceIdx];
if (!source) return;
let html = '';
source.groups.forEach((group, groupIdx) => {
html += renderGroupCard(group, groupIdx);
});
if (source.groups.length === 0) {
html = '<div class="alert alert-info">Нет групп для отображения</div>';
}
document.getElementById('modal-body-content').innerHTML = html;
addModalEventListeners();
}
// Render group card
function renderGroupCard(group, groupIdx) {
const headerClass = group.has_outliers ? 'has-outliers' : '';
let pointsHtml = '';
group.points.forEach((point, pointIdx) => {
const rowClass = point.is_outlier ? 'outlier' : 'valid';
pointsHtml += `
<tr class="${rowClass}">
<td>${point.id}</td>
<td>${point.name}</td>
<td>${point.frequency}</td>
<td>${point.freq_range}</td>
<td>${point.bod_velocity}</td>
<td>${point.modulation}</td>
<td>${point.snr}</td>
<td>${point.timestamp}</td>
<td>${point.mirrors}</td>
<td>${point.coordinates}</td>
<td>${point.distance_from_avg}</td>
<td>
<button class="btn btn-sm btn-outline-danger btn-delete-point"
data-group-idx="${groupIdx}"
data-point-idx="${pointIdx}"
title="Удалить точку">
<i class="bi bi-x"></i>
</button>
</td>
</tr>
`;
});
return `
<div class="group-card" data-group-idx="${groupIdx}">
<div class="group-header ${headerClass}">
<div class="group-info">
<span class="group-info-item"><strong>Интервал:</strong> ${group.interval_label}</span>
<span class="group-info-item"><strong>Усреднённые координаты:</strong> ${group.avg_coordinates} <span class="badge bg-secondary">${group.avg_type || 'ГК'}</span></span>
<span class="group-info-item"><strong>Медианное время:</strong> ${group.avg_time}</span>
<span class="group-info-item"><strong>Точек:</strong> ${group.valid_points_count}/${group.total_points}</span>
${group.has_outliers ? `<span class="badge bg-warning">Выбросов: ${group.outliers_count}</span>` : ''}
</div>
<div class="group-actions">
<button class="btn btn-sm btn-primary btn-average-group" data-group-idx="${groupIdx}" title="Пересчитать усреднение">
<i class="bi bi-calculator"></i> Усреднить
</button>
${group.has_outliers ? `
<button class="btn btn-sm btn-warning btn-average-all" data-group-idx="${groupIdx}" title="Усреднить все точки">
<i class="bi bi-arrow-repeat"></i> Все точки
</button>
` : ''}
<button class="btn btn-sm btn-danger btn-delete-group" data-group-idx="${groupIdx}" title="Удалить группу">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="group-body">
<table class="points-table">
<thead>
<tr>
<th>ID</th>
<th>Имя</th>
<th>Частота</th>
<th>Полоса</th>
<th>Симв. скорость</th>
<th>Модуляция</th>
<th>ОСШ</th>
<th>Дата/Время</th>
<th>Зеркала</th>
<th>Координаты</th>
<th>Расст., км</th>
<th></th>
</tr>
</thead>
<tbody>${pointsHtml}</tbody>
</table>
</div>
</div>
`;
}
// Add event listeners for modal
function addModalEventListeners() {
document.querySelectorAll('.btn-delete-group').forEach(btn => {
btn.addEventListener('click', function() {
const groupIdx = parseInt(this.dataset.groupIdx);
deleteGroup(groupIdx);
});
});
document.querySelectorAll('.btn-delete-point').forEach(btn => {
btn.addEventListener('click', function() {
const groupIdx = parseInt(this.dataset.groupIdx);
const pointIdx = parseInt(this.dataset.pointIdx);
deletePoint(groupIdx, pointIdx);
});
});
document.querySelectorAll('.btn-average-group').forEach(btn => {
btn.addEventListener('click', function() {
const groupIdx = parseInt(this.dataset.groupIdx);
recalculateGroup(groupIdx, false);
});
});
document.querySelectorAll('.btn-average-all').forEach(btn => {
btn.addEventListener('click', function() {
const groupIdx = parseInt(this.dataset.groupIdx);
recalculateGroup(groupIdx, true);
});
});
}
// Delete group
function deleteGroup(groupIdx) {
if (!confirm('Удалить эту группу точек?')) return;
const source = allSourcesData[currentSourceIdx];
source.groups.splice(groupIdx, 1);
if (source.groups.length === 0) {
allSourcesData.splice(currentSourceIdx, 1);
bootstrap.Modal.getInstance(document.getElementById('sourceDetailsModal')).hide();
updateSourcesTable();
} else {
renderModalContent();
updateSourcesTable();
}
}
// Delete point
function deletePoint(groupIdx, pointIdx) {
const source = allSourcesData[currentSourceIdx];
const group = source.groups[groupIdx];
if (group.points.length <= 1) {
alert('Нельзя удалить последнюю точку. Удалите группу целиком.');
return;
}
if (!confirm('Удалить эту точку и пересчитать усреднение?')) return;
group.points.splice(pointIdx, 1);
group.total_points = group.points.length;
recalculateGroup(groupIdx, true);
}
// Recalculate group
async function recalculateGroup(groupIdx, includeAll) {
const source = allSourcesData[currentSourceIdx];
const group = source.groups[groupIdx];
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
Object.assign(group, {
avg_coordinates: data.avg_coordinates,
avg_coord_tuple: data.avg_coord_tuple,
avg_type: data.avg_type,
total_points: data.total_points,
valid_points_count: data.valid_points_count,
outliers_count: data.outliers_count,
has_outliers: data.has_outliers,
mirrors: data.mirrors || group.mirrors,
avg_time: data.avg_time || group.avg_time,
points: data.points,
});
renderModalContent();
updateSourcesTable();
} catch (error) {
console.error('Error:', error);
alert('Произошла ошибка при пересчёте');
} finally {
hideLoading();
}
}
// 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; }
data.sources.forEach(source => {
const existingIdx = allSourcesData.findIndex(s => s.source_id === source.source_id);
if (existingIdx >= 0) {
source.groups.forEach(newGroup => {
const existingGroupIdx = allSourcesData[existingIdx].groups.findIndex(g => g.interval_key === newGroup.interval_key);
if (existingGroupIdx < 0) {
allSourcesData[existingIdx].groups.push(newGroup);
}
});
} else {
allSourcesData.push(source);
}
});
updateSourcesTable();
} catch (error) {
console.error('Error:', error);
alert('Произошла ошибка при обработке данных');
} finally {
hideLoading();
}
});
// Clear all
document.getElementById('clear-all').addEventListener('click', function() {
if (!confirm('Очистить все данные?')) return;
allSourcesData = [];
updateSourcesTable();
});
// Export to Excel
document.getElementById('export-xlsx').addEventListener('click', function() {
if (allSourcesData.length === 0) { alert('Нет данных для экспорта'); return; }
const summaryData = [];
allSourcesData.forEach(source => {
source.groups.forEach(group => {
summaryData.push({
'Объект': source.source_name,
'Частота, МГц': group.frequency,
'Полоса, МГц': group.freq_range,
'Символьная скорость, БОД': group.bod_velocity,
'Модуляция': group.modulation,
'ОСШ': group.snr,
'Зеркала': group.mirrors,
'Усреднённые координаты': group.avg_coordinates,
// 'Тип усреднения': group.avg_type || 'ГК',
'Время': group.avg_time || '-',
'Кол-во точек': group.valid_points_count
});
});
});
// Sort by frequency
summaryData.sort((a, b) => {
const freqA = parseFloat(a['Частота, МГц']) || 0;
const freqB = parseFloat(b['Частота, МГц']) || 0;
return freqA - freqB;
});
const allPointsData = [];
allSourcesData.forEach(source => {
source.groups.forEach(group => {
group.points.forEach(point => {
allPointsData.push({
'Объект': source.source_name,
'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'
});
});
});
});
// Sort by frequency
allPointsData.sort((a, b) => {
const freqA = parseFloat(a['Частота, МГц']) || 0;
const freqB = parseFloat(b['Частота, МГц']) || 0;
return freqA - freqB;
});
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(summaryData), "Усреднение");
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(allPointsData), "Все точки");
const dateStr = new Date().toISOString().slice(0, 10);
XLSX.writeFile(wb, `averaging_${dateStr}.xlsx`);
});
// Export to JSON
document.getElementById('export-json').addEventListener('click', function() {
if (allSourcesData.length === 0) { alert('Нет данных для экспорта'); return; }
const CREATOR_ID = '6fd12c90-7f17-43d9-a03e-ee14e880f757';
const pathObject = {
"tacticObjectType": "path",
"captionPosition": "right",
"points": [
{"id": "b92b9cbb-dd27-49aa-bcb6-e89a147bc02c", "latitude": 57, "longitude": -13, "altitude": 0, "customActions": [], "tags": {"creator": CREATOR_ID}, "tacticObjectType": "point"},
{"id": "8e3666d4-4990-4cb9-9594-63ad06333489", "latitude": 57, "longitude": 64, "altitude": 0, "customActions": [], "tags": {"creator": CREATOR_ID}, "tacticObjectType": "point"},
{"id": "5f137485-d2fc-443d-8507-c936f02f3569", "latitude": 11, "longitude": 64, "altitude": 0, "customActions": [], "tags": {"creator": CREATOR_ID}, "tacticObjectType": "point"},
{"id": "0fb90df7-8eb0-49fa-9d00-336389171bf5", "latitude": 11, "longitude": -13, "altitude": 0, "customActions": [], "tags": {"creator": CREATOR_ID}, "tacticObjectType": "point"},
{"id": "3ef12637-585e-40a4-b0ee-8f1786c89ce6", "latitude": 57, "longitude": -13, "altitude": 0, "customActions": [], "tags": {"creator": CREATOR_ID}, "tacticObjectType": "point"}
],
"isCycle": false,
"id": "2f604051-4984-4c2f-8c4c-c0cb64008f5f",
"draggable": false, "selectable": false, "editable": false,
"caption": "Ограничение для работы с поверхностями",
"line": {"color": "rgb(148,0,211)", "thickness": 1, "dash": "solid", "border": null},
"customActions": [],
"tags": {"creator": CREATOR_ID}
};
const result = [pathObject];
const jsonSourceColors = [
"rgb(0,128,0)", "rgb(0,0,255)", "rgb(255,0,0)", "rgb(255,165,0)", "rgb(128,0,128)",
"rgb(0,128,128)", "rgb(255,20,147)", "rgb(139,69,19)", "rgb(0,100,0)", "rgb(70,130,180)"
];
allSourcesData.forEach((source, sourceIdx) => {
const sourceColor = jsonSourceColors[sourceIdx % jsonSourceColors.length];
source.groups.forEach(group => {
const avgCoord = group.avg_coord_tuple;
const avgLat = avgCoord[1];
const avgLon = avgCoord[0];
const avgCaption = `${source.source_name} (усредн) - ${group.avg_time || '-'}`;
const avgSourceId = generateUUID();
result.push({
"tacticObjectType": "source",
"captionPosition": "right",
"id": avgSourceId,
"icon": {"type": "triangle", "color": sourceColor},
"caption": avgCaption,
"name": avgCaption,
"customActions": [],
"trackBehavior": {},
"bearingStyle": {"color": sourceColor, "thickness": 2, "dash": "solid", "border": null},
"bearingBehavior": {},
"tags": {"creator": CREATOR_ID}
});
result.push({
"tacticObjectType": "position",
"id": generateUUID(),
"parentId": avgSourceId,
"timeStamp": Date.now() / 1000,
"latitude": avgLat,
"altitude": 0,
"longitude": avgLon,
"caption": "",
"tooltip": "",
"customActions": [],
"tags": {"layers": [], "creator": CREATOR_ID}
});
// group.points.forEach(point => {
// if (point.is_outlier) return;
// const pointCoord = point.coord_tuple;
// const pointCaption = `${point.name || '-'} - ${point.timestamp || '-'}`;
// const pointSourceId = generateUUID();
// result.push({
// "tacticObjectType": "source",
// "captionPosition": "right",
// "id": pointSourceId,
// "icon": {"type": "circle", "color": sourceColor},
// "caption": pointCaption,
// "name": pointCaption,
// "customActions": [],
// "trackBehavior": {},
// "bearingStyle": {"color": sourceColor, "thickness": 2, "dash": "solid", "border": null},
// "bearingBehavior": {},
// "tags": {"creator": CREATOR_ID}
// });
// result.push({
// "tacticObjectType": "position",
// "id": generateUUID(),
// "parentId": pointSourceId,
// "timeStamp": point.timestamp_unix || (Date.now() / 1000),
// "latitude": pointCoord[1],
// "altitude": 0,
// "longitude": pointCoord[0],
// "caption": "",
// "tooltip": "",
// "customActions": [],
// "tags": {"layers": [], "creator": CREATOR_ID}
// });
// });
});
});
const jsonString = JSON.stringify(result, null, 2);
const blob = new Blob(['\uFEFF' + jsonString], {type: 'application/json;charset=utf-8'});
const dateStr = new Date().toISOString().slice(0, 10);
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `averaging_${dateStr}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
});
// Initialize
updateSourcesTable();
});
</script>
{% endblock %}