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

1476 lines
57 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">
<link href="{% static 'leaflet-geoman/leaflet-geoman.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-panel/leaflet-panel-layers.css' %}" rel="stylesheet">
<link href="{% static 'css/multi_sources_playback_map.css' %}" rel="stylesheet">
{% 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>
<!-- Layer Toggle Button -->
<button class="layer-toggle-btn" id="layerToggleBtn" title="Управление слоями">
<i class="bi bi-layers"></i> Слои
</button>
<!-- Layer Manager Panel -->
<div class="layer-manager-panel" id="layerManagerPanel" style="display: none;">
<div class="layer-manager-header">
<h6> Управление слоями</h6>
<button type="button" class="btn-close btn-close-white btn-sm" id="closeLayerPanel"></button>
</div>
<div class="layer-manager-body">
<!-- Base Layers Section -->
<div class="layer-section">
<div class="layer-section-title">
<span>Базовые слои (тайлы)</span>
</div>
<div id="baseLayers"></div>
</div>
<!-- Markers Layer Section (Playback) -->
<div class="layer-section">
<div class="layer-section-title">
<span> Слой маркеров (Playback)</span>
</div>
<div id="markersLayerControl"></div>
</div>
<!-- Drawing Layers Section -->
<div class="layer-section">
<div class="layer-section-title">
<span> Слои рисования</span>
<button class="btn btn-sm btn-primary add-layer-btn" id="addDrawingLayerBtn" style="width: auto; margin: 0; padding: 2px 8px;">
<i class="bi bi-plus"></i> Добавить
</button>
</div>
<div id="drawingLayers"></div>
</div>
</div>
<!-- Import/Export -->
<div class="io-buttons">
<button class="btn-import" id="importLayersBtn">
<i class="bi bi-upload"></i> Импорт
</button>
<button class="btn-export" id="exportLayersBtn">
<i class="bi bi-download"></i> Экспорт
</button>
</div>
</div>
<div class="marker-size-control">
<label>Размер маркеров:</label>
<input type="range" id="markerSizeSlider" min="0.5" max="2" step="0.1" value="1">
<span class="size-value" id="sizeValue">1.0x</span>
</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>
<!-- Style Edit Modal -->
<div class="style-modal-overlay" id="styleModalOverlay"></div>
<div class="style-modal" id="styleModal">
<h5 id="styleModalTitle">Редактирование элемента</h5>
<div class="form-group">
<label for="elementLabel">Подпись / Текст:</label>
<input type="text" id="elementLabel" placeholder="Введите подпись">
</div>
<div class="form-group">
<label for="elementColor">Цвет:</label>
<input type="color" id="elementColor" value="#3388ff">
</div>
<div class="form-group" id="fillColorGroup">
<label for="elementFillColor">Цвет заливки:</label>
<input type="color" id="elementFillColor" value="#3388ff">
</div>
<div class="form-group" id="strokeWidthGroup">
<label for="elementStrokeWidth">Толщина линии:</label>
<input type="number" id="elementStrokeWidth" value="3" min="1" max="20">
</div>
<div class="form-group" id="opacityGroup">
<label for="elementOpacity">Прозрачность заливки:</label>
<input type="range" id="elementOpacity" min="0" max="1" step="0.1" value="0.2">
</div>
<div class="btn-row">
<button class="btn-cancel" id="styleModalCancel">Отмена</button>
<button class="btn-save" id="styleModalSave">Сохранить</button>
</div>
</div>
<!-- Hidden file input for import -->
<input type="file" id="importFileInput" accept=".json,.geojson" style="display: none;">
<!-- Custom edit mode indicator -->
<div class="custom-edit-indicator" id="customEditIndicator">
<i class="bi bi-pencil-square"></i> Режим редактирования стилей активен. Кликните на элемент для редактирования.
</div>
{% endblock content %}
{% block extra_js %}
<script src="{% static 'leaflet/leaflet.js' %}"></script>
<script src="{% static 'leaflet-measure/leaflet-measure.ru.js' %}"></script>
<script src="{% static 'leaflet-geoman/leafllet-geoman.js' %}"></script>
<script src="{% static 'leaflet-panel/leaflet-panel-layers.js' %}"></script>
<script src="{% static 'js/custom_marker_tool.js' %}"></script>
<script>
const SOURCE_IDS = "{{ source_ids }}";
const API_URL = "{% url 'mainapp:multi_sources_playback_api' %}";
// ==================== MAP INITIALIZATION ====================
let map = L.map('map', {
center: [55.75, 37.62],
zoom: 5,
pmIgnore: false
});
L.control.scale({ imperial: false, metric: true }).addTo(map);
map.attributionControl.setPrefix(false);
// Base tile layers
const baseLayers = {
'Улицы (OSM)': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; OpenStreetMap contributors'
}),
'Спутник (Esri)': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri'
}),
'Топографическая': L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
maxZoom: 17,
attribution: '&copy; OpenTopoMap'
}),
'Локальная': L.tileLayer('http://127.0.0.1:8090/styles/basic-preview/512/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: 'Local Tiles'
})
};
// Set default base layer
let currentBaseLayer = baseLayers['Улицы (OSM)'];
currentBaseLayer.addTo(map);
// Measure control - moved to left side
L.control.measure({
primaryLengthUnit: 'kilometers',
position: 'topleft'
}).addTo(map);
// ==================== LAYER MANAGEMENT ====================
// Make these global for CustomMarkerTool access
window.drawingLayers = {}; // { layerId: { name, layerGroup, visible, elements: [] } }
let drawingLayers = window.drawingLayers;
let layerIdCounter = 0;
// Use getter/setter for activeDrawingLayerId to keep window.activeDrawingLayerId in sync
let _activeDrawingLayerId = null;
Object.defineProperty(window, 'activeDrawingLayerId', {
get: () => _activeDrawingLayerId,
set: (value) => { _activeDrawingLayerId = value; }
});
let activeDrawingLayerId = null;
// Markers layer for playback
let markersLayerGroup = L.layerGroup().addTo(map);
let markersLayerVisible = true;
// ==================== GEOMAN SETUP ====================
// Disable standard marker tool, use custom one instead
map.pm.addControls({
position: 'topleft',
drawCircle: true,
drawCircleMarker: true,
drawPolyline: true,
drawRectangle: true,
drawPolygon: true,
drawMarker: false, // Disabled - using custom marker tool integrated below
drawText: true,
editMode: true,
dragMode: true,
cutPolygon: false,
removalMode: true,
rotateMode: true,
scaleMode: true
});
// Add custom edit control after Geoman is initialized
addCustomEditControl();
// Set default path options
map.pm.setPathOptions({
color: '#3388ff',
fillColor: '#3388ff',
fillOpacity: 0.2,
weight: 3
});
// ==================== PLAYBACK STATE ====================
let sourcesData = [];
let timeRange = null;
let currentProgress = 0;
let playbackInterval = null;
let playbackSpeed = 50;
let speedMultiplier = 1;
let totalSteps = 1000;
let sourceLayerGroups = {};
let currentMarkers = {};
let trailPolylines = {};
let staticMarkers = {};
// Color and shape mappings
const colorMap = {
'red': '#dc3545', 'blue': '#007bff', 'green': '#28a745', 'purple': '#6f42c1',
'orange': '#fd7e14', 'cyan': '#17a2b8', 'magenta': '#e83e8c', 'pink': '#ff69b4',
'teal': '#20c997', 'indigo': '#6610f2', 'brown': '#8b4513', 'navy': '#000080',
'maroon': '#800000', 'olive': '#808000', 'coral': '#ff7f50', 'turquoise': '#40e0d0'
};
const shapeMap = {
'circle': (color, size) => {
const adjustedSize = Math.round(size * 0.85);
return `<div style="background: ${color}; width: ${adjustedSize}px; height: ${adjustedSize}px; border-radius: 50%; border: 1px solid black; box-sizing: border-box;"></div>`;
},
'square': (color, size) => {
const adjustedSize = Math.round(size * 0.85);
return `<div style="background: ${color}; width: ${adjustedSize}px; height: ${adjustedSize}px; border: 1px solid black; box-sizing: border-box;"></div>`;
},
'triangle': (color, size) => {
const adjustedSize = Math.round(size * 0.8);
return `<svg width="${adjustedSize}" height="${adjustedSize}" viewBox="0 0 100 100"><polygon points="50,10 90,90 10,90" fill="${color}" stroke="black" stroke-width="2"/></svg>`;
},
'star': (color, size) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="${color}" stroke="black" stroke-width="0.8"/></svg>`,
'pentagon': (color, size) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24"><path d="M12 2l7.5 5.5-2.9 9H7.4l-2.9-9z" fill="${color}" stroke="black" stroke-width="0.8"/></svg>`,
'hexagon': (color, size) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24"><path d="M12 2l6 4v8l-6 4-6-4V6z" fill="${color}" stroke="black" stroke-width="0.8"/></svg>`,
'diamond': (color, size) => {
const adjustedSize = Math.round(size * 0.85);
return `<div style="background: ${color}; width: ${adjustedSize}px; height: ${adjustedSize}px; transform: rotate(45deg); border: 1px solid black; box-sizing: border-box;"></div>`;
},
'cross': (color, size) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24"><path d="M9 2h6v7h7v6h-7v7H9v-7H2V9h7V2z" fill="${color}" stroke="black" stroke-width="0.8"/></svg>`
};
const availableShapes = ['circle', 'square', 'triangle', 'star', 'pentagon', 'hexagon', 'diamond', 'cross'];
let markerSizeMultiplier = 1.0;
// ==================== MARKER ICON FUNCTIONS ====================
function createStaticMarkerIcon(type, color, shape) {
const hexColor = colorMap[color] || color;
let baseSize = 12;
if (type === 'start' || type === 'end') baseSize = 14;
else if (type === 'intermediate') baseSize = 10;
const size = Math.round(baseSize * markerSizeMultiplier);
const shapeFunc = shapeMap[shape] || shapeMap['circle'];
return L.divIcon({
className: 'static-marker',
iconSize: [size, size],
iconAnchor: [size/2, size/2],
popupAnchor: [0, -size/2],
html: shapeFunc(hexColor, size)
});
}
function createMovingMarkerIcon(color, shape) {
const hexColor = colorMap[color] || color;
const baseSize = 16;
const size = Math.round(baseSize * markerSizeMultiplier);
const shapeFunc = shapeMap[shape] || shapeMap['circle'];
return L.divIcon({
className: 'current-marker moving-marker',
iconSize: [size, size],
iconAnchor: [size/2, size/2],
popupAnchor: [0, -size/2],
html: shapeFunc(hexColor, size)
});
}
// ==================== UTILITY FUNCTIONS ====================
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'
});
}
function interpolatePosition(p1, p2, t) {
return { lat: p1.lat + (p2.lat - p1.lat) * t, lng: p1.lng + (p2.lng - p1.lng) * t };
}
function getPositionAtProgress(source, progress) {
const points = source.points;
if (!points || points.length === 0) return null;
if (points.length === 1) return { lat: points[0].lat, lng: points[0].lng, pointIndex: 0, segmentProgress: 1 };
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;
const p1 = points[segmentIndex];
const p2 = points[segmentIndex + 1];
const pos = interpolatePosition(p1, p2, segmentProgress);
pos.pointIndex = segmentIndex;
pos.segmentProgress = segmentProgress;
if (progress >= 1) {
const lastPoint = points[points.length - 1];
return { lat: lastPoint.lat, lng: lastPoint.lng, pointIndex: points.length - 1, segmentProgress: 1 };
}
return pos;
}
// ==================== LAYER PANEL UI ====================
function renderLayerPanel() {
// Base layers
const baseLayersDiv = document.getElementById('baseLayers');
baseLayersDiv.innerHTML = '';
Object.keys(baseLayers).forEach(name => {
const isActive = map.hasLayer(baseLayers[name]);
const item = document.createElement('div');
item.className = 'layer-item';
item.innerHTML = `
<input type="radio" name="baseLayer" ${isActive ? 'checked' : ''} data-layer="${name}">
<span class="layer-name">${name}</span>
`;
item.querySelector('input').addEventListener('change', () => {
Object.values(baseLayers).forEach(l => map.removeLayer(l));
baseLayers[name].addTo(map);
currentBaseLayer = baseLayers[name];
});
baseLayersDiv.appendChild(item);
});
// Markers layer control
const markersDiv = document.getElementById('markersLayerControl');
markersDiv.innerHTML = `
<div class="layer-item ${markersLayerVisible ? '' : 'opacity-50'}">
<input type="checkbox" id="markersLayerCheckbox" ${markersLayerVisible ? 'checked' : ''}>
<span class="layer-name">Точки из базы (Playback)</span>
</div>
`;
document.getElementById('markersLayerCheckbox').addEventListener('change', function() {
markersLayerVisible = this.checked;
if (markersLayerVisible) {
markersLayerGroup.addTo(map);
Object.values(sourceLayerGroups).forEach(g => g.addTo(map));
} else {
map.removeLayer(markersLayerGroup);
Object.values(sourceLayerGroups).forEach(g => map.removeLayer(g));
}
});
// Drawing layers
renderDrawingLayers();
}
// Make this function global for CustomMarkerTool
window.renderDrawingLayers = renderDrawingLayers;
function renderDrawingLayers() {
const drawingLayersDiv = document.getElementById('drawingLayers');
drawingLayersDiv.innerHTML = '';
if (Object.keys(drawingLayers).length === 0) {
drawingLayersDiv.innerHTML = '<p style="font-size: 11px; color: #888; margin: 5px 0;">Нет слоёв рисования</p>';
return;
}
Object.entries(drawingLayers).forEach(([layerId, layer]) => {
const isActive = activeDrawingLayerId === layerId;
const item = document.createElement('div');
item.className = `layer-item ${isActive ? 'active' : ''}`;
item.innerHTML = `
<input type="checkbox" ${layer.visible ? 'checked' : ''} data-layer-id="${layerId}">
<span class="layer-name" data-layer-id="${layerId}">${layer.name}</span>
<div class="layer-actions">
<button class="btn-expand" data-layer-id="${layerId}" title="Развернуть">▼</button>
<button class="btn-edit" data-layer-id="${layerId}" title="Переименовать">✎</button>
<button class="btn-delete" data-layer-id="${layerId}" title="Удалить">✕</button>
</div>
`;
// Visibility toggle
item.querySelector('input[type="checkbox"]').addEventListener('change', function() {
toggleLayerVisibility(layerId, this.checked);
});
// Select as active
item.querySelector('.layer-name').addEventListener('click', () => {
setActiveDrawingLayer(layerId);
});
// Expand/collapse elements
const expandBtn = item.querySelector('.btn-expand');
expandBtn.addEventListener('click', () => {
const children = item.nextElementSibling;
if (children && children.classList.contains('layer-children')) {
children.classList.toggle('expanded');
expandBtn.textContent = children.classList.contains('expanded') ? '▲' : '▼';
}
});
// Rename
item.querySelector('.btn-edit').addEventListener('click', () => {
const newName = prompt('Новое имя слоя:', layer.name);
if (newName && newName.trim()) {
layer.name = newName.trim();
renderDrawingLayers();
}
});
// Delete
item.querySelector('.btn-delete').addEventListener('click', () => {
if (confirm(`Удалить слой "${layer.name}"?`)) {
deleteDrawingLayer(layerId);
}
});
drawingLayersDiv.appendChild(item);
// Elements list
const childrenDiv = document.createElement('div');
childrenDiv.className = 'layer-children';
layer.elements.forEach((el, idx) => {
const childItem = document.createElement('div');
childItem.className = 'layer-child-item';
const elType = getElementTypeName(el.layer);
childItem.innerHTML = `
<input type="checkbox" ${el.visible ? 'checked' : ''} data-el-idx="${idx}">
<span class="layer-name" style="flex:1">${el.label || elType} #${idx + 1}</span>
<div class="layer-actions">
<button class="btn-edit" data-el-idx="${idx}" title="Редактировать">✎</button>
<button class="btn-delete" data-el-idx="${idx}" title="Удалить">✕</button>
</div>
`;
// Element visibility
childItem.querySelector('input[type="checkbox"]').addEventListener('change', function() {
toggleElementVisibility(layerId, idx, this.checked);
});
// Edit element
childItem.querySelector('.btn-edit').addEventListener('click', () => {
openStyleModal(layerId, idx);
});
// Delete element
childItem.querySelector('.btn-delete').addEventListener('click', () => {
deleteElement(layerId, idx);
});
childrenDiv.appendChild(childItem);
});
drawingLayersDiv.appendChild(childrenDiv);
});
}
function getElementTypeName(layer) {
if (layer instanceof L.Marker) return 'Маркер';
if (layer instanceof L.Circle) return 'Круг';
if (layer instanceof L.Rectangle) return 'Прямоугольник';
if (layer instanceof L.Polygon) return 'Полигон';
if (layer instanceof L.Polyline) return 'Линия';
if (layer instanceof L.CircleMarker) return 'Точка';
if (layer.pm && layer.pm.textArea) return 'Текст';
return 'Элемент';
}
// ==================== LAYER OPERATIONS ====================
function createDrawingLayer(name) {
const layerId = 'layer_' + (++layerIdCounter);
const layerGroup = L.layerGroup().addTo(map);
drawingLayers[layerId] = {
name: name || `Слой ${layerIdCounter}`,
layerGroup: layerGroup,
visible: true,
elements: []
};
setActiveDrawingLayer(layerId);
renderDrawingLayers();
return layerId;
}
function setActiveDrawingLayer(layerId) {
_activeDrawingLayerId = layerId;
activeDrawingLayerId = layerId;
renderDrawingLayers();
}
function toggleLayerVisibility(layerId, visible) {
const layer = drawingLayers[layerId];
if (!layer) return;
layer.visible = visible;
if (visible) {
layer.layerGroup.addTo(map);
} else {
map.removeLayer(layer.layerGroup);
}
renderDrawingLayers();
}
function toggleElementVisibility(layerId, elementIdx, visible) {
const layer = drawingLayers[layerId];
if (!layer || !layer.elements[elementIdx]) return;
const el = layer.elements[elementIdx];
el.visible = visible;
if (visible) {
layer.layerGroup.addLayer(el.layer);
} else {
layer.layerGroup.removeLayer(el.layer);
}
}
function deleteDrawingLayer(layerId) {
const layer = drawingLayers[layerId];
if (!layer) return;
map.removeLayer(layer.layerGroup);
delete drawingLayers[layerId];
if (activeDrawingLayerId === layerId) {
const newActiveId = Object.keys(drawingLayers)[0] || null;
_activeDrawingLayerId = newActiveId;
activeDrawingLayerId = newActiveId;
}
renderDrawingLayers();
}
function deleteElement(layerId, elementIdx) {
const layer = drawingLayers[layerId];
if (!layer || !layer.elements[elementIdx]) return;
const el = layer.elements[elementIdx];
layer.layerGroup.removeLayer(el.layer);
layer.elements.splice(elementIdx, 1);
renderDrawingLayers();
}
// ==================== CUSTOM EDIT MODE ====================
// Make this global for CustomMarkerTool access
window.customEditModeActive = false;
function toggleCustomEditMode() {
window.customEditModeActive = !window.customEditModeActive;
const btn = document.querySelector('.leaflet-pm-icon-custom-edit');
const indicator = document.getElementById('customEditIndicator');
const mapContainer = map.getContainer();
if (btn && btn.parentElement) {
if (window.customEditModeActive) {
btn.parentElement.classList.add('active');
// Disable other modes
map.pm.disableDraw();
map.pm.disableGlobalEditMode();
map.pm.disableGlobalDragMode();
map.pm.disableGlobalRemovalMode();
// Show indicator
if (indicator) {
indicator.classList.add('active');
}
// Add cursor class
mapContainer.classList.add('custom-edit-mode');
// Fire custom event
map.fire('customeditmodetoggled', { enabled: true });
} else {
btn.parentElement.classList.remove('active');
// Hide indicator
if (indicator) {
indicator.classList.remove('active');
}
// Remove cursor class
mapContainer.classList.remove('custom-edit-mode');
// Fire custom event
map.fire('customeditmodetoggled', { enabled: false });
}
}
}
// Add custom edit mode control to Geoman
function addCustomEditControl() {
const customEditAction = {
name: 'customEdit',
block: 'edit',
title: 'Редактировать стили элементов',
className: 'leaflet-pm-icon-custom-edit',
toggle: true,
onClick: () => {},
afterClick: () => {
toggleCustomEditMode();
}
};
// map.pm.Toolbar.createCustomControl(customEditAction);
// Add custom icon style
const style = document.createElement('style');
style.textContent = `
.leaflet-pm-icon-custom-edit {
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTEgNEgyMGE0IDQgMCAwIDEgNCA0djhhNCA0IDAgMCAxLTQgNEg0YTQgNCAwIDAgMS00LTRWOGE0IDQgMCAwIDEgNC00aDciPjwvcGF0aD48cGF0aCBkPSJNOSAxNWgxMCI+PC9wYXRoPjxwYXRoIGQ9Ik05IDExaDE0Ij48L3BhdGg+PC9zdmc+');
background-size: 18px 18px;
background-position: center;
background-repeat: no-repeat;
}
`;
document.head.appendChild(style);
}
// ==================== GEOMAN EVENT HANDLERS ====================
map.on('pm:create', function(e) {
const layer = e.layer;
const shape = e.shape;
// Ensure we have an active drawing layer
if (!activeDrawingLayerId || !drawingLayers[activeDrawingLayerId]) {
createDrawingLayer('Новый слой');
}
const activeLayer = drawingLayers[activeDrawingLayerId];
// Handle Text elements specially - keep on map for Geoman text editing to work
if (shape === 'Text') {
const elementInfo = {
layer: layer,
visible: true,
label: 'Текст',
style: extractStyle(layer),
isText: true
};
activeLayer.elements.push(elementInfo);
// Don't move text layer - Geoman needs it on the map for editing
renderDrawingLayers();
return;
}
// Remove from map (will be added to layer group)
map.removeLayer(layer);
// Add to active layer group
activeLayer.layerGroup.addLayer(layer);
// Store element info
const elementInfo = {
layer: layer,
visible: true,
label: '',
style: extractStyle(layer)
};
activeLayer.elements.push(elementInfo);
// Add click handler for editing ONLY in custom edit mode
// Capture layerId in closure for correct reference when clicked later
const capturedLayerId = activeDrawingLayerId;
layer.on('click', function(e) {
if (window.customEditModeActive) {
L.DomEvent.stopPropagation(e);
const layerData = drawingLayers[capturedLayerId];
if (layerData) {
const idx = layerData.elements.findIndex(el => el.layer === layer);
if (idx !== -1) {
openStyleModal(capturedLayerId, idx);
}
}
}
});
renderDrawingLayers();
});
map.on('pm:remove', function(e) {
const layer = e.layer;
// Find and remove from our tracking
Object.entries(drawingLayers).forEach(([layerId, layerData]) => {
const idx = layerData.elements.findIndex(el => el.layer === layer);
if (idx !== -1) {
layerData.elements.splice(idx, 1);
}
});
renderDrawingLayers();
});
// Helper function to disable custom edit mode
function disableCustomEditMode() {
if (window.customEditModeActive) {
window.customEditModeActive = false;
const btn = document.querySelector('.leaflet-pm-icon-custom-edit');
if (btn && btn.parentElement) {
btn.parentElement.classList.remove('active');
}
const indicator = document.getElementById('customEditIndicator');
if (indicator) {
indicator.classList.remove('active');
}
const mapContainer = map.getContainer();
mapContainer.classList.remove('custom-edit-mode');
}
}
// Disable custom edit mode when other modes are activated
map.on('pm:globaldrawmodetoggled', (e) => {
if (e.enabled) {
disableCustomEditMode();
}
});
map.on('pm:globaleditmodetoggled', (e) => {
if (e.enabled) {
disableCustomEditMode();
}
});
map.on('pm:globaldragmodetoggled', (e) => {
if (e.enabled) {
disableCustomEditMode();
}
});
map.on('pm:globalremovalmodetoggled', (e) => {
if (e.enabled) {
disableCustomEditMode();
}
});
function extractStyle(layer) {
if (layer.options) {
return {
color: layer.options.color || '#3388ff',
fillColor: layer.options.fillColor || '#3388ff',
fillOpacity: layer.options.fillOpacity || 0.2,
weight: layer.options.weight || 3
};
}
return { color: '#3388ff', fillColor: '#3388ff', fillOpacity: 0.2, weight: 3 };
}
// ==================== STYLE MODAL ====================
let currentEditingLayerId = null;
let currentEditingElementIdx = null;
// Make this function global for CustomMarkerTool
window.openStyleModal = openStyleModal;
function openStyleModal(layerId, elementIdx) {
const layer = drawingLayers[layerId];
if (!layer || !layer.elements[elementIdx]) return;
currentEditingLayerId = layerId;
currentEditingElementIdx = elementIdx;
const el = layer.elements[elementIdx];
const style = el.style || extractStyle(el.layer);
document.getElementById('elementLabel').value = el.label || '';
document.getElementById('elementColor').value = style.color || '#3388ff';
document.getElementById('elementFillColor').value = style.fillColor || '#3388ff';
document.getElementById('elementStrokeWidth').value = style.weight || 3;
document.getElementById('elementOpacity').value = style.fillOpacity || 0.2;
// Show/hide relevant fields based on element type
const isMarker = el.layer instanceof L.Marker;
const isLine = el.layer instanceof L.Polyline && !(el.layer instanceof L.Polygon);
document.getElementById('fillColorGroup').style.display = isMarker || isLine ? 'none' : 'block';
document.getElementById('opacityGroup').style.display = isMarker || isLine ? 'none' : 'block';
document.getElementById('strokeWidthGroup').style.display = isMarker ? 'none' : 'block';
document.getElementById('styleModalTitle').textContent = `Редактирование: ${getElementTypeName(el.layer)}`;
document.getElementById('styleModalOverlay').classList.add('show');
document.getElementById('styleModal').classList.add('show');
}
function closeStyleModal() {
document.getElementById('styleModalOverlay').classList.remove('show');
document.getElementById('styleModal').classList.remove('show');
currentEditingLayerId = null;
currentEditingElementIdx = null;
}
function saveStyleModal() {
if (currentEditingLayerId === null || currentEditingElementIdx === null) return;
const layer = drawingLayers[currentEditingLayerId];
if (!layer || !layer.elements[currentEditingElementIdx]) return;
const el = layer.elements[currentEditingElementIdx];
el.label = document.getElementById('elementLabel').value;
el.style = {
color: document.getElementById('elementColor').value,
fillColor: document.getElementById('elementFillColor').value,
fillOpacity: parseFloat(document.getElementById('elementOpacity').value),
weight: parseInt(document.getElementById('elementStrokeWidth').value)
};
// Apply style to layer
if (el.layer.setStyle) {
el.layer.setStyle(el.style);
}
// Update popup/tooltip with label
if (el.label) {
el.layer.bindTooltip(el.label, { permanent: false, direction: 'top' });
} else {
el.layer.unbindTooltip();
}
closeStyleModal();
renderDrawingLayers();
}
document.getElementById('styleModalCancel').addEventListener('click', closeStyleModal);
document.getElementById('styleModalSave').addEventListener('click', saveStyleModal);
document.getElementById('styleModalOverlay').addEventListener('click', closeStyleModal);
// ==================== IMPORT/EXPORT ====================
function exportDrawingLayers() {
const exportData = {
version: 2,
layers: {}
};
Object.entries(drawingLayers).forEach(([layerId, layer]) => {
const layerExport = {
name: layer.name,
elements: []
};
layer.elements.forEach(el => {
const elExport = {
type: getElementTypeName(el.layer),
label: el.label,
style: el.style,
geojson: el.layer.toGeoJSON ? el.layer.toGeoJSON() : null
};
// Handle markers specially - save custom marker data
if (el.layer instanceof L.Marker) {
const latlng = el.layer.getLatLng();
elExport.latlng = { lat: latlng.lat, lng: latlng.lng };
// Check if it's a custom marker with shape/color info in style
if (el.style && el.style.shape) {
elExport.isCustomMarker = true;
elExport.customStyle = {
shape: el.style.shape,
color: el.style.color, // Can be hex or color name
size: el.style.size || 1.0,
opacity: el.style.opacity || 1.0
};
}
}
// Handle circles
if (el.layer instanceof L.Circle) {
elExport.radius = el.layer.getRadius();
}
// Handle text layers
if (el.isText || (el.layer.pm && el.layer.pm.textArea)) {
elExport.isText = true;
elExport.text = el.layer.pm && el.layer.pm.getText ? el.layer.pm.getText() : (el.label || '');
// Save position for text
if (el.layer.getLatLng) {
const latlng = el.layer.getLatLng();
elExport.latlng = { lat: latlng.lat, lng: latlng.lng };
}
}
// Handle imported text markers
if (el.style && el.style.isImportedText) {
elExport.isText = true;
elExport.text = el.style.textContent || el.label || '';
if (el.layer.getLatLng) {
const latlng = el.layer.getLatLng();
elExport.latlng = { lat: latlng.lat, lng: latlng.lng };
}
}
layerExport.elements.push(elExport);
});
exportData.layers[layerId] = layerExport;
});
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `map_layers_${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(url);
}
// Helper function to create custom marker icon for import
function createCustomMarkerIcon(customStyle) {
const colorInput = customStyle.color;
// Handle both color names (e.g. 'red') and hex colors (e.g. '#dc3545')
let hexColor;
if (colorInput && colorInput.startsWith('#')) {
hexColor = colorInput; // Already a hex color
} else {
hexColor = colorMap[colorInput] || colorInput || '#3388ff';
}
const baseSize = 16;
const size = Math.round(baseSize * (customStyle.size || 1.0));
const shape = customStyle.shape || 'circle';
const shapeFunc = shapeMap[shape] || shapeMap['circle'];
const opacity = customStyle.opacity || 1.0;
return L.divIcon({
className: 'custom-placed-marker',
iconSize: [size, size],
iconAnchor: [size/2, size/2],
popupAnchor: [0, -size/2],
html: `<div style="opacity: ${opacity}">${shapeFunc(hexColor, size)}</div>`
});
}
function importDrawingLayers(file) {
const reader = new FileReader();
reader.onload = function(e) {
try {
const data = JSON.parse(e.target.result);
if (!data.layers) {
alert('Неверный формат файла');
return;
}
Object.entries(data.layers).forEach(([_, layerData]) => {
const newLayerId = createDrawingLayer(layerData.name);
const newLayer = drawingLayers[newLayerId];
layerData.elements.forEach(elData => {
let layer = null;
let elementStyle = elData.style || {};
let isTextElement = false;
// Handle text elements first - they need special treatment
if (elData.isText || elData.type === 'Текст') {
// Text elements cannot be fully restored programmatically
// Create a marker with text label as fallback
if (elData.latlng || (elData.geojson && elData.geojson.geometry)) {
let lat, lng;
if (elData.latlng) {
lat = elData.latlng.lat;
lng = elData.latlng.lng;
} else {
const coords = elData.geojson.geometry.coordinates;
lng = coords[0];
lat = coords[1];
}
// Create a text marker using divIcon
const textContent = elData.text || elData.label || 'Текст';
const textIcon = L.divIcon({
className: 'imported-text-marker',
html: `<div style="background: white; padding: 4px 8px; border: 1px solid #333; border-radius: 3px; white-space: nowrap; font-size: 14px;">${textContent}</div>`,
iconSize: null,
iconAnchor: [0, 0]
});
layer = L.marker([lat, lng], { icon: textIcon });
elementStyle.isImportedText = true;
elementStyle.textContent = textContent;
isTextElement = true;
}
}
// Handle custom markers with shape/color
else if (elData.type === 'Маркер' && elData.latlng) {
if (elData.isCustomMarker && elData.customStyle) {
// Restore custom marker with icon
const icon = createCustomMarkerIcon(elData.customStyle);
layer = L.marker([elData.latlng.lat, elData.latlng.lng], { icon: icon });
// Preserve custom style info
elementStyle = {
...elementStyle,
shape: elData.customStyle.shape,
color: elData.customStyle.color,
size: elData.customStyle.size,
opacity: elData.customStyle.opacity
};
} else {
// Standard marker
layer = L.marker([elData.latlng.lat, elData.latlng.lng]);
}
} else if (elData.type === 'Круг' && elData.geojson && elData.radius) {
const coords = elData.geojson.geometry.coordinates;
layer = L.circle([coords[1], coords[0]], { radius: elData.radius });
} else if (elData.geojson) {
layer = L.geoJSON(elData.geojson).getLayers()[0];
}
if (layer) {
if (elData.style && layer.setStyle) {
layer.setStyle(elData.style);
}
newLayer.layerGroup.addLayer(layer);
const elementInfo = {
layer: layer,
visible: true,
label: elData.label || '',
style: elementStyle
};
if (elData.label) {
layer.bindTooltip(elData.label, { permanent: false, direction: 'top' });
layer.bindPopup(`<b>${elData.label}</b>`);
}
// Capture layerId for click handler
const capturedLayerId = newLayerId;
layer.on('click', function(e) {
if (window.customEditModeActive) {
L.DomEvent.stopPropagation(e);
const ld = drawingLayers[capturedLayerId];
if (ld) {
const idx = ld.elements.findIndex(el => el.layer === layer);
if (idx !== -1) {
openStyleModal(capturedLayerId, idx);
}
}
}
});
newLayer.elements.push(elementInfo);
}
});
});
renderDrawingLayers();
alert('Слои успешно импортированы');
} catch (err) {
console.error('Import error:', err);
alert('Ошибка при импорте: ' + err.message);
}
};
reader.readAsText(file);
}
document.getElementById('exportLayersBtn').addEventListener('click', exportDrawingLayers);
document.getElementById('importLayersBtn').addEventListener('click', () => {
document.getElementById('importFileInput').click();
});
document.getElementById('importFileInput').addEventListener('change', function() {
if (this.files.length > 0) {
importDrawingLayers(this.files[0]);
this.value = '';
}
});
// ==================== LAYER PANEL TOGGLE ====================
document.getElementById('layerToggleBtn').addEventListener('click', () => {
const panel = document.getElementById('layerManagerPanel');
const btn = document.getElementById('layerToggleBtn');
if (panel.style.display === 'none') {
panel.style.display = 'flex';
btn.style.display = 'none';
}
});
document.getElementById('closeLayerPanel').addEventListener('click', () => {
document.getElementById('layerManagerPanel').style.display = 'none';
document.getElementById('layerToggleBtn').style.display = 'block';
});
document.getElementById('addDrawingLayerBtn').addEventListener('click', () => {
const name = prompt('Название нового слоя:', `Слой ${layerIdCounter + 1}`);
if (name && name.trim()) {
createDrawingLayer(name.trim());
}
});
// Prevent map interactions on panel
const layerPanel = document.getElementById('layerManagerPanel');
L.DomEvent.disableScrollPropagation(layerPanel);
L.DomEvent.disableClickPropagation(layerPanel);
</script>
<script>
// ==================== PLAYBACK FUNCTIONS ====================
function updateDisplay(progress) {
const timeDisplay = document.getElementById('timeDisplay');
const slider = document.getElementById('timeSlider');
slider.value = progress * 100;
const progressPercent = Math.round(progress * 100);
timeDisplay.textContent = `Прогресс: ${progressPercent}%`;
sourcesData.forEach(source => {
const pos = getPositionAtProgress(source, progress);
const color = source.color;
const shape = source.shape;
if (pos) {
const isAtEnd = (progress >= 1 || pos.pointIndex === source.points.length - 1) && pos.segmentProgress >= 0.99;
if (isAtEnd) {
if (currentMarkers[source.source_id]) {
sourceLayerGroups[source.source_id].removeLayer(currentMarkers[source.source_id]);
delete currentMarkers[source.source_id];
}
} else {
if (!currentMarkers[source.source_id]) {
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, shape),
zIndexOffset: 1000
}).bindPopup(popupContent);
sourceLayerGroups[source.source_id].addLayer(currentMarkers[source.source_id]);
} else {
currentMarkers[source.source_id].setLatLng([pos.lat, pos.lng]);
}
}
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);
}
source.points.forEach((point, idx) => {
const markerKey = `${source.source_id}_${idx}`;
if (staticMarkers[markerKey]) return;
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, color, shape),
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;
}
});
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', color, shape),
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;
}
}
}
});
}
function resetPlayback() {
Object.values(sourceLayerGroups).forEach(group => group.clearLayers());
currentMarkers = {};
staticMarkers = {};
sourcesData.forEach(source => {
const color = colorMap[source.color] || source.color;
const shape = source.shape;
trailPolylines[source.source_id] = L.polyline([], {
color: color,
weight: 2,
opacity: 0.7,
dashArray: '5, 10'
});
sourceLayerGroups[source.source_id].addLayer(trailPolylines[source.source_id]);
if (source.points && source.points.length > 0) {
const firstPoint = source.points[0];
const startMarker = L.marker([firstPoint.lat, firstPoint.lng], {
icon: createStaticMarkerIcon('start', color, shape),
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);
}
function updateMarkerSizes() {
pause();
const currentProg = currentProgress;
resetPlayback();
currentProgress = currentProg;
updateDisplay(currentProgress);
}
function play() {
if (playbackInterval) return;
document.getElementById('playBtn').disabled = true;
document.getElementById('pauseBtn').disabled = false;
const progressStep = 1 / totalSteps;
playbackInterval = setInterval(() => {
currentProgress += progressStep * speedMultiplier;
if (currentProgress >= 1) {
currentProgress = 1;
pause();
}
updateDisplay(currentProgress);
}, playbackSpeed);
}
function pause() {
if (playbackInterval) {
clearInterval(playbackInterval);
playbackInterval = null;
}
document.getElementById('playBtn').disabled = false;
document.getElementById('pauseBtn').disabled = true;
}
// ==================== LEGEND ====================
function addLegend() {
const legend = L.control({ position: 'bottomleft' });
legend.onAdd = function(map) {
const div = L.DomUtil.create('div', '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 shape = source.shape;
const points = source.points;
const shapeFunc = shapeMap[shape] || shapeMap['circle'];
const shapePreview = shapeFunc(color, 16);
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;">
<div style="width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; margin-right: 6px;">
${shapePreview}
</div>
<span><strong>ID ${source.source_id}:</strong> ${firstPointName}</span>
</div>
<div style="margin-left: 26px;">
<small style="color: #666;">${source.points_count} точек</small>
${timeInfo}
</div>
</div>
`;
});
div.innerHTML = html;
return div;
};
legend.addTo(map);
}
// ==================== DATA LOADING ====================
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;
sourcesData = sourcesData.filter(s => s.points && s.points.length > 0);
sourcesData.forEach((source, idx) => {
source.shape = availableShapes[idx % availableShapes.length];
});
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;
}
const bounds = L.latLngBounds();
sourcesData.forEach(source => {
sourceLayerGroups[source.source_id] = L.layerGroup().addTo(map);
source.points.forEach(point => {
bounds.extend([point.lat, point.lng]);
});
});
if (bounds.isValid()) {
map.fitBounds(bounds.pad(0.1));
}
const slider = document.getElementById('timeSlider');
slider.min = 0;
slider.max = 100;
slider.value = 0;
addLegend();
resetPlayback();
document.getElementById('loadingOverlay').style.display = 'none';
const playbackControl = document.getElementById('playbackControl');
playbackControl.style.display = 'flex';
L.DomEvent.disableScrollPropagation(playbackControl);
L.DomEvent.disableClickPropagation(playbackControl);
// Setup playback 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 (newProgress < currentProgress) {
resetPlayback();
}
currentProgress = newProgress;
updateDisplay(currentProgress);
});
document.getElementById('speedSelect').addEventListener('change', function() {
speedMultiplier = parseFloat(this.value);
});
// Marker size control
const markerSizeSlider = document.getElementById('markerSizeSlider');
const sizeValue = document.getElementById('sizeValue');
markerSizeSlider.addEventListener('input', function() {
markerSizeMultiplier = parseFloat(this.value);
sizeValue.textContent = markerSizeMultiplier.toFixed(1) + 'x';
updateMarkerSizes();
});
const markerSizeControl = document.querySelector('.marker-size-control');
L.DomEvent.disableScrollPropagation(markerSizeControl);
L.DomEvent.disableClickPropagation(markerSizeControl);
// Initialize layer panel
renderLayerPanel();
// Create default drawing layer
createDrawingLayer('Рисунки');
// Initialize custom marker tool
const customMarkerTool = new CustomMarkerTool(map, shapeMap, colorMap);
} 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>
`;
}
}
// Start loading
loadData();
</script>
{% endblock extra_js %}