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

677 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

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