Compare commits
3 Commits
24314b84ac
...
f954f77a6d
| Author | SHA1 | Date | |
|---|---|---|---|
| f954f77a6d | |||
| 027f971f5a | |||
| 30b56de709 |
@@ -6,7 +6,7 @@
|
||||
.multiselect-input-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
min-height: 38px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.25rem;
|
||||
@@ -27,7 +27,8 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
flex: 0 0 auto;
|
||||
flex: 1 1 auto;
|
||||
max-width: calc(100% - 150px);
|
||||
}
|
||||
|
||||
.multiselect-tag {
|
||||
|
||||
@@ -28,14 +28,14 @@
|
||||
<!-- Layer Manager Panel -->
|
||||
<div class="layer-manager-panel" id="layerManagerPanel" style="display: none;">
|
||||
<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>
|
||||
</div>
|
||||
<div class="layer-manager-body">
|
||||
<!-- Base Layers Section -->
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">
|
||||
<span>🗺️ Базовые слои (тайлы)</span>
|
||||
<span>Базовые слои (тайлы)</span>
|
||||
</div>
|
||||
<div id="baseLayers"></div>
|
||||
</div>
|
||||
@@ -43,7 +43,7 @@
|
||||
<!-- Markers Layer Section (Playback) -->
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">
|
||||
<span>📍 Слой маркеров (Playback)</span>
|
||||
<span> Слой маркеров (Playback)</span>
|
||||
</div>
|
||||
<div id="markersLayerControl"></div>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@
|
||||
<!-- Drawing Layers Section -->
|
||||
<div class="layer-section">
|
||||
<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;">
|
||||
<i class="bi bi-plus"></i> Добавить
|
||||
</button>
|
||||
|
||||
971
dbapp/mainapp/templates/mainapp/secret_stats.html
Normal file
971
dbapp/mainapp/templates/mainapp/secret_stats.html
Normal 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>
|
||||
@@ -104,6 +104,9 @@
|
||||
<a href="{% url 'mainapp:tech_analyze_list' %}" class="btn btn-info btn-sm" title="Технический анализ">
|
||||
<i class="bi bi-gear-wide-connected"></i> Тех. анализ
|
||||
</a>
|
||||
<a href="{% url 'mainapp:statistics' %}" class="btn btn-secondary btn-sm" title="Статистика">
|
||||
<i class="bi bi-bar-chart-line"></i> Статистика
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Add to List Button -->
|
||||
|
||||
486
dbapp/mainapp/templates/mainapp/statistics.html
Normal file
486
dbapp/mainapp/templates/mainapp/statistics.html
Normal 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 %}
|
||||
@@ -70,6 +70,8 @@ from .views.tech_analyze import (
|
||||
TechAnalyzeAPIView,
|
||||
)
|
||||
from .views.points_averaging import PointsAveragingView, PointsAveragingAPIView, RecalculateGroupAPIView
|
||||
from .views.statistics import StatisticsView, StatisticsAPIView
|
||||
from .views.secret_stats import SecretStatsView
|
||||
|
||||
app_name = 'mainapp'
|
||||
|
||||
@@ -146,5 +148,8 @@ urlpatterns = [
|
||||
path('points-averaging/', PointsAveragingView.as_view(), name='points_averaging'),
|
||||
path('api/points-averaging/', PointsAveragingAPIView.as_view(), name='points_averaging_api'),
|
||||
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'),
|
||||
]
|
||||
@@ -71,6 +71,10 @@ from .points_averaging import (
|
||||
PointsAveragingAPIView,
|
||||
RecalculateGroupAPIView,
|
||||
)
|
||||
from .statistics import (
|
||||
StatisticsView,
|
||||
StatisticsAPIView,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Base
|
||||
@@ -144,4 +148,7 @@ __all__ = [
|
||||
'PointsAveragingView',
|
||||
'PointsAveragingAPIView',
|
||||
'RecalculateGroupAPIView',
|
||||
# Statistics
|
||||
'StatisticsView',
|
||||
'StatisticsAPIView',
|
||||
]
|
||||
|
||||
287
dbapp/mainapp/views/secret_stats.py
Normal file
287
dbapp/mainapp/views/secret_stats.py
Normal 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
|
||||
280
dbapp/mainapp/views/statistics.py
Normal file
280
dbapp/mainapp/views/statistics.py
Normal 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,
|
||||
})
|
||||
7
dbapp/static/chartjs/chart-datalabels.js
Normal file
7
dbapp/static/chartjs/chart-datalabels.js
Normal file
File diff suppressed because one or more lines are too long
14
dbapp/static/chartjs/chart.js
Normal file
14
dbapp/static/chartjs/chart.js
Normal file
File diff suppressed because one or more lines are too long
591
dbapp/static/chartjs/color.esm.js
Normal file
591
dbapp/static/chartjs/color.esm.js
Normal 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 };
|
||||
@@ -385,11 +385,12 @@ class CustomMarkerTool {
|
||||
this.map.on('click', this.clickHandler);
|
||||
|
||||
// Show instruction
|
||||
this.showInstruction('Кликните на карту для размещения маркера. ESC для отмены.');
|
||||
this.showInstruction('Кликните на карту для размещения маркера.');
|
||||
|
||||
// Add keyboard handlers
|
||||
this.keyHandler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
console.log("Жму кнопку");
|
||||
this.deactivate();
|
||||
} else if (e.key === 'Enter' && this.pendingMarkerLatLng) {
|
||||
this.placeMarker(this.pendingMarkerLatLng);
|
||||
@@ -526,17 +527,17 @@ class CustomMarkerTool {
|
||||
}
|
||||
instruction.innerHTML = `
|
||||
<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';
|
||||
|
||||
// Add finish button handler
|
||||
|
||||
Reference in New Issue
Block a user