1476 lines
57 KiB
HTML
1476 lines
57 KiB
HTML
{% 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: '© OpenStreetMap contributors'
|
||
}),
|
||
'Спутник (Esri)': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
||
attribution: 'Tiles © Esri'
|
||
}),
|
||
'Топографическая': L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
|
||
maxZoom: 17,
|
||
attribution: '© 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 %}
|