Добавил локально библиотеку chart js. Сделал секретную статистику

This commit is contained in:
2025-12-04 12:35:08 +03:00
parent 027f971f5a
commit f954f77a6d
7 changed files with 1875 additions and 2 deletions

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

@@ -286,8 +286,9 @@
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
<!-- <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() {

View File

@@ -71,6 +71,7 @@ from .views.tech_analyze import (
)
from .views.points_averaging import PointsAveragingView, PointsAveragingAPIView, RecalculateGroupAPIView
from .views.statistics import StatisticsView, StatisticsAPIView
from .views.secret_stats import SecretStatsView
app_name = 'mainapp'
@@ -149,5 +150,6 @@ urlpatterns = [
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'),
]

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