Добавил плавную анимацию для нескольких источников
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');
|
||||
}
|
||||
|
||||
// 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> Объединить
|
||||
|
||||
Reference in New Issue
Block a user