Добавил плавную анимацию для нескольких источников

This commit is contained in:
2025-11-26 23:09:29 +03:00
parent cfaaae9360
commit d832171325
7 changed files with 1106 additions and 496 deletions

View File

@@ -0,0 +1,676 @@
{% extends "mainapp/base.html" %}
{% load static %}
{% block title %}Анимация движения объектов{% endblock title %}
{% block extra_css %}
<link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-measure/leaflet-measure.css' %}" rel="stylesheet">
<style>
body {
overflow: hidden;
}
#map {
position: fixed;
top: 56px;
bottom: 0;
left: 0;
right: 0;
z-index: 1;
}
.legend {
background: white;
padding: 10px;
border-radius: 4px;
box-shadow: 0 0 10px rgba(0,0,0,0.2);
font-size: 11px;
max-height: 400px;
overflow-y: auto;
}
.legend h6 {
font-size: 12px;
margin: 0 0 8px 0;
}
.legend-item {
margin: 4px 0;
display: flex;
align-items: center;
}
.legend-section {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #ddd;
}
.legend-section:first-child {
margin-top: 0;
padding-top: 0;
border-top: none;
}
.playback-control {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
background: white;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
max-width: 90%;
}
.playback-control button {
padding: 8px 14px;
border: none;
border-radius: 4px;
background: #007bff;
color: white;
cursor: pointer;
font-size: 14px;
}
.playback-control button:hover {
background: #0056b3;
}
.playback-control button:disabled {
background: #ccc;
cursor: not-allowed;
}
.playback-control .time-display {
font-size: 14px;
font-weight: bold;
min-width: 180px;
text-align: center;
}
.playback-control input[type="range"] {
width: 300px;
}
.playback-control .speed-control {
display: flex;
align-items: center;
gap: 8px;
}
.playback-control .speed-control label {
font-size: 12px;
margin: 0;
}
.playback-control .speed-control select {
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #ccc;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255,255,255,0.9);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.loading-overlay .spinner-border {
width: 3rem;
height: 3rem;
}
.moving-marker {
transition: transform 0.1s linear;
}
</style>
{% endblock %}
{% block content %}
<div id="loadingOverlay" class="loading-overlay">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
<p class="mt-3">Загрузка данных...</p>
</div>
<div id="map"></div>
<div class="playback-control" id="playbackControl" style="display: none;">
<button id="playBtn" title="Воспроизвести"></button>
<button id="pauseBtn" title="Пауза" disabled></button>
<button id="resetBtn" title="В начало"></button>
<input type="range" id="timeSlider" min="0" max="100" value="0" step="1">
<div class="time-display" id="timeDisplay">--</div>
<div class="speed-control">
<label for="speedSelect">Скорость:</label>
<select id="speedSelect">
<option value="0.25">0.25x</option>
<option value="0.5">0.5x</option>
<option value="1" selected>1x</option>
<option value="2">2x</option>
<option value="5">5x</option>
<option value="10">10x</option>
<option value="20">20x</option>
</select>
</div>
</div>
{% endblock content %}
{% block extra_js %}
<script src="{% static 'leaflet/leaflet.js' %}"></script>
<script src="{% static 'leaflet-measure/leaflet-measure.ru.js' %}"></script>
<script>
const SOURCE_IDS = "{{ source_ids }}";
const API_URL = "{% url 'mainapp:multi_sources_playback_api' %}";
// Map initialization
let map = L.map('map').setView([55.75, 37.62], 5);
L.control.scale({ imperial: false, metric: true }).addTo(map);
map.attributionControl.setPrefix(false);
const street = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; OpenStreetMap contributors'
});
street.addTo(map);
const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri'
});
L.control.layers({ "Улицы": street, "Спутник": satellite }).addTo(map);
L.control.measure({ primaryLengthUnit: 'kilometers' }).addTo(map);
// Playback state
let sourcesData = [];
let timeRange = null;
let currentProgress = 0; // 0 to 1 (progress-based instead of time-based)
let playbackInterval = null;
let playbackSpeed = 50; // ms per frame
let speedMultiplier = 1;
let totalSteps = 1000; // Total animation steps
// Map layers for each source
let sourceLayerGroups = {};
let currentMarkers = {};
let trailPolylines = {};
let staticMarkers = {};
// Color mapping
const colorMap = {
'red': '#dc3545',
'blue': '#007bff',
'green': '#28a745',
'purple': '#6f42c1',
'orange': '#fd7e14',
'cyan': '#17a2b8',
'magenta': '#e83e8c',
'yellow': '#ffc107',
'lime': '#32cd32',
'pink': '#ff69b4'
};
// Create marker icon using leaflet-markers library for static markers
function createStaticMarkerIcon(type) {
let markerColor = 'grey';
if (type === 'start') {
markerColor = 'green';
} else if (type === 'end') {
markerColor = 'red';
} else if (type === 'intermediate') {
markerColor = 'grey';
}
return L.icon({
iconUrl: '{% static "leaflet-markers/img/marker-icon-" %}' + markerColor + '.png',
shadowUrl: '{% static "leaflet-markers/img/marker-shadow.png" %}',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
}
// Create moving marker icon (colored circle)
function createMovingMarkerIcon(color) {
const hexColor = colorMap[color] || color;
return L.divIcon({
className: 'current-marker',
iconSize: [18, 18],
iconAnchor: [9, 9],
html: `<div style="background: ${hexColor}; width: 100%; height: 100%; border-radius: 50%; border: 3px solid white; box-shadow: 0 0 6px rgba(0,0,0,0.5);"></div>`
});
}
// Format date for display
function formatDate(isoString) {
const date = new Date(isoString);
return date.toLocaleString('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
// Interpolate position between two points
function interpolatePosition(p1, p2, t) {
return {
lat: p1.lat + (p2.lat - p1.lat) * t,
lng: p1.lng + (p2.lng - p1.lng) * t
};
}
// Get interpolated position for a source at given progress (0 to 1)
// Each source moves at the same visual speed regardless of actual time span
function getPositionAtProgress(source, progress) {
const points = source.points;
if (!points || points.length === 0) return null;
if (points.length === 1) {
// Single point - always show it
return { lat: points[0].lat, lng: points[0].lng, pointIndex: 0, segmentProgress: 1 };
}
// Clamp progress
progress = Math.max(0, Math.min(1, progress));
// Calculate which segment we're in based on progress
// Each segment gets equal time in the animation
const numSegments = points.length - 1;
const segmentLength = 1 / numSegments;
// Find current segment
const segmentIndex = Math.min(Math.floor(progress / segmentLength), numSegments - 1);
const segmentProgress = (progress - segmentIndex * segmentLength) / segmentLength;
// Interpolate within segment
const p1 = points[segmentIndex];
const p2 = points[segmentIndex + 1];
const pos = interpolatePosition(p1, p2, segmentProgress);
pos.pointIndex = segmentIndex;
pos.segmentProgress = segmentProgress;
// If we're at the very end
if (progress >= 1) {
const lastPoint = points[points.length - 1];
return { lat: lastPoint.lat, lng: lastPoint.lng, pointIndex: points.length - 1, segmentProgress: 1 };
}
return pos;
}
// Get the timestamp to display based on progress for a specific source
function getTimestampAtProgress(source, progress) {
const points = source.points;
if (!points || points.length === 0) return null;
if (points.length === 1) return points[0].timestamp;
progress = Math.max(0, Math.min(1, progress));
const numSegments = points.length - 1;
const segmentLength = 1 / numSegments;
const segmentIndex = Math.min(Math.floor(progress / segmentLength), numSegments - 1);
const segmentProgress = (progress - segmentIndex * segmentLength) / segmentLength;
// Interpolate timestamp
const t1 = points[segmentIndex].timestamp_ms;
const t2 = points[segmentIndex + 1].timestamp_ms;
return t1 + (t2 - t1) * segmentProgress;
}
// Update display for current progress (0 to 1)
function updateDisplay(progress) {
const timeDisplay = document.getElementById('timeDisplay');
// Update slider
const slider = document.getElementById('timeSlider');
slider.value = progress * 100;
// Show progress percentage and time range info
const progressPercent = Math.round(progress * 100);
timeDisplay.textContent = `Прогресс: ${progressPercent}%`;
// Update each source
sourcesData.forEach(source => {
const pos = getPositionAtProgress(source, progress);
const color = source.color;
if (pos) {
// Show/update current marker (moving object - colored circle)
if (!currentMarkers[source.source_id]) {
// Get first point name for popup
const firstPointName = source.points && source.points.length > 0 ? source.points[0].name : '';
const popupContent = `<b>ID ${source.source_id}:</b> ${firstPointName}<br><i style="color: #666;">Текущая позиция</i>`;
currentMarkers[source.source_id] = L.marker([pos.lat, pos.lng], {
icon: createMovingMarkerIcon(color),
zIndexOffset: 1000
}).bindPopup(popupContent);
sourceLayerGroups[source.source_id].addLayer(currentMarkers[source.source_id]);
} else {
currentMarkers[source.source_id].setLatLng([pos.lat, pos.lng]);
}
// Update trail (dashed line showing path)
const trailCoords = [];
for (let i = 0; i <= pos.pointIndex; i++) {
trailCoords.push([source.points[i].lat, source.points[i].lng]);
}
trailCoords.push([pos.lat, pos.lng]);
if (trailPolylines[source.source_id]) {
trailPolylines[source.source_id].setLatLngs(trailCoords);
}
// Show passed static markers (intermediate points)
source.points.forEach((point, idx) => {
const markerKey = `${source.source_id}_${idx}`;
// Skip if marker already exists
if (staticMarkers[markerKey]) return;
// Only show markers for points we've passed
if (idx <= pos.pointIndex) {
let markerType = 'intermediate';
let popupPrefix = '';
if (idx === 0) {
markerType = 'start';
popupPrefix = '<span style="color: green;">▶ Начало</span><br>';
} else if (idx === source.points.length - 1) {
markerType = 'end';
popupPrefix = '<span style="color: red;">■ Конец</span><br>';
} else {
popupPrefix = `<span style="color: gray;">● Точка ${idx + 1}</span><br>`;
}
const marker = L.marker([point.lat, point.lng], {
icon: createStaticMarkerIcon(markerType),
zIndexOffset: markerType === 'start' ? 500 : (markerType === 'end' ? 600 : 100)
}).bindPopup(`<b>${source.source_name}</b><br>${popupPrefix}${point.name}<br>${point.frequency}<br>${formatDate(point.timestamp)}`);
sourceLayerGroups[source.source_id].addLayer(marker);
staticMarkers[markerKey] = marker;
}
});
// Check if we've reached the end - show end marker
if (progress >= 1 || pos.pointIndex === source.points.length - 1) {
const endMarkerKey = `${source.source_id}_${source.points.length - 1}`;
if (!staticMarkers[endMarkerKey] && source.points.length > 1) {
const lastPoint = source.points[source.points.length - 1];
const endMarker = L.marker([lastPoint.lat, lastPoint.lng], {
icon: createStaticMarkerIcon('end'),
zIndexOffset: 600
}).bindPopup(`<b>${source.source_name}</b><br><span style="color: red;">■ Конец</span><br>${lastPoint.name}<br>${lastPoint.frequency}<br>${formatDate(lastPoint.timestamp)}`);
sourceLayerGroups[source.source_id].addLayer(endMarker);
staticMarkers[endMarkerKey] = endMarker;
}
}
}
});
}
// Reset playback state
function resetPlayback() {
// Clear all markers and trails
Object.values(sourceLayerGroups).forEach(group => group.clearLayers());
currentMarkers = {};
staticMarkers = {};
// Recreate trails
sourcesData.forEach(source => {
const color = colorMap[source.color] || source.color;
trailPolylines[source.source_id] = L.polyline([], {
color: color,
weight: 3,
opacity: 0.7,
dashArray: '5, 10'
});
sourceLayerGroups[source.source_id].addLayer(trailPolylines[source.source_id]);
// Add start marker immediately (green)
if (source.points && source.points.length > 0) {
const firstPoint = source.points[0];
const startMarker = L.marker([firstPoint.lat, firstPoint.lng], {
icon: createStaticMarkerIcon('start'),
zIndexOffset: 500
}).bindPopup(`<b>${source.source_name}</b><br><span style="color: green;">▶ Начало</span><br>${firstPoint.name}<br>${firstPoint.frequency}<br>${formatDate(firstPoint.timestamp)}`);
sourceLayerGroups[source.source_id].addLayer(startMarker);
staticMarkers[`${source.source_id}_0`] = startMarker;
}
});
currentProgress = 0;
updateDisplay(currentProgress);
}
// Play animation
function play() {
if (playbackInterval) return;
document.getElementById('playBtn').disabled = true;
document.getElementById('pauseBtn').disabled = false;
const progressStep = 1 / totalSteps; // Each step is 0.1% progress
playbackInterval = setInterval(() => {
currentProgress += progressStep * speedMultiplier;
if (currentProgress >= 1) {
currentProgress = 1;
pause();
}
updateDisplay(currentProgress);
}, playbackSpeed);
}
// Pause animation
function pause() {
if (playbackInterval) {
clearInterval(playbackInterval);
playbackInterval = null;
}
document.getElementById('playBtn').disabled = false;
document.getElementById('pauseBtn').disabled = true;
}
// Load data and initialize
async function loadData() {
try {
const response = await fetch(`${API_URL}?ids=${SOURCE_IDS}`);
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
sourcesData = data.sources;
timeRange = data.time_range;
// Filter out sources with no points
sourcesData = sourcesData.filter(s => s.points && s.points.length > 0);
if (!sourcesData || sourcesData.length === 0) {
document.getElementById('loadingOverlay').innerHTML = `
<div class="alert alert-warning">
<h5>Нет данных для отображения</h5>
<p>Выбранные источники не содержат точек с временными метками.</p>
<a href="{% url 'mainapp:source_list' %}" class="btn btn-primary">Вернуться к списку</a>
</div>
`;
return;
}
// Initialize layer groups for each source
const bounds = L.latLngBounds();
sourcesData.forEach(source => {
sourceLayerGroups[source.source_id] = L.layerGroup().addTo(map);
// Add all points to bounds
source.points.forEach(point => {
bounds.extend([point.lat, point.lng]);
});
});
// Fit map to bounds
if (bounds.isValid()) {
map.fitBounds(bounds.pad(0.1));
}
// Setup slider
const slider = document.getElementById('timeSlider');
slider.min = 0;
slider.max = 100;
slider.value = 0;
// Add legend
addLegend();
// Initialize playback
resetPlayback();
// Hide loading, show controls
document.getElementById('loadingOverlay').style.display = 'none';
const playbackControl = document.getElementById('playbackControl');
playbackControl.style.display = 'flex';
// Disable map scroll/zoom when mouse is over playback control
L.DomEvent.disableScrollPropagation(playbackControl);
L.DomEvent.disableClickPropagation(playbackControl);
// Setup event listeners
document.getElementById('playBtn').addEventListener('click', play);
document.getElementById('pauseBtn').addEventListener('click', pause);
document.getElementById('resetBtn').addEventListener('click', () => {
pause();
resetPlayback();
});
slider.addEventListener('input', function() {
pause();
const newProgress = this.value / 100;
// If going backwards, need to reset markers
if (newProgress < currentProgress) {
resetPlayback();
}
currentProgress = newProgress;
updateDisplay(currentProgress);
});
document.getElementById('speedSelect').addEventListener('change', function() {
speedMultiplier = parseFloat(this.value);
});
} catch (error) {
console.error('Error loading data:', error);
document.getElementById('loadingOverlay').innerHTML = `
<div class="alert alert-danger">
<h5>Ошибка загрузки данных</h5>
<p>${error.message}</p>
<a href="{% url 'mainapp:source_list' %}" class="btn btn-primary">Вернуться к списку</a>
</div>
`;
}
}
// Add legend
function addLegend() {
const legend = L.control({ position: 'bottomleft' });
legend.onAdd = function(map) {
const div = L.DomUtil.create('div', 'legend');
// Disable map scroll/zoom when mouse is over legend
L.DomEvent.disableScrollPropagation(div);
L.DomEvent.disableClickPropagation(div);
let html = '<h6><strong>Объекты</strong></h6>';
sourcesData.forEach(source => {
const color = colorMap[source.color] || source.color;
const points = source.points;
// Get marker color for this source
const markerColorMap = {
'red': 'red',
'blue': 'blue',
'green': 'green',
'purple': 'violet',
'orange': 'orange',
'cyan': 'blue',
'magenta': 'red',
'yellow': 'yellow',
'lime': 'green',
'pink': 'red'
};
const markerColor = markerColorMap[source.color] || 'blue';
// Get first point name and time info
let firstPointName = '';
let timeInfo = '';
if (points && points.length > 0) {
firstPointName = points[0].name || '';
const firstDate = formatDate(points[0].timestamp).split(',')[0];
const lastDate = formatDate(points[points.length - 1].timestamp).split(',')[0];
if (firstDate !== lastDate) {
timeInfo = `<br><small style="color: #666;">${firstDate}${lastDate}</small>`;
} else {
timeInfo = `<br><small style="color: #666;">${firstDate}</small>`;
}
}
html += `
<div class="legend-item" style="flex-direction: column; align-items: flex-start; margin-bottom: 8px;">
<div style="display: flex; align-items: center;">
<img src="{% static "leaflet-markers/img/marker-icon-" %}${markerColor}.png"
style="width: 18px; height: 30px; margin-right: 6px;">
<span><strong>ID ${source.source_id}:</strong> ${firstPointName}</span>
</div>
<div style="margin-left: 24px;">
<small style="color: #666;">${source.points_count} точек</small>
${timeInfo}
</div>
</div>
`;
});
html += '<div class="legend-section"><strong>Маркеры:</strong></div>';
html += `
<div class="legend-item">
<img src="{% static "leaflet-markers/img/marker-icon-green.png" %}"
style="width: 18px; height: 30px; margin-right: 6px;">
<span>Начальная точка</span>
</div>
<div class="legend-item">
<img src="{% static "leaflet-markers/img/marker-icon-red.png" %}"
style="width: 18px; height: 30px; margin-right: 6px;">
<span>Конечная точка</span>
</div>
<div class="legend-item">
<img src="{% static "leaflet-markers/img/marker-icon-grey.png" %}"
style="width: 18px; height: 30px; margin-right: 6px;">
<span>Промежуточная точка</span>
</div>
<div class="legend-item">
<div style="width: 18px; height: 18px; border-radius: 50%; background: #007bff; border: 3px solid white; box-shadow: 0 0 4px rgba(0,0,0,0.4); margin-right: 6px;"></div>
<span>Движущийся объект (цвет по объекту)</span>
</div>
`;
html += '<div class="legend-section"><small style="color: #666;">Все объекты движутся<br>с одинаковой скоростью</small></div>';
div.innerHTML = html;
return div;
};
legend.addTo(map);
}
// Start loading
loadData();
</script>
{% endblock extra_js %}

View File

@@ -1435,6 +1435,26 @@ function showSelectedSourcesOnMap() {
window.open(url, '_blank');
}
// Function to show playback animation for selected sources
function showPlaybackAnimation() {
if (!window.selectedSources || window.selectedSources.length === 0) {
alert('Список источников пуст');
return;
}
// Check if any source has points
const sourcesWithPoints = window.selectedSources.filter(source => parseInt(source.objitem_count) > 0);
if (sourcesWithPoints.length === 0) {
alert('Выбранные источники не содержат точек ГЛ');
return;
}
const selectedIds = window.selectedSources.map(source => source.id);
const url = '{% url "mainapp:multi_sources_playback_map" %}' + '?ids=' + selectedIds.join(',');
window.open(url, '_blank');
}
// Function to merge selected sources
function mergeSelectedSources() {
if (!window.selectedSources || window.selectedSources.length < 2) {
@@ -2065,6 +2085,9 @@ function showTransponderModal(transponderId) {
<button type="button" class="btn btn-outline-primary btn-sm" onclick="showSelectedSourcesOnMap()">
<i class="bi bi-map"></i> Карта
</button>
<button type="button" class="btn btn-outline-info btn-sm" onclick="showPlaybackAnimation()" title="Анимация движения объектов">
<i class="bi bi-play-circle"></i> Анимация
</button>
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<button type="button" class="btn btn-outline-success btn-sm" onclick="mergeSelectedSources()">
<i class="bi bi-union"></i> Объединить

View File

@@ -27,6 +27,8 @@ from .views import (
LyngsatTaskStatusAPIView,
LyngsatTaskStatusView,
MergeSourcesView,
MultiSourcesPlaybackDataAPIView,
MultiSourcesPlaybackMapView,
ObjItemCreateView,
ObjItemDeleteView,
ObjItemDetailView,
@@ -93,6 +95,7 @@ urlpatterns = [
path('show-sources-map/', ShowSourcesMapView.as_view(), name='show_sources_map'),
path('show-source-with-points-map/<int:source_id>/', ShowSourceWithPointsMapView.as_view(), name='show_source_with_points_map'),
path('show-source-averaging-map/<int:source_id>/', ShowSourceAveragingStepsMapView.as_view(), name='show_source_averaging_map'),
path('multi-sources-playback-map/', MultiSourcesPlaybackMapView.as_view(), name='multi_sources_playback_map'),
path('delete-selected-objects/', DeleteSelectedObjectsView.as_view(), name='delete_selected_objects'),
# path('cluster/', ClusterTestView.as_view(), name='cluster'),
path('vch-upload/', UploadVchLoadView.as_view(), name='vch_load'),
@@ -104,6 +107,7 @@ urlpatterns = [
path('api/transponder/<int:transponder_id>/', TransponderDataAPIView.as_view(), name='transponder_data_api'),
path('api/satellite/<int:satellite_id>/', SatelliteDataAPIView.as_view(), name='satellite_data_api'),
path('api/geo-points/', GeoPointsAPIView.as_view(), name='geo_points_api'),
path('api/multi-sources-playback/', MultiSourcesPlaybackDataAPIView.as_view(), name='multi_sources_playback_api'),
path('kubsat-excel/', ProcessKubsatView.as_view(), name='kubsat_excel'),
path('object/create/', ObjItemCreateView.as_view(), name='objitem_create'),
path('object/<int:pk>/edit/', ObjItemUpdateView.as_view(), name='objitem_update'),

View File

@@ -26,6 +26,7 @@ from .api import (
SourceObjItemsAPIView,
LyngsatTaskStatusAPIView,
TransponderDataAPIView,
MultiSourcesPlaybackDataAPIView,
)
from .lyngsat import (
LinkLyngsatSourcesView,
@@ -53,6 +54,7 @@ from .map import (
ShowSourcesMapView,
ShowSourceWithPointsMapView,
ShowSourceAveragingStepsMapView,
MultiSourcesPlaybackMapView,
# ClusterTestView,
)
from .kubsat import (
@@ -93,6 +95,7 @@ __all__ = [
'SourceObjItemsAPIView',
'LyngsatTaskStatusAPIView',
'TransponderDataAPIView',
'MultiSourcesPlaybackDataAPIView',
# LyngSat
'LinkLyngsatSourcesView',
'FillLyngsatDataView',
@@ -122,6 +125,7 @@ __all__ = [
'ShowSourcesMapView',
'ShowSourceWithPointsMapView',
'ShowSourceAveragingStepsMapView',
'MultiSourcesPlaybackMapView',
# 'ClusterTestView',
# Kubsat
'KubsatView',

View File

@@ -622,3 +622,103 @@ class SatelliteDataAPIView(LoginRequiredMixin, View):
return JsonResponse({'error': 'Спутник не найден'}, status=404)
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)
class MultiSourcesPlaybackDataAPIView(LoginRequiredMixin, View):
"""API endpoint for getting playback data for multiple sources."""
def get(self, request):
from ..models import Source
ids = request.GET.get('ids', '')
if not ids:
return JsonResponse({'error': 'Не указаны ID источников'}, status=400)
try:
id_list = [int(x) for x in ids.split(',') if x.isdigit()]
if not id_list:
return JsonResponse({'error': 'Некорректные ID источников'}, status=400)
sources = Source.objects.filter(id__in=id_list).prefetch_related(
'source_objitems',
'source_objitems__parameter_obj',
'source_objitems__geo_obj',
)
# Collect data for each source
sources_data = []
global_min_time = None
global_max_time = None
# Define colors for different sources
colors = ['red', 'blue', 'green', 'purple', 'orange', 'cyan', 'magenta', 'yellow', 'lime', 'pink']
for idx, source in enumerate(sources):
# Get all ObjItems with geo data and timestamp
objitems = source.source_objitems.filter(
geo_obj__isnull=False,
geo_obj__coords__isnull=False,
geo_obj__timestamp__isnull=False
).select_related('geo_obj', 'parameter_obj').order_by('geo_obj__timestamp')
points = []
for objitem in objitems:
geo = objitem.geo_obj
param = getattr(objitem, 'parameter_obj', None)
timestamp = geo.timestamp
# Update global min/max time
if global_min_time is None or timestamp < global_min_time:
global_min_time = timestamp
if global_max_time is None or timestamp > global_max_time:
global_max_time = timestamp
freq_str = '-'
if param and param.frequency:
freq_str = f"{param.frequency} МГц"
points.append({
'lat': geo.coords.y,
'lng': geo.coords.x,
'timestamp': timestamp.isoformat(),
'timestamp_ms': int(timestamp.timestamp() * 1000),
'name': objitem.name or f'Точка #{objitem.id}',
'frequency': freq_str,
'location': geo.location or '-',
})
if points:
# Get source name from first objitem or use ID
source_name = f"Объект #{source.id}"
if source.source_objitems.exists():
first_objitem = source.source_objitems.first()
if first_objitem and first_objitem.name:
# Extract base name (without frequency info)
source_name = first_objitem.name.split(' ')[0] if first_objitem.name else source_name
sources_data.append({
'source_id': source.id,
'source_name': source_name,
'color': colors[idx % len(colors)],
'points': points,
'points_count': len(points),
})
# Format global time range
time_range = None
if global_min_time and global_max_time:
time_range = {
'min': global_min_time.isoformat(),
'max': global_max_time.isoformat(),
'min_ms': int(global_min_time.timestamp() * 1000),
'max_ms': int(global_max_time.timestamp() * 1000),
}
return JsonResponse({
'sources': sources_data,
'time_range': time_range,
'total_sources': len(sources_data),
})
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)

View File

@@ -422,6 +422,28 @@ class ShowSourceAveragingStepsMapView(LoginRequiredMixin, View):
return render(request, "mainapp/source_averaging_map.html", context)
class MultiSourcesPlaybackMapView(LoginRequiredMixin, View):
"""View for displaying animated playback of multiple sources on map."""
def get(self, request):
from ..models import Source
ids = request.GET.get("ids", "")
if not ids:
return redirect("mainapp:source_list")
id_list = [int(x) for x in ids.split(",") if x.isdigit()]
if not id_list:
return redirect("mainapp:source_list")
context = {
"source_ids": ",".join(str(x) for x in id_list),
"source_count": len(id_list),
}
return render(request, "mainapp/multi_sources_playback_map.html", context)
# class ClusterTestView(LoginRequiredMixin, View):
# """Test view for clustering functionality."""