682 lines
24 KiB
HTML
682 lines
24 KiB
HTML
{% extends "mainapp/base.html" %}
|
||
{% load static %}
|
||
{% block title %}Карта объектов{% endblock title %}
|
||
|
||
{% block extra_css %}
|
||
<!-- MapLibre GL CSS -->
|
||
<link href="{% static 'maplibre/maplibre-gl.css' %}" rel="stylesheet">
|
||
<style>
|
||
body {
|
||
overflow: hidden;
|
||
}
|
||
#map {
|
||
position: fixed;
|
||
top: 56px;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 0;
|
||
}
|
||
|
||
/* Легенда */
|
||
.maplibregl-ctrl-legend {
|
||
background: white;
|
||
padding: 10px;
|
||
border-radius: 4px;
|
||
box-shadow: 0 0 0 2px rgba(0,0,0,.1);
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
font-size: 11px;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.maplibregl-ctrl-legend h6 {
|
||
font-size: 12px;
|
||
margin: 0 0 8px 0;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.legend-item {
|
||
margin: 4px 0;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 2px;
|
||
}
|
||
|
||
.legend-marker {
|
||
width: 12px;
|
||
height: 12px;
|
||
margin-right: 8px;
|
||
border-radius: 50%;
|
||
border: 2px solid white;
|
||
box-shadow: 0 0 3px rgba(0,0,0,0.3);
|
||
}
|
||
|
||
.legend-section {
|
||
margin-bottom: 8px;
|
||
padding-bottom: 6px;
|
||
border-bottom: 1px solid #ddd;
|
||
}
|
||
|
||
.legend-section:last-child {
|
||
border-bottom: none;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
/* Слои контрол */
|
||
.maplibregl-ctrl-layers {
|
||
background: white;
|
||
padding: 10px;
|
||
border-radius: 4px;
|
||
box-shadow: 0 0 0 2px rgba(0,0,0,.1);
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
max-width: 250px;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.maplibregl-ctrl-layers h6 {
|
||
margin: 0 0 10px 0;
|
||
font-size: 13px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.layer-item {
|
||
margin: 5px 0;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.layer-item label {
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
user-select: none;
|
||
}
|
||
|
||
.layer-item input[type="checkbox"] {
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* Кастомные кнопки контролов */
|
||
.maplibregl-ctrl-projection .maplibregl-ctrl-icon,
|
||
.maplibregl-ctrl-3d .maplibregl-ctrl-icon,
|
||
.maplibregl-ctrl-style .maplibregl-ctrl-icon {
|
||
background-size: 20px 20px;
|
||
background-position: center;
|
||
background-repeat: no-repeat;
|
||
}
|
||
|
||
.maplibregl-ctrl-projection .maplibregl-ctrl-icon {
|
||
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="6" fill="none" stroke="%23333" stroke-width="1.5"/><ellipse cx="10" cy="10" rx="3" ry="6" fill="none" stroke="%23333" stroke-width="1.5"/><line x1="4" y1="10" x2="16" y2="10" stroke="%23333" stroke-width="1.5"/></svg>');
|
||
}
|
||
|
||
.maplibregl-ctrl-3d .maplibregl-ctrl-icon {
|
||
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path d="M 5 13 L 5 8 L 10 5 L 15 8 L 15 13 L 10 16 Z M 5 8 L 10 10.5 M 10 10.5 L 15 8 M 10 10.5 L 10 16" fill="none" stroke="%23333" stroke-width="1.5" stroke-linejoin="round"/></svg>');
|
||
}
|
||
|
||
.maplibregl-ctrl-style .maplibregl-ctrl-icon {
|
||
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><rect x="3" y="3" width="6" height="6" fill="none" stroke="%23333" stroke-width="1.5"/><rect x="11" y="3" width="6" height="6" fill="none" stroke="%23333" stroke-width="1.5"/><rect x="3" y="11" width="6" height="6" fill="none" stroke="%23333" stroke-width="1.5"/><rect x="11" y="11" width="6" height="6" fill="none" stroke="%23333" stroke-width="1.5"/></svg>');
|
||
}
|
||
|
||
/* Popup стили */
|
||
.maplibregl-popup-content {
|
||
padding: 10px 15px;
|
||
min-width: 150px;
|
||
}
|
||
|
||
.popup-title {
|
||
font-weight: bold;
|
||
margin-bottom: 5px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.popup-info {
|
||
font-size: 12px;
|
||
color: #666;
|
||
}
|
||
|
||
/* Стили меню */
|
||
.style-menu {
|
||
background: white;
|
||
border-radius: 4px;
|
||
box-shadow: 0 0 0 2px rgba(0,0,0,.1);
|
||
padding: 5px;
|
||
min-width: 120px;
|
||
}
|
||
|
||
.style-menu button {
|
||
display: block;
|
||
width: 100%;
|
||
padding: 6px 10px;
|
||
border: none;
|
||
background: none;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.style-menu button:hover {
|
||
background-color: #f0f0f0;
|
||
}
|
||
|
||
.style-menu button.active {
|
||
background-color: #e3f2fd;
|
||
color: #1976d2;
|
||
font-weight: 500;
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div id="map"></div>
|
||
{% endblock content %}
|
||
|
||
{% block extra_js %}
|
||
<!-- MapLibre GL JavaScript -->
|
||
<script src="{% static 'maplibre/maplibre-gl.js' %}"></script>
|
||
|
||
<script>
|
||
// Цвета для маркеров
|
||
const markerColors = {
|
||
'blue': '#3388ff',
|
||
'orange': '#ff8c00',
|
||
'green': '#28a745',
|
||
'violet': '#9c27b0'
|
||
};
|
||
|
||
// Данные групп из Django
|
||
const groups = [
|
||
{% for group in groups %}
|
||
{
|
||
name: '{{ group.name|escapejs }}',
|
||
color: '{{ group.color }}',
|
||
points: [
|
||
{% for point_data in group.points %}
|
||
{
|
||
id: '{{ point_data.source_id|escapejs }}',
|
||
coordinates: [{{ point_data.point.0|safe }}, {{ point_data.point.1|safe }}]
|
||
}{% if not forloop.last %},{% endif %}
|
||
{% endfor %}
|
||
]
|
||
}{% if not forloop.last %},{% endif %}
|
||
{% endfor %}
|
||
];
|
||
|
||
// Полигон фильтра
|
||
const polygonCoords = {% if polygon_coords %}{{ polygon_coords|safe }}{% else %}null{% endif %};
|
||
|
||
// Стили карт
|
||
const mapStyles = {
|
||
streets: {
|
||
version: 8,
|
||
sources: {
|
||
'osm': {
|
||
type: 'raster',
|
||
tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||
tileSize: 256,
|
||
attribution: '© OpenStreetMap'
|
||
}
|
||
},
|
||
layers: [{
|
||
id: 'osm',
|
||
type: 'raster',
|
||
source: 'osm'
|
||
}]
|
||
},
|
||
satellite: {
|
||
version: 8,
|
||
sources: {
|
||
'satellite': {
|
||
type: 'raster',
|
||
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
|
||
tileSize: 256,
|
||
attribution: '© Esri'
|
||
}
|
||
},
|
||
layers: [{
|
||
id: 'satellite',
|
||
type: 'raster',
|
||
source: 'satellite'
|
||
}]
|
||
},
|
||
local: {
|
||
version: 8,
|
||
sources: {
|
||
'local': {
|
||
type: 'raster',
|
||
tiles: ['http://127.0.0.1:8080/styles/basic-preview/{z}/{x}/{y}.png'],
|
||
tileSize: 256,
|
||
attribution: 'Local'
|
||
}
|
||
},
|
||
layers: [{
|
||
id: 'local',
|
||
type: 'raster',
|
||
source: 'local'
|
||
}]
|
||
}
|
||
};
|
||
|
||
// Инициализация карты
|
||
const map = new maplibregl.Map({
|
||
container: 'map',
|
||
style: mapStyles.streets,
|
||
center: [37.62, 55.75],
|
||
zoom: 10,
|
||
maxZoom: 18,
|
||
minZoom: 0
|
||
});
|
||
|
||
let currentStyle = 'streets';
|
||
let is3DEnabled = false;
|
||
let isGlobeProjection = false;
|
||
const allMarkers = [];
|
||
|
||
// Добавляем стандартные контролы
|
||
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||
map.addControl(new maplibregl.ScaleControl({ unit: 'metric' }), 'bottom-right');
|
||
map.addControl(new maplibregl.FullscreenControl(), 'top-right');
|
||
|
||
// Кастомный контрол для переключения проекции
|
||
class ProjectionControl {
|
||
onAdd(map) {
|
||
this._map = map;
|
||
this._container = document.createElement('div');
|
||
this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group maplibregl-ctrl-projection';
|
||
this._container.innerHTML = '<button type="button" title="Переключить проекцию"><span class="maplibregl-ctrl-icon"></span></button>';
|
||
this._container.onclick = () => {
|
||
if (isGlobeProjection) {
|
||
map.setProjection({ type: 'mercator' });
|
||
isGlobeProjection = false;
|
||
} else {
|
||
map.setProjection({ type: 'globe' });
|
||
isGlobeProjection = true;
|
||
}
|
||
};
|
||
return this._container;
|
||
}
|
||
onRemove() {
|
||
this._container.parentNode.removeChild(this._container);
|
||
this._map = undefined;
|
||
}
|
||
}
|
||
|
||
|
||
// Кастомный контрол для переключения стилей
|
||
class StyleControl {
|
||
onAdd(map) {
|
||
this._map = map;
|
||
this._container = document.createElement('div');
|
||
this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group maplibregl-ctrl-style';
|
||
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.title = 'Стиль карты';
|
||
button.innerHTML = '<span class="maplibregl-ctrl-icon"></span>';
|
||
|
||
const menu = document.createElement('div');
|
||
menu.className = 'style-menu';
|
||
menu.style.display = 'none';
|
||
menu.style.position = 'absolute';
|
||
menu.style.top = '100%';
|
||
menu.style.right = '0';
|
||
menu.style.marginTop = '5px';
|
||
|
||
const styles = [
|
||
{ id: 'streets', name: 'Улицы' },
|
||
{ id: 'satellite', name: 'Спутник' },
|
||
{ id: 'local', name: 'Локально' }
|
||
];
|
||
|
||
styles.forEach(style => {
|
||
const btn = document.createElement('button');
|
||
btn.textContent = style.name;
|
||
btn.className = style.id === currentStyle ? 'active' : '';
|
||
btn.onclick = () => {
|
||
this.switchStyle(style.id);
|
||
menu.querySelectorAll('button').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
menu.style.display = 'none';
|
||
};
|
||
menu.appendChild(btn);
|
||
});
|
||
|
||
button.onclick = (e) => {
|
||
e.stopPropagation();
|
||
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
|
||
};
|
||
|
||
document.addEventListener('click', () => {
|
||
menu.style.display = 'none';
|
||
});
|
||
|
||
this._container.appendChild(button);
|
||
this._container.appendChild(menu);
|
||
this._container.style.position = 'relative';
|
||
|
||
return this._container;
|
||
}
|
||
|
||
switchStyle(styleName) {
|
||
const center = this._map.getCenter();
|
||
const zoom = this._map.getZoom();
|
||
const bearing = this._map.getBearing();
|
||
const pitch = this._map.getPitch();
|
||
|
||
this._map.setStyle(mapStyles[styleName]);
|
||
currentStyle = styleName;
|
||
|
||
this._map.once('styledata', () => {
|
||
this._map.setCenter(center);
|
||
this._map.setZoom(zoom);
|
||
this._map.setBearing(bearing);
|
||
this._map.setPitch(pitch);
|
||
|
||
addMarkersToMap();
|
||
addFilterPolygon();
|
||
});
|
||
}
|
||
|
||
onRemove() {
|
||
this._container.parentNode.removeChild(this._container);
|
||
this._map = undefined;
|
||
}
|
||
}
|
||
|
||
// Кастомный контрол для слоев
|
||
class LayersControl {
|
||
onAdd(map) {
|
||
this._map = map;
|
||
this._container = document.createElement('div');
|
||
this._container.className = 'maplibregl-ctrl maplibregl-ctrl-layers';
|
||
|
||
const title = document.createElement('h6');
|
||
title.textContent = 'Слои точек';
|
||
this._container.appendChild(title);
|
||
|
||
groups.forEach((group, groupIndex) => {
|
||
const layerId = `points-layer-${groupIndex}`;
|
||
|
||
const layerItem = document.createElement('div');
|
||
layerItem.className = 'layer-item';
|
||
|
||
const label = document.createElement('label');
|
||
const checkbox = document.createElement('input');
|
||
checkbox.type = 'checkbox';
|
||
checkbox.checked = true;
|
||
checkbox.addEventListener('change', (e) => {
|
||
const visibility = e.target.checked ? 'visible' : 'none';
|
||
if (map.getLayer(layerId)) {
|
||
map.setLayoutProperty(layerId, 'visibility', visibility);
|
||
}
|
||
// Также скрываем/показываем внутренний круг
|
||
const innerLayerId = `${layerId}-inner`;
|
||
if (map.getLayer(innerLayerId)) {
|
||
map.setLayoutProperty(innerLayerId, 'visibility', visibility);
|
||
}
|
||
});
|
||
|
||
const colorSpan = document.createElement('span');
|
||
colorSpan.className = 'legend-marker';
|
||
colorSpan.style.backgroundColor = markerColors[group.color];
|
||
|
||
const nameSpan = document.createElement('span');
|
||
nameSpan.textContent = `${group.name} (${group.points.length})`;
|
||
|
||
label.appendChild(checkbox);
|
||
label.appendChild(colorSpan);
|
||
label.appendChild(nameSpan);
|
||
layerItem.appendChild(label);
|
||
this._container.appendChild(layerItem);
|
||
});
|
||
|
||
return this._container;
|
||
}
|
||
onRemove() {
|
||
this._container.parentNode.removeChild(this._container);
|
||
this._map = undefined;
|
||
}
|
||
}
|
||
|
||
// Кастомный контрол для легенды
|
||
class LegendControl {
|
||
onAdd(map) {
|
||
this._map = map;
|
||
this._container = document.createElement('div');
|
||
this._container.className = 'maplibregl-ctrl maplibregl-ctrl-legend';
|
||
|
||
const title = document.createElement('h6');
|
||
title.textContent = 'Легенда';
|
||
this._container.appendChild(title);
|
||
|
||
groups.forEach(group => {
|
||
const section = document.createElement('div');
|
||
section.className = 'legend-section';
|
||
|
||
const item = document.createElement('div');
|
||
item.className = 'legend-item';
|
||
|
||
const marker = document.createElement('div');
|
||
marker.className = 'legend-marker';
|
||
marker.style.backgroundColor = markerColors[group.color];
|
||
|
||
const text = document.createElement('span');
|
||
text.textContent = `${group.name} (${group.points.length})`;
|
||
|
||
item.appendChild(marker);
|
||
item.appendChild(text);
|
||
section.appendChild(item);
|
||
this._container.appendChild(section);
|
||
});
|
||
|
||
if (polygonCoords && polygonCoords.length > 0) {
|
||
const section = document.createElement('div');
|
||
section.className = 'legend-section';
|
||
|
||
const item = document.createElement('div');
|
||
item.className = 'legend-item';
|
||
|
||
const marker = document.createElement('div');
|
||
marker.style.width = '18px';
|
||
marker.style.height = '18px';
|
||
marker.style.marginRight = '8px';
|
||
marker.style.backgroundColor = 'rgba(51, 136, 255, 0.2)';
|
||
marker.style.border = '2px solid #3388ff';
|
||
marker.style.borderRadius = '2px';
|
||
|
||
const text = document.createElement('span');
|
||
text.textContent = 'Область фильтра';
|
||
|
||
item.appendChild(marker);
|
||
item.appendChild(text);
|
||
section.appendChild(item);
|
||
this._container.appendChild(section);
|
||
}
|
||
|
||
return this._container;
|
||
}
|
||
onRemove() {
|
||
this._container.parentNode.removeChild(this._container);
|
||
this._map = undefined;
|
||
}
|
||
}
|
||
|
||
// Добавляем кастомные контролы
|
||
map.addControl(new ProjectionControl(), 'top-right');
|
||
map.addControl(new StyleControl(), 'top-right');
|
||
map.addControl(new LayersControl(), 'top-left');
|
||
map.addControl(new LegendControl(), 'bottom-left');
|
||
|
||
// Добавление маркеров на карту
|
||
function addMarkersToMap() {
|
||
groups.forEach((group, groupIndex) => {
|
||
const sourceId = `points-${groupIndex}`;
|
||
const layerId = `points-layer-${groupIndex}`;
|
||
|
||
// Создаем GeoJSON для группы
|
||
const geojson = {
|
||
type: 'FeatureCollection',
|
||
features: group.points.map(point => ({
|
||
type: 'Feature',
|
||
geometry: {
|
||
type: 'Point',
|
||
coordinates: point.coordinates
|
||
},
|
||
properties: {
|
||
id: point.id,
|
||
groupName: group.name,
|
||
color: markerColors[group.color]
|
||
}
|
||
}))
|
||
};
|
||
|
||
// Добавляем источник данных
|
||
if (!map.getSource(sourceId)) {
|
||
map.addSource(sourceId, {
|
||
type: 'geojson',
|
||
data: geojson
|
||
});
|
||
}
|
||
|
||
// Добавляем слой с кругами (основной маркер)
|
||
if (!map.getLayer(layerId)) {
|
||
map.addLayer({
|
||
id: layerId,
|
||
type: 'circle',
|
||
source: sourceId,
|
||
paint: {
|
||
'circle-radius': 10,
|
||
'circle-color': ['get', 'color'],
|
||
'circle-stroke-width': 3,
|
||
'circle-stroke-color': '#ffffff',
|
||
'circle-opacity': 1
|
||
}
|
||
});
|
||
|
||
// Добавляем внутренний круг
|
||
map.addLayer({
|
||
id: `${layerId}-inner`,
|
||
type: 'circle',
|
||
source: sourceId,
|
||
paint: {
|
||
'circle-radius': 4,
|
||
'circle-color': '#ffffff',
|
||
'circle-opacity': 1
|
||
}
|
||
});
|
||
|
||
// Добавляем popup при клике
|
||
map.on('click', layerId, (e) => {
|
||
const coordinates = e.features[0].geometry.coordinates.slice();
|
||
const { id, groupName } = e.features[0].properties;
|
||
|
||
new maplibregl.Popup()
|
||
.setLngLat(coordinates)
|
||
.setHTML(`<div class="popup-title">${id}</div><div class="popup-info">Группа: ${groupName}</div>`)
|
||
.addTo(map);
|
||
});
|
||
|
||
// Меняем курсор при наведении
|
||
map.on('mouseenter', layerId, () => {
|
||
map.getCanvas().style.cursor = 'pointer';
|
||
});
|
||
|
||
map.on('mouseleave', layerId, () => {
|
||
map.getCanvas().style.cursor = '';
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// Добавление полигона фильтра
|
||
function addFilterPolygon() {
|
||
if (!polygonCoords || polygonCoords.length === 0) return;
|
||
|
||
try {
|
||
const polygonGeoJSON = {
|
||
type: 'Feature',
|
||
geometry: {
|
||
type: 'Polygon',
|
||
coordinates: [polygonCoords]
|
||
}
|
||
};
|
||
|
||
if (!map.getSource('filter-polygon')) {
|
||
map.addSource('filter-polygon', {
|
||
type: 'geojson',
|
||
data: polygonGeoJSON
|
||
});
|
||
}
|
||
|
||
if (!map.getLayer('filter-polygon-fill')) {
|
||
map.addLayer({
|
||
id: 'filter-polygon-fill',
|
||
type: 'fill',
|
||
source: 'filter-polygon',
|
||
paint: {
|
||
'fill-color': '#3388ff',
|
||
'fill-opacity': 0.2
|
||
}
|
||
});
|
||
}
|
||
|
||
if (!map.getLayer('filter-polygon-outline')) {
|
||
map.addLayer({
|
||
id: 'filter-polygon-outline',
|
||
type: 'line',
|
||
source: 'filter-polygon',
|
||
paint: {
|
||
'line-color': '#3388ff',
|
||
'line-width': 2,
|
||
'line-dasharray': [2, 2]
|
||
}
|
||
});
|
||
}
|
||
|
||
map.on('click', 'filter-polygon-fill', (e) => {
|
||
new maplibregl.Popup()
|
||
.setLngLat(e.lngLat)
|
||
.setHTML('<div class="popup-title">Область фильтра</div><div class="popup-info">Отображаются только источники с точками в этой области</div>')
|
||
.addTo(map);
|
||
});
|
||
} catch (e) {
|
||
console.error('Ошибка при отображении полигона фильтра:', e);
|
||
}
|
||
}
|
||
|
||
// Подгонка карты под все маркеры
|
||
function fitMapToBounds() {
|
||
const allCoordinates = [];
|
||
|
||
groups.forEach(group => {
|
||
group.points.forEach(point => {
|
||
allCoordinates.push(point.coordinates);
|
||
});
|
||
});
|
||
|
||
if (polygonCoords && polygonCoords.length > 0) {
|
||
polygonCoords.forEach(coord => {
|
||
allCoordinates.push(coord);
|
||
});
|
||
}
|
||
|
||
if (allCoordinates.length > 0) {
|
||
const bounds = allCoordinates.reduce((bounds, coord) => {
|
||
return bounds.extend(coord);
|
||
}, new maplibregl.LngLatBounds(allCoordinates[0], allCoordinates[0]));
|
||
|
||
map.fitBounds(bounds, { padding: 50 });
|
||
}
|
||
}
|
||
|
||
// Инициализация после загрузки карты
|
||
map.on('load', () => {
|
||
addMarkersToMap();
|
||
addFilterPolygon();
|
||
fitMapToBounds();
|
||
});
|
||
</script>
|
||
{% endblock extra_js %}
|