Добавил плавную анимацию для нескольких источников
This commit is contained in:
676
dbapp/mainapp/templates/mainapp/multi_sources_playback_map.html
Normal file
676
dbapp/mainapp/templates/mainapp/multi_sources_playback_map.html
Normal 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: '© 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 © 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 %}
|
||||||
@@ -1435,6 +1435,26 @@ function showSelectedSourcesOnMap() {
|
|||||||
window.open(url, '_blank');
|
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 to merge selected sources
|
||||||
function mergeSelectedSources() {
|
function mergeSelectedSources() {
|
||||||
if (!window.selectedSources || window.selectedSources.length < 2) {
|
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()">
|
<button type="button" class="btn btn-outline-primary btn-sm" onclick="showSelectedSourcesOnMap()">
|
||||||
<i class="bi bi-map"></i> Карта
|
<i class="bi bi-map"></i> Карта
|
||||||
</button>
|
</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' %}
|
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
|
||||||
<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> Объединить
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ from .views import (
|
|||||||
LyngsatTaskStatusAPIView,
|
LyngsatTaskStatusAPIView,
|
||||||
LyngsatTaskStatusView,
|
LyngsatTaskStatusView,
|
||||||
MergeSourcesView,
|
MergeSourcesView,
|
||||||
|
MultiSourcesPlaybackDataAPIView,
|
||||||
|
MultiSourcesPlaybackMapView,
|
||||||
ObjItemCreateView,
|
ObjItemCreateView,
|
||||||
ObjItemDeleteView,
|
ObjItemDeleteView,
|
||||||
ObjItemDetailView,
|
ObjItemDetailView,
|
||||||
@@ -93,6 +95,7 @@ urlpatterns = [
|
|||||||
path('show-sources-map/', ShowSourcesMapView.as_view(), name='show_sources_map'),
|
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-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('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('delete-selected-objects/', DeleteSelectedObjectsView.as_view(), name='delete_selected_objects'),
|
||||||
# path('cluster/', ClusterTestView.as_view(), name='cluster'),
|
# path('cluster/', ClusterTestView.as_view(), name='cluster'),
|
||||||
path('vch-upload/', UploadVchLoadView.as_view(), name='vch_load'),
|
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/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/satellite/<int:satellite_id>/', SatelliteDataAPIView.as_view(), name='satellite_data_api'),
|
||||||
path('api/geo-points/', GeoPointsAPIView.as_view(), name='geo_points_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('kubsat-excel/', ProcessKubsatView.as_view(), name='kubsat_excel'),
|
||||||
path('object/create/', ObjItemCreateView.as_view(), name='objitem_create'),
|
path('object/create/', ObjItemCreateView.as_view(), name='objitem_create'),
|
||||||
path('object/<int:pk>/edit/', ObjItemUpdateView.as_view(), name='objitem_update'),
|
path('object/<int:pk>/edit/', ObjItemUpdateView.as_view(), name='objitem_update'),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from .api import (
|
|||||||
SourceObjItemsAPIView,
|
SourceObjItemsAPIView,
|
||||||
LyngsatTaskStatusAPIView,
|
LyngsatTaskStatusAPIView,
|
||||||
TransponderDataAPIView,
|
TransponderDataAPIView,
|
||||||
|
MultiSourcesPlaybackDataAPIView,
|
||||||
)
|
)
|
||||||
from .lyngsat import (
|
from .lyngsat import (
|
||||||
LinkLyngsatSourcesView,
|
LinkLyngsatSourcesView,
|
||||||
@@ -53,6 +54,7 @@ from .map import (
|
|||||||
ShowSourcesMapView,
|
ShowSourcesMapView,
|
||||||
ShowSourceWithPointsMapView,
|
ShowSourceWithPointsMapView,
|
||||||
ShowSourceAveragingStepsMapView,
|
ShowSourceAveragingStepsMapView,
|
||||||
|
MultiSourcesPlaybackMapView,
|
||||||
# ClusterTestView,
|
# ClusterTestView,
|
||||||
)
|
)
|
||||||
from .kubsat import (
|
from .kubsat import (
|
||||||
@@ -93,6 +95,7 @@ __all__ = [
|
|||||||
'SourceObjItemsAPIView',
|
'SourceObjItemsAPIView',
|
||||||
'LyngsatTaskStatusAPIView',
|
'LyngsatTaskStatusAPIView',
|
||||||
'TransponderDataAPIView',
|
'TransponderDataAPIView',
|
||||||
|
'MultiSourcesPlaybackDataAPIView',
|
||||||
# LyngSat
|
# LyngSat
|
||||||
'LinkLyngsatSourcesView',
|
'LinkLyngsatSourcesView',
|
||||||
'FillLyngsatDataView',
|
'FillLyngsatDataView',
|
||||||
@@ -122,6 +125,7 @@ __all__ = [
|
|||||||
'ShowSourcesMapView',
|
'ShowSourcesMapView',
|
||||||
'ShowSourceWithPointsMapView',
|
'ShowSourceWithPointsMapView',
|
||||||
'ShowSourceAveragingStepsMapView',
|
'ShowSourceAveragingStepsMapView',
|
||||||
|
'MultiSourcesPlaybackMapView',
|
||||||
# 'ClusterTestView',
|
# 'ClusterTestView',
|
||||||
# Kubsat
|
# Kubsat
|
||||||
'KubsatView',
|
'KubsatView',
|
||||||
|
|||||||
@@ -622,3 +622,103 @@ class SatelliteDataAPIView(LoginRequiredMixin, View):
|
|||||||
return JsonResponse({'error': 'Спутник не найден'}, status=404)
|
return JsonResponse({'error': 'Спутник не найден'}, status=404)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse({'error': str(e)}, status=500)
|
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)
|
||||||
|
|||||||
@@ -422,6 +422,28 @@ class ShowSourceAveragingStepsMapView(LoginRequiredMixin, View):
|
|||||||
return render(request, "mainapp/source_averaging_map.html", context)
|
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):
|
# class ClusterTestView(LoginRequiredMixin, View):
|
||||||
# """Test view for clustering functionality."""
|
# """Test view for clustering functionality."""
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,6 @@
|
|||||||
транспондеров</button>
|
транспондеров</button>
|
||||||
<button id="clearMarkersBtn" type="button" onclick="clearAllMarkers()"
|
<button id="clearMarkersBtn" type="button" onclick="clearAllMarkers()"
|
||||||
style="display: block; width: 100%; margin-top: 10px;">Очистить маркеры</button>
|
style="display: block; width: 100%; margin-top: 10px;">Очистить маркеры</button>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footprint-control"
|
<div class="footprint-control"
|
||||||
@@ -32,392 +30,68 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.panel-title {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.object-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
.load-btn {
|
||||||
|
padding: 5px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.footprint-actions {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.footprint-actions button {
|
||||||
|
margin-right: 5px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.layers-control {
|
||||||
|
background: white;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0,0,0,.1);
|
||||||
|
font-size: 12px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.layers-control h6 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.layer-group {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 2px solid #ddd;
|
||||||
|
}
|
||||||
|
.layer-group-title {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.layer-item {
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
.layer-item label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock extra_css %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
function clearAllMarkers() {
|
const markerColors = ['#e41a1c', '#4daf4a', '#ff7f00', '#984ea3', '#999999', '#000000', '#377eb8'];
|
||||||
if (window.mainTreeControl && window.mainTreeControl._map) {
|
let markersData = [];
|
||||||
map.removeControl(window.mainTreeControl);
|
let layersControlContainer = null;
|
||||||
delete window.mainTreeControl;
|
|
||||||
if (window.geoJsonOverlaysControl) {
|
|
||||||
delete window.geoJsonOverlaysControl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
map.eachLayer(function (layer) {
|
|
||||||
if (!(layer instanceof L.TileLayer)) {
|
|
||||||
map.removeLayer(layer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let markerColors = ['red', 'green', 'orange', 'violet', 'grey', 'black', 'blue'];
|
|
||||||
|
|
||||||
function getColorIcon(color) {
|
|
||||||
return L.icon({
|
|
||||||
iconUrl: '{% static "leaflet-markers/img/marker-icon-" %}' + color + '.png',
|
|
||||||
shadowUrl: '{% static "leaflet-markers/img/marker-shadow.png" %}',
|
|
||||||
iconSize: [25, 41],
|
|
||||||
iconAnchor: [12, 41],
|
|
||||||
popupAnchor: [1, -34],
|
|
||||||
shadowSize: [41, 41]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Новая функция загрузки и отображения GeoJSON ---
|
|
||||||
function loadGeoJsonForSatellite(satId) {
|
|
||||||
if (!satId) {
|
|
||||||
alert('Пожалуйста, выберите объект.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.mainTreeControl && window.mainTreeControl._map) {
|
|
||||||
map.removeControl(window.mainTreeControl);
|
|
||||||
delete window.mainTreeControl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `/api/locations/${encodeURIComponent(satId)}/geojson`;
|
|
||||||
|
|
||||||
fetch(url)
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Сервер вернул ошибку: ' + response.status);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
if (!data.features || data.features.length === 0) {
|
|
||||||
alert('Объекты с таким именем не найдены.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Группировка данных по частоте ---
|
|
||||||
const groupedByFreq = {};
|
|
||||||
data.features.forEach(feature => {
|
|
||||||
const freq = feature.properties.freq / 1000000;
|
|
||||||
if (!groupedByFreq[freq]) {
|
|
||||||
groupedByFreq[freq] = [];
|
|
||||||
}
|
|
||||||
groupedByFreq[freq].push(feature);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Создание overlay слоев для L.control.layers.tree ---
|
|
||||||
const overlays = [];
|
|
||||||
let freqIndex = 0;
|
|
||||||
|
|
||||||
for (const [freq, features] of Object.entries(groupedByFreq)) {
|
|
||||||
const colorName = markerColors[freqIndex % markerColors.length];
|
|
||||||
const freqIcon = getColorIcon(colorName);
|
|
||||||
const freqGroupLayer = L.layerGroup();
|
|
||||||
const subgroup = [];
|
|
||||||
|
|
||||||
features.forEach((feature, idx) => {
|
|
||||||
const [lon, lat] = feature.geometry.coordinates;
|
|
||||||
const pointName = feature.properties.name || `Точка ${idx}`;
|
|
||||||
const marker = L.marker([lat, lon], { icon: freqIcon })
|
|
||||||
.bindPopup(`${pointName}<br>Частота: ${freq}`);
|
|
||||||
freqGroupLayer.addLayer(marker);
|
|
||||||
|
|
||||||
subgroup.push({
|
|
||||||
label: freq == -1 ? `${idx + 1} - Неизвестно` : `${idx + 1} - ${freq} МГц`,
|
|
||||||
layer: marker
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Группа для частоты
|
|
||||||
overlays.push({
|
|
||||||
label: `${features[0].properties.name} (${freq} МГц)`,
|
|
||||||
selectAllCheckbox: true,
|
|
||||||
children: subgroup,
|
|
||||||
layer: freqGroupLayer
|
|
||||||
});
|
|
||||||
|
|
||||||
freqIndex++;
|
|
||||||
}
|
|
||||||
const rootGroup = {
|
|
||||||
label: "Все точки",
|
|
||||||
selectAllCheckbox: true,
|
|
||||||
children: overlays,
|
|
||||||
layer: L.layerGroup()
|
|
||||||
};
|
|
||||||
|
|
||||||
const geoJsonControl = L.control.layers.tree(baseLayers, [rootGroup], {
|
|
||||||
collapsed: false,
|
|
||||||
autoZIndex: true
|
|
||||||
});
|
|
||||||
|
|
||||||
window.geoJsonOverlaysControl = geoJsonControl;
|
|
||||||
if (window.mainTreeControl && window.mainTreeControl._map) {
|
|
||||||
map.removeControl(window.mainTreeControl);
|
|
||||||
delete window.mainTreeControl;
|
|
||||||
}
|
|
||||||
window.mainTreeControl = geoJsonControl.addTo(map);
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('Ошибка загрузки GeoJSON:', err);
|
|
||||||
alert('Не удалось загрузить объекты: ' + err.message);
|
|
||||||
if (window.mainTreeControl && window.mainTreeControl._map) {
|
|
||||||
map.removeControl(window.mainTreeControl);
|
|
||||||
delete window.mainTreeControl;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadTranspondersPointsForSatellite(satId) {
|
|
||||||
if (!satId) {
|
|
||||||
alert('Пожалуйста, выберите объект.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Явная очистка перед началом ---
|
|
||||||
if (window.mainTreeControl && window.mainTreeControl._map) {
|
|
||||||
console.log('Удаляем старый контрол точек');
|
|
||||||
map.removeControl(window.mainTreeControl);
|
|
||||||
delete window.mainTreeControl;
|
|
||||||
// window.geoJsonOverlaysControl также можно удалить, если он больше не нужен
|
|
||||||
if (window.geoJsonOverlaysControl) {
|
|
||||||
delete window.geoJsonOverlaysControl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// --- Конец очистки ---
|
|
||||||
|
|
||||||
const url_points = `/api/locations/${encodeURIComponent(satId)}/geojson`;
|
|
||||||
const url_trans = `/api/transponders/${encodeURIComponent(satId)}`;
|
|
||||||
|
|
||||||
fetch(url_trans)
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Сервер вернул ошибку при загрузке транспондеров: ' + response.status);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(data_trans => {
|
|
||||||
console.log('Загруженные транспондеры:', data_trans);
|
|
||||||
|
|
||||||
return fetch(url_points)
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Сервер вернул ошибку при загрузке точек: ' + response.status);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(data_points => {
|
|
||||||
console.log('Загруженные точки:', data_points);
|
|
||||||
processAndDisplayTransponderPointsByZone(data_points, data_trans);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('Ошибка загрузки транспондеров или точек:', err);
|
|
||||||
alert('Не удалось загрузить данные: ' + err.message);
|
|
||||||
// Повторная проверка на случай ошибки
|
|
||||||
if (window.mainTreeControl && window.mainTreeControl._map) {
|
|
||||||
map.removeControl(window.mainTreeControl);
|
|
||||||
delete window.mainTreeControl;
|
|
||||||
if (window.geoJsonOverlaysControl) {
|
|
||||||
delete window.geoJsonOverlaysControl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function processAndDisplayTransponderPointsByZone(data_points, transpondersData) {
|
|
||||||
if (!data_points.features || data_points.features.length === 0) {
|
|
||||||
alert('Точки с таким идентификатором спутника не найдены.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!transpondersData || !Array.isArray(transpondersData)) {
|
|
||||||
console.error('Данные транспондеров недоступны или некорректны.');
|
|
||||||
alert('Ошибка: данные транспондеров отсутствуют.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Функция для определения транспондера по частоте и поляризации ---
|
|
||||||
function findTransponderForPoint(pointFreqHz, pointPolarization) {
|
|
||||||
const pointFreqMhz = pointFreqHz / 1000000; // Переводим в МГц
|
|
||||||
for (const trans of transpondersData) {
|
|
||||||
if (typeof trans.frequency !== 'number' || typeof trans.frequency_range !== 'number' || typeof trans.polarization !== 'string') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const centerFreq = trans.frequency;
|
|
||||||
const bandwidth = trans.frequency_range;
|
|
||||||
const halfBandwidth = bandwidth / 2;
|
|
||||||
const lowerBound = centerFreq - halfBandwidth;
|
|
||||||
const upperBound = centerFreq + halfBandwidth;
|
|
||||||
|
|
||||||
if (
|
|
||||||
pointFreqMhz >= lowerBound &&
|
|
||||||
pointFreqMhz <= upperBound &&
|
|
||||||
pointPolarization === trans.polarization
|
|
||||||
) {
|
|
||||||
return trans;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Группировка точек по транспондерам ---
|
|
||||||
const groupedByTransponder = {};
|
|
||||||
data_points.features.forEach(feature => {
|
|
||||||
const pointFreqHz = feature.properties.freq;
|
|
||||||
let pointPolarization = feature.properties.polarization;
|
|
||||||
if (pointPolarization === undefined || pointPolarization === null) {
|
|
||||||
pointPolarization = feature.properties.pol;
|
|
||||||
}
|
|
||||||
if (pointPolarization === undefined || pointPolarization === null) {
|
|
||||||
pointPolarization = feature.properties.polar;
|
|
||||||
}
|
|
||||||
if (pointPolarization === undefined || pointPolarization === null) {
|
|
||||||
pointPolarization = feature.properties.polarisation;
|
|
||||||
}
|
|
||||||
if (pointPolarization === undefined || pointPolarization === null) {
|
|
||||||
console.warn('Точка без поляризации, игнорируется:', feature);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const transponder = findTransponderForPoint(pointFreqHz, pointPolarization);
|
|
||||||
|
|
||||||
if (transponder) {
|
|
||||||
// --- ИСПРАВЛЕНО: используем name как уникальный идентификатор ---
|
|
||||||
const transId = transponder.name;
|
|
||||||
if (!groupedByTransponder[transId]) {
|
|
||||||
groupedByTransponder[transId] = {
|
|
||||||
transponder: transponder,
|
|
||||||
features: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
groupedByTransponder[transId].features.push(feature);
|
|
||||||
} else {
|
|
||||||
console.log(`Точка ${pointFreqHz / 1000000} МГц, ${pointPolarization} -> Не найден транспондер`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Сгруппированные данные:', groupedByTransponder);
|
|
||||||
|
|
||||||
// --- Создание иерархии: зоны -> транспондеры -> точки (внутри слоя) ---
|
|
||||||
const zonesMap = {};
|
|
||||||
|
|
||||||
for (const [transId, groupData] of Object.entries(groupedByTransponder)) {
|
|
||||||
const trans = groupData.transponder;
|
|
||||||
const zoneName = trans.zone_name || 'Без зоны';
|
|
||||||
|
|
||||||
if (!zonesMap[zoneName]) {
|
|
||||||
zonesMap[zoneName] = [];
|
|
||||||
}
|
|
||||||
zonesMap[zoneName].push({
|
|
||||||
transponder: trans,
|
|
||||||
features: groupData.features
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Сгруппировано по зонам:', zonesMap);
|
|
||||||
|
|
||||||
// --- Создание overlay слоев для L.control.layers.tree ---
|
|
||||||
const overlays = [];
|
|
||||||
let zoneIndex = 0;
|
|
||||||
|
|
||||||
for (const [zoneName, transponderGroups] of Object.entries(zonesMap)) {
|
|
||||||
const zoneGroupLayer = L.layerGroup();
|
|
||||||
const zoneChildren = [];
|
|
||||||
|
|
||||||
let transIndex = 0;
|
|
||||||
for (const transGroup of transponderGroups) {
|
|
||||||
const trans = transGroup.transponder;
|
|
||||||
const features = transGroup.features;
|
|
||||||
|
|
||||||
// Слой для одного транспондера
|
|
||||||
const transGroupLayer = L.layerGroup();
|
|
||||||
|
|
||||||
// Проходим по точкам транспондера
|
|
||||||
features.forEach(feature => {
|
|
||||||
const [lon, lat] = feature.geometry.coordinates;
|
|
||||||
const pointName = feature.properties.name || `Точка`;
|
|
||||||
const pointFreqHz = feature.properties.freq;
|
|
||||||
const pointFreqMhz = (pointFreqHz / 1000000).toFixed(2);
|
|
||||||
const pointPolarization = feature.properties.polarization || feature.properties.pol || feature.properties.polar || feature.properties.polarisation || '?';
|
|
||||||
|
|
||||||
// --- НОВОЕ: определяем цвет по частоте точки ---
|
|
||||||
// Округляем частоту до ближайшего целого Гц или, например, до 1000 Гц (1 кГц) для группировки
|
|
||||||
// const freqKey = Math.round(pointFreqHz / 1000); // Группировка по 1 кГц
|
|
||||||
const freqKey = Math.round(pointFreqHz); // Группировка по 1 Гц (можно изменить)
|
|
||||||
const colorIndex = freqKey % markerColors.length; // Индекс цвета зависит от частоты
|
|
||||||
const colorName = markerColors[colorIndex];
|
|
||||||
const pointIcon = getColorIcon(colorName); // Создаём иконку с цветом для этой точки
|
|
||||||
|
|
||||||
const marker = L.marker([lat, lon], { icon: pointIcon }) // Используем иконку точки
|
|
||||||
.bindPopup(`${pointName}<br>Частота: ${pointFreqMhz} МГц<br>Поляр.: ${pointPolarization}<br>Транспондер: ${trans.name}<br>Зона: ${trans.zone_name}`);
|
|
||||||
transGroupLayer.addLayer(marker);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Добавляем транспондер в дочерние элементы зоны
|
|
||||||
// Транспондер не будет иметь фиксированного цвета, только его точки
|
|
||||||
const lowerBound = (trans.frequency - trans.frequency_range / 2).toFixed(2);
|
|
||||||
const upperBound = (trans.frequency + trans.frequency_range / 2).toFixed(2);
|
|
||||||
zoneChildren.push({
|
|
||||||
label: `${trans.name} (${lowerBound} - ${upperBound})`,
|
|
||||||
selectAllCheckbox: true,
|
|
||||||
layer: transGroupLayer // Этот слой содержит точки с разными цветами
|
|
||||||
});
|
|
||||||
|
|
||||||
zoneGroupLayer.addLayer(transGroupLayer);
|
|
||||||
transIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
overlays.push({
|
|
||||||
label: zoneName,
|
|
||||||
selectAllCheckbox: true,
|
|
||||||
children: zoneChildren,
|
|
||||||
layer: zoneGroupLayer
|
|
||||||
});
|
|
||||||
|
|
||||||
zoneIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Корневая группа ---
|
|
||||||
const rootGroup = {
|
|
||||||
label: "Все точки",
|
|
||||||
selectAllCheckbox: true,
|
|
||||||
children: overlays,
|
|
||||||
layer: L.layerGroup()
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Создаем контрол и добавляем на карту ---
|
|
||||||
const geoJsonControl = L.control.layers.tree(baseLayers, [rootGroup], {
|
|
||||||
collapsed: false,
|
|
||||||
autoZIndex: true
|
|
||||||
});
|
|
||||||
|
|
||||||
window.geoJsonOverlaysControl = geoJsonControl;
|
|
||||||
if (window.mainTreeControl && window.mainTreeControl._map) {
|
|
||||||
map.removeControl(window.mainTreeControl);
|
|
||||||
delete window.mainTreeControl;
|
|
||||||
}
|
|
||||||
window.mainTreeControl = geoJsonControl.addTo(map);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Обработчики событий ---
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const select = document.getElementById('objectSelector');
|
|
||||||
select.selectedIndex = 0;
|
|
||||||
const loadBtn = document.getElementById('loadObjectBtn');
|
|
||||||
const transBtn = document.getElementById('loadObjectTransBtn')
|
|
||||||
|
|
||||||
// Загружаем footprint'ы при смене выбора
|
|
||||||
select.addEventListener('change', function () {
|
|
||||||
const satId = this.value;
|
|
||||||
console.log(satId);
|
|
||||||
loadFootprintsForSatellite(satId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Загружаем GeoJSON при нажатии кнопки
|
|
||||||
loadBtn.addEventListener('click', function () {
|
|
||||||
const satId = select.value;
|
|
||||||
loadGeoJsonForSatellite(satId);
|
|
||||||
});
|
|
||||||
|
|
||||||
transBtn.addEventListener('click', function () {
|
|
||||||
const satId = select.value;
|
|
||||||
loadTranspondersPointsForSatellite(satId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
let currentFootprintLayers = {};
|
let currentFootprintLayers = {};
|
||||||
let currentSatelliteId = null;
|
let currentSatelliteId = null;
|
||||||
|
|
||||||
@@ -425,141 +99,248 @@
|
|||||||
const showAllBtn = document.getElementById('showAllFootprints');
|
const showAllBtn = document.getElementById('showAllFootprints');
|
||||||
const hideAllBtn = document.getElementById('hideAllFootprints');
|
const hideAllBtn = document.getElementById('hideAllFootprints');
|
||||||
|
|
||||||
// --- Функции ---
|
function clearAllMarkers() {
|
||||||
|
markersData.forEach((group, groupIndex) => {
|
||||||
|
const sourceId = `markers-${groupIndex}`;
|
||||||
|
const layerId = `markers-layer-${groupIndex}`;
|
||||||
|
const innerLayerId = `markers-layer-${groupIndex}-inner`;
|
||||||
|
if (map.getLayer(innerLayerId)) map.removeLayer(innerLayerId);
|
||||||
|
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
||||||
|
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
||||||
|
});
|
||||||
|
markersData = [];
|
||||||
|
if (layersControlContainer) {
|
||||||
|
layersControlContainer.remove();
|
||||||
|
layersControlContainer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(unsafe) {
|
function addMarkersToMap() {
|
||||||
// Простая функция для экранирования HTML-символов в именах
|
markersData.forEach((group, groupIndex) => {
|
||||||
return unsafe
|
const sourceId = `markers-${groupIndex}`;
|
||||||
.replace(/&/g, "&")
|
const layerId = `markers-layer-${groupIndex}`;
|
||||||
.replace(/</g, "<")
|
if (map.getSource(sourceId)) return;
|
||||||
.replace(/>/g, ">")
|
const geojson = {
|
||||||
.replace(/"/g, """)
|
type: 'FeatureCollection',
|
||||||
.replace(/'/g, "'");
|
features: group.features.map(f => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: f.coordinates },
|
||||||
|
properties: { name: f.name, freq: f.freq, color: group.color, groupName: group.name }
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
map.addSource(sourceId, { type: 'geojson', data: geojson });
|
||||||
|
map.addLayer({
|
||||||
|
id: layerId, type: 'circle', source: sourceId,
|
||||||
|
paint: { 'circle-radius': 8, 'circle-color': group.color, 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff' }
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: `${layerId}-inner`, type: 'circle', source: sourceId,
|
||||||
|
paint: { 'circle-radius': 3, 'circle-color': '#ffffff' }
|
||||||
|
});
|
||||||
|
map.on('click', layerId, (e) => {
|
||||||
|
const props = e.features[0].properties;
|
||||||
|
const coords = e.features[0].geometry.coordinates.slice();
|
||||||
|
new maplibregl.Popup().setLngLat(coords)
|
||||||
|
.setHTML(`<div class="popup-title">${props.name}</div><div class="popup-info">Частота: ${props.freq}</div>`)
|
||||||
|
.addTo(map);
|
||||||
|
});
|
||||||
|
map.on('mouseenter', layerId, () => { map.getCanvas().style.cursor = 'pointer'; });
|
||||||
|
map.on('mouseleave', layerId, () => { map.getCanvas().style.cursor = ''; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLayersControl() {
|
||||||
|
if (layersControlContainer) layersControlContainer.remove();
|
||||||
|
layersControlContainer = document.createElement('div');
|
||||||
|
layersControlContainer.className = 'layers-control';
|
||||||
|
layersControlContainer.style.cssText = 'position:absolute;top:10px;left:10px;z-index:1000;';
|
||||||
|
const title = document.createElement('h6');
|
||||||
|
title.textContent = 'Все точки';
|
||||||
|
layersControlContainer.appendChild(title);
|
||||||
|
markersData.forEach((group, groupIndex) => {
|
||||||
|
const groupDiv = document.createElement('div');
|
||||||
|
groupDiv.className = 'layer-group';
|
||||||
|
const groupTitle = document.createElement('div');
|
||||||
|
groupTitle.className = 'layer-group-title';
|
||||||
|
const groupCheckbox = document.createElement('input');
|
||||||
|
groupCheckbox.type = 'checkbox';
|
||||||
|
groupCheckbox.checked = true;
|
||||||
|
groupCheckbox.addEventListener('change', (e) => {
|
||||||
|
const visibility = e.target.checked ? 'visible' : 'none';
|
||||||
|
const layerId = `markers-layer-${groupIndex}`;
|
||||||
|
if (map.getLayer(layerId)) {
|
||||||
|
map.setLayoutProperty(layerId, 'visibility', visibility);
|
||||||
|
map.setLayoutProperty(`${layerId}-inner`, 'visibility', visibility);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const colorSpan = document.createElement('span');
|
||||||
|
colorSpan.style.cssText = `display:inline-block;width:12px;height:12px;border-radius:50%;background-color:${group.color};margin-right:5px;`;
|
||||||
|
groupTitle.appendChild(groupCheckbox);
|
||||||
|
groupTitle.appendChild(colorSpan);
|
||||||
|
groupTitle.appendChild(document.createTextNode(` ${group.name} (${group.features.length})`));
|
||||||
|
groupDiv.appendChild(groupTitle);
|
||||||
|
layersControlContainer.appendChild(groupDiv);
|
||||||
|
});
|
||||||
|
document.body.appendChild(layersControlContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitBoundsToMarkers() {
|
||||||
|
const allCoords = [];
|
||||||
|
markersData.forEach(group => group.features.forEach(f => allCoords.push(f.coordinates)));
|
||||||
|
if (allCoords.length > 0) {
|
||||||
|
const bounds = allCoords.reduce((b, c) => b.extend(c), new maplibregl.LngLatBounds(allCoords[0], allCoords[0]));
|
||||||
|
map.fitBounds(bounds, { padding: 50 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadGeoJsonForSatellite(satId) {
|
||||||
|
if (!satId) { alert('Пожалуйста, выберите объект.'); return; }
|
||||||
|
clearAllMarkers();
|
||||||
|
fetch(`/api/locations/${encodeURIComponent(satId)}/geojson`)
|
||||||
|
.then(r => { if (!r.ok) throw new Error('Ошибка: ' + r.status); return r.json(); })
|
||||||
|
.then(data => {
|
||||||
|
if (!data.features || data.features.length === 0) { alert('Объекты не найдены.'); return; }
|
||||||
|
const groupedByFreq = {};
|
||||||
|
data.features.forEach(f => {
|
||||||
|
const freq = f.properties.freq / 1000000;
|
||||||
|
if (!groupedByFreq[freq]) groupedByFreq[freq] = [];
|
||||||
|
groupedByFreq[freq].push(f);
|
||||||
|
});
|
||||||
|
let freqIndex = 0;
|
||||||
|
for (const [freq, features] of Object.entries(groupedByFreq)) {
|
||||||
|
markersData.push({
|
||||||
|
name: `${features[0].properties.name} (${freq} МГц)`,
|
||||||
|
color: markerColors[freqIndex % markerColors.length],
|
||||||
|
features: features.map((f, idx) => ({
|
||||||
|
name: f.properties.name || `Точка ${idx}`,
|
||||||
|
freq: freq == -1 ? 'Неизвестно' : `${freq} МГц`,
|
||||||
|
coordinates: f.geometry.coordinates
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
freqIndex++;
|
||||||
|
}
|
||||||
|
addMarkersToMap();
|
||||||
|
createLayersControl();
|
||||||
|
fitBoundsToMarkers();
|
||||||
|
})
|
||||||
|
.catch(err => { console.error(err); alert('Ошибка: ' + err.message); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTranspondersPointsForSatellite(satId) {
|
||||||
|
if (!satId) { alert('Пожалуйста, выберите объект.'); return; }
|
||||||
|
clearAllMarkers();
|
||||||
|
Promise.all([
|
||||||
|
fetch(`/api/transponders/${encodeURIComponent(satId)}`).then(r => { if (!r.ok) throw new Error('Ошибка транспондеров'); return r.json(); }),
|
||||||
|
fetch(`/api/locations/${encodeURIComponent(satId)}/geojson`).then(r => { if (!r.ok) throw new Error('Ошибка точек'); return r.json(); })
|
||||||
|
]).then(([trans, points]) => processAndDisplayTransponderPointsByZone(points, trans))
|
||||||
|
.catch(err => { console.error(err); alert('Ошибка: ' + err.message); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function processAndDisplayTransponderPointsByZone(data_points, transpondersData) {
|
||||||
|
if (!data_points.features?.length) { alert('Точки не найдены.'); return; }
|
||||||
|
if (!Array.isArray(transpondersData)) { alert('Данные транспондеров отсутствуют.'); return; }
|
||||||
|
function findTransponder(freqHz, pol) {
|
||||||
|
const freqMhz = freqHz / 1000000;
|
||||||
|
for (const t of transpondersData) {
|
||||||
|
if (typeof t.frequency !== 'number') continue;
|
||||||
|
const half = (t.frequency_range || 0) / 2;
|
||||||
|
if (freqMhz >= t.frequency - half && freqMhz <= t.frequency + half && pol === t.polarization) return t;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const grouped = {};
|
||||||
|
data_points.features.forEach(f => {
|
||||||
|
const pol = f.properties.polarization || f.properties.pol || f.properties.polar || f.properties.polarisation;
|
||||||
|
if (!pol) return;
|
||||||
|
const trans = findTransponder(f.properties.freq, pol);
|
||||||
|
if (trans) {
|
||||||
|
if (!grouped[trans.name]) grouped[trans.name] = { transponder: trans, features: [] };
|
||||||
|
grouped[trans.name].features.push(f);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const zones = {};
|
||||||
|
for (const [, g] of Object.entries(grouped)) {
|
||||||
|
const zone = g.transponder.zone_name || 'Без зоны';
|
||||||
|
if (!zones[zone]) zones[zone] = [];
|
||||||
|
zones[zone].push(g);
|
||||||
|
}
|
||||||
|
let idx = 0;
|
||||||
|
for (const [zone, groups] of Object.entries(zones)) {
|
||||||
|
groups.forEach((g, ti) => {
|
||||||
|
const t = g.transponder;
|
||||||
|
const lb = (t.frequency - (t.frequency_range || 0) / 2).toFixed(2);
|
||||||
|
const ub = (t.frequency + (t.frequency_range || 0) / 2).toFixed(2);
|
||||||
|
markersData.push({
|
||||||
|
name: `${zone} - ${t.name} (${lb} - ${ub})`,
|
||||||
|
color: markerColors[(idx + ti) % markerColors.length],
|
||||||
|
features: g.features.map(f => ({
|
||||||
|
name: f.properties.name || 'Точка',
|
||||||
|
freq: `${(f.properties.freq / 1000000).toFixed(2)} МГц`,
|
||||||
|
coordinates: f.geometry.coordinates
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
addMarkersToMap();
|
||||||
|
createLayersControl();
|
||||||
|
fitBoundsToMarkers();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFootprintUIAndLayers() {
|
function clearFootprintUIAndLayers() {
|
||||||
// Удаляем все текущие слои footprint'ов с карты и очищаем объект
|
Object.keys(currentFootprintLayers).forEach(name => {
|
||||||
Object.entries(currentFootprintLayers).forEach(([name, layer]) => {
|
const layerId = `footprint-${name}`, sourceId = `footprint-source-${name}`;
|
||||||
if (map.hasLayer(layer)) {
|
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
||||||
map.removeLayer(layer);
|
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
currentFootprintLayers = {};
|
currentFootprintLayers = {};
|
||||||
|
|
||||||
// Очищаем контейнер с чекбоксами
|
|
||||||
togglesContainer.innerHTML = '';
|
togglesContainer.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFootprintsForSatellite(satId) {
|
function loadFootprintsForSatellite(satId) {
|
||||||
// Проверка, если satId пустой - очищаем
|
if (!satId) { clearFootprintUIAndLayers(); currentSatelliteId = null; return; }
|
||||||
if (!satId) {
|
|
||||||
clearFootprintUIAndLayers();
|
|
||||||
currentSatelliteId = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сохраняем текущий ID спутника
|
|
||||||
currentSatelliteId = satId;
|
currentSatelliteId = satId;
|
||||||
|
fetch(`/api/footprint-names/${encodeURIComponent(satId)}`)
|
||||||
const url = `/api/footprint-names/${encodeURIComponent(satId)}`;
|
.then(r => { if (!r.ok) throw new Error('Ошибка'); return r.json(); })
|
||||||
|
|
||||||
fetch(url)
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Ошибка загрузки footprint\'ов: ' + response.statusText);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(footprints => {
|
.then(footprints => {
|
||||||
if (!Array.isArray(footprints)) {
|
if (!Array.isArray(footprints)) throw new Error('Неверный формат');
|
||||||
throw new Error('Ожидался массив footprint\'ов');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Очищаем старое состояние
|
|
||||||
clearFootprintUIAndLayers();
|
clearFootprintUIAndLayers();
|
||||||
|
|
||||||
// Создаём новые слои и чекбоксы
|
|
||||||
footprints.forEach(fp => {
|
footprints.forEach(fp => {
|
||||||
// 1. Создаём тайловый слой Leaflet
|
const sourceId = `footprint-source-${fp.name}`, layerId = `footprint-${fp.name}`;
|
||||||
// Убедитесь, что URL соответствует вашей структуре тайлов
|
map.addSource(sourceId, { type: 'raster', tiles: [`/tiles/${fp.name}/{z}/{x}/{y}.png`], tileSize: 256 });
|
||||||
const layer = L.tileLayer(`/tiles/${fp.name}/{z}/{x}/{y}.png`, {
|
map.addLayer({ id: layerId, type: 'raster', source: sourceId, paint: { 'raster-opacity': 0.7 } });
|
||||||
minZoom: 0,
|
currentFootprintLayers[fp.name] = layerId;
|
||||||
maxZoom: 21, // Установите соответствующий maxZoom
|
|
||||||
opacity: 0.7, // Установите нужную прозрачность
|
|
||||||
// attribution: 'SatBeams Rendered' // Можно добавить атрибуцию
|
|
||||||
});
|
|
||||||
|
|
||||||
// Слои изначально ДОБАВЛЕНЫ на карту (и видимы), если хотите изначально скрытыми - закомментируйте следующую строку
|
|
||||||
layer.addTo(map);
|
|
||||||
|
|
||||||
// Сохраняем слой в объекте
|
|
||||||
currentFootprintLayers[fp.name] = layer;
|
|
||||||
|
|
||||||
const safeNameAttr = encodeURIComponent(fp.name); // для data-атрибута
|
|
||||||
const safeFullName = escapeHtml(fp.fullname); // для отображения
|
|
||||||
|
|
||||||
// 2. Создаём чекбокс и метку
|
|
||||||
const label = document.createElement('label');
|
const label = document.createElement('label');
|
||||||
label.style.display = 'block';
|
label.style.cssText = 'display:block;margin:4px 0;';
|
||||||
label.style.margin = '4px 0';
|
label.innerHTML = `<input type="checkbox" data-footprint="${encodeURIComponent(fp.name)}" checked> ${fp.fullname.replace(/</g,'<')}`;
|
||||||
// Чекбокс изначально отмечен, если слой добавлен на карту
|
|
||||||
label.innerHTML = `
|
|
||||||
<input type="checkbox"
|
|
||||||
data-footprint="${safeNameAttr}"
|
|
||||||
checked> <!-- Отмечен, так как слой добавлен -->
|
|
||||||
${safeFullName}
|
|
||||||
`;
|
|
||||||
togglesContainer.appendChild(label);
|
togglesContainer.appendChild(label);
|
||||||
|
label.querySelector('input').addEventListener('change', function() {
|
||||||
// 3. Связываем чекбокс со слоем
|
const lid = currentFootprintLayers[decodeURIComponent(this.dataset.footprint)];
|
||||||
const checkbox = label.querySelector('input');
|
if (lid && map.getLayer(lid)) map.setLayoutProperty(lid, 'visibility', this.checked ? 'visible' : 'none');
|
||||||
checkbox.addEventListener('change', function () {
|
|
||||||
const footprintName = decodeURIComponent(this.dataset.footprint);
|
|
||||||
const layer = currentFootprintLayers[footprintName];
|
|
||||||
if (layer) {
|
|
||||||
if (this.checked && !map.hasLayer(layer)) {
|
|
||||||
// Если чекбокс отмечен и слой не на карте - добавляем
|
|
||||||
map.addLayer(layer);
|
|
||||||
} else if (!this.checked && map.hasLayer(layer)) {
|
|
||||||
// Если чекбокс снят и слой на карте - удаляем
|
|
||||||
map.removeLayer(layer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => { console.error(err); alert('Ошибка: ' + err.message); clearFootprintUIAndLayers(); });
|
||||||
console.error('Ошибка загрузки footprint\'ов:', err);
|
|
||||||
alert('Не удалось загрузить области покрытия: ' + err.message);
|
|
||||||
clearFootprintUIAndLayers(); // При ошибке очищаем UI
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function showAllFootprints() {
|
function showAllFootprints() {
|
||||||
Object.entries(currentFootprintLayers).forEach(([name, layer]) => {
|
Object.values(currentFootprintLayers).forEach(lid => { if (map.getLayer(lid)) map.setLayoutProperty(lid, 'visibility', 'visible'); });
|
||||||
if (!map.hasLayer(layer)) {
|
document.querySelectorAll('#footprintToggles input').forEach(cb => cb.checked = true);
|
||||||
map.addLayer(layer);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
// Синхронизируем чекбоксы
|
|
||||||
document.querySelectorAll('#footprintToggles input[type="checkbox"]').forEach(cb => {
|
|
||||||
cb.checked = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideAllFootprints() {
|
function hideAllFootprints() {
|
||||||
Object.entries(currentFootprintLayers).forEach(([name, layer]) => {
|
Object.values(currentFootprintLayers).forEach(lid => { if (map.getLayer(lid)) map.setLayoutProperty(lid, 'visibility', 'none'); });
|
||||||
if (map.hasLayer(layer)) {
|
document.querySelectorAll('#footprintToggles input').forEach(cb => cb.checked = false);
|
||||||
map.removeLayer(layer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.querySelectorAll('#footprintToggles input[type="checkbox"]').forEach(cb => {
|
|
||||||
cb.checked = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Обработчики событий для кнопок ---
|
onStyleChange = function() { addMarkersToMap(); if (currentSatelliteId) loadFootprintsForSatellite(currentSatelliteId); };
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const select = document.getElementById('objectSelector');
|
||||||
|
select.selectedIndex = 0;
|
||||||
|
select.addEventListener('change', function() { loadFootprintsForSatellite(this.value); });
|
||||||
|
document.getElementById('loadObjectBtn').addEventListener('click', () => loadGeoJsonForSatellite(select.value));
|
||||||
|
document.getElementById('loadObjectTransBtn').addEventListener('click', () => loadTranspondersPointsForSatellite(select.value));
|
||||||
|
});
|
||||||
showAllBtn.addEventListener('click', showAllFootprints);
|
showAllBtn.addEventListener('click', showAllFootprints);
|
||||||
hideAllBtn.addEventListener('click', hideAllFootprints);
|
hideAllBtn.addEventListener('click', hideAllFootprints);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user