Compare commits

...

3 Commits

13 changed files with 2670 additions and 17 deletions

View File

@@ -6,7 +6,7 @@
.multiselect-input-container { .multiselect-input-container {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: flex-start;
min-height: 38px; min-height: 38px;
border: 1px solid #ced4da; border: 1px solid #ced4da;
border-radius: 0.25rem; border-radius: 0.25rem;
@@ -27,7 +27,8 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 4px;
flex: 0 0 auto; flex: 1 1 auto;
max-width: calc(100% - 150px);
} }
.multiselect-tag { .multiselect-tag {

View File

@@ -28,14 +28,14 @@
<!-- Layer Manager Panel --> <!-- Layer Manager Panel -->
<div class="layer-manager-panel" id="layerManagerPanel" style="display: none;"> <div class="layer-manager-panel" id="layerManagerPanel" style="display: none;">
<div class="layer-manager-header"> <div class="layer-manager-header">
<h6><i class="bi bi-layers"></i> Управление слоями</h6> <h6> Управление слоями</h6>
<button type="button" class="btn-close btn-close-white btn-sm" id="closeLayerPanel"></button> <button type="button" class="btn-close btn-close-white btn-sm" id="closeLayerPanel"></button>
</div> </div>
<div class="layer-manager-body"> <div class="layer-manager-body">
<!-- Base Layers Section --> <!-- Base Layers Section -->
<div class="layer-section"> <div class="layer-section">
<div class="layer-section-title"> <div class="layer-section-title">
<span>🗺️ Базовые слои (тайлы)</span> <span>Базовые слои (тайлы)</span>
</div> </div>
<div id="baseLayers"></div> <div id="baseLayers"></div>
</div> </div>
@@ -43,7 +43,7 @@
<!-- Markers Layer Section (Playback) --> <!-- Markers Layer Section (Playback) -->
<div class="layer-section"> <div class="layer-section">
<div class="layer-section-title"> <div class="layer-section-title">
<span>📍 Слой маркеров (Playback)</span> <span> Слой маркеров (Playback)</span>
</div> </div>
<div id="markersLayerControl"></div> <div id="markersLayerControl"></div>
</div> </div>
@@ -51,7 +51,7 @@
<!-- Drawing Layers Section --> <!-- Drawing Layers Section -->
<div class="layer-section"> <div class="layer-section">
<div class="layer-section-title"> <div class="layer-section-title">
<span>✏️ Слои рисования</span> <span> Слои рисования</span>
<button class="btn btn-sm btn-primary add-layer-btn" id="addDrawingLayerBtn" style="width: auto; margin: 0; padding: 2px 8px;"> <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> Добавить <i class="bi bi-plus"></i> Добавить
</button> </button>

View File

@@ -0,0 +1,971 @@
{% load static %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🎉 Итоги {{ year }} года</title>
<link href="{% static 'bootstrap/bootstrap.min.css' %}" rel="stylesheet">
<link href="{% static 'bootstrap-icons/bootstrap-icons.css' %}" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700;900&display=swap" rel="stylesheet">
<style>
:root {
--gradient-1: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-2: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--gradient-3: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
--gradient-4: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
--gradient-5: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
--gradient-6: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
--dark-bg: #0d1117;
--card-bg: rgba(255, 255, 255, 0.05);
}
* { box-sizing: border-box; }
body {
font-family: 'Montserrat', sans-serif;
background: var(--dark-bg);
color: #fff;
overflow-x: hidden;
min-height: 100vh;
}
.particles {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.particle {
position: absolute;
width: 10px;
height: 10px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
animation: float 15s infinite ease-in-out;
}
@keyframes float {
0%, 100% { transform: translateY(100vh) rotate(0deg); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { transform: translateY(-100vh) rotate(720deg); opacity: 0; }
}
.slide {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 40px 20px;
position: relative;
z-index: 1;
}
.slide-intro { background: var(--gradient-1); }
.slide-points { background: var(--gradient-2); }
.slide-new { background: var(--gradient-3); }
.slide-satellites { background: var(--gradient-4); }
.slide-time { background: var(--gradient-5); }
.slide-summary { background: var(--gradient-1); }
.big-number {
font-size: clamp(4rem, 15vw, 12rem);
font-weight: 900;
line-height: 1;
text-shadow: 0 10px 30px rgba(0,0,0,0.3);
opacity: 0;
transform: scale(0.5);
animation: popIn 0.8s ease-out forwards;
}
.big-text {
font-size: clamp(1.5rem, 4vw, 3rem);
font-weight: 700;
text-shadow: 0 5px 15px rgba(0,0,0,0.2);
opacity: 0;
transform: translateY(30px);
animation: slideUp 0.6s ease-out 0.3s forwards;
}
.sub-text {
font-size: clamp(1rem, 2vw, 1.5rem);
font-weight: 400;
opacity: 0.9;
margin-top: 10px;
opacity: 0;
animation: fadeIn 0.6s ease-out 0.5s forwards;
}
@keyframes popIn {
0% { opacity: 0; transform: scale(0.5); }
70% { transform: scale(1.1); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes slideUp {
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
to { opacity: 0.9; }
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.stat-card {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 24px;
padding: 30px;
margin: 15px;
text-align: center;
transition: all 0.3s ease;
border: 1px solid rgba(255, 255, 255, 0.2);
opacity: 0;
transform: translateY(50px);
animation: cardSlideUp 0.6s ease-out forwards;
}
.stat-card:hover {
transform: translateY(-10px) scale(1.02);
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
}
@keyframes cardSlideUp {
to { opacity: 1; transform: translateY(0); }
}
.stat-card:nth-child(1) { animation-delay: 0.2s; }
.stat-card:nth-child(2) { animation-delay: 0.4s; }
.stat-card:nth-child(3) { animation-delay: 0.6s; }
.stat-card:nth-child(4) { animation-delay: 0.8s; }
.stat-value {
font-size: 3rem;
font-weight: 900;
margin-bottom: 10px;
}
.stat-label {
font-size: 1rem;
opacity: 0.9;
}
.satellite-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 15px;
max-width: 1200px;
margin-top: 30px;
}
.satellite-item {
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 20px 30px;
text-align: center;
transition: all 0.3s ease;
border: 1px solid rgba(255, 255, 255, 0.3);
opacity: 0;
transform: scale(0.8);
animation: satelliteIn 0.5s ease-out forwards;
}
.satellite-item:hover {
transform: scale(1.1);
background: rgba(255, 255, 255, 0.3);
}
@keyframes satelliteIn {
to { opacity: 1; transform: scale(1); }
}
.satellite-item:nth-child(1) { animation-delay: 0.1s; }
.satellite-item:nth-child(2) { animation-delay: 0.2s; }
.satellite-item:nth-child(3) { animation-delay: 0.3s; }
.satellite-item:nth-child(4) { animation-delay: 0.4s; }
.satellite-item:nth-child(5) { animation-delay: 0.5s; }
.satellite-item:nth-child(6) { animation-delay: 0.6s; }
.satellite-item:nth-child(7) { animation-delay: 0.7s; }
.satellite-item:nth-child(8) { animation-delay: 0.8s; }
.satellite-item:nth-child(9) { animation-delay: 0.9s; }
.satellite-item:nth-child(10) { animation-delay: 1.0s; }
.satellite-name {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 5px;
}
.satellite-stats {
font-size: 0.9rem;
opacity: 0.9;
}
.chart-container {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 24px;
padding: 30px;
margin: 20px;
max-width: 800px;
width: 100%;
opacity: 0;
animation: fadeIn 0.8s ease-out 0.5s forwards;
}
.chart-title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 20px;
text-align: center;
}
.year-selector {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 10px 20px;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.year-selector select {
background: transparent;
border: none;
color: #fff;
font-size: 1.2rem;
font-weight: 700;
cursor: pointer;
outline: none;
}
.year-selector select option {
background: #333;
color: #fff;
}
.scroll-indicator {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
animation: bounce 2s infinite;
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% { transform: translateX(-50%) translateY(0); }
40% { transform: translateX(-50%) translateY(-20px); }
60% { transform: translateX(-50%) translateY(-10px); }
}
.scroll-indicator i {
font-size: 2rem;
color: rgba(255, 255, 255, 0.7);
}
.emoji-rain {
position: fixed;
top: -50px;
font-size: 2rem;
animation: rain 3s linear forwards;
z-index: 100;
}
@keyframes rain {
to { transform: translateY(110vh) rotate(360deg); }
}
.glow-text {
text-shadow: 0 0 10px currentColor, 0 0 20px currentColor, 0 0 30px currentColor;
}
.counter {
display: inline-block;
}
.progress-bar-custom {
height: 30px;
border-radius: 15px;
background: rgba(255, 255, 255, 0.2);
overflow: hidden;
margin: 10px 0;
}
.progress-fill {
height: 100%;
border-radius: 15px;
background: linear-gradient(90deg, #fff 0%, rgba(255,255,255,0.7) 100%);
transition: width 1.5s ease-out;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 15px;
font-weight: 700;
font-size: 0.9rem;
color: #333;
}
.new-emissions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
max-width: 1200px;
margin-top: 30px;
width: 100%;
padding: 0 20px;
}
.emission-card {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 15px 20px;
border: 1px solid rgba(255, 255, 255, 0.2);
opacity: 0;
transform: translateX(-30px);
animation: slideRight 0.5s ease-out forwards;
}
@keyframes slideRight {
to { opacity: 1; transform: translateX(0); }
}
.emission-name {
font-weight: 700;
font-size: 1.1rem;
margin-bottom: 5px;
}
.emission-info {
font-size: 0.85rem;
opacity: 0.8;
}
.confetti {
position: fixed;
width: 10px;
height: 10px;
top: -10px;
z-index: 1000;
animation: confetti-fall 3s linear forwards;
}
@keyframes confetti-fall {
0% { transform: translateY(0) rotate(0deg); opacity: 1; }
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
}
.heatmap-container {
display: flex;
flex-wrap: wrap;
gap: 5px;
justify-content: center;
margin-top: 20px;
}
.heatmap-cell {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.8rem;
transition: all 0.3s ease;
cursor: pointer;
}
.heatmap-cell:hover {
transform: scale(1.2);
z-index: 10;
}
.nav-dots {
position: fixed;
right: 20px;
top: 50%;
transform: translateY(-50%);
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
}
.nav-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
cursor: pointer;
transition: all 0.3s ease;
}
.nav-dot:hover, .nav-dot.active {
background: #fff;
transform: scale(1.3);
}
@media (max-width: 768px) {
.big-number { font-size: 4rem; }
.big-text { font-size: 1.5rem; }
.stat-card { padding: 20px; margin: 10px; }
.stat-value { font-size: 2rem; }
.nav-dots { display: none; }
.year-selector { top: 10px; right: 10px; padding: 8px 15px; }
}
</style>
</head>
<body>
<!-- Particles Background -->
<div class="particles" id="particles"></div>
<!-- Year Selector -->
<div class="year-selector">
<select id="yearSelect" onchange="changeYear(this.value)">
{% for y in available_years %}
<option value="{{ y }}" {% if y == year %}selected{% endif %}>{{ y }}</option>
{% endfor %}
</select>
</div>
<!-- Navigation Dots -->
<div class="nav-dots">
<div class="nav-dot active" data-slide="0" title="Начало"></div>
<div class="nav-dot" data-slide="1" title="Точки ГЛ"></div>
<div class="nav-dot" data-slide="2" title="Новые излучения"></div>
<div class="nav-dot" data-slide="3" title="Спутники"></div>
<div class="nav-dot" data-slide="4" title="Время"></div>
<div class="nav-dot" data-slide="5" title="Итоги"></div>
</div>
<!-- Scroll Indicator -->
<div class="scroll-indicator" id="scrollIndicator">
<i class="bi bi-chevron-double-down"></i>
</div>
<!-- Slide 1: Intro -->
<section class="slide slide-intro" data-slide="0">
<div class="text-center">
<div class="big-text" style="animation-delay: 0s;">🎉 Ваш {{ year }} год</div>
<div class="big-number" style="animation-delay: 0.3s;">в цифрах</div>
<div class="sub-text" style="animation-delay: 0.6s;">Итоги работы системы геолокации</div>
</div>
</section>
<!-- Slide 2: Total Points -->
<section class="slide slide-points" data-slide="1">
<div class="text-center">
<div class="sub-text">За {{ year }} год вы получили</div>
<div class="big-number counter" data-target="{{ total_points }}">0</div>
<div class="big-text">точек геолокации</div>
<div class="sub-text">по <span class="counter" data-target="{{ total_sources }}">0</span> объектам</div>
{% if busiest_day %}
<div class="stat-card mt-5" style="display: inline-block;">
<div class="stat-label">🔥 Самый активный день</div>
<div class="stat-value">{{ busiest_day.date|date:"d.m.Y" }}</div>
<div class="stat-label">{{ busiest_day.points }} точек</div>
</div>
{% endif %}
</div>
</section>
<!-- Slide 3: New Emissions -->
<section class="slide slide-new" data-slide="2">
<div class="text-center">
<div class="sub-text">✨ Новые открытия</div>
<div class="big-number counter" data-target="{{ new_emissions_count }}">0</div>
<div class="big-text">новых излучений</div>
<div class="sub-text">впервые обнаруженных в {{ year }} году</div>
<div class="sub-text">по <span class="counter" data-target="{{ new_emissions_sources }}">0</span> объектам</div>
{% if new_emission_objects %}
<div class="new-emissions-grid">
{% for obj in new_emission_objects %}
<div class="emission-card" style="animation-delay: {{ forloop.counter0|divisibleby:10 }}s;">
<div class="emission-name">{{ obj.name }}</div>
<div class="emission-info">{{ obj.info }} • {{ obj.ownership }}</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</section>
<!-- Slide 4: Satellites -->
<section class="slide slide-satellites" data-slide="3">
<div class="text-center">
<div class="sub-text">📡 Спутники</div>
<div class="big-number counter" data-target="{{ satellite_count }}">0</div>
<div class="big-text">спутников с данными</div>
<div class="satellite-list">
{% for sat in satellite_stats %}
<div class="satellite-item">
<div class="satellite-name">{{ sat.parameter_obj__id_satellite__name }}</div>
<div class="satellite-stats">
<strong>{{ sat.points_count }}</strong> точек •
<strong>{{ sat.sources_count }}</strong> объектов
</div>
</div>
{% endfor %}
</div>
<div class="chart-container mt-4">
<div class="chart-title">Распределение точек по спутникам</div>
<canvas id="satelliteChart" height="300"></canvas>
</div>
</div>
</section>
<!-- Slide 5: Time Analysis -->
<section class="slide slide-time" data-slide="4">
<div class="text-center">
<div class="sub-text">⏰ Когда вы работали</div>
<div class="big-text">Анализ по времени</div>
<div class="row justify-content-center mt-4">
<div class="col-md-5">
<div class="chart-container">
<div class="chart-title">По месяцам</div>
<canvas id="monthlyChart" height="250"></canvas>
</div>
</div>
<div class="col-md-5">
<div class="chart-container">
<div class="chart-title">По дням недели</div>
<canvas id="weekdayChart" height="250"></canvas>
</div>
</div>
</div>
<div class="chart-container" style="max-width: 600px;">
<div class="chart-title">По часам</div>
<canvas id="hourlyChart" height="200"></canvas>
</div>
</div>
</section>
<!-- Slide 6: Summary -->
<section class="slide slide-summary" data-slide="5">
<div class="text-center">
<div class="big-text">🏆 Итоги {{ year }}</div>
<div class="row justify-content-center mt-4">
<div class="col-auto">
<div class="stat-card">
<div class="stat-value glow-text">{{ total_points }}</div>
<div class="stat-label">Точек ГЛ</div>
</div>
</div>
<div class="col-auto">
<div class="stat-card">
<div class="stat-value glow-text">{{ total_sources }}</div>
<div class="stat-label">Объектов</div>
</div>
</div>
<div class="col-auto">
<div class="stat-card">
<div class="stat-value glow-text">{{ new_emissions_count }}</div>
<div class="stat-label">Новых излучений</div>
</div>
</div>
<div class="col-auto">
<div class="stat-card">
<div class="stat-value glow-text">{{ satellite_count }}</div>
<div class="stat-label">Спутников</div>
</div>
</div>
</div>
<div class="chart-container mt-4" style="max-width: 700px;">
<div class="chart-title">🌟 Топ-10 объектов по количеству точек</div>
<canvas id="topObjectsChart" height="300"></canvas>
</div>
<div class="mt-5">
<div class="big-text">До встречи в {{ year|add:1 }}! 🚀</div>
</div>
</div>
</section>
<script src="{% static 'chartjs/chart.js' %}"></script>
<script src="{% static 'chartjs/chart-datalabels.js' %}"></script>
<script>
// Data from Django
const monthlyData = {{ monthly_data_json|safe }};
const satelliteStats = {{ satellite_stats_json|safe }};
const weekdayData = {{ weekday_data_json|safe }};
const hourlyData = {{ hourly_data_json|safe }};
const topObjects = {{ top_objects_json|safe }};
// Create particles
function createParticles() {
const container = document.getElementById('particles');
for (let i = 0; i < 50; i++) {
const particle = document.createElement('div');
particle.className = 'particle';
particle.style.left = Math.random() * 100 + '%';
particle.style.animationDelay = Math.random() * 15 + 's';
particle.style.animationDuration = (10 + Math.random() * 10) + 's';
particle.style.width = (5 + Math.random() * 10) + 'px';
particle.style.height = particle.style.width;
container.appendChild(particle);
}
}
createParticles();
// Counter animation
function animateCounters() {
const counters = document.querySelectorAll('.counter');
counters.forEach(counter => {
const target = parseInt(counter.dataset.target) || 0;
const duration = 2000;
const step = target / (duration / 16);
let current = 0;
const updateCounter = () => {
current += step;
if (current < target) {
counter.textContent = Math.floor(current).toLocaleString('ru-RU');
requestAnimationFrame(updateCounter);
} else {
counter.textContent = target.toLocaleString('ru-RU');
}
};
updateCounter();
});
}
// Intersection Observer for animations
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const slideIndex = entry.target.dataset.slide;
document.querySelectorAll('.nav-dot').forEach((dot, i) => {
dot.classList.toggle('active', i == slideIndex);
});
// Trigger counter animation when slide is visible
if (slideIndex == 1 || slideIndex == 2 || slideIndex == 3 || slideIndex == 5) {
entry.target.querySelectorAll('.counter').forEach(counter => {
if (!counter.dataset.animated) {
counter.dataset.animated = 'true';
const target = parseInt(counter.dataset.target) || 0;
animateCounter(counter, target);
}
});
}
// Create confetti on summary slide
if (slideIndex == 5 && !window.confettiCreated) {
window.confettiCreated = true;
createConfetti();
}
}
});
}, { threshold: 0.5 });
document.querySelectorAll('.slide').forEach(slide => observer.observe(slide));
function animateCounter(element, target) {
const duration = 2000;
const step = target / (duration / 16);
let current = 0;
const update = () => {
current += step;
if (current < target) {
element.textContent = Math.floor(current).toLocaleString('ru-RU');
requestAnimationFrame(update);
} else {
element.textContent = target.toLocaleString('ru-RU');
}
};
update();
}
// Confetti effect
function createConfetti() {
const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dfe6e9', '#fd79a8', '#a29bfe'];
for (let i = 0; i < 100; i++) {
setTimeout(() => {
const confetti = document.createElement('div');
confetti.className = 'confetti';
confetti.style.left = Math.random() * 100 + '%';
confetti.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
confetti.style.animationDuration = (2 + Math.random() * 2) + 's';
confetti.style.borderRadius = Math.random() > 0.5 ? '50%' : '0';
document.body.appendChild(confetti);
setTimeout(() => confetti.remove(), 4000);
}, i * 30);
}
}
// Navigation dots click
document.querySelectorAll('.nav-dot').forEach(dot => {
dot.addEventListener('click', () => {
const slideIndex = dot.dataset.slide;
document.querySelector(`[data-slide="${slideIndex}"]`).scrollIntoView({ behavior: 'smooth' });
});
});
// Hide scroll indicator on scroll
window.addEventListener('scroll', () => {
const indicator = document.getElementById('scrollIndicator');
if (window.scrollY > 100) {
indicator.style.opacity = '0';
} else {
indicator.style.opacity = '1';
}
});
// Year change
function changeYear(year) {
window.location.href = '?year=' + year;
}
// Chart.js configuration
Chart.defaults.color = '#fff';
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
// Monthly Chart
if (monthlyData.length > 0) {
const monthNames = ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек'];
new Chart(document.getElementById('monthlyChart'), {
type: 'bar',
data: {
labels: monthlyData.map(d => {
if (d.month) {
const [year, month] = d.month.split('-');
return monthNames[parseInt(month) - 1];
}
return '';
}),
datasets: [{
label: 'Точки',
data: monthlyData.map(d => d.points),
backgroundColor: 'rgba(255, 255, 255, 0.7)',
borderRadius: 8,
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
datalabels: { display: false }
},
scales: {
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' } },
x: { grid: { display: false } }
}
}
});
}
// Weekday Chart
if (weekdayData.length > 0) {
const weekdayNames = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
const sortedWeekday = [...weekdayData].sort((a, b) => {
// Convert Sunday (1) to 7 for proper sorting (Mon-Sun)
const aDay = a.weekday === 1 ? 8 : a.weekday;
const bDay = b.weekday === 1 ? 8 : b.weekday;
return aDay - bDay;
});
new Chart(document.getElementById('weekdayChart'), {
type: 'polarArea',
data: {
labels: sortedWeekday.map(d => weekdayNames[d.weekday - 1]),
datasets: [{
data: sortedWeekday.map(d => d.points),
backgroundColor: [
'rgba(255, 99, 132, 0.7)',
'rgba(54, 162, 235, 0.7)',
'rgba(255, 206, 86, 0.7)',
'rgba(75, 192, 192, 0.7)',
'rgba(153, 102, 255, 0.7)',
'rgba(255, 159, 64, 0.7)',
'rgba(199, 199, 199, 0.7)'
],
borderWidth: 2,
borderColor: '#fff'
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'right' },
datalabels: { display: false }
}
}
});
}
// Hourly Chart
if (hourlyData.length > 0) {
// Fill missing hours with 0
const fullHourlyData = Array.from({length: 24}, (_, i) => {
const found = hourlyData.find(d => d.hour === i);
return found ? found.points : 0;
});
new Chart(document.getElementById('hourlyChart'), {
type: 'line',
data: {
labels: Array.from({length: 24}, (_, i) => i + ':00'),
datasets: [{
label: 'Точки',
data: fullHourlyData,
borderColor: 'rgba(255, 255, 255, 0.9)',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: '#fff'
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
datalabels: { display: false }
},
scales: {
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' } },
x: { grid: { display: false } }
}
}
});
}
// Satellite Pie Chart
if (satelliteStats.length > 0) {
const top10 = satelliteStats.slice(0, 10);
const otherPoints = satelliteStats.slice(10).reduce((sum, s) => sum + s.points_count, 0);
const labels = top10.map(s => s.parameter_obj__id_satellite__name);
const data = top10.map(s => s.points_count);
if (otherPoints > 0) {
labels.push('Другие');
data.push(otherPoints);
}
const colors = [
'#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7',
'#dfe6e9', '#fd79a8', '#a29bfe', '#00b894', '#e17055', '#636e72'
];
new Chart(document.getElementById('satelliteChart'), {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: colors.slice(0, data.length),
borderWidth: 3,
borderColor: 'rgba(255,255,255,0.3)'
}]
},
options: {
responsive: true,
cutout: '60%',
plugins: {
legend: { position: 'right', labels: { padding: 15 } },
datalabels: {
color: '#fff',
font: { weight: 'bold', size: 11 },
formatter: (value, ctx) => {
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
const pct = ((value / total) * 100).toFixed(1);
return pct > 5 ? pct + '%' : '';
}
}
}
},
plugins: [ChartDataLabels]
});
}
// Top Objects Chart
if (topObjects.length > 0) {
const colors = [
'#ffd700', '#c0c0c0', '#cd7f32', '#4ecdc4', '#45b7d1',
'#96ceb4', '#ffeaa7', '#fd79a8', '#a29bfe', '#00b894'
];
new Chart(document.getElementById('topObjectsChart'), {
type: 'bar',
data: {
labels: topObjects.map(o => o.name.length > 20 ? o.name.substring(0, 20) + '...' : o.name),
datasets: [{
label: 'Точки',
data: topObjects.map(o => o.points),
backgroundColor: colors,
borderRadius: 8,
borderSkipped: false
}]
},
options: {
indexAxis: 'y',
responsive: true,
plugins: {
legend: { display: false },
datalabels: {
anchor: 'end',
align: 'end',
color: '#fff',
font: { weight: 'bold' },
formatter: (value) => value.toLocaleString('ru-RU')
}
},
scales: {
x: {
beginAtZero: true,
grid: { color: 'rgba(255,255,255,0.1)' },
grace: '15%'
},
y: { grid: { display: false } }
}
},
plugins: [ChartDataLabels]
});
}
// Emoji rain on intro
setTimeout(() => {
const emojis = ['🛰️', '📡', '🌍', '✨', '🎯', '📍', '🔭', '⭐'];
for (let i = 0; i < 20; i++) {
setTimeout(() => {
const emoji = document.createElement('div');
emoji.className = 'emoji-rain';
emoji.textContent = emojis[Math.floor(Math.random() * emojis.length)];
emoji.style.left = Math.random() * 100 + '%';
emoji.style.animationDuration = (2 + Math.random() * 2) + 's';
document.body.appendChild(emoji);
setTimeout(() => emoji.remove(), 4000);
}, i * 200);
}
}, 1000);
</script>
</body>
</html>

View File

@@ -104,6 +104,9 @@
<a href="{% url 'mainapp:tech_analyze_list' %}" class="btn btn-info btn-sm" title="Технический анализ"> <a href="{% url 'mainapp:tech_analyze_list' %}" class="btn btn-info btn-sm" title="Технический анализ">
<i class="bi bi-gear-wide-connected"></i> Тех. анализ <i class="bi bi-gear-wide-connected"></i> Тех. анализ
</a> </a>
<a href="{% url 'mainapp:statistics' %}" class="btn btn-secondary btn-sm" title="Статистика">
<i class="bi bi-bar-chart-line"></i> Статистика
</a>
</div> </div>
<!-- Add to List Button --> <!-- Add to List Button -->

View File

@@ -0,0 +1,486 @@
{% extends 'mainapp/base.html' %}
{% load static %}
{% block title %}Статистика{% endblock %}
{% block extra_css %}
<link href="{% static 'css/checkbox-select-multiple.css' %}" rel="stylesheet">
<style>
.stat-card {
transition: transform 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
}
.stat-value {
font-size: 2.5rem;
font-weight: bold;
line-height: 1.2;
}
.stat-label {
font-size: 0.9rem;
color: #6c757d;
}
.satellite-stat-row:hover {
background-color: #f8f9fa;
}
.preset-btn.active {
background-color: #0d6efd;
color: white;
}
#dailyChart {
min-height: 300px;
}
.new-emission-badge {
font-size: 0.75rem;
margin: 2px;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-3">
<!-- Header -->
<div class="row mb-3">
<div class="col-12 d-flex justify-content-between align-items-center">
<h2><i class="bi bi-bar-chart-line"></i> Статистика</h2>
<a href="{% url 'mainapp:source_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> К списку объектов
</a>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<form method="get" id="filter-form">
<div class="row g-3 align-items-end">
<!-- Date presets -->
<div class="col-auto">
<label class="form-label">Период:</label>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-primary preset-btn {% if preset == 'week' %}active{% endif %}"
data-preset="week">Неделя</button>
<button type="button" class="btn btn-outline-primary preset-btn {% if preset == 'month' %}active{% endif %}"
data-preset="month">Месяц</button>
<button type="button" class="btn btn-outline-primary preset-btn {% if preset == '3months' %}active{% endif %}"
data-preset="3months">3 месяца</button>
<button type="button" class="btn btn-outline-primary preset-btn {% if preset == '6months' %}active{% endif %}"
data-preset="6months">Полгода</button>
<button type="button" class="btn btn-outline-primary preset-btn {% if preset == 'all' or not preset and not date_from %}active{% endif %}"
data-preset="all">Всё время</button>
</div>
</div>
<!-- Custom date range -->
<div class="col-auto">
<label for="date_from" class="form-label">С:</label>
<input type="date" class="form-control" id="date_from" name="date_from"
value="{{ date_from }}">
</div>
<div class="col-auto">
<label for="date_to" class="form-label">По:</label>
<input type="date" class="form-control" id="date_to" name="date_to"
value="{{ date_to }}">
</div>
<!-- Satellite filter with custom widget -->
<div class="col-md-3">
<label class="form-label">Спутники:</label>
<div class="checkbox-multiselect-wrapper" data-widget-id="satellite_id">
<div class="multiselect-input-container">
<div class="multiselect-tags" id="satellite_id_tags"></div>
<input type="text"
class="multiselect-search form-control"
placeholder="Выберите спутники..."
id="satellite_id_search"
autocomplete="off">
<button type="button" class="multiselect-clear" id="satellite_id_clear" title="Очистить все">×</button>
</div>
<div class="multiselect-dropdown" id="satellite_id_dropdown">
<div class="multiselect-options">
{% for satellite in satellites %}
<label class="multiselect-option">
<input type="checkbox"
name="satellite_id"
value="{{ satellite.id }}"
{% if satellite.id in selected_satellites %}checked{% endif %}
data-label="{{ satellite.name }}">
<span class="option-label">{{ satellite.name }}</span>
</label>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Submit -->
<div class="col-auto">
<button type="submit" class="btn btn-primary">
<i class="bi bi-funnel"></i> Применить
</button>
<a href="{% url 'mainapp:statistics' %}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> Сбросить
</a>
</div>
</div>
<input type="hidden" name="preset" id="preset-input" value="{{ preset }}">
</form>
</div>
</div>
</div>
</div>
<!-- Main Statistics Cards -->
<div class="row mb-4">
<!-- Total Points -->
<div class="col-md-4">
<div class="card stat-card h-100 border-primary">
<div class="card-body text-center">
<div class="stat-value text-primary">{{ total_points }}</div>
<div class="stat-label">Точек геолокации</div>
<small class="text-muted">по {{ total_sources }} объектам</small>
</div>
</div>
</div>
<!-- New Emissions -->
<div class="col-md-4">
<div class="card stat-card h-100 border-success">
<div class="card-body text-center">
<div class="stat-value text-success">{{ new_emissions_count }}</div>
<div class="stat-label">Новых уникальных излучений</div>
<small class="text-muted">впервые появившихся за период</small>
</div>
</div>
</div>
<!-- Satellites Count -->
<div class="col-md-4">
<div class="card stat-card h-100 border-info">
<div class="card-body text-center">
<div class="stat-value text-info">{{ satellite_stats|length }}</div>
<div class="stat-label">Спутников с данными</div>
</div>
</div>
</div>
</div>
<!-- New Emissions Table -->
{% if new_emission_objects %}
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-light">
<i class="bi bi-stars"></i> Новые излучения (уникальные имена, появившиеся впервые в выбранном периоде)
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 320px; overflow-y: auto;">
<table class="table table-sm table-hover table-striped mb-0">
<thead class="table-light sticky-top">
<tr>
<th style="width: 5%;" class="text-center"></th>
<th style="width: 45%;">Имя объекта</th>
<th style="width: 25%;">Тип объекта</th>
<th style="width: 25%;">Принадлежность</th>
</tr>
</thead>
<tbody>
{% for obj in new_emission_objects %}
<tr>
<td class="text-center">{{ forloop.counter }}</td>
<td>{{ obj.name }}</td>
<td>{{ obj.info }}</td>
<td>{{ obj.ownership }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class="row">
<!-- Daily Chart -->
<div class="col-md-8 mb-4">
<div class="card h-100">
<div class="card-header">
<i class="bi bi-graph-up"></i> Динамика по дням
</div>
<div class="card-body">
<canvas id="dailyChart"></canvas>
</div>
</div>
</div>
<!-- Satellite Statistics -->
<div class="col-md-4 mb-4">
<div class="card h-100">
<div class="card-header">
<i class="bi bi-broadcast"></i> Статистика по спутникам
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
<table class="table table-sm table-hover mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Спутник</th>
<th class="text-center">Точек</th>
<th class="text-center">Объектов</th>
</tr>
</thead>
<tbody>
{% for stat in satellite_stats %}
<tr class="satellite-stat-row">
<td>{{ stat.parameter_obj__id_satellite__name }}</td>
<td class="text-center">
<span class="badge bg-primary">{{ stat.points_count }}</span>
</td>
<td class="text-center">
<span class="badge bg-secondary">{{ stat.sources_count }}</span>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center text-muted">Нет данных</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Satellite Charts -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<i class="bi bi-pie-chart"></i> Распределение точек по спутникам
</div>
<div class="card-body">
<canvas id="satellitePieChart"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<i class="bi bi-bar-chart"></i> Топ-10 спутников по количеству точек
</div>
<div class="card-body">
<canvas id="satelliteBarChart"></canvas>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<!-- <script type="module" src="{% static 'chartjs/color.esm.js' %}"></script> -->
<script src="{% static 'chartjs/chart.js' %}"></script>
<script src="{% static 'chartjs/chart-datalabels.js' %}"></script>
<script src="{% static 'js/checkbox-select-multiple.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize multiselect widget
const wrapper = document.querySelector('.checkbox-multiselect-wrapper[data-widget-id="satellite_id"]');
if (wrapper) {
initCheckboxMultiselect(wrapper);
}
// Preset buttons handling
const presetBtns = document.querySelectorAll('.preset-btn');
const presetInput = document.getElementById('preset-input');
const dateFromInput = document.getElementById('date_from');
const dateToInput = document.getElementById('date_to');
presetBtns.forEach(btn => {
btn.addEventListener('click', function() {
presetBtns.forEach(b => b.classList.remove('active'));
this.classList.add('active');
presetInput.value = this.dataset.preset;
// Clear custom dates when using preset
dateFromInput.value = '';
dateToInput.value = '';
// Submit form
document.getElementById('filter-form').submit();
});
});
// Clear preset when custom dates are entered
dateFromInput.addEventListener('change', function() {
presetInput.value = '';
presetBtns.forEach(b => b.classList.remove('active'));
});
dateToInput.addEventListener('change', function() {
presetInput.value = '';
presetBtns.forEach(b => b.classList.remove('active'));
});
// Register datalabels plugin
Chart.register(ChartDataLabels);
// Daily Chart
const dailyData = {{ daily_data|safe }};
const dailyLabels = dailyData.map(d => {
if (d.date) {
const date = new Date(d.date);
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year:"2-digit" });
}
return '';
});
const dailyPoints = dailyData.map(d => d.points);
const dailySources = dailyData.map(d => d.sources);
if (dailyData.length > 0) {
new Chart(document.getElementById('dailyChart'), {
type: 'line',
data: {
labels: dailyLabels,
datasets: [{
label: 'Точки ГЛ',
data: dailyPoints,
borderColor: 'rgb(13, 110, 253)',
backgroundColor: 'rgba(13, 110, 253, 0.1)',
fill: false,
// tension: 0.3
}, {
label: 'Объекты',
data: dailySources,
borderColor: 'rgb(25, 135, 84)',
backgroundColor: 'rgba(25, 135, 84, 0.1)',
fill: false,
// tension: 0.3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
datalabels: {
display: false
}
},
scales: {
y: {
beginAtZero: true
}
}
}
});
}
// Satellite Statistics
const satelliteStats = {{ satellite_stats_json|safe }};
// Pie Chart (top 10)
const top10Stats = satelliteStats.slice(0, 10);
const otherPoints = satelliteStats.slice(10).reduce((sum, s) => sum + s.points_count, 0);
const pieLabels = top10Stats.map(s => s.parameter_obj__id_satellite__name);
const pieData = top10Stats.map(s => s.points_count);
if (otherPoints > 0) {
pieLabels.push('Другие');
pieData.push(otherPoints);
}
const colors = [
'#0d6efd', '#198754', '#dc3545', '#ffc107', '#0dcaf0',
'#6f42c1', '#fd7e14', '#20c997', '#6c757d', '#d63384', '#adb5bd'
];
if (pieData.length > 0) {
new Chart(document.getElementById('satellitePieChart'), {
type: 'doughnut',
data: {
labels: pieLabels,
datasets: [{
data: pieData,
backgroundColor: colors.slice(0, pieData.length)
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'right',
},
datalabels: {
color: '#fff',
font: {
weight: 'bold',
size: 11
},
formatter: function(value, context) {
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
if (percentage < 5) return '';
return value + '\n(' + percentage + '%)';
},
textAlign: 'center'
}
}
}
});
}
// Bar Chart (top 10) with data labels
if (top10Stats.length > 0) {
new Chart(document.getElementById('satelliteBarChart'), {
type: 'bar',
data: {
labels: top10Stats.map(s => s.parameter_obj__id_satellite__name),
datasets: [{
label: 'Количество точек',
data: top10Stats.map(s => s.points_count),
backgroundColor: colors.slice(0, top10Stats.length)
}]
},
options: {
responsive: true,
indexAxis: 'y',
plugins: {
legend: {
display: false
},
datalabels: {
anchor: 'end',
align: 'end',
color: '#333',
font: {
weight: 'bold',
size: 11
},
formatter: function(value) {
return value;
}
}
},
scales: {
x: {
beginAtZero: true,
grace: '10%'
}
}
}
});
}
});
</script>
{% endblock %}

View File

@@ -70,6 +70,8 @@ from .views.tech_analyze import (
TechAnalyzeAPIView, TechAnalyzeAPIView,
) )
from .views.points_averaging import PointsAveragingView, PointsAveragingAPIView, RecalculateGroupAPIView from .views.points_averaging import PointsAveragingView, PointsAveragingAPIView, RecalculateGroupAPIView
from .views.statistics import StatisticsView, StatisticsAPIView
from .views.secret_stats import SecretStatsView
app_name = 'mainapp' app_name = 'mainapp'
@@ -146,5 +148,8 @@ urlpatterns = [
path('points-averaging/', PointsAveragingView.as_view(), name='points_averaging'), path('points-averaging/', PointsAveragingView.as_view(), name='points_averaging'),
path('api/points-averaging/', PointsAveragingAPIView.as_view(), name='points_averaging_api'), path('api/points-averaging/', PointsAveragingAPIView.as_view(), name='points_averaging_api'),
path('api/points-averaging/recalculate/', RecalculateGroupAPIView.as_view(), name='points_averaging_recalculate'), path('api/points-averaging/recalculate/', RecalculateGroupAPIView.as_view(), name='points_averaging_recalculate'),
path('statistics/', StatisticsView.as_view(), name='statistics'),
path('api/statistics/', StatisticsAPIView.as_view(), name='statistics_api'),
path('secret-stat/', SecretStatsView.as_view(), name='secret_stats'),
path('logout/', custom_logout, name='logout'), path('logout/', custom_logout, name='logout'),
] ]

View File

@@ -71,6 +71,10 @@ from .points_averaging import (
PointsAveragingAPIView, PointsAveragingAPIView,
RecalculateGroupAPIView, RecalculateGroupAPIView,
) )
from .statistics import (
StatisticsView,
StatisticsAPIView,
)
__all__ = [ __all__ = [
# Base # Base
@@ -144,4 +148,7 @@ __all__ = [
'PointsAveragingView', 'PointsAveragingView',
'PointsAveragingAPIView', 'PointsAveragingAPIView',
'RecalculateGroupAPIView', 'RecalculateGroupAPIView',
# Statistics
'StatisticsView',
'StatisticsAPIView',
] ]

View File

@@ -0,0 +1,287 @@
"""
Секретная страница статистики в стиле Spotify Wrapped / Яндекс.Музыка.
Красивые анимации, диаграммы и визуализации.
"""
import json
from datetime import timedelta, datetime
from collections import defaultdict
from django.db.models import Count, Q, Min, Max, Avg, Sum
from django.db.models.functions import TruncDate, TruncMonth, ExtractWeekDay, ExtractHour
from django.utils import timezone
from django.views.generic import TemplateView
from ..models import ObjItem, Source, Satellite, Geo, Parameter
class SecretStatsView(TemplateView):
"""Секретная страница статистики - итоги года в стиле Spotify Wrapped."""
template_name = 'mainapp/secret_stats.html'
def get_year_range(self):
"""Получает диапазон дат для текущего года."""
now = timezone.now()
year = self.request.GET.get('year', now.year)
try:
year = int(year)
except (ValueError, TypeError):
year = now.year
date_from = datetime(year, 1, 1).date()
date_to = datetime(year, 12, 31).date()
return date_from, date_to, year
def get_base_queryset(self, date_from, date_to):
"""Возвращает базовый queryset ObjItem с фильтрами по дате ГЛ."""
qs = ObjItem.objects.filter(
geo_obj__isnull=False,
geo_obj__timestamp__isnull=False,
geo_obj__timestamp__date__gte=date_from,
geo_obj__timestamp__date__lte=date_to
)
return qs
def get_main_stats(self, date_from, date_to):
"""Основная статистика: точки и объекты."""
base_qs = self.get_base_queryset(date_from, date_to)
total_points = base_qs.count()
total_sources = base_qs.filter(source__isnull=False).values('source').distinct().count()
return {
'total_points': total_points,
'total_sources': total_sources,
}
def get_new_emissions(self, date_from, date_to):
"""
Новые излучения - объекты, у которых имя появилось впервые в выбранном периоде.
"""
# Получаем все имена объектов, которые появились ДО выбранного периода
existing_names = set(
ObjItem.objects.filter(
geo_obj__isnull=False,
geo_obj__timestamp__isnull=False,
geo_obj__timestamp__date__lt=date_from,
name__isnull=False
).exclude(name='').values_list('name', flat=True).distinct()
)
# Базовый queryset для выбранного периода
period_qs = self.get_base_queryset(date_from, date_to).filter(
name__isnull=False
).exclude(name='')
# Получаем уникальные имена в выбранном периоде
period_names = set(period_qs.values_list('name', flat=True).distinct())
# Новые имена = имена в периоде, которых не было раньше
new_names = period_names - existing_names
if not new_names:
return {'count': 0, 'objects': [], 'sources_count': 0}
# Получаем данные о новых объектах
objitems_data = period_qs.filter(
name__in=new_names
).select_related(
'source__info', 'source__ownership'
).values(
'name',
'source__info__name',
'source__ownership__name'
).distinct()
seen_names = set()
new_objects = []
for item in objitems_data:
name = item['name']
if name not in seen_names:
seen_names.add(name)
new_objects.append({
'name': name,
'info': item['source__info__name'] or '-',
'ownership': item['source__ownership__name'] or '-',
})
new_objects.sort(key=lambda x: x['name'])
# Количество источников для новых излучений
new_sources_count = period_qs.filter(
name__in=new_names, source__isnull=False
).values('source').distinct().count()
return {
'count': len(new_names),
'objects': new_objects[:20], # Топ-20 для отображения
'sources_count': new_sources_count
}
def get_satellite_stats(self, date_from, date_to):
"""Статистика по спутникам."""
base_qs = self.get_base_queryset(date_from, date_to)
stats = base_qs.filter(
parameter_obj__id_satellite__isnull=False
).values(
'parameter_obj__id_satellite__id',
'parameter_obj__id_satellite__name'
).annotate(
points_count=Count('id'),
sources_count=Count('source', distinct=True),
unique_names=Count('name', distinct=True)
).order_by('-points_count')
return list(stats)
def get_monthly_stats(self, date_from, date_to):
"""Статистика по месяцам."""
base_qs = self.get_base_queryset(date_from, date_to)
monthly = base_qs.annotate(
month=TruncMonth('geo_obj__timestamp')
).values('month').annotate(
points=Count('id'),
sources=Count('source', distinct=True)
).order_by('month')
return list(monthly)
def get_weekday_stats(self, date_from, date_to):
"""Статистика по дням недели."""
base_qs = self.get_base_queryset(date_from, date_to)
weekday = base_qs.annotate(
weekday=ExtractWeekDay('geo_obj__timestamp')
).values('weekday').annotate(
points=Count('id')
).order_by('weekday')
return list(weekday)
def get_hourly_stats(self, date_from, date_to):
"""Статистика по часам."""
base_qs = self.get_base_queryset(date_from, date_to)
hourly = base_qs.annotate(
hour=ExtractHour('geo_obj__timestamp')
).values('hour').annotate(
points=Count('id')
).order_by('hour')
return list(hourly)
def get_top_objects(self, date_from, date_to):
"""Топ объектов по количеству точек."""
base_qs = self.get_base_queryset(date_from, date_to)
top = base_qs.filter(
name__isnull=False
).exclude(name='').values('name').annotate(
points=Count('id')
).order_by('-points')[:10]
return list(top)
def get_busiest_day(self, date_from, date_to):
"""Самый активный день."""
base_qs = self.get_base_queryset(date_from, date_to)
daily = base_qs.annotate(
date=TruncDate('geo_obj__timestamp')
).values('date').annotate(
points=Count('id')
).order_by('-points').first()
return daily
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
date_from, date_to, year = self.get_year_range()
# Основная статистика
main_stats = self.get_main_stats(date_from, date_to)
# Новые излучения
new_emissions = self.get_new_emissions(date_from, date_to)
# Статистика по спутникам
satellite_stats = self.get_satellite_stats(date_from, date_to)
# Статистика по месяцам
monthly_stats = self.get_monthly_stats(date_from, date_to)
# Статистика по дням недели
weekday_stats = self.get_weekday_stats(date_from, date_to)
# Статистика по часам
hourly_stats = self.get_hourly_stats(date_from, date_to)
# Топ объектов
top_objects = self.get_top_objects(date_from, date_to)
# Самый активный день
busiest_day = self.get_busiest_day(date_from, date_to)
# Доступные годы для выбора
years_with_data = ObjItem.objects.filter(
geo_obj__isnull=False,
geo_obj__timestamp__isnull=False
).dates('geo_obj__timestamp', 'year')
available_years = sorted([d.year for d in years_with_data], reverse=True)
# JSON данные для графиков
monthly_data_json = json.dumps([
{
'month': item['month'].strftime('%Y-%m') if item['month'] else None,
'month_name': item['month'].strftime('%B') if item['month'] else None,
'points': item['points'],
'sources': item['sources'],
}
for item in monthly_stats
])
satellite_stats_json = json.dumps(satellite_stats)
weekday_names = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']
weekday_data_json = json.dumps([
{
'weekday': item['weekday'],
'weekday_name': weekday_names[item['weekday'] - 1] if item['weekday'] else '',
'points': item['points'],
}
for item in weekday_stats
])
hourly_data_json = json.dumps([
{
'hour': item['hour'],
'points': item['points'],
}
for item in hourly_stats
])
top_objects_json = json.dumps(top_objects)
context.update({
'year': year,
'available_years': available_years,
'total_points': main_stats['total_points'],
'total_sources': main_stats['total_sources'],
'new_emissions_count': new_emissions['count'],
'new_emissions_sources': new_emissions['sources_count'],
'new_emission_objects': new_emissions['objects'],
'satellite_stats': satellite_stats[:10], # Топ-10
'satellite_count': len(satellite_stats),
'busiest_day': busiest_day,
'monthly_data_json': monthly_data_json,
'satellite_stats_json': satellite_stats_json,
'weekday_data_json': weekday_data_json,
'hourly_data_json': hourly_data_json,
'top_objects_json': top_objects_json,
})
return context

View File

@@ -0,0 +1,280 @@
"""
Представление для страницы статистики.
"""
import json
from datetime import timedelta
from django.db.models import Count, Q, Min
from django.db.models.functions import TruncDate
from django.utils import timezone
from django.views.generic import TemplateView
from django.http import JsonResponse
from ..models import ObjItem, Source, Satellite, Geo
class StatisticsView(TemplateView):
"""Страница статистики по данным геолокации."""
template_name = 'mainapp/statistics.html'
def get_date_range(self):
"""Получает диапазон дат из параметров запроса."""
date_from = self.request.GET.get('date_from')
date_to = self.request.GET.get('date_to')
preset = self.request.GET.get('preset')
now = timezone.now()
# Обработка пресетов
if preset == 'week':
date_from = (now - timedelta(days=7)).date()
date_to = now.date()
elif preset == 'month':
date_from = (now - timedelta(days=30)).date()
date_to = now.date()
elif preset == '3months':
date_from = (now - timedelta(days=90)).date()
date_to = now.date()
elif preset == '6months':
date_from = (now - timedelta(days=180)).date()
date_to = now.date()
elif preset == 'all':
date_from = None
date_to = None
else:
# Парсинг дат из параметров
from datetime import datetime
if date_from:
try:
date_from = datetime.strptime(date_from, '%Y-%m-%d').date()
except ValueError:
date_from = None
if date_to:
try:
date_to = datetime.strptime(date_to, '%Y-%m-%d').date()
except ValueError:
date_to = None
return date_from, date_to, preset
def get_selected_satellites(self):
"""Получает выбранные спутники из параметров запроса."""
satellite_ids = self.request.GET.getlist('satellite_id')
return [int(sid) for sid in satellite_ids if sid.isdigit()]
def get_base_queryset(self, date_from, date_to, satellite_ids):
"""Возвращает базовый queryset ObjItem с фильтрами."""
qs = ObjItem.objects.filter(
geo_obj__isnull=False,
geo_obj__timestamp__isnull=False
)
if date_from:
qs = qs.filter(geo_obj__timestamp__date__gte=date_from)
if date_to:
qs = qs.filter(geo_obj__timestamp__date__lte=date_to)
if satellite_ids:
qs = qs.filter(parameter_obj__id_satellite__id__in=satellite_ids)
return qs
def get_statistics(self, date_from, date_to, satellite_ids):
"""Вычисляет основную статистику."""
base_qs = self.get_base_queryset(date_from, date_to, satellite_ids)
# Общее количество точек
total_points = base_qs.count()
# Количество уникальных объектов (Source)
total_sources = base_qs.filter(source__isnull=False).values('source').distinct().count()
# Новые излучения - объекты, у которых имя появилось впервые в выбранном периоде
new_emissions_data = self._calculate_new_emissions(date_from, date_to, satellite_ids)
# Статистика по спутникам
satellite_stats = self._get_satellite_statistics(date_from, date_to, satellite_ids)
# Данные для графика по дням
daily_data = self._get_daily_statistics(date_from, date_to, satellite_ids)
return {
'total_points': total_points,
'total_sources': total_sources,
'new_emissions_count': new_emissions_data['count'],
'new_emission_objects': new_emissions_data['objects'],
'satellite_stats': satellite_stats,
'daily_data': daily_data,
}
def _calculate_new_emissions(self, date_from, date_to, satellite_ids):
"""
Вычисляет новые излучения - уникальные имена объектов,
которые появились впервые в выбранном периоде.
Возвращает количество уникальных новых имён и данные об объектах.
Оптимизировано для минимизации SQL запросов.
"""
if not date_from:
# Если нет начальной даты, берём все данные - новых излучений нет
return {'count': 0, 'objects': []}
# Получаем все имена объектов, которые появились ДО выбранного периода
existing_names = set(
ObjItem.objects.filter(
geo_obj__isnull=False,
geo_obj__timestamp__isnull=False,
geo_obj__timestamp__date__lt=date_from,
name__isnull=False
).exclude(name='').values_list('name', flat=True).distinct()
)
# Базовый queryset для выбранного периода
period_qs = self.get_base_queryset(date_from, date_to, satellite_ids).filter(
name__isnull=False
).exclude(name='')
# Получаем уникальные имена в выбранном периоде
period_names = set(period_qs.values_list('name', flat=True).distinct())
# Новые имена = имена в периоде, которых не было раньше
new_names = period_names - existing_names
if not new_names:
return {'count': 0, 'objects': []}
# Оптимизация: получаем все данные одним запросом с группировкой по имени
# Используем values() для получения уникальных комбинаций name + info + ownership
objitems_data = period_qs.filter(
name__in=new_names
).select_related(
'source__info', 'source__ownership'
).values(
'name',
'source__info__name',
'source__ownership__name'
).distinct()
# Собираем данные, оставляя только первую запись для каждого имени
seen_names = set()
new_objects = []
for item in objitems_data:
name = item['name']
if name not in seen_names:
seen_names.add(name)
new_objects.append({
'name': name,
'info': item['source__info__name'] or '-',
'ownership': item['source__ownership__name'] or '-',
})
# Сортируем по имени
new_objects.sort(key=lambda x: x['name'])
return {'count': len(new_names), 'objects': new_objects}
def _get_satellite_statistics(self, date_from, date_to, satellite_ids):
"""Получает статистику по каждому спутнику."""
base_qs = self.get_base_queryset(date_from, date_to, satellite_ids)
# Группируем по спутникам
stats = base_qs.filter(
parameter_obj__id_satellite__isnull=False
).values(
'parameter_obj__id_satellite__id',
'parameter_obj__id_satellite__name'
).annotate(
points_count=Count('id'),
sources_count=Count('source', distinct=True)
).order_by('-points_count')
return list(stats)
def _get_daily_statistics(self, date_from, date_to, satellite_ids):
"""Получает статистику по дням для графика."""
base_qs = self.get_base_queryset(date_from, date_to, satellite_ids)
daily = base_qs.annotate(
date=TruncDate('geo_obj__timestamp')
).values('date').annotate(
points=Count('id'),
sources=Count('source', distinct=True)
).order_by('date')
return list(daily)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
date_from, date_to, preset = self.get_date_range()
satellite_ids = self.get_selected_satellites()
# Получаем только спутники, у которых есть точки ГЛ
satellites_with_points = ObjItem.objects.filter(
geo_obj__isnull=False,
geo_obj__timestamp__isnull=False,
parameter_obj__id_satellite__isnull=False
).values_list('parameter_obj__id_satellite__id', flat=True).distinct()
satellites = Satellite.objects.filter(
id__in=satellites_with_points
).order_by('name')
# Получаем статистику
stats = self.get_statistics(date_from, date_to, satellite_ids)
# Сериализуем данные для JavaScript
daily_data_json = json.dumps([
{
'date': item['date'].isoformat() if item['date'] else None,
'points': item['points'],
'sources': item['sources'],
}
for item in stats['daily_data']
])
satellite_stats_json = json.dumps(stats['satellite_stats'])
context.update({
'satellites': satellites,
'selected_satellites': satellite_ids,
'date_from': date_from.isoformat() if date_from else '',
'date_to': date_to.isoformat() if date_to else '',
'preset': preset or '',
'total_points': stats['total_points'],
'total_sources': stats['total_sources'],
'new_emissions_count': stats['new_emissions_count'],
'new_emission_objects': stats['new_emission_objects'],
'satellite_stats': stats['satellite_stats'],
'daily_data': daily_data_json,
'satellite_stats_json': satellite_stats_json,
})
return context
class StatisticsAPIView(StatisticsView):
"""API endpoint для получения статистики в JSON формате."""
def get(self, request, *args, **kwargs):
date_from, date_to, preset = self.get_date_range()
satellite_ids = self.get_selected_satellites()
stats = self.get_statistics(date_from, date_to, satellite_ids)
# Преобразуем даты в строки для JSON
daily_data = []
for item in stats['daily_data']:
daily_data.append({
'date': item['date'].isoformat() if item['date'] else None,
'points': item['points'],
'sources': item['sources'],
})
return JsonResponse({
'total_points': stats['total_points'],
'total_sources': stats['total_sources'],
'new_emissions_count': stats['new_emissions_count'],
'new_emission_objects': stats['new_emission_objects'],
'satellite_stats': stats['satellite_stats'],
'daily_data': daily_data,
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,591 @@
/*!
* @kurkle/color v0.4.0
* https://github.com/kurkle/color#readme
* (c) 2025 Jukka Kurkela
* Released under the MIT License
*/
function round(v) {
return v + 0.5 | 0;
}
const lim = (v, l, h) => Math.max(Math.min(v, h), l);
function p2b(v) {
return lim(round(v * 2.55), 0, 255);
}
function b2p(v) {
return lim(round(v / 2.55), 0, 100);
}
function n2b(v) {
return lim(round(v * 255), 0, 255);
}
function b2n(v) {
return lim(round(v / 2.55) / 100, 0, 1);
}
function n2p(v) {
return lim(round(v * 100), 0, 100);
}
const map$1 = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, A: 10, B: 11, C: 12, D: 13, E: 14, F: 15, a: 10, b: 11, c: 12, d: 13, e: 14, f: 15};
const hex = [...'0123456789ABCDEF'];
const h1 = b => hex[b & 0xF];
const h2 = b => hex[(b & 0xF0) >> 4] + hex[b & 0xF];
const eq = b => ((b & 0xF0) >> 4) === (b & 0xF);
const isShort = v => eq(v.r) && eq(v.g) && eq(v.b) && eq(v.a);
function hexParse(str) {
var len = str.length;
var ret;
if (str[0] === '#') {
if (len === 4 || len === 5) {
ret = {
r: 255 & map$1[str[1]] * 17,
g: 255 & map$1[str[2]] * 17,
b: 255 & map$1[str[3]] * 17,
a: len === 5 ? map$1[str[4]] * 17 : 255
};
} else if (len === 7 || len === 9) {
ret = {
r: map$1[str[1]] << 4 | map$1[str[2]],
g: map$1[str[3]] << 4 | map$1[str[4]],
b: map$1[str[5]] << 4 | map$1[str[6]],
a: len === 9 ? (map$1[str[7]] << 4 | map$1[str[8]]) : 255
};
}
}
return ret;
}
const alpha = (a, f) => a < 255 ? f(a) : '';
function hexString(v) {
var f = isShort(v) ? h1 : h2;
return v
? '#' + f(v.r) + f(v.g) + f(v.b) + alpha(v.a, f)
: undefined;
}
const HUE_RE = /^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;
function hsl2rgbn(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = (n, k = (n + h / 30) % 12) => l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return [f(0), f(8), f(4)];
}
function hsv2rgbn(h, s, v) {
const f = (n, k = (n + h / 60) % 6) => v - v * s * Math.max(Math.min(k, 4 - k, 1), 0);
return [f(5), f(3), f(1)];
}
function hwb2rgbn(h, w, b) {
const rgb = hsl2rgbn(h, 1, 0.5);
let i;
if (w + b > 1) {
i = 1 / (w + b);
w *= i;
b *= i;
}
for (i = 0; i < 3; i++) {
rgb[i] *= 1 - w - b;
rgb[i] += w;
}
return rgb;
}
function hueValue(r, g, b, d, max) {
if (r === max) {
return ((g - b) / d) + (g < b ? 6 : 0);
}
if (g === max) {
return (b - r) / d + 2;
}
return (r - g) / d + 4;
}
function rgb2hsl(v) {
const range = 255;
const r = v.r / range;
const g = v.g / range;
const b = v.b / range;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h, s, d;
if (max !== min) {
d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
h = hueValue(r, g, b, d, max);
h = h * 60 + 0.5;
}
return [h | 0, s || 0, l];
}
function calln(f, a, b, c) {
return (
Array.isArray(a)
? f(a[0], a[1], a[2])
: f(a, b, c)
).map(n2b);
}
function hsl2rgb(h, s, l) {
return calln(hsl2rgbn, h, s, l);
}
function hwb2rgb(h, w, b) {
return calln(hwb2rgbn, h, w, b);
}
function hsv2rgb(h, s, v) {
return calln(hsv2rgbn, h, s, v);
}
function hue(h) {
return (h % 360 + 360) % 360;
}
function hueParse(str) {
const m = HUE_RE.exec(str);
let a = 255;
let v;
if (!m) {
return;
}
if (m[5] !== v) {
a = m[6] ? p2b(+m[5]) : n2b(+m[5]);
}
const h = hue(+m[2]);
const p1 = +m[3] / 100;
const p2 = +m[4] / 100;
if (m[1] === 'hwb') {
v = hwb2rgb(h, p1, p2);
} else if (m[1] === 'hsv') {
v = hsv2rgb(h, p1, p2);
} else {
v = hsl2rgb(h, p1, p2);
}
return {
r: v[0],
g: v[1],
b: v[2],
a: a
};
}
function rotate(v, deg) {
var h = rgb2hsl(v);
h[0] = hue(h[0] + deg);
h = hsl2rgb(h);
v.r = h[0];
v.g = h[1];
v.b = h[2];
}
function hslString(v) {
if (!v) {
return;
}
const a = rgb2hsl(v);
const h = a[0];
const s = n2p(a[1]);
const l = n2p(a[2]);
return v.a < 255
? `hsla(${h}, ${s}%, ${l}%, ${b2n(v.a)})`
: `hsl(${h}, ${s}%, ${l}%)`;
}
const map = {
x: 'dark',
Z: 'light',
Y: 're',
X: 'blu',
W: 'gr',
V: 'medium',
U: 'slate',
A: 'ee',
T: 'ol',
S: 'or',
B: 'ra',
C: 'lateg',
D: 'ights',
R: 'in',
Q: 'turquois',
E: 'hi',
P: 'ro',
O: 'al',
N: 'le',
M: 'de',
L: 'yello',
F: 'en',
K: 'ch',
G: 'arks',
H: 'ea',
I: 'ightg',
J: 'wh'
};
const names$1 = {
OiceXe: 'f0f8ff',
antiquewEte: 'faebd7',
aqua: 'ffff',
aquamarRe: '7fffd4',
azuY: 'f0ffff',
beige: 'f5f5dc',
bisque: 'ffe4c4',
black: '0',
blanKedOmond: 'ffebcd',
Xe: 'ff',
XeviTet: '8a2be2',
bPwn: 'a52a2a',
burlywood: 'deb887',
caMtXe: '5f9ea0',
KartYuse: '7fff00',
KocTate: 'd2691e',
cSO: 'ff7f50',
cSnflowerXe: '6495ed',
cSnsilk: 'fff8dc',
crimson: 'dc143c',
cyan: 'ffff',
xXe: '8b',
xcyan: '8b8b',
xgTMnPd: 'b8860b',
xWay: 'a9a9a9',
xgYF: '6400',
xgYy: 'a9a9a9',
xkhaki: 'bdb76b',
xmagFta: '8b008b',
xTivegYF: '556b2f',
xSange: 'ff8c00',
xScEd: '9932cc',
xYd: '8b0000',
xsOmon: 'e9967a',
xsHgYF: '8fbc8f',
xUXe: '483d8b',
xUWay: '2f4f4f',
xUgYy: '2f4f4f',
xQe: 'ced1',
xviTet: '9400d3',
dAppRk: 'ff1493',
dApskyXe: 'bfff',
dimWay: '696969',
dimgYy: '696969',
dodgerXe: '1e90ff',
fiYbrick: 'b22222',
flSOwEte: 'fffaf0',
foYstWAn: '228b22',
fuKsia: 'ff00ff',
gaRsbSo: 'dcdcdc',
ghostwEte: 'f8f8ff',
gTd: 'ffd700',
gTMnPd: 'daa520',
Way: '808080',
gYF: '8000',
gYFLw: 'adff2f',
gYy: '808080',
honeyMw: 'f0fff0',
hotpRk: 'ff69b4',
RdianYd: 'cd5c5c',
Rdigo: '4b0082',
ivSy: 'fffff0',
khaki: 'f0e68c',
lavFMr: 'e6e6fa',
lavFMrXsh: 'fff0f5',
lawngYF: '7cfc00',
NmoncEffon: 'fffacd',
ZXe: 'add8e6',
ZcSO: 'f08080',
Zcyan: 'e0ffff',
ZgTMnPdLw: 'fafad2',
ZWay: 'd3d3d3',
ZgYF: '90ee90',
ZgYy: 'd3d3d3',
ZpRk: 'ffb6c1',
ZsOmon: 'ffa07a',
ZsHgYF: '20b2aa',
ZskyXe: '87cefa',
ZUWay: '778899',
ZUgYy: '778899',
ZstAlXe: 'b0c4de',
ZLw: 'ffffe0',
lime: 'ff00',
limegYF: '32cd32',
lRF: 'faf0e6',
magFta: 'ff00ff',
maPon: '800000',
VaquamarRe: '66cdaa',
VXe: 'cd',
VScEd: 'ba55d3',
VpurpN: '9370db',
VsHgYF: '3cb371',
VUXe: '7b68ee',
VsprRggYF: 'fa9a',
VQe: '48d1cc',
VviTetYd: 'c71585',
midnightXe: '191970',
mRtcYam: 'f5fffa',
mistyPse: 'ffe4e1',
moccasR: 'ffe4b5',
navajowEte: 'ffdead',
navy: '80',
Tdlace: 'fdf5e6',
Tive: '808000',
TivedBb: '6b8e23',
Sange: 'ffa500',
SangeYd: 'ff4500',
ScEd: 'da70d6',
pOegTMnPd: 'eee8aa',
pOegYF: '98fb98',
pOeQe: 'afeeee',
pOeviTetYd: 'db7093',
papayawEp: 'ffefd5',
pHKpuff: 'ffdab9',
peru: 'cd853f',
pRk: 'ffc0cb',
plum: 'dda0dd',
powMrXe: 'b0e0e6',
purpN: '800080',
YbeccapurpN: '663399',
Yd: 'ff0000',
Psybrown: 'bc8f8f',
PyOXe: '4169e1',
saddNbPwn: '8b4513',
sOmon: 'fa8072',
sandybPwn: 'f4a460',
sHgYF: '2e8b57',
sHshell: 'fff5ee',
siFna: 'a0522d',
silver: 'c0c0c0',
skyXe: '87ceeb',
UXe: '6a5acd',
UWay: '708090',
UgYy: '708090',
snow: 'fffafa',
sprRggYF: 'ff7f',
stAlXe: '4682b4',
tan: 'd2b48c',
teO: '8080',
tEstN: 'd8bfd8',
tomato: 'ff6347',
Qe: '40e0d0',
viTet: 'ee82ee',
JHt: 'f5deb3',
wEte: 'ffffff',
wEtesmoke: 'f5f5f5',
Lw: 'ffff00',
LwgYF: '9acd32'
};
function unpack() {
const unpacked = {};
const keys = Object.keys(names$1);
const tkeys = Object.keys(map);
let i, j, k, ok, nk;
for (i = 0; i < keys.length; i++) {
ok = nk = keys[i];
for (j = 0; j < tkeys.length; j++) {
k = tkeys[j];
nk = nk.replace(k, map[k]);
}
k = parseInt(names$1[ok], 16);
unpacked[nk] = [k >> 16 & 0xFF, k >> 8 & 0xFF, k & 0xFF];
}
return unpacked;
}
let names;
function nameParse(str) {
if (!names) {
names = unpack();
names.transparent = [0, 0, 0, 0];
}
const a = names[str.toLowerCase()];
return a && {
r: a[0],
g: a[1],
b: a[2],
a: a.length === 4 ? a[3] : 255
};
}
const RGB_RE = /^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;
function rgbParse(str) {
const m = RGB_RE.exec(str);
let a = 255;
let r, g, b;
if (!m) {
return;
}
if (m[7] !== r) {
const v = +m[7];
a = m[8] ? p2b(v) : lim(v * 255, 0, 255);
}
r = +m[1];
g = +m[3];
b = +m[5];
r = 255 & (m[2] ? p2b(r) : lim(r, 0, 255));
g = 255 & (m[4] ? p2b(g) : lim(g, 0, 255));
b = 255 & (m[6] ? p2b(b) : lim(b, 0, 255));
return {
r: r,
g: g,
b: b,
a: a
};
}
function rgbString(v) {
return v && (
v.a < 255
? `rgba(${v.r}, ${v.g}, ${v.b}, ${b2n(v.a)})`
: `rgb(${v.r}, ${v.g}, ${v.b})`
);
}
const to = v => v <= 0.0031308 ? v * 12.92 : Math.pow(v, 1.0 / 2.4) * 1.055 - 0.055;
const from = v => v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
function interpolate(rgb1, rgb2, t) {
const r = from(b2n(rgb1.r));
const g = from(b2n(rgb1.g));
const b = from(b2n(rgb1.b));
return {
r: n2b(to(r + t * (from(b2n(rgb2.r)) - r))),
g: n2b(to(g + t * (from(b2n(rgb2.g)) - g))),
b: n2b(to(b + t * (from(b2n(rgb2.b)) - b))),
a: rgb1.a + t * (rgb2.a - rgb1.a)
};
}
const COMMENT_REGEXP = /\/\*[^]*?\*\//g;
function modHSL(v, i, ratio) {
if (v) {
let tmp = rgb2hsl(v);
tmp[i] = Math.max(0, Math.min(tmp[i] + tmp[i] * ratio, i === 0 ? 360 : 1));
tmp = hsl2rgb(tmp);
v.r = tmp[0];
v.g = tmp[1];
v.b = tmp[2];
}
}
function clone(v, proto) {
return v ? Object.assign(proto || {}, v) : v;
}
function fromObject(input) {
var v = {r: 0, g: 0, b: 0, a: 255};
if (Array.isArray(input)) {
if (input.length >= 3) {
v = {r: input[0], g: input[1], b: input[2], a: 255};
if (input.length > 3) {
v.a = n2b(input[3]);
}
}
} else {
v = clone(input, {r: 0, g: 0, b: 0, a: 1});
v.a = n2b(v.a);
}
return v;
}
function functionParse(str) {
if (str.charAt(0) === 'r') {
return rgbParse(str);
}
return hueParse(str);
}
class Color {
constructor(input) {
if (input instanceof Color) {
return input;
}
const type = typeof input;
let v;
if (type === 'object') {
v = fromObject(input);
} else if (type === 'string') {
const clean = input.replace(COMMENT_REGEXP, '');
v = hexParse(clean) || nameParse(clean) || functionParse(clean);
}
this._rgb = v;
this._valid = !!v;
}
get valid() {
return this._valid;
}
get rgb() {
var v = clone(this._rgb);
if (v) {
v.a = b2n(v.a);
}
return v;
}
set rgb(obj) {
this._rgb = fromObject(obj);
}
rgbString() {
return this._valid ? rgbString(this._rgb) : undefined;
}
hexString() {
return this._valid ? hexString(this._rgb) : undefined;
}
hslString() {
return this._valid ? hslString(this._rgb) : undefined;
}
mix(color, weight) {
if (color) {
const c1 = this.rgb;
const c2 = color.rgb;
let w2;
const p = weight === w2 ? 0.5 : weight;
const w = 2 * p - 1;
const a = c1.a - c2.a;
const w1 = ((w * a === -1 ? w : (w + a) / (1 + w * a)) + 1) / 2.0;
w2 = 1 - w1;
c1.r = 0xFF & w1 * c1.r + w2 * c2.r + 0.5;
c1.g = 0xFF & w1 * c1.g + w2 * c2.g + 0.5;
c1.b = 0xFF & w1 * c1.b + w2 * c2.b + 0.5;
c1.a = p * c1.a + (1 - p) * c2.a;
this.rgb = c1;
}
return this;
}
interpolate(color, t) {
if (color) {
this._rgb = interpolate(this._rgb, color._rgb, t);
}
return this;
}
clone() {
return new Color(this.rgb);
}
alpha(a) {
this._rgb.a = n2b(a);
return this;
}
clearer(ratio) {
const rgb = this._rgb;
rgb.a *= 1 - ratio;
return this;
}
greyscale() {
const rgb = this._rgb;
const val = round(rgb.r * 0.3 + rgb.g * 0.59 + rgb.b * 0.11);
rgb.r = rgb.g = rgb.b = val;
return this;
}
opaquer(ratio) {
const rgb = this._rgb;
rgb.a *= 1 + ratio;
return this;
}
negate() {
const v = this._rgb;
v.r = 255 - v.r;
v.g = 255 - v.g;
v.b = 255 - v.b;
return this;
}
lighten(ratio) {
modHSL(this._rgb, 2, ratio);
return this;
}
darken(ratio) {
modHSL(this._rgb, 2, -ratio);
return this;
}
saturate(ratio) {
modHSL(this._rgb, 1, ratio);
return this;
}
desaturate(ratio) {
modHSL(this._rgb, 1, -ratio);
return this;
}
rotate(deg) {
rotate(this._rgb, deg);
return this;
}
}
function index_esm(input) {
return new Color(input);
}
export { Color, b2n, b2p, index_esm as default, hexParse, hexString, hsl2rgb, hslString, hsv2rgb, hueParse, hwb2rgb, lim, n2b, n2p, nameParse, p2b, rgb2hsl, rgbParse, rgbString, rotate, round };

View File

@@ -385,11 +385,12 @@ class CustomMarkerTool {
this.map.on('click', this.clickHandler); this.map.on('click', this.clickHandler);
// Show instruction // Show instruction
this.showInstruction('Кликните на карту для размещения маркера. ESC для отмены.'); this.showInstruction('Кликните на карту для размещения маркера.');
// Add keyboard handlers // Add keyboard handlers
this.keyHandler = (e) => { this.keyHandler = (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
console.log("Жму кнопку");
this.deactivate(); this.deactivate();
} else if (e.key === 'Enter' && this.pendingMarkerLatLng) { } else if (e.key === 'Enter' && this.pendingMarkerLatLng) {
this.placeMarker(this.pendingMarkerLatLng); this.placeMarker(this.pendingMarkerLatLng);
@@ -526,17 +527,17 @@ class CustomMarkerTool {
} }
instruction.innerHTML = ` instruction.innerHTML = `
<span>${text}</span> <span>${text}</span>
<button id="finishMarkerBtn" style="
background: white;
color: #007bff;
border: none;
padding: 4px 12px;
border-radius: 3px;
cursor: pointer;
font-weight: 500;
font-size: 12px;
">Отмена (ESC)</button>
`; `;
// <button id="finishMarkerBtn" style="
// background: white;
// color: #007bff;ы
// border: none;
// padding: 4px 12px;
// border-radius: 3px;
// cursor: pointer;
// font-weight: 500;
// font-size: 12px;
// ">Отмена (ESC)</button>
instruction.style.display = 'flex'; instruction.style.display = 'flex';
// Add finish button handler // Add finish button handler